From ddb7ebefb7b0bf6cbc37727ff0ad3ea2c00f73e1 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Sat, 16 Sep 2023 12:58:30 -0400 Subject: [PATCH] Add support for converting SVG images to PNG, JPEG, and PDF (#108) * Add svg input conversions * --test-threads=1 for Rust tests for Deno --- .github/workflows/CI.yml | 7 +- README.md | 5 + vl-convert-pdf/examples/pdf_conversion.rs | 2 +- vl-convert-python/src/lib.rs | 55 +++++- vl-convert-rs/src/converter.rs | 203 ++++++++++++---------- vl-convert/README.md | 51 ++++++ vl-convert/src/main.rs | 103 +++++++++++ 7 files changed, 325 insertions(+), 101 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 1dd95425..b7ec92f4 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -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() diff --git a/README.md b/README.md index b0760cb0..228445d5 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/vl-convert-pdf/examples/pdf_conversion.rs b/vl-convert-pdf/examples/pdf_conversion.rs index e15af77b..37012355 100644 --- a/vl-convert-pdf/examples/pdf_conversion.rs +++ b/vl-convert-pdf/examples/pdf_conversion.rs @@ -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(); } diff --git a/vl-convert-python/src/lib.rs b/vl-convert-python/src/lib.rs index 0856c4b8..b2eed48b 100644 --- a/vl-convert-python/src/lib.rs +++ b/vl-convert-python/src/lib.rs @@ -392,7 +392,7 @@ fn vega_to_pdf(vg_spec: PyObject, scale: Option) -> PyResult { /// 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( @@ -473,6 +473,56 @@ fn vega_to_url(vg_spec: PyObject, fullscreen: Option) -> PyResult )?) } +/// 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, ppi: Option) -> PyResult { + 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, quality: Option) -> PyResult { + 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) -> PyResult { + 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 { Python::with_gil(|py| -> PyResult { @@ -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)?)?; diff --git a/vl-convert-rs/src/converter.rs b/vl-convert-rs/src/converter.rs index f9df31d9..9f7d21b7 100644 --- a/vl-convert-rs/src/converter.rs +++ b/vl-convert-rs/src/converter.rs @@ -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}; @@ -744,7 +743,7 @@ impl VlConverter { ) -> Result, 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( @@ -756,7 +755,7 @@ impl VlConverter { ) -> Result, 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( @@ -767,7 +766,7 @@ impl VlConverter { ) -> Result, 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( @@ -779,7 +778,7 @@ impl VlConverter { ) -> Result, 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( @@ -789,20 +788,7 @@ impl VlConverter { ) -> Result, 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( @@ -813,83 +799,7 @@ impl VlConverter { ) -> Result, 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) -> Result, 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) -> Result, 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 = 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, AnyError> { @@ -987,6 +897,107 @@ pub fn encode_png(pixmap: Pixmap, ppi: f32) -> Result, AnyError> { Ok(data) } +pub fn svg_to_png(svg: &str, scale: f32, ppi: Option) -> Result, 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) -> Result, 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 = 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, 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 { + 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 ..." + ) + } + } + + Ok(usvg::Tree::from_xmltree(&doc, &opts)?) +} + pub fn vegalite_to_url(vl_spec: &serde_json::Value, fullscreen: bool) -> Result { let spec_str = serde_json::to_string(vl_spec)?; let compressed_data = lz_str::compress_to_encoded_uri_component(&spec_str); diff --git a/vl-convert/README.md b/vl-convert/README.md index f75b7d5e..99dbcc1f 100644 --- a/vl-convert/README.md +++ b/vl-convert/README.md @@ -28,6 +28,9 @@ Commands: 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) @@ -232,6 +235,54 @@ Options: -h, --help Print help ``` +### svg2png +Convert an SVG image to a PNG image + +``` +Convert an SVG image to a PNG image + +Usage: vl-convert svg2png [OPTIONS] --input --output + +Options: + -i, --input Path to input SVG file + -o, --output Path to output PNG file to be created + --scale Image scale factor [default: 1.0] + -p, --ppi Pixels per inch [default: 72.0] + --font-dir Additional directory to search for fonts + -h, --help Print help +``` + +### svg2jpeg +Convert an SVG image to a JPEG image +``` +Convert an SVG image to a JPEG image + +Usage: vl-convert svg2jpeg [OPTIONS] --input --output + +Options: + -i, --input Path to input SVG file + -o, --output Path to output JPEG file to be created + --scale Image scale factor [default: 1.0] + -q, --quality JPEG Quality between 0 (worst) and 100 (best) [default: 90] + --font-dir Additional directory to search for fonts + -h, --help Print help +``` + +### svg2pdf +Convert an SVG image to a PDF image +``` +Convert an SVG image to a PDF image + +Usage: vl-convert svg2pdf [OPTIONS] --input --output + +Options: + -i, --input Path to input SVG file + -o, --output Path to output PDF file to be created + --scale Image scale factor [default: 1.0] + --font-dir Additional directory to search for fonts + -h, --help Print help +``` + ### ls-themes ``` $ vl-convert ls-themes --help diff --git a/vl-convert/src/main.rs b/vl-convert/src/main.rs index b4435f7e..281bd123 100644 --- a/vl-convert/src/main.rs +++ b/vl-convert/src/main.rs @@ -310,6 +310,74 @@ enum Commands { fullscreen: bool, }, + /// Convert an SVG image to a PNG image + #[command(arg_required_else_help = true)] + Svg2png { + /// Path to input SVG file + #[arg(short, long)] + input: String, + + /// Path to output PNG file to be created + #[arg(short, long)] + output: String, + + /// Image scale factor + #[arg(long, default_value = "1.0")] + scale: f32, + + /// Pixels per inch + #[arg(short, long, default_value = "72.0")] + ppi: f32, + + /// Additional directory to search for fonts + #[arg(long)] + font_dir: Option, + }, + + /// Convert an SVG image to a JPEG image + #[command(arg_required_else_help = true)] + Svg2jpeg { + /// Path to input SVG file + #[arg(short, long)] + input: String, + + /// Path to output JPEG file to be created + #[arg(short, long)] + output: String, + + /// Image scale factor + #[arg(long, default_value = "1.0")] + scale: f32, + + /// JPEG Quality between 0 (worst) and 100 (best) + #[arg(short, long, default_value = "90")] + quality: u8, + + /// Additional directory to search for fonts + #[arg(long)] + font_dir: Option, + }, + + /// Convert an SVG image to a PDF image + #[command(arg_required_else_help = true)] + Svg2pdf { + /// Path to input SVG file + #[arg(short, long)] + input: String, + + /// Path to output PDF file to be created + #[arg(short, long)] + output: String, + + /// Image scale factor + #[arg(long, default_value = "1.0")] + scale: f32, + + /// Additional directory to search for fonts + #[arg(long)] + font_dir: Option, + }, + /// List available themes LsThemes, @@ -475,6 +543,41 @@ async fn main() -> Result<(), anyhow::Error> { let vg_spec = serde_json::from_str(&vg_str)?; println!("{}", vega_to_url(&vg_spec, fullscreen)?) } + Svg2png { + input, + output, + scale, + ppi, + font_dir, + } => { + register_font_dir(font_dir)?; + let svg = read_input_string(&input)?; + let png_data = vl_convert_rs::converter::svg_to_png(&svg, scale, Some(ppi))?; + write_output_binary(&output, &png_data)?; + } + Svg2jpeg { + input, + output, + scale, + quality, + font_dir, + } => { + register_font_dir(font_dir)?; + let svg = read_input_string(&input)?; + let jpeg_data = vl_convert_rs::converter::svg_to_jpeg(&svg, scale, Some(quality))?; + write_output_binary(&output, &jpeg_data)?; + } + Svg2pdf { + input, + output, + scale, + font_dir, + } => { + register_font_dir(font_dir)?; + let svg = read_input_string(&input)?; + let pdf_data = vl_convert_rs::converter::svg_to_pdf(&svg, scale)?; + write_output_binary(&output, &pdf_data)?; + } LsThemes => list_themes().await?, CatTheme { theme } => cat_theme(&theme).await?, }