Add bounded SVG tree rendering facade

This commit is contained in:
Dylan M. Taylor committed 2026-11-13 04:31:22 -05:00
1 parent 59eccec4ff
commit 00d82f5bc7
4 files changed
+233 -25

No files matched your search

Generated
+37 -2
View File
@@ -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"
+7
View File
@@ -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"]
+80 -23
View File
@@ -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<Option<usize
struct Used {
bytes: AtomicUsize,
depth: AtomicUsize,
raster_pixels: AtomicU64,
raster_data: Mutex<HashSet<usize>>,
}
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<craft_image_codecs::Format> {
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<Vec<u8>>, format: craft_image_codecs::Format) -> Option<usvg::ImageKind> {
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),
}
+109
View File
@@ -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<tiny_skia::Pixmap, Refusal> {
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<bool, Refusal> {
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(()) }