Skip to content

Commit

Permalink
Add support for converting SVG images to PNG, JPEG, and PDF (#108)
Browse files Browse the repository at this point in the history
* Add svg input conversions

* --test-threads=1 for Rust tests for Deno
  • Loading branch information
jonmmease authored Sep 16, 2023
1 parent f26b610 commit ddb7ebe
Show file tree
Hide file tree
Showing 7 changed files with 325 additions and 101 deletions.
7 changes: 4 additions & 3 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,10 @@ jobs:
run: |
echo ttf-mscorefonts-installer msttcorefonts/accepted-mscorefonts-eula select true | sudo debconf-set-selections
sudo apt-get install ttf-mscorefonts-installer
- uses: actions-rs/cargo@v1
with:
command: test
- name: Run tests
# Run tests on single thread for Deno, which expects this
run: |
cargo test -- --test-threads=1
- name: Upload test failures
uses: actions/upload-artifact@v2
if: always()
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,15 @@ Commands:
vl2png Convert a Vega-Lite specification to an PNG image
vl2jpeg Convert a Vega-Lite specification to an JPEG image
vl2pdf Convert a Vega-Lite specification to a PDF image
vl2url Convert a Vega-Lite specification to a URL that opens the chart in the Vega editor
vg2svg Convert a Vega specification to an SVG image
vg2png Convert a Vega specification to an PNG image
vg2jpeg Convert a Vega specification to an JPEG image
vg2pdf Convert a Vega specification to an PDF image
vg2url Convert a Vega specification to a URL that opens the chart in the Vega editor
svg2png Convert an SVG image to a PNG image
svg2jpeg Convert an SVG image to a JPEG image
svg2pdf Convert an SVG image to a PDF image
ls-themes List available themes
cat-theme Print the config JSON for a theme
help Print this message or the help of the given subcommand(s)
Expand Down
2 changes: 1 addition & 1 deletion vl-convert-pdf/examples/pdf_conversion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@ fn main() {
font_db.load_system_fonts();

let pdf_bytes = svg_to_pdf(&tree, &font_db, 1.0).unwrap();
fs::write("target/hello.pdf".to_string(), pdf_bytes).unwrap();
fs::write("target/hello.pdf", pdf_bytes).unwrap();
}
55 changes: 54 additions & 1 deletion vl-convert-python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,7 @@ fn vega_to_pdf(vg_spec: PyObject, scale: Option<f32>) -> PyResult<PyObject> {
/// theme (str | None): Named theme (e.g. "dark") to apply during conversion
///
/// Returns:
/// bytes: JPEG image data
/// bytes: PDF image data
#[pyfunction]
#[pyo3(text_signature = "(vl_spec, vl_version, scale, config, theme)")]
fn vegalite_to_pdf(
Expand Down Expand Up @@ -473,6 +473,56 @@ fn vega_to_url(vg_spec: PyObject, fullscreen: Option<bool>) -> PyResult<String>
)?)
}

/// Convert an SVG image string to PNG image data
///
/// Args:
/// svg (str): SVG image string
/// scale (float): Image scale factor (default 1.0)
/// ppi (float): Pixels per inch (default 72)
/// Returns:
/// bytes: PNG image data
#[pyfunction]
#[pyo3(text_signature = "(svg, scale, ppi)")]
fn svg_to_png(svg: &str, scale: Option<f32>, ppi: Option<f32>) -> PyResult<PyObject> {
let png_data = vl_convert_rs::converter::svg_to_png(svg, scale.unwrap_or(1.0), ppi)?;
Ok(Python::with_gil(|py| -> PyObject {
PyObject::from(PyBytes::new(py, png_data.as_slice()))
}))
}

/// Convert an SVG image string to JPEG image data
///
/// Args:
/// svg (str): SVG image string
/// scale (float): Image scale factor (default 1.0)
/// quality (int): JPEG Quality between 0 (worst) and 100 (best). Default 90
/// Returns:
/// bytes: JPEG image data
#[pyfunction]
#[pyo3(text_signature = "(svg, scale, quality)")]
fn svg_to_jpeg(svg: &str, scale: Option<f32>, quality: Option<u8>) -> PyResult<PyObject> {
let jpeg_data = vl_convert_rs::converter::svg_to_jpeg(svg, scale.unwrap_or(1.0), quality)?;
Ok(Python::with_gil(|py| -> PyObject {
PyObject::from(PyBytes::new(py, jpeg_data.as_slice()))
}))
}

/// Convert an SVG image string to PDF document data
///
/// Args:
/// svg (str): SVG image string
/// scale (float): Image scale factor (default 1.0)
/// Returns:
/// bytes: PDF document data
#[pyfunction]
#[pyo3(text_signature = "(svg, scale)")]
fn svg_to_pdf(svg: &str, scale: Option<f32>) -> PyResult<PyObject> {
let pdf_data = vl_convert_rs::converter::svg_to_pdf(svg, scale.unwrap_or(1.0))?;
Ok(Python::with_gil(|py| -> PyObject {
PyObject::from(PyBytes::new(py, pdf_data.as_slice()))
}))
}

/// Helper function to parse an input Python string or dict as a serde_json::Value
fn parse_json_spec(vl_spec: PyObject) -> PyResult<serde_json::Value> {
Python::with_gil(|py| -> PyResult<serde_json::Value> {
Expand Down Expand Up @@ -575,6 +625,9 @@ fn vl_convert(_py: Python, m: &PyModule) -> PyResult<()> {
m.add_function(wrap_pyfunction!(vega_to_jpeg, m)?)?;
m.add_function(wrap_pyfunction!(vega_to_pdf, m)?)?;
m.add_function(wrap_pyfunction!(vega_to_url, m)?)?;
m.add_function(wrap_pyfunction!(svg_to_png, m)?)?;
m.add_function(wrap_pyfunction!(svg_to_jpeg, m)?)?;
m.add_function(wrap_pyfunction!(svg_to_pdf, m)?)?;
m.add_function(wrap_pyfunction!(register_font_directory, m)?)?;
m.add_function(wrap_pyfunction!(get_local_tz, m)?)?;
m.add_function(wrap_pyfunction!(get_themes, m)?)?;
Expand Down
203 changes: 107 additions & 96 deletions vl-convert-rs/src/converter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ use tiny_skia::{Pixmap, PremultipliedColorU8};
use usvg::{TreeParsing, TreeTextToPath};

use image::io::Reader as ImageReader;
use vl_convert_pdf::svg_to_pdf;

use crate::text::{op_text_width, FONT_DB, USVG_OPTIONS};

Expand Down Expand Up @@ -744,7 +743,7 @@ impl VlConverter {
) -> Result<Vec<u8>, AnyError> {
let scale = scale.unwrap_or(1.0);
let svg = self.vega_to_svg(vg_spec).await?;
Self::svg_to_png(&svg, scale, ppi)
svg_to_png(&svg, scale, ppi)
}

pub async fn vegalite_to_png(
Expand All @@ -756,7 +755,7 @@ impl VlConverter {
) -> Result<Vec<u8>, AnyError> {
let scale = scale.unwrap_or(1.0);
let svg = self.vegalite_to_svg(vl_spec, vl_opts).await?;
Self::svg_to_png(&svg, scale, ppi)
svg_to_png(&svg, scale, ppi)
}

pub async fn vega_to_jpeg(
Expand All @@ -767,7 +766,7 @@ impl VlConverter {
) -> Result<Vec<u8>, AnyError> {
let scale = scale.unwrap_or(1.0);
let svg = self.vega_to_svg(vg_spec).await?;
Self::svg_to_jpeg(&svg, scale, quality)
svg_to_jpeg(&svg, scale, quality)
}

pub async fn vegalite_to_jpeg(
Expand All @@ -779,7 +778,7 @@ impl VlConverter {
) -> Result<Vec<u8>, AnyError> {
let scale = scale.unwrap_or(1.0);
let svg = self.vegalite_to_svg(vl_spec, vl_opts).await?;
Self::svg_to_jpeg(&svg, scale, quality)
svg_to_jpeg(&svg, scale, quality)
}

pub async fn vega_to_pdf(
Expand All @@ -789,20 +788,7 @@ impl VlConverter {
) -> Result<Vec<u8>, AnyError> {
let scale = scale.unwrap_or(1.0);
let svg = self.vega_to_svg(vg_spec).await?;

// Load system fonts
let font_db = FONT_DB
.lock()
.map_err(|err| anyhow!("Failed to acquire fontdb lock: {}", err.to_string()))?;

// Parse SVG and convert text nodes to paths
let opts = USVG_OPTIONS
.lock()
.map_err(|err| anyhow!("Failed to acquire usvg options lock: {}", err.to_string()))?;

let tree = usvg::Tree::from_str(&svg, &opts)?;

svg_to_pdf(&tree, &font_db, scale)
svg_to_pdf(&svg, scale)
}

pub async fn vegalite_to_pdf(
Expand All @@ -813,83 +799,7 @@ impl VlConverter {
) -> Result<Vec<u8>, AnyError> {
let scale = scale.unwrap_or(1.0);
let svg = self.vegalite_to_svg(vl_spec, vl_opts).await?;

// Load system fonts
let font_db = FONT_DB
.lock()
.map_err(|err| anyhow!("Failed to acquire fontdb lock: {}", err.to_string()))?;

// Parse SVG and convert text nodes to paths
let opts = USVG_OPTIONS
.lock()
.map_err(|err| anyhow!("Failed to acquire usvg options lock: {}", err.to_string()))?;

let tree = usvg::Tree::from_str(&svg, &opts)?;

svg_to_pdf(&tree, &font_db, scale)
}

fn svg_to_png(svg: &str, scale: f32, ppi: Option<f32>) -> Result<Vec<u8>, AnyError> {
// default ppi to 72
let ppi = ppi.unwrap_or(72.0);
let scale = scale * ppi / 72.0;
let opts = USVG_OPTIONS
.lock()
.map_err(|err| anyhow!("Failed to acquire usvg options lock: {}", err.to_string()))?;

let font_database = FONT_DB
.lock()
.map_err(|err| anyhow!("Failed to acquire fontdb lock: {}", err.to_string()))?;

// catch_unwind so that we don't poison Mutexes
// if usvg/resvg panics
let response = panic::catch_unwind(|| {
let mut rtree = match usvg::Tree::from_str(svg, &opts) {
Ok(rtree) => rtree,
Err(err) => {
bail!("Failed to parse SVG string: {}", err.to_string())
}
};
rtree.convert_text(&font_database);

let rtree = resvg::Tree::from_usvg(&rtree);

let mut pixmap = tiny_skia::Pixmap::new(
(rtree.size.width() * scale) as u32,
(rtree.size.height() * scale) as u32,
)
.unwrap();

let transform = tiny_skia::Transform::from_scale(scale, scale);
resvg::Tree::render(&rtree, transform, &mut pixmap.as_mut());

Ok(encode_png(pixmap, ppi))
});
match response {
Ok(Ok(Ok(png_result))) => Ok(png_result),
err => bail!("{err:?}"),
}
}

fn svg_to_jpeg(svg: &str, scale: f32, quality: Option<u8>) -> Result<Vec<u8>, AnyError> {
let png_bytes = Self::svg_to_png(svg, scale, None)?;
let img = ImageReader::new(Cursor::new(png_bytes))
.with_guessed_format()?
.decode()?;

let quality = quality.unwrap_or(90);
if quality > 100 {
bail!(
"JPEG quality parameter must be between 0 and 100 inclusive. Received: {quality}"
);
}

let mut jpeg_bytes: Vec<u8> = Vec::new();
img.write_to(
&mut Cursor::new(&mut jpeg_bytes),
image::ImageOutputFormat::Jpeg(quality),
)?;
Ok(jpeg_bytes)
svg_to_pdf(&svg, scale)
}

pub async fn get_local_tz(&mut self) -> Result<Option<String>, AnyError> {
Expand Down Expand Up @@ -987,6 +897,107 @@ pub fn encode_png(pixmap: Pixmap, ppi: f32) -> Result<Vec<u8>, AnyError> {
Ok(data)
}

pub fn svg_to_png(svg: &str, scale: f32, ppi: Option<f32>) -> Result<Vec<u8>, AnyError> {
// default ppi to 72
let ppi = ppi.unwrap_or(72.0);
let scale = scale * ppi / 72.0;
let font_database = FONT_DB
.lock()
.map_err(|err| anyhow!("Failed to acquire fontdb lock: {}", err.to_string()))?;

// catch_unwind so that we don't poison Mutexes
// if usvg/resvg panics
let response = panic::catch_unwind(|| {
let mut rtree = match parse_svg(svg) {
Ok(rtree) => rtree,
Err(err) => return Err(err),
};
rtree.convert_text(&font_database);

let rtree = resvg::Tree::from_usvg(&rtree);

let mut pixmap = tiny_skia::Pixmap::new(
(rtree.size.width() * scale) as u32,
(rtree.size.height() * scale) as u32,
)
.unwrap();

let transform = tiny_skia::Transform::from_scale(scale, scale);
resvg::Tree::render(&rtree, transform, &mut pixmap.as_mut());

Ok(encode_png(pixmap, ppi))
});
match response {
Ok(Ok(Ok(png_result))) => Ok(png_result),
Ok(Err(err)) => Err(err),
err => bail!("{err:?}"),
}
}

pub fn svg_to_jpeg(svg: &str, scale: f32, quality: Option<u8>) -> Result<Vec<u8>, AnyError> {
let png_bytes = svg_to_png(svg, scale, None)?;
let img = ImageReader::new(Cursor::new(png_bytes))
.with_guessed_format()?
.decode()?;

let quality = quality.unwrap_or(90);
if quality > 100 {
bail!("JPEG quality parameter must be between 0 and 100 inclusive. Received: {quality}");
}

let mut jpeg_bytes: Vec<u8> = Vec::new();
img.write_to(
&mut Cursor::new(&mut jpeg_bytes),
image::ImageOutputFormat::Jpeg(quality),
)?;
Ok(jpeg_bytes)
}

pub fn svg_to_pdf(svg: &str, scale: f32) -> Result<Vec<u8>, AnyError> {
// Load system fonts
let font_db = FONT_DB
.lock()
.map_err(|err| anyhow!("Failed to acquire fontdb lock: {}", err.to_string()))?;

let tree = parse_svg(svg)?;
vl_convert_pdf::svg_to_pdf(&tree, &font_db, scale)
}

/// Helper to parse svg string to usvg Tree with more helpful error messages
fn parse_svg(svg: &str) -> Result<usvg::Tree, AnyError> {
let xml_opt = usvg::roxmltree::ParsingOptions {
allow_dtd: true,
..Default::default()
};

let opts = USVG_OPTIONS
.lock()
.map_err(|err| anyhow!("Failed to acquire usvg options lock: {}", err.to_string()))?;

let doc = usvg::roxmltree::Document::parse_with_options(svg, xml_opt)?;

match doc.root_element().tag_name().namespace() {
Some("http://www.w3.org/2000/svg") => {
// All good
}
Some(other) => {
bail!(
"Invalid xmlns for SVG file. \n\
Expected \"http://www.w3.org/2000/svg\". \n\
Found \"{other}\""
);
}
None => {
bail!(
"SVG file must have the xmlns attribute set to \"http://www.w3.org/2000/svg\"\n\
For example <svg width=\"100\", height=\"100\", xmlns=\"http://www.w3.org/2000/svg\">...</svg>"
)
}
}

Ok(usvg::Tree::from_xmltree(&doc, &opts)?)
}

pub fn vegalite_to_url(vl_spec: &serde_json::Value, fullscreen: bool) -> Result<String, AnyError> {
let spec_str = serde_json::to_string(vl_spec)?;
let compressed_data = lz_str::compress_to_encoded_uri_component(&spec_str);
Expand Down
Loading

0 comments on commit ddb7ebe

Please sign in to comment.