From b3bb622b746c41f2fea159f1a812cd31b22142bc Mon Sep 17 00:00:00 2001 From: James Ding Date: Mon, 25 May 2026 16:30:56 -0700 Subject: [PATCH 1/4] feat(cli): add Format enum and converter-style format resolution --- crates/sdocx-cli/src/main.rs | 72 +++++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/crates/sdocx-cli/src/main.rs b/crates/sdocx-cli/src/main.rs index a49f18f..45a191d 100644 --- a/crates/sdocx-cli/src/main.rs +++ b/crates/sdocx-cli/src/main.rs @@ -1,5 +1,5 @@ use base64::Engine as _; -use clap::Parser; +use clap::{Parser, ValueEnum}; use sdocx::{ Color, Document, MediaAsset, Page, PageElement, PageTemplate, PageTemplateSource, RichTextBox, RichTextRun, Stroke, @@ -8,6 +8,44 @@ use std::fmt::Write as FmtWrite; use std::fs; use std::path::PathBuf; +#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)] +enum Format { + Svg, + Png, +} + +/// Resolve the output format: explicit flag wins, else infer from the output +/// file extension, else default to SVG. +// Used by `main` once format selection is wired in (Task 3); also exercised by tests. +#[allow(dead_code)] +fn resolve_format( + flag: Option, + output: Option<&std::path::Path>, +) -> Result { + if let Some(f) = flag { + return Ok(f); + } + match output.and_then(|p| p.extension()).and_then(|e| e.to_str()) { + Some("svg") => Ok(Format::Svg), + Some("png") => Ok(Format::Png), + Some(other) => Err(format!( + "unknown output extension '.{other}'; use -f/--format to set svg or png" + )), + None => Ok(Format::Svg), + } +} + +impl Format { + // Used by `main` once multi-page output paths are wired in (Task 3). + #[allow(dead_code)] + fn ext(self) -> &'static str { + match self { + Format::Svg => "svg", + Format::Png => "png", + } + } +} + // Default ink for uncolored strokes, by canvas: light on dark, dark on light. const DEFAULT_INK_DARK_MODE: &str = "#ffffff"; const DEFAULT_INK_LIGHT_MODE: &str = "#1a1a1a"; @@ -382,8 +420,9 @@ fn format_template(template: PageTemplate) -> String { #[cfg(test)] mod tests { - use super::{normalized_stroke_width, render_page_svg}; + use super::{Format, normalized_stroke_width, render_page_svg, resolve_format}; use sdocx::{BoundingBox, Color, Page, Point, Stroke}; + use std::path::Path; #[test] fn normalizes_invalid_stroke_widths() { @@ -481,6 +520,35 @@ mod tests { "dark-mode fallback bg" ); } + + #[test] + fn format_flag_wins_over_extension() { + let f = resolve_format(Some(Format::Svg), Some(Path::new("out.png"))).unwrap(); + assert_eq!(f, Format::Svg); + } + + #[test] + fn format_inferred_from_png_extension() { + let f = resolve_format(None, Some(Path::new("out.png"))).unwrap(); + assert_eq!(f, Format::Png); + } + + #[test] + fn format_inferred_from_svg_extension() { + let f = resolve_format(None, Some(Path::new("out.svg"))).unwrap(); + assert_eq!(f, Format::Svg); + } + + #[test] + fn format_defaults_to_svg_when_no_output_and_no_flag() { + let f = resolve_format(None, None).unwrap(); + assert_eq!(f, Format::Svg); + } + + #[test] + fn unknown_extension_without_flag_is_error() { + assert!(resolve_format(None, Some(Path::new("out.gif"))).is_err()); + } } fn main() { From d6e025338ca87fb6dde6f559e0d9c93ee6353cde Mon Sep 17 00:00:00 2001 From: James Ding Date: Mon, 25 May 2026 16:44:07 -0700 Subject: [PATCH 2/4] feat(cli): rasterize SVG to PNG via resvg --- Cargo.lock | 463 +++++++++++++++++++++++++++++++++++ crates/sdocx-cli/Cargo.toml | 1 + crates/sdocx-cli/src/main.rs | 32 ++- 3 files changed, 495 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 6fe4d74..2228a94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,18 +58,54 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "cfg-if" version = "1.0.4" @@ -116,12 +152,27 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -131,22 +182,86 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "flate2" version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ + "crc32fast", "miniz_oxide", "zlib-rs", ] +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree 0.20.0", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -159,6 +274,22 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" + [[package]] name = "indexmap" version = "2.13.0" @@ -185,6 +316,30 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "log" version = "0.4.29" @@ -197,6 +352,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -207,6 +371,15 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -219,6 +392,34 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -228,6 +429,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quote" version = "1.0.45" @@ -237,12 +444,71 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "resvg" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9be183ad6a216aa96f33e4c8033b0988b8b3ea6fd2359d19af5bac4643fd8e81" +dependencies = [ + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", + "zune-jpeg", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + [[package]] name = "sdocx" version = "0.4.0" @@ -258,6 +524,7 @@ version = "0.4.0" dependencies = [ "base64", "clap", + "resvg", "sdocx", ] @@ -317,12 +584,61 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo", + "siphasher", +] + [[package]] name = "syn" version = "2.0.117" @@ -354,24 +670,144 @@ dependencies = [ "syn", ] +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + [[package]] name = "typed-path" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + +[[package]] +name = "usvg" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree 0.21.1", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "ttf-parser", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasm-bindgen" version = "0.2.114" @@ -417,6 +853,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "windows-link" version = "0.2.1" @@ -432,6 +874,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + [[package]] name = "zip" version = "8.2.0" @@ -463,3 +911,18 @@ dependencies = [ "log", "simd-adler32", ] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/crates/sdocx-cli/Cargo.toml b/crates/sdocx-cli/Cargo.toml index 5212bbc..a6f9d20 100644 --- a/crates/sdocx-cli/Cargo.toml +++ b/crates/sdocx-cli/Cargo.toml @@ -11,3 +11,4 @@ repository = "https://github.com/twangodev/sdocx" sdocx = { version = "0.4.0", path = "../sdocx" } clap = { version = "4.5.60", features = ["derive"] } base64 = "0.22" +resvg = "0.47.0" diff --git a/crates/sdocx-cli/src/main.rs b/crates/sdocx-cli/src/main.rs index 45a191d..8a7cfd0 100644 --- a/crates/sdocx-cli/src/main.rs +++ b/crates/sdocx-cli/src/main.rs @@ -46,6 +46,24 @@ impl Format { } } +// Used by `main` once PNG output is wired in (Task 3); exercised by tests now. +#[allow(dead_code)] +fn svg_to_png(svg: &str) -> Result, String> { + let mut opt = resvg::usvg::Options::default(); + // Load system fonts so elements render instead of being silently dropped. + opt.fontdb_mut().load_system_fonts(); + let tree = resvg::usvg::Tree::from_str(svg, &opt).map_err(|e| format!("invalid SVG: {e}"))?; + let size = tree.size().to_int_size(); + let (w, h) = (size.width(), size.height()); + let mut pixmap = resvg::tiny_skia::Pixmap::new(w, h) + .ok_or_else(|| "failed to allocate pixmap".to_string())?; + let mut pm = pixmap.as_mut(); + resvg::render(&tree, resvg::tiny_skia::Transform::identity(), &mut pm); + pixmap + .encode_png() + .map_err(|e| format!("PNG encode failed: {e}")) +} + // Default ink for uncolored strokes, by canvas: light on dark, dark on light. const DEFAULT_INK_DARK_MODE: &str = "#ffffff"; const DEFAULT_INK_LIGHT_MODE: &str = "#1a1a1a"; @@ -420,7 +438,7 @@ fn format_template(template: PageTemplate) -> String { #[cfg(test)] mod tests { - use super::{Format, normalized_stroke_width, render_page_svg, resolve_format}; + use super::{Format, normalized_stroke_width, render_page_svg, resolve_format, svg_to_png}; use sdocx::{BoundingBox, Color, Page, Point, Stroke}; use std::path::Path; @@ -549,6 +567,18 @@ mod tests { fn unknown_extension_without_flag_is_error() { assert!(resolve_format(None, Some(Path::new("out.gif"))).is_err()); } + + #[test] + fn svg_to_png_produces_valid_png_with_expected_size() { + let svg = r##""##; + let png = svg_to_png(svg).expect("render should succeed"); + // Full 8-byte PNG signature. + assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n"); + // IHDR width/height are big-endian u32 at byte offsets 16 and 20. + let w = u32::from_be_bytes([png[16], png[17], png[18], png[19]]); + let h = u32::from_be_bytes([png[20], png[21], png[22], png[23]]); + assert_eq!((w, h), (20, 10)); + } } fn main() { From ba61a6b8073cfe546d4f486dbf0d72035550f67d Mon Sep 17 00:00:00 2001 From: James Ding Date: Mon, 25 May 2026 16:56:30 -0700 Subject: [PATCH 3/4] feat(cli): select output format from extension or --format flag Wires resolve_format, Format::ext, and svg_to_png into main() via a new write_page helper; adds --format/-f flag to Cli; removes all transient #[allow(dead_code)] attrs; adds renders_sample_to_valid_png end-to-end test. --- crates/sdocx-cli/src/main.rs | 82 +++++++++++++++++++++++++++++------- 1 file changed, 66 insertions(+), 16 deletions(-) diff --git a/crates/sdocx-cli/src/main.rs b/crates/sdocx-cli/src/main.rs index 8a7cfd0..98bb9fc 100644 --- a/crates/sdocx-cli/src/main.rs +++ b/crates/sdocx-cli/src/main.rs @@ -16,8 +16,6 @@ enum Format { /// Resolve the output format: explicit flag wins, else infer from the output /// file extension, else default to SVG. -// Used by `main` once format selection is wired in (Task 3); also exercised by tests. -#[allow(dead_code)] fn resolve_format( flag: Option, output: Option<&std::path::Path>, @@ -36,8 +34,6 @@ fn resolve_format( } impl Format { - // Used by `main` once multi-page output paths are wired in (Task 3). - #[allow(dead_code)] fn ext(self) -> &'static str { match self { Format::Svg => "svg", @@ -46,8 +42,6 @@ impl Format { } } -// Used by `main` once PNG output is wired in (Task 3); exercised by tests now. -#[allow(dead_code)] fn svg_to_png(svg: &str) -> Result, String> { let mut opt = resvg::usvg::Options::default(); // Load system fonts so elements render instead of being silently dropped. @@ -79,9 +73,13 @@ struct Cli { /// Path to an .sdocx file path: PathBuf, - /// Output SVG file (defaults to input path with .svg extension) + /// Output file path (format inferred from extension; defaults to the input path with a format-appropriate extension) #[arg(short, long)] output: Option, + + /// Output format (overrides extension inference): svg or png + #[arg(short, long, value_enum)] + format: Option, } fn color_hex(c: &Color) -> String { @@ -579,6 +577,48 @@ mod tests { let h = u32::from_be_bytes([png[20], png[21], png[22], png[23]]); assert_eq!((w, h), (20, 10)); } + + #[test] + fn renders_sample_to_valid_png() { + let doc = sdocx::parse("../../samples/handwritten.sdocx").expect("parse sample"); + assert!(!doc.pages.is_empty(), "sample has no pages"); + let svg = render_page_svg( + &doc.pages[0], + doc.metadata.background_color.as_ref(), + &doc.metadata.media_assets, + doc.metadata.dark_mode_compatibility.unwrap_or(false), + ); + let png = svg_to_png(&svg).expect("render sample to png"); + assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n"); + assert!( + png.len() > 100, + "PNG should be non-trivial, got {} bytes", + png.len() + ); + } +} + +fn write_page(path: &std::path::Path, svg: &str, format: Format) { + match format { + Format::Svg => { + if let Err(e) = fs::write(path, svg) { + eprintln!("Error: failed to write {}: {e}", path.display()); + std::process::exit(1); + } + eprintln!("Wrote {} ({} bytes)", path.display(), svg.len()); + } + Format::Png => { + let png = svg_to_png(svg).unwrap_or_else(|e| { + eprintln!("Error: {e}"); + std::process::exit(1); + }); + if let Err(e) = fs::write(path, &png) { + eprintln!("Error: failed to write {}: {e}", path.display()); + std::process::exit(1); + } + eprintln!("Wrote {} ({} bytes)", path.display(), png.len()); + } + } } fn main() { @@ -594,17 +634,28 @@ fn main() { print_info(&doc); - let output_base = cli.output.unwrap_or_else(|| cli.path.with_extension("svg")); + let format = match resolve_format(cli.format, cli.output.as_deref()) { + Ok(f) => f, + Err(e) => { + eprintln!("Error: {e}"); + std::process::exit(1); + } + }; + + let output_base = cli + .output + .unwrap_or_else(|| cli.path.with_extension(format.ext())); + + let dark_mode = doc.metadata.dark_mode_compatibility.unwrap_or(false); if doc.pages.len() == 1 { let svg = render_page_svg( &doc.pages[0], doc.metadata.background_color.as_ref(), &doc.metadata.media_assets, - doc.metadata.dark_mode_compatibility.unwrap_or(false), + dark_mode, ); - fs::write(&output_base, &svg).expect("failed to write SVG"); - eprintln!("Wrote {} ({} bytes)", output_base.display(), svg.len()); + write_page(&output_base, &svg, format); } else { for (i, page) in doc.pages.iter().enumerate() { let stem = output_base @@ -613,17 +664,16 @@ fn main() { .to_string_lossy(); let ext = output_base .extension() - .unwrap_or_default() - .to_string_lossy(); + .and_then(|e| e.to_str()) + .unwrap_or(format.ext()); let path = output_base.with_file_name(format!("{stem}_page{i}.{ext}")); let svg = render_page_svg( page, doc.metadata.background_color.as_ref(), &doc.metadata.media_assets, - doc.metadata.dark_mode_compatibility.unwrap_or(false), + dark_mode, ); - fs::write(&path, &svg).expect("failed to write SVG"); - eprintln!("Wrote {} ({} bytes)", path.display(), svg.len()); + write_page(&path, &svg, format); } } } From 929341e173cb37e67630e765dfd99eacd2d7fea3 Mon Sep 17 00:00:00 2001 From: James Ding Date: Mon, 25 May 2026 18:53:25 -0700 Subject: [PATCH 4/4] fix(cli): infer output format case-insensitively from extension --- crates/sdocx-cli/src/main.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/sdocx-cli/src/main.rs b/crates/sdocx-cli/src/main.rs index 98bb9fc..54352c4 100644 --- a/crates/sdocx-cli/src/main.rs +++ b/crates/sdocx-cli/src/main.rs @@ -23,7 +23,12 @@ fn resolve_format( if let Some(f) = flag { return Ok(f); } - match output.and_then(|p| p.extension()).and_then(|e| e.to_str()) { + match output + .and_then(|p| p.extension()) + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()) + .as_deref() + { Some("svg") => Ok(Format::Svg), Some("png") => Ok(Format::Png), Some(other) => Err(format!( @@ -566,6 +571,18 @@ mod tests { assert!(resolve_format(None, Some(Path::new("out.gif"))).is_err()); } + #[test] + fn extension_inference_is_case_insensitive() { + assert_eq!( + resolve_format(None, Some(Path::new("out.PNG"))).unwrap(), + Format::Png + ); + assert_eq!( + resolve_format(None, Some(Path::new("out.Svg"))).unwrap(), + Format::Svg + ); + } + #[test] fn svg_to_png_produces_valid_png_with_expected_size() { let svg = r##""##;