From 60ddfb5425fe5d5f809da60425205f1501f5a854 Mon Sep 17 00:00:00 2001 From: Sameer Puri Date: Thu, 9 Apr 2026 20:54:41 -0700 Subject: [PATCH] Image decoding in `Turtle` --- Cargo.lock | 128 ++++++++++++++++++++++++++++++-- star/Cargo.toml | 11 +++ star/src/lower/visit.rs | 48 ++++++++++++ star/src/turtle/dpi.rs | 11 +++ star/src/turtle/elements/mod.rs | 10 +++ star/src/turtle/mod.rs | 20 +++++ 6 files changed, 221 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d4bec2e..7efb7c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -106,6 +112,18 @@ 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 = "bytes" version = "1.11.1" @@ -303,6 +321,25 @@ 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", +] + [[package]] name = "fnv" version = "1.0.7" @@ -670,9 +707,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" [[package]] name = "heck" @@ -718,6 +755,21 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + [[package]] name = "implicit-clone" version = "0.6.0" @@ -740,12 +792,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.1" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.0", "serde", "serde_core", ] @@ -850,6 +902,26 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "nom" version = "8.0.0" @@ -967,6 +1039,19 @@ dependencies = [ "thiserror 1.0.69", ] +[[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 = "portable-atomic" version = "1.13.1" @@ -1045,6 +1130,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + [[package]] name = "quote" version = "1.0.45" @@ -1217,6 +1308,12 @@ dependencies = [ "serde", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "siphasher" version = "1.0.2" @@ -1305,7 +1402,9 @@ dependencies = [ name = "svg2star" version = "0.4.0" dependencies = [ + "base64", "euclid", + "image", "log", "lyon_geom", "peg", @@ -1400,9 +1499,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.51.0" +version = "1.51.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" dependencies = [ "pin-project-lite", "tokio-macros", @@ -1895,3 +1994,18 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[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/star/Cargo.toml b/star/Cargo.toml index 58158a9..7d726ac 100644 --- a/star/Cargo.toml +++ b/star/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true [features] serde = ["dep:serde"] +image = ["dep:image", "dep:base64"] [dependencies] peg = "0.8" @@ -26,5 +27,15 @@ workspace = true optional = true features = ["derive", "std"] +[dependencies.image] +version = "0.25" +optional = true +default-features = false +features = ["png", "jpeg"] + +[dependencies.base64] +version = "0.22" +optional = true + [dev-dependencies] serde_json.workspace = true diff --git a/star/src/lower/visit.rs b/star/src/lower/visit.rs index af336a3..1426a15 100644 --- a/star/src/lower/visit.rs +++ b/star/src/lower/visit.rs @@ -464,6 +464,54 @@ impl<'a, T: Turtle> XmlVisitor for ConversionVisitor<'a, T> { } } } + #[cfg(feature = "image")] + "image" => { + use base64::{Engine, engine::general_purpose::STANDARD}; + + use crate::turtle::elements::RasterImage; + + let Some(href) = node + .attribute("href") + .or_else(|| node.attribute(("http://www.w3.org/1999/xlink", "href"))) + else { + warn!("image element has no href: {node:?}"); + return; + }; + let Some(b64) = href + .strip_prefix("data:image/png;base64,") + .or_else(|| href.strip_prefix("data:image/jpeg;base64,")) + else { + warn!("Unsupported image href {href}"); + return; + }; + + let b64_no_whitespace: String = + b64.chars().filter(|c| !c.is_ascii_whitespace()).collect(); + let bytes = match STANDARD.decode(&b64_no_whitespace) { + Ok(bytes) => bytes, + Err(err) => { + warn!("image base64 decode failed: {err}"); + return; + } + }; + let image = match image::load_from_memory(&bytes) { + Ok(img) => img, + Err(e) => { + warn!("image decode failed: {e}"); + return; + } + }; + + let x = self.length_attr_to_user_units(&node, "x").unwrap_or(0.); + let y = self.length_attr_to_user_units(&node, "y").unwrap_or(0.); + let width = self.length_attr_to_user_units(&node, "width").unwrap_or(0.); + let height = self + .length_attr_to_user_units(&node, "height") + .unwrap_or(0.); + + self.comment(&node); + self.terrarium.image(image, x, y, width, height); + } // No-op tags SVG_TAG_NAME | GROUP_TAG_NAME | USE_TAG_NAME | SYMBOL_TAG_NAME => {} _ => { diff --git a/star/src/turtle/dpi.rs b/star/src/turtle/dpi.rs index f242a94..0688f0e 100644 --- a/star/src/turtle/dpi.rs +++ b/star/src/turtle/dpi.rs @@ -96,4 +96,15 @@ impl Turtle for DpiConvertingTurtle { ctrl: self.point_to_mm(ctrl), }) } + + #[cfg(feature = "image")] + fn image(&mut self, img: super::elements::RasterImage) { + self.inner.image(super::elements::RasterImage { + x: self.to_mm(img.x), + y: self.to_mm(img.y), + width: self.to_mm(img.width), + height: self.to_mm(img.height), + image: img.image, + }) + } } diff --git a/star/src/turtle/elements/mod.rs b/star/src/turtle/elements/mod.rs index 9a242cf..396e332 100644 --- a/star/src/turtle/elements/mod.rs +++ b/star/src/turtle/elements/mod.rs @@ -16,6 +16,16 @@ mod arc; /// Reorders strokes to minimize pen-up travel using TSP heuristics mod tsp; +/// Raster image decoded from an inline PNG/JPEG. +/// +/// +#[cfg(feature = "image")] +pub struct RasterImage { + pub position: Point, + pub dimensions: Vector, + pub image: image::DynamicImage, +} + /// Atomic unit of a [Stroke]. #[derive(Debug, Clone)] pub enum DrawCommand { diff --git a/star/src/turtle/mod.rs b/star/src/turtle/mod.rs index 843a900..91fdbf3 100644 --- a/star/src/turtle/mod.rs +++ b/star/src/turtle/mod.rs @@ -30,6 +30,9 @@ pub trait Turtle: Debug { fn arc(&mut self, svg_arc: SvgArc); fn cubic_bezier(&mut self, cbs: CubicBezierSegment); fn quadratic_bezier(&mut self, qbs: QuadraticBezierSegment); + #[cfg(feature = "image")] + /// This is the only function with a default as most turtles have no way to handle a raster image. + fn image(&mut self, _image: self::elements::RasterImage) {} } /// Handles SVG complexities outside of [Turtle] scope (transforms, position, offsets, etc.) @@ -330,6 +333,23 @@ impl Terrarium { self.turtle.arc(svg_arc); } + /// + #[cfg(feature = "image")] + pub fn image(&mut self, image: image::DynamicImage, x: f64, y: f64, width: f64, height: f64) { + // Transform the corners to get the final x, y, width, height. + let t0 = self.current_transform.transform_point(point(x, y)); + let t1 = self + .current_transform + .transform_point(point(x, y) + vector(width, height)); + self.turtle.image(crate::turtle::elements::RasterImage { + // After transformation, the corners may be swapped resulting in a new x y. + // Also need to pick the larger y because of the G-Code coordinate space swap (?). + position: point(t0.x.min(t1.x), t0.y.max(t1.y)), + dimensions: (t1 - t0).abs(), + image, + }); + } + /// Push a generic transform onto the stack /// Could be any valid CSS transform https://drafts.csswg.org/css-transforms-1/#typedef-transform-function ///