diff --git a/Cargo.lock b/Cargo.lock index 09d5fa6a9..9a8abed4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2391,9 +2391,11 @@ dependencies = [ name = "craft-svg" version = "0.1.0" dependencies = [ + "craft-image-codecs", "craft-xml", "flate2", "kurbo 0.13.1", + "resvg 0.48.1", "roxmltree 0.21.2", "thiserror 2.0.21", "usvg 0.48.1", @@ -3381,7 +3383,7 @@ dependencies = [ "itertools 0.15.0", "log", "profiling", - "resvg", + "resvg 0.45.1", ] [[package]] @@ -7567,10 +7569,28 @@ dependencies = [ "pico-args", "rgb", "svgtypes 0.15.3", - "tiny-skia", + "tiny-skia 0.11.4", "usvg 0.45.1", ] +[[package]] +name = "resvg" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e3803f97b999e80cbf7c6ecdd07a8102204d92e1633cf48783720c521196bd" +dependencies = [ + "bytemuck", + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes 0.16.2", + "tiny-skia 0.12.0", + "usvg 0.48.1", + "zune-jpeg", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -9108,6 +9128,21 @@ dependencies = [ "tiny-skia-path 0.11.4", ] +[[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 0.18.1", + "tiny-skia-path 0.12.0", +] + [[package]] name = "tiny-skia-path" version = "0.11.4" diff --git a/crates/svg/Cargo.toml b/crates/svg/Cargo.toml index 49b22e213..3d20a0e5b 100644 --- a/crates/svg/Cargo.toml +++ b/crates/svg/Cargo.toml @@ -12,6 +12,7 @@ workspace = true [dependencies] craft-xml = { workspace = true } +craft-image-codecs = { workspace = true } # SVGZ (gzip), pure-Rust backend. flate2 = { version = "1", default-features = false, features = ["rust_backend"] } kurbo = { workspace = true } @@ -20,6 +21,9 @@ thiserror = { workspace = true } # The full SVG parser (Apache-2.0 OR MIT). The one version every Craft app uses: apps depend on # craft-svg's `tree` feature and take usvg from its re-export, never directly. usvg = { version = "=0.48.1", default-features = false, optional = true } +# Rendering the parsed tree uses the matching renderer version. Keep it behind an opt-in feature +# so callers that only need bounded SVG text/tree parsing do not pull in raster backends. +resvg = { version = "=0.48.1", default-features = false, features = ["text", "raster-images"], optional = true } [features] # `craft_svg::tree`: usvg behind the bounded entry point. @@ -28,3 +32,6 @@ tree = ["dep:usvg"] tree-text = ["tree", "usvg/text"] # The system's fonts in `usvg::fontdb`. tree-system-fonts = ["tree-text", "usvg/system-fonts"] +# Bounded rendering of a parsed tree through the matching resvg/tiny-skia versions. +tree-render = ["tree", "dep:resvg"] +tree-render-system-fonts = ["tree-render", "resvg/system-fonts"] diff --git a/crates/svg/src/tree.rs b/crates/svg/src/tree.rs index dd14be619..742c5c3e1 100644 --- a/crates/svg/src/tree.rs +++ b/crates/svg/src/tree.rs @@ -22,10 +22,10 @@ //! 16 MB or so, and a 1 MB stack takes a depth of about 34. use std::borrow::Cow; -use std::collections::HashMap; use std::collections::hash_map::Entry; -use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; pub use usvg; @@ -58,6 +58,10 @@ pub struct Limits { pub embedded_bytes: usize, /// SVG documents embedded inside one another, for [`image_resolver`]. pub embedded_depth: usize, + /// Decoded RGBA pixels retained across the raster images embedded in one drawing. Dimensions + /// are probed before usvg hands the bytes to a decoder; repeated references to the same data + /// URL share one charge. + pub embedded_raster_pixels: u64, } impl Default for Limits { @@ -72,6 +76,7 @@ impl Default for Limits { xml_nodes: 4_000_000, embedded_bytes: 32 << 20, embedded_depth: 4, + embedded_raster_pixels: 24_000_000, } } } @@ -264,13 +269,56 @@ pub fn height(xml: &roxmltree::Document<'_>, most: usize) -> Result>, } -fn raster(data: &[u8]) -> bool { - data.starts_with(b"\x89PNG") - || data.starts_with(&[0xff, 0xd8, 0xff]) - || data.starts_with(b"GIF8") - || (data.len() > 12 && data.get(8..12) == Some(b"WEBP")) +fn raster_format(data: &[u8]) -> Option { + match craft_image_codecs::detect(data)? { + format @ (craft_image_codecs::Format::Png + | craft_image_codecs::Format::Jpeg + | craft_image_codecs::Format::Gif + | craft_image_codecs::Format::WebP) => Some(format), + _ => None, + } +} + +fn raster_image(used: &Used, limits: &Limits, data: Arc>, format: craft_image_codecs::Format) -> Option { + let (width, height) = craft_image_codecs::probe_allocation_size(&data).ok()?; + if !matches!( + format, + craft_image_codecs::Format::Png | craft_image_codecs::Format::Jpeg | craft_image_codecs::Format::Gif | craft_image_codecs::Format::WebP + ) { + return None; + } + let pixels = u64::from(width).checked_mul(u64::from(height))?; + if pixels == 0 || pixels > limits.embedded_raster_pixels { + return None; + } + let pointer = Arc::as_ptr(&data) as usize; + let mut seen = used.raster_data.lock().unwrap_or_else(PoisonError::into_inner); + if seen.insert(pointer) { + let mut current = used.raster_pixels.load(Ordering::Relaxed); + loop { + let next = current.checked_add(pixels)?; + if next > limits.embedded_raster_pixels { + seen.remove(&pointer); + return None; + } + match used.raster_pixels.compare_exchange_weak(current, next, Ordering::Relaxed, Ordering::Relaxed) { + Ok(_) => break, + Err(actual) => current = actual, + } + } + } + drop(seen); + Some(match format { + craft_image_codecs::Format::Png => usvg::ImageKind::PNG(data), + craft_image_codecs::Format::Jpeg => usvg::ImageKind::JPEG(data), + craft_image_codecs::Format::Gif => usvg::ImageKind::GIF(data), + craft_image_codecs::Format::WebP => usvg::ImageKind::WEBP(data), + _ => return None, + }) } /// An embedded SVG document, if the budgets allow it. @@ -302,22 +350,31 @@ fn embedded(used: &Used, limits: &Limits, data: &[u8], options: &usvg::Options<' /// [`Limits::embedded_depth`] inside one another. An image past a limit is left out. Linked /// files are never read. One resolver is one import's budget: make a new one for each [`parse`]. pub fn image_resolver(limits: Limits) -> usvg::ImageHrefResolver<'static> { - let used = Arc::new(Used { bytes: AtomicUsize::new(0), depth: AtomicUsize::new(0) }); + let used = Arc::new(Used { + bytes: AtomicUsize::new(0), + depth: AtomicUsize::new(0), + raster_pixels: AtomicU64::new(0), + raster_data: Mutex::new(HashSet::new()), + }); usvg::ImageHrefResolver { - resolve_data: Box::new(move |mime, data, options| match mime { - "image/jpg" | "image/jpeg" => Some(usvg::ImageKind::JPEG(data)), - "image/png" => Some(usvg::ImageKind::PNG(data)), - "image/gif" => Some(usvg::ImageKind::GIF(data)), - "image/webp" => Some(usvg::ImageKind::WEBP(data)), - "image/svg+xml" => embedded(&used, &limits, &data, options), - "text/plain" if raster(&data) => match data.first() { - Some(0x89) => Some(usvg::ImageKind::PNG(data)), - Some(0xff) => Some(usvg::ImageKind::JPEG(data)), - Some(b'G') => Some(usvg::ImageKind::GIF(data)), - _ => Some(usvg::ImageKind::WEBP(data)), - }, - "text/plain" => embedded(&used, &limits, &data, options), - _ => None, + resolve_data: Box::new(move |mime, data, options| { + let detected = raster_format(&data); + let format = match mime { + "image/jpg" | "image/jpeg" => (detected == Some(craft_image_codecs::Format::Jpeg)).then_some(craft_image_codecs::Format::Jpeg), + "image/png" => (detected == Some(craft_image_codecs::Format::Png)).then_some(craft_image_codecs::Format::Png), + "image/gif" => (detected == Some(craft_image_codecs::Format::Gif)).then_some(craft_image_codecs::Format::Gif), + "image/webp" => (detected == Some(craft_image_codecs::Format::WebP)).then_some(craft_image_codecs::Format::WebP), + "text/plain" => detected, + _ => None, + }; + if let Some(format) = format { + return raster_image(&used, &limits, data, format); + } + match mime { + "image/svg+xml" => embedded(&used, &limits, &data, options), + "text/plain" => embedded(&used, &limits, &data, options), + _ => None, + } }), resolve_string: Box::new(|_, _| None), } diff --git a/crates/svg/src/tree/draw.rs b/crates/svg/src/tree/draw.rs index fed7ac569..d472fdc7d 100644 --- a/crates/svg/src/tree/draw.rs +++ b/crates/svg/src/tree/draw.rs @@ -24,6 +24,9 @@ use std::collections::HashMap; use usvg::Transform; +#[cfg(feature = "tree-render")] +pub use resvg::tiny_skia; + /// Filter primitives per filter. A result of every primitive is kept; real filters have 1 to 12 /// (Inkscape's drop shadow has 5), and 64 leaves headroom for generated ones. const MAX_PRIMITIVES: usize = 64; @@ -157,6 +160,112 @@ pub fn check(nodes: &[usvg::Node], ts: Transform, width: u32, height: u32, held: walk.nodes(nodes, ts, cx).map(|_| ()) } +/// Draws a complete tree onto a transparent pixmap after checking the peak RGBA allocation. +/// `held` is caller-owned RGBA storage that remains live during the draw; the target itself is +/// charged by this function. +#[cfg(feature = "tree-render")] +pub fn render(tree: &usvg::Tree, ts: Transform, width: u32, height: u32, held: u64, budget: u64) -> Result { + check_filter_cost(tree, ts, 2_000_000_000, 10_000_000, 200)?; + check(tree.root().children(), ts, width, height, held, budget)?; + let mut pixmap = tiny_skia::Pixmap::new(width, height).ok_or_else(|| Refusal { + complex: false, + memory: true, + message: format!("the {width}×{height} px target pixmap could not be allocated"), + })?; + resvg::render(tree, ts, &mut pixmap.as_mut()); + Ok(pixmap) +} + +/// Draws one node onto `target` after checking the peak RGBA allocation. The node remains in its +/// parent's coordinate space, matching `resvg::render_node`. +#[cfg(feature = "tree-render")] +pub fn render_node(node: &usvg::Node, ts: Transform, target: &mut tiny_skia::Pixmap, held: u64, budget: u64) -> Result { + check(std::slice::from_ref(node), ts, target.width(), target.height(), held, budget)?; + Ok(resvg::render_node(node, ts, &mut target.as_mut()).is_some()) +} + +/// Refuse a tree whose filters would exceed `max_work` pixel reads, or whose referenced roots +/// exceed `max_visits` and `max_depth`. +pub fn check_filter_cost(tree: &usvg::Tree, ts: Transform, max_work: u64, max_visits: usize, max_depth: usize) -> Result<(), Refusal> { + let mut visits = max_visits; + let cost = filter_cost(tree.root(), ts, 0, &mut visits, max_depth); + if cost > max_work { + Err(Refusal { + complex: visits == 0, + memory: false, + message: "the drawing's filters are too costly to render (a large morphology radius, convolution kernel or turbulence octave count, or patterns nested too deep)".into(), + }) + } else { + Ok(()) + } +} + +fn filter_cost(root: &usvg::Group, base: Transform, rec: usize, visits: &mut usize, max_depth: usize) -> u64 { + use usvg::filter::Kind; + let mut cost = 0u64; + let mut stack: Vec<&usvg::Group> = vec![root]; + while let Some(group) = stack.pop() { + let ts = base.pre_concat(group.abs_transform()); + let (sx, sy) = ts.get_scale(); + for filter in group.filters() { + let Some(rect) = filter.rect().transform(ts) else { continue }; + let (width, height) = (rect.width().ceil() as u64, rect.height().ceil() as u64); + let window = |radius: f32, scale: f32, side: u64| ((radius * scale).ceil() as u64).saturating_mul(2).min(side); + for primitive in filter.primitives() { + let reads = match primitive.kind() { + Kind::ConvolveMatrix(conv) => u64::from(conv.matrix().columns()) * u64::from(conv.matrix().rows()), + Kind::Morphology(morphology) => { + window(morphology.radius_x().get(), sx, width).saturating_mul(window(morphology.radius_y().get(), sy, height)) + } + Kind::Turbulence(turbulence) => u64::from(turbulence.num_octaves()).saturating_mul(16), + _ => 0, + }; + cost = cost.saturating_add(width.saturating_mul(height).saturating_mul(reads.saturating_sub(256))); + } + } + for node in group.children() { + *visits = visits.saturating_sub(1); + if *visits == 0 { + return u64::MAX; + } + if let usvg::Node::Group(child) = node { + stack.push(child); + } + if rec >= max_depth { + let mut skipped = false; + match node { + usvg::Node::Path(path) => { + skipped = [path.fill().map(|fill| fill.paint()), path.stroke().map(|stroke| stroke.paint())] + .into_iter() + .flatten() + .any(|paint| matches!(paint, usvg::Paint::Pattern(_))); + } + _ => node.subroots(|_| skipped = true), + } + if skipped { + return u64::MAX; + } + continue; + } + let world = base.pre_concat(node.abs_transform()); + let mut sub = + |root: &usvg::Group, transform: Transform| cost = cost.saturating_add(filter_cost(root, transform, rec + 1, visits, max_depth)); + match node { + usvg::Node::Path(path) => { + for paint in [path.fill().map(|fill| fill.paint()), path.stroke().map(|stroke| stroke.paint())].into_iter().flatten() { + if let usvg::Paint::Pattern(pattern) = paint { + let (sx, sy) = world.pre_concat(pattern.transform()).get_scale(); + sub(pattern.root(), Transform::from_scale(sx, sy)); + } + } + } + _ => node.subroots(|root| sub(root, world)), + } + } + } + cost +} + impl Walk { fn need(&self, live: f64, what: impl FnOnce() -> String) -> Result<(), Refusal> { if live > self.budget { Err(memory(&what(), live, self.budget)) } else { Ok(()) }