mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 22:42:46 +00:00
Add bounded SVG tree rendering facade
This commit is contained in:
4 files changed
+233
-25
No files matched your search
Generated
+37
-2
@@ -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"
|
||||
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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(()) }
|
||||
|
||||
Reference in new issue
Block a user