From bb1b7e4f6675898709ba9235e70aed4b48275f91 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 08:47:35 -0800 Subject: [PATCH 1/2] SVG import: image data URLs are decoded once each, within 512 MB per import usvg decodes an image data URL every time it converts the element that holds it, a `` copy, a filter or a nested SVG image included, before the image resolver is called, and nothing bounded the bytes decoded per import. Before usvg reads the imported SVG and each SVG shown as an image, every `href` and `xlink:href` of an `` or `` that is a data URL is replaced with a key. The resolver decodes each URL once, the first time its key comes, and every later use gets the same image. All data URLs of one import decode to at most 512 MB together (MAX_DATA_URL_BYTES): decoding stops where a URL would pass it, and that image is left out with a warning. A link spelled like a key gets a key too and is still read as that link. --- Cargo.lock | 1 + crates/svg/Cargo.toml | 2 + crates/svg/src/import.rs | 2 +- crates/svg/src/import/files.rs | 411 +++++++++++++++++++++++--- crates/svg/src/import/prepass.rs | 10 +- crates/svg/src/lib.rs | 5 +- crates/svg/tests/it/import_details.rs | 15 + docs/target-app-parity.md | 1 + 8 files changed, 410 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f3e85a08a7..96ece9d808 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6812,6 +6812,7 @@ version = "0.24.1" dependencies = [ "craft-docpath", "craft-svg", + "data-url", "flate2", "image", "kurbo 0.13.1", diff --git a/crates/svg/Cargo.toml b/crates/svg/Cargo.toml index 36744c8de5..e7d869e6f6 100644 --- a/crates/svg/Cargo.toml +++ b/crates/svg/Cargo.toml @@ -28,6 +28,8 @@ serde_json = { workspace = true } # SVGZ (gzip, pure-Rust backend: builds for wasm). flate2 = { workspace = true } image = { workspace = true } +# Image data URLs decoded as usvg decodes them, within a byte limit (MIT OR Apache-2.0; already used by usvg). +data-url = "0.3.2" # Colour / transform / length parsing for the fallback (MIT OR Apache-2.0; already used by usvg). svgtypes = "0.16" simplecss = "0.2.2" diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 087512bf90..1aa571a6ed 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -20,7 +20,7 @@ use text::TextSlots; pub use depth::MAX_ELEMENT_DEPTH; pub(crate) use depth::xml_too_deep; -pub use files::{MAX_NESTED_SVG_IMAGES, MAX_NESTED_SVG_READS, MAX_NESTED_SVG_TEXT}; +pub use files::{MAX_DATA_URL_BYTES, MAX_NESTED_SVG_IMAGES, MAX_NESTED_SVG_READS, MAX_NESTED_SVG_TEXT}; pub(crate) use fx::BLEND; /// Most objects an imported SVG may expand to: a guard against `` and nested-image diff --git a/crates/svg/src/import/files.rs b/crates/svg/src/import/files.rs index d2b5504f9d..fe3bf05e8f 100644 --- a/crates/svg/src/import/files.rs +++ b/crates/svg/src/import/files.rs @@ -11,6 +11,12 @@ //! A raster image, linked or a data URL, whose orientation (EXIF, or a TIFF's Orientation tag) //! turns or flips its pixels is turned before usvg gets it, so it is sized, fitted and stored as //! it is shown. +//! +//! usvg decodes a data URL each time it converts the element holding it, a `` copy +//! included, before the resolver sees it. So before usvg reads an SVG (the imported one, and +//! each SVG image), each `` and `` href that is a data URL is changed to a key, +//! and the resolver decodes the URL once, the first time a key comes, within what the data URLs +//! may decode to together ([`MAX_DATA_URL_BYTES`]); every later use of it gets the same image. use std::collections::HashMap; use std::path::Path; @@ -34,6 +40,13 @@ pub const MAX_NESTED_SVG_READS: usize = 1024; /// Most bytes of nested SVG image text an imported SVG may read, decoded or not, all reads together. pub const MAX_NESTED_SVG_TEXT: u64 = 512 << 20; +/// Most bytes the image data URLs an imported SVG shows may decode to, those in its SVG images +/// included, all together; each URL is decoded once, however often it is shown. +pub const MAX_DATA_URL_BYTES: u64 = 512 << 20; + +/// What a data URL's key starts with, the URL's number following it. +const DATA_URL_KEY: &str = "vectorcraft-data-url-"; + /// The files an SVG's images link to. #[derive(Default)] pub(in crate::import) struct Files { @@ -74,6 +87,38 @@ pub(in crate::import) struct Files { /// The CMYK TIFF stored in place of a turned CMYK image, by the address of the PNG usvg got /// (kept here too, so the address stays its own). cmyk: Mutex>, + /// The data URLs changed to keys before usvg read them. + data: Mutex, + /// The bytes the image data URLs decoded to so far, whether or not they are images. + data_bytes: AtomicU64, + /// Images were left out for their data URLs decoding past [`MAX_DATA_URL_BYTES`]. + too_much_data: AtomicBool, +} + +/// The hrefs changed to keys, by number: an href's key is [`DATA_URL_KEY`] and its number. +#[derive(Default)] +struct DataUrls { + /// The number of each href. + numbers: HashMap, usize>, + /// Each href: a data URL, then what it decoded to, or a link. + urls: Vec, +} + +enum DataUrl { + Encoded(Arc), + Decoded(Decoded), + /// A link that starts as a key does, looked up as it is. + Link(Arc), +} + +/// What a data URL decoded to, as usvg gets it. +#[derive(Clone)] +enum Decoded { + Raster(RasterKind, Bytes), + /// SVG text (unpacked when it was SVGZ), read where it is shown ([`Files::svg`]). + Svg(Arc), + /// Not an image VectorCraft reads, or left out. + Nothing, } /// Encoded image bytes, shared. @@ -245,15 +290,46 @@ fn key(bytes: &Arc>) -> usize { impl Files { /// Read the files the `` elements of `xml` (the text `svg`) link to. Placeholders /// stretch to the image's box (`preserveAspectRatio="none"`), which the file fills once - /// relinked. + /// relinked. The data URLs of its images are changed to keys ([`Files::take_data_urls`]). pub(in crate::import) fn read(svg: &str, xml: &roxmltree::Document, opts: &ImportOptions, edits: &mut Edits, warnings: &mut Vec) -> Self { let mut files = Files::default(); let mut reading = Reading { opts, warnings, placeholder: None, by_file: HashMap::new(), active: vec![], placed: false, svg_reads: 0, svg_text: 0 }; files.sources = files.links_of(svg, xml, opts.folder, None, edits, &mut reading); + files.take_data_urls(xml, edits); files } + /// Change each href of the `` and `` elements of `xml` that usvg would decode + /// as a data URL to that URL's key, numbering the URLs not seen before: usvg gives a key to + /// the resolver as it is ([`Files::data_url`]). An href that starts as a key does, a link, + /// gets a key too, which the resolver reads as that link. + fn take_data_urls(&self, xml: &roxmltree::Document, edits: &mut Edits) { + let Ok(mut data) = self.data.lock() else { return }; + let hrefs = xml + .descendants() + .filter(|n| n.is_element() && matches!(n.tag_name().name(), "image" | "feImage")) + .flat_map(|n| n.attributes()) + .filter(|a| a.name() == "href" && matches!(a.namespace(), None | Some("http://www.w3.org/1999/xlink"))); + for a in hrefs { + let url = data_url::DataUrl::process(a.value()).is_ok(); + if !url && !a.value().starts_with(DATA_URL_KEY) { + continue; + } + let n = match data.numbers.get(a.value()) { + Some(n) => *n, + None => { + let href: Arc = Arc::from(a.value()); + let n = data.urls.len(); + data.numbers.insert(href.clone(), n); + data.urls.push(if url { DataUrl::Encoded(href) } else { DataUrl::Link(href) }); + n + } + }; + edits.set_value(a, &format!("{DATA_URL_KEY}{n}")); + } + } + /// What usvg gets for each href the `` elements of `xml` (the text `svg`) link to, /// relative paths taken from `folder`. `inside` is how deep the SVG images `xml` shows nest in /// it and the SVGs around it, measured when an SVG file is read (`None` for `xml` itself). @@ -476,6 +552,9 @@ impl Files { MAX_NESTED_SVG_TEXT >> 20 )); } + if self.too_much_data.load(Ordering::Relaxed) { + notes.push(format!("images were left out: together their data URLs decode to more than {} MB", MAX_DATA_URL_BYTES >> 20)); + } notes } @@ -485,47 +564,138 @@ impl Files { } } - /// usvg's image resolver, for an SVG whose elements nest `height` deep: data URLs as usvg - /// reads them, other hrefs only from the files read. SVG images are read here - /// ([`Files::svg_data`], [`Files::svg`]), never by usvg's own resolver, which caps neither how - /// deep they nest nor what they unpack to. + /// usvg's image resolver, for an SVG whose elements nest `height` deep: data URLs by their + /// keys ([`Files::data_url`]), and those usvg decoded itself as it reads them, within + /// [`MAX_DATA_URL_BYTES`]; other hrefs only from the files read. SVG images are read here + /// ([`Files::svg`]), never by usvg's own resolver, which caps neither how deep they nest nor + /// what they unpack to. pub(in crate::import) fn resolver(&self, height: usize) -> ImageHrefResolver<'_> { self.depth.store(height, Ordering::Relaxed); ImageHrefResolver { - // By type as usvg reads them, and TIFF; one without a type (`text/plain`) by its - // bytes: one of those rasters, else SVG. A raster as [`shown`] gives it. resolve_data: Box::new(move |mime, bytes, opts| { - use image::ImageFormat as F; - let format = match mime { - "image/jpg" | "image/jpeg" => F::Jpeg, - "image/png" => F::Png, - "image/gif" => F::Gif, - "image/webp" => F::WebP, - "image/tiff" => F::Tiff, - "image/svg+xml" => return self.svg_data(&bytes, opts), - "text/plain" => match image::guess_format(&bytes).ok().filter(|f| raster(*f).is_some() || *f == F::Tiff) { - Some(format) => format, - None => return self.svg_data(&bytes, opts), - }, - _ => return None, - }; - let (kind, bytes) = self.keep(shown(bytes, format)?); - Some(kind(bytes)) + let spent = self.data_bytes.load(Ordering::Relaxed).saturating_add(bytes.len() as u64); + if spent > MAX_DATA_URL_BYTES { + return leave_out(&self.too_much_data, true); + } + self.data_bytes.store(spent, Ordering::Relaxed); + self.show(self.decoded(mime, bytes), opts) }), - resolve_string: Box::new(move |h, opts| match self.find(h)? { - Source::Raster(kind, bytes) => Some(kind(bytes)), - Source::Svg(file) => self.svg(&file.text.clone(), Some(file), opts), + resolve_string: Box::new(move |h, opts| { + let link = match self.data_number(h).map(|n| self.data_url(n, opts)) { + Some(Ok(image)) => return image, + Some(Err(link)) => link, + None => Arc::from(h), + }; + match self.find(&link)? { + Source::Raster(kind, bytes) => Some(kind(bytes)), + Source::Svg(file) => self.svg(&file.text.clone(), Some(file), opts), + } }), } } - /// The SVG image of data `bytes` (SVG or SVGZ), as [`Files::svg`] reads it. Unpacked here, - /// within what the SVGZ images have left, so usvg unpacks nothing itself; past it, `None`, - /// noted. - fn svg_data(&self, bytes: &[u8], opts: &usvg::Options) -> Option { + /// The number of the href whose key is `h`. + fn data_number(&self, h: &str) -> Option { + let n: usize = h.strip_prefix(DATA_URL_KEY)?.parse().ok()?; + let known = n < self.data.lock().ok()?.urls.len(); + (known && h.len() == DATA_URL_KEY.len() + n.to_string().len()).then_some(n) + } + + /// The image of data URL `n`: decoded the first time ([`Files::decode`]), then kept; the link + /// when href `n` is one. + fn data_url(&self, n: usize, opts: &usvg::Options) -> Result, Arc> { + // The lock is let go before the image is shown: an SVG image takes it again. + let state = match self.data.lock().ok().as_deref().and_then(|d| d.urls.get(n)) { + Some(DataUrl::Decoded(decoded)) => Ok(decoded.clone()), + Some(DataUrl::Encoded(url)) => Err(url.clone()), + Some(DataUrl::Link(link)) => return Err(link.clone()), + None => return Ok(None), + }; + let url = match state { + Ok(decoded) => return Ok(self.show(decoded, opts)), + Err(url) => url, + }; + let decoded = self.decode(&url); + if let Ok(mut data) = self.data.lock() + && let Some(slot) = data.urls.get_mut(n) + { + *slot = DataUrl::Decoded(decoded.clone()); + } + Ok(self.show(decoded, opts)) + } + + /// What data URL `url` decodes to, as usvg decodes it, in what is left of + /// [`MAX_DATA_URL_BYTES`]: decoding stops where it would go past, the image left out, noted. + fn decode(&self, url: &str) -> Decoded { + let Ok(parsed) = data_url::DataUrl::process(url) else { return Decoded::Nothing }; + let spent = self.data_bytes.load(Ordering::Relaxed); + let room = MAX_DATA_URL_BYTES.saturating_sub(spent); + let mut body = Vec::new(); + let decoded = parsed.decode(|bytes| { + if (body.len() as u64).saturating_add(bytes.len() as u64) > room { + return Err(()); + } + body.extend_from_slice(bytes); + Ok(()) + }); + self.data_bytes.store(spent.saturating_add(body.len() as u64), Ordering::Relaxed); + match decoded { + Ok(_) => {} + Err(data_url::forgiving_base64::DecodeError::WriteError(())) => { + self.too_much_data.store(true, Ordering::Relaxed); + return Decoded::Nothing; + } + Err(_) => return Decoded::Nothing, + } + let mime = format!("{}/{}", parsed.mime_type().type_, parsed.mime_type().subtype); + self.decoded(&mime, Arc::new(body)) + } + + /// What usvg gets for data `bytes` of type `mime`: by type as usvg reads them, and TIFF; one + /// without a type (`text/plain`) by its bytes: one of those rasters, else SVG. A raster as + /// [`shown`] gives it; SVG or SVGZ as [`Files::svg_text`] gives it. + fn decoded(&self, mime: &str, bytes: Bytes) -> Decoded { + use image::ImageFormat as F; + let format = match mime { + "image/jpg" | "image/jpeg" => F::Jpeg, + "image/png" => F::Png, + "image/gif" => F::Gif, + "image/webp" => F::WebP, + "image/tiff" => F::Tiff, + "image/svg+xml" => return self.svg_text(&bytes), + "text/plain" => match image::guess_format(&bytes).ok().filter(|f| raster(*f).is_some() || *f == F::Tiff) { + Some(format) => format, + None => return self.svg_text(&bytes), + }, + _ => return Decoded::Nothing, + }; + match shown(bytes, format) { + Some(shown) => { + let (kind, bytes) = self.keep(shown); + Decoded::Raster(kind, bytes) + } + None => Decoded::Nothing, + } + } + + /// The text of SVG image data `bytes` (SVG or SVGZ). Unpacked here, within what the SVGZ + /// images have left, so usvg unpacks nothing itself; past it, nothing, noted. + fn svg_text(&self, bytes: &[u8]) -> Decoded { match crate::text_within(bytes, &self.unpacked) { - Ok(text) => self.svg(&text, None, opts), - Err(e) => leave_out(&self.too_big, matches!(e, SvgError::TooBig(_))), + Ok(text) => Decoded::Svg(Arc::from(text.as_ref())), + Err(e) => { + leave_out(&self.too_big, matches!(e, SvgError::TooBig(_))); + Decoded::Nothing + } + } + } + + /// The image usvg gets for `decoded`: an SVG image read here, where it is shown. + fn show(&self, decoded: Decoded, opts: &usvg::Options) -> Option { + match decoded { + Decoded::Raster(kind, bytes) => Some(kind(bytes)), + Decoded::Svg(text) => self.svg(&text, None, opts), + Decoded::Nothing => None, } } @@ -569,6 +739,9 @@ impl Files { let Some(height) = depth::height(&xml, left) else { return leave_out(&self.too_deep, true) }; // usvg's parser would overflow on it, as on the SVG holding it (`import`). super::convolve_matrices_ok(&xml).ok()?; + let mut edits = Edits::default(); + self.take_data_urls(&xml, &mut edits); + let keyed = edits.apply(text); self.depth.store(used + height, Ordering::Relaxed); let within = self.within.lock().ok().map(|mut w| w.push(file)).is_some(); // usvg's `Tree::from_data_nested`, except that hrefs to files go to the resolver. @@ -587,7 +760,7 @@ impl Files { }, ..usvg::Options::default() }; - let tree = usvg::Tree::from_str(text, &nested); + let tree = usvg::Tree::from_str(&keyed, &nested); if within && let Ok(mut w) = self.within.lock() { w.pop(); } @@ -980,6 +1153,178 @@ mod tests { assert_eq!(files.left_out().len(), 2, "{:?}", files.left_out()); } + /// A base64 data URL of `bytes` of type `mime`. + fn data(mime: &str, bytes: &[u8]) -> String { + format!("data:{mime};base64,{}", crate::base64_encode(bytes)) + } + + /// A PNG of `w` × 1. + fn png_of(w: u32) -> Vec { + let mut out = vec![]; + image::RgbaImage::new(w, 1).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out + } + + /// SVG text of 2 × 2 holding `body`. + fn doc(body: &str) -> String { + format!(r#"{body}"#) + } + + /// `svg` read as the import reads it, its data URLs changed to keys first, `spent` bytes of + /// [`MAX_DATA_URL_BYTES`] already decoded: the files, and the PNG bytes of the images usvg + /// made, filters' included, in order. + fn read_keyed(svg: &str, spent: u64) -> (Files, Vec>>) { + let xml = super::super::parse(svg).unwrap(); + let mut edits = Edits::default(); + let files = Files::read(svg, &xml, &ImportOptions::default(), &mut edits, &mut vec![]); + files.data_bytes.store(spent, Ordering::Relaxed); + let text = edits.apply(svg).into_owned(); + let tree = { + let height = depth::height(&xml, MAX_ELEMENT_DEPTH).unwrap(); + let opts = usvg::Options { image_href_resolver: files.resolver(height), ..usvg::Options::default() }; + usvg::Tree::from_str(&text, &opts).unwrap() + }; + // Filters' images and SVG images' trees are subroots. + fn pngs(g: &usvg::Group, out: &mut Vec>>) { + for n in g.children() { + match n { + usvg::Node::Group(g) => pngs(g, out), + usvg::Node::Image(im) => { + if let ImageKind::PNG(b) = im.kind() { + out.push(b.clone()); + } + } + _ => {} + } + n.subroots(|g| pngs(g, out)); + } + } + let mut out = vec![]; + pngs(tree.root(), &mut out); + (files, out) + } + + /// A data URL shown by many `` elements is decoded once, and every use gets the same + /// image, where usvg decoded it again for each. + #[test] + fn a_data_url_is_decoded_once_however_often_it_is_used() { + let png = png(); + let uses = r##""##.repeat(64); + let svg = doc(&format!(r#"{uses}"#, data("image/png", &png))); + let (files, shown) = read_keyed(&svg, 0); + assert_eq!(shown.len(), 64); + assert!(shown.iter().all(|b| Arc::ptr_eq(b, &shown[0]) && **b == png)); + assert_eq!(files.data_bytes.load(Ordering::Relaxed), png.len() as u64); + assert!(files.left_out().is_empty(), "{:?}", files.left_out()); + } + + /// The data URLs inside an SVG image get keys too: each is decoded once, together with the + /// SVG image's own URL. + #[test] + fn data_urls_in_svg_images_are_decoded_once() { + let png = png(); + let uses = r##""##.repeat(16); + let inner = doc(&format!(r#"{uses}"#, data("image/png", &png))); + let svg = doc(&format!(r#""#, data("image/svg+xml", inner.as_bytes()))); + let (files, shown) = read_keyed(&svg, 0); + assert_eq!(shown.len(), 16); + assert_eq!(files.data_bytes.load(Ordering::Relaxed), (inner.len() + png.len()) as u64); + } + + /// An ``'s data URL gets a key too. + #[test] + fn fe_image_data_urls_are_decoded_once() { + let png = png(); + let rects: String = (1..=8).map(|i| format!(r#""#)).collect(); + let svg = doc(&format!(r#"{rects}"#, data("image/png", &png))); + let (files, shown) = read_keyed(&svg, 0); + assert_eq!(shown.len(), 8); + assert_eq!(files.data_bytes.load(Ordering::Relaxed), png.len() as u64); + } + + /// Both hrefs get keys, and usvg still takes the plain `href` over `xlink:href`, in either + /// order: only its URL is decoded. + #[test] + fn the_plain_href_still_wins_over_xlink_href() { + let (one, two) = (png_of(1), png_of(2)); + let (one_url, two_url) = (data("image/png", &one), data("image/png", &two)); + for image in [ + format!(r#""#), + format!(r#""#), + ] { + let (files, shown) = read_keyed(&doc(&image), 0); + assert_eq!(shown.len(), 1); + assert_eq!(*shown[0], one); + assert_eq!(files.data_bytes.load(Ordering::Relaxed), one.len() as u64); + } + } + + /// An image whose data URL is written in an entity, and so in every element the entity + /// makes, gets a key too. + #[test] + fn data_urls_written_in_entities_are_decoded_once() { + let png = png(); + let uses = r##""##.repeat(16); + let svg = format!( + r#"">]>{}"#, + data("image/png", &png), + doc(&format!("&img;{uses}")) + ); + let (files, shown) = read_keyed(&svg, 0); + assert_eq!(shown.len(), 16); + assert_eq!(files.data_bytes.load(Ordering::Relaxed), png.len() as u64); + } + + /// The data URLs decode to at most [`MAX_DATA_URL_BYTES`] together: a URL that fits what is + /// left is decoded, and decoding stops where a URL would go past it, that image left out and + /// noted. + #[test] + fn data_urls_past_the_limit_are_left_out() { + let (small, big) = (png(), png_of(64)); + assert!(small.len() < big.len()); + let svg = doc(&format!( + r#""#, + data("image/png", &small), + data("image/png", &big) + )); + let spent = MAX_DATA_URL_BYTES - (small.len() + big.len()) as u64 + 1; + let (files, shown) = read_keyed(&svg, spent); + assert_eq!(shown.len(), 1); + assert_eq!(*shown[0], small); + assert!(files.data_bytes.load(Ordering::Relaxed) <= MAX_DATA_URL_BYTES); + assert_eq!(files.left_out(), [format!("images were left out: together their data URLs decode to more than {} MB", MAX_DATA_URL_BYTES >> 20)]); + // A data URL usvg decodes itself counts too. + let files = Files::default(); + files.data_bytes.store(MAX_DATA_URL_BYTES, Ordering::Relaxed); + let resolver = files.resolver(1); + assert!((resolver.resolve_data)("image/png", Arc::new(small.clone()), &usvg::Options::default()).is_none()); + drop(resolver); + assert_eq!(files.left_out().len(), 1); + } + + /// A link that looks like a key, spelled plainly or with character references, in the + /// imported SVG or in an SVG image, stays that link: it never shows a data URL's image. + #[test] + fn links_that_look_like_keys_stay_links() { + let png = png(); + let url = data("image/png", &png); + let inner = doc(&format!(r#""#)); + let svg = doc(&format!( + r#""#, + &DATA_URL_KEY[1..], + data("image/svg+xml", inner.as_bytes()) + )); + let (files, shown) = read_keyed(&svg, 0); + // The data URL's image, then the placeholder of the missing file, twice; the SVG image's + // link names no file there, so it shows nothing. + assert_eq!(shown.len(), 3); + assert_eq!(*shown[0], png); + assert!(shown[1..].iter().all(|b| **b != png && files.links.get(&key(b)).is_some_and(|l| l.missing))); + assert_eq!(files.data_number(&format!("{DATA_URL_KEY}00")), None); + assert_eq!(files.data_number(&format!("{DATA_URL_KEY}+0")), None); + assert_eq!(files.data_number(&format!("{DATA_URL_KEY}9")), None); + } + /// The nested SVG images read, decoded or not, take at most [`MAX_NESTED_SVG_TEXT`] bytes of /// text together: an image whose text goes past it is left out and noted. A broken image's /// text counts too, and its decoded place is given back. diff --git a/crates/svg/src/import/prepass.rs b/crates/svg/src/import/prepass.rs index 3d1b04ad60..e541963561 100644 --- a/crates/svg/src/import/prepass.rs +++ b/crates/svg/src/import/prepass.rs @@ -95,6 +95,11 @@ impl Edits { } } + /// Set the value of attribute `a` to `value` (XML-escaped already). + pub(crate) fn set_value(&mut self, a: roxmltree::Attribute, value: &str) { + self.list.push((a.range_value(), value.to_string())); + } + /// Add the declaration `decl` (XML-escaped already) to the `style` of element `n` (in `svg`), /// after the ones already there (so it wins over them and over style sheet rules). fn style(&mut self, svg: &str, n: XNode, decl: &str) { @@ -163,7 +168,8 @@ fn named_made_up_ids(xml: &roxmltree::Document) -> HashSet { /// * so do `` and `` elements, recorded: usvg makes their arcs into a varying /// number of curves, and they come back as the ellipse VectorCraft draws (not in a file whose /// style sheet has an attribute selector); -/// * linked files are read ([`files`]); +/// * linked files are read, and the data URLs of `` and `` elements are each +/// changed to a key that the resolver decodes once ([`files`]); /// * percentage lengths in a `` with a `viewBox` become numbers ([`symbol_units`]); /// * what style sheet rules with a `:not()` set goes in `style` attributes ([`not_rules`]). /// @@ -190,7 +196,7 @@ pub(super) fn prepass<'s>( && !events && !foreign_attrs && !ellipses - && ![" bool) -> Resu } pub use glyph::glyph_art; -pub use import::{MAX_ELEMENT_DEPTH, MAX_FOREIGN, MAX_FOREIGN_VALUE, MAX_NESTED_SVG_IMAGES, MAX_NESTED_SVG_READS, MAX_NESTED_SVG_TEXT, MAX_NODES}; +pub use import::{ + MAX_DATA_URL_BYTES, MAX_ELEMENT_DEPTH, MAX_FOREIGN, MAX_FOREIGN_VALUE, MAX_NESTED_SVG_IMAGES, MAX_NESTED_SVG_READS, MAX_NESTED_SVG_TEXT, + MAX_NODES, +}; /// Import an SVG document reading the files it links to as `opts` says, also returning warnings. pub fn import_with(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec), SvgError> { diff --git a/crates/svg/tests/it/import_details.rs b/crates/svg/tests/it/import_details.rs index 69e5cafa21..9a048e0825 100644 --- a/crates/svg/tests/it/import_details.rs +++ b/crates/svg/tests/it/import_details.rs @@ -862,6 +862,21 @@ fn nested_svg_images_past_count_cap_are_left_out() { assert!(warnings.iter().any(|w| w.contains(&format!("more than {MAX_NESTED_SVG_IMAGES} nested SVG images")))); } +/// A data URL shown by `` elements, in the SVG and in an SVG image of it, imports as one +/// image shown at each use. +#[test] +fn data_url_images_shown_by_uses_import_at_each_use() { + let png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAwS2OUAAAAABJRU5ErkJggg=="; + let image = format!(r#""#); + let uses = r##""##.repeat(3); + let inner = format!(r#"{image}{uses}"#); + let (d, w) = import_with_report(&svg(&format!("{image}{uses}{}", image_of("image/svg+xml", &inner)))).unwrap(); + assert!(w.is_empty(), "{w:?}"); + let found = images(&d); + assert_eq!(found.len(), 6); + assert!(found.iter().all(|(_, im)| im.key == found[0].1.key && (im.width, im.height) == (1, 1))); +} + /// Nested SVG images that can't be read: text that isn't XML, and an SVG without a size. const BROKEN_SVG_IMAGES: [&str; 2] = [r#""#]; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9ba8866a8c..f6dd2ae146 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1821,6 +1821,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - An image that Open and Place store again keeps the ICC profile its file embeds and its 16-bit samples. A turned PNG, JPEG, WebP or TIFF stored as PNG, and a TIFF the image decoder reads, keep their gray or RGB samples at 8 or 16 bits with their gray or RGB profile in the PNG's `iCCP` chunk ([PNG specification, iCCP](https://www.w3.org/TR/png-3/#11iCCP)). A CMYK TIFF stored with one plane per ink and a turned CMYK JPEG or TIFF keep their CMYK profile as TIFF tag 34675, a JPEG's put together from all its APP2 segments ([ICC image-format guide](https://github.com/InternationalColorConsortium/iccDEV/blob/master/iccdev-mcp/docs/icc-format-guide.md)). A profile whose color space differs from the stored samples' is left out. The profile is kept in the stored image; how CMYK inks are drawn and exported to PDF does not change (`crates/engine/src/cmd/fileio/tests.rs`). - SVG import shows a raster `` whose EXIF orientation or TIFF Orientation tag turns or flips it upright, linked or as a data URL, and inside an SVG shown as an image: the pixels are stored turned, the width and height swap, and the `` box fits the turned image under `preserveAspectRatio`, as CSS `image-orientation: from-image` describes (). A turned CMYK JPEG or TIFF keeps its inks as a CMYK TIFF, as does a CMYK TIFF that is not turned. TIFF data URLs (`image/tiff`, or untyped TIFF bytes) are read, and TIFFs with an alpha or other extra channel, CCITT Group 3 TIFFs and CMYK TIFFs stored one plane per ink are read as Open and Place read them. The pixels are decoded within the decode allowance, and a TIFF claiming more strips or tiles than the decoder can count is refused before it is decoded (`crates/svg/tests/it/image_orientation.rs`). - SVG import leaves out an SVG shown as an image that it can't read (text that isn't XML, an SVG without a size) and goes on with the images after it, as SVG 2's error processing describes (). Only images that decode count toward the 256 nested SVG images an SVG may show, so an image after many broken ones is imported. Every nested SVG image read, decoded or not, counts toward VectorCraft's own limits of 1,024 reads and 512 MB of text per import; past them, nested SVG images are left out with a warning (`crates/svg/tests/it/import_details.rs`). + - SVG import decodes each image data URL (RFC 2397, ) of an `` or `` once, in the imported SVG and in the SVGs shown as its images, however many `` elements, filters or SVG images show it; every use gets the same image. The data URLs of one import decode to at most 512 MB together, VectorCraft's own limit: decoding stops where a URL would pass it, and that image is left out with a warning (`crates/svg/src/import/files.rs`, `crates/svg/tests/it/import_details.rs`). - SVG import reads the files that a linked SVG file's own `` elements link to, relative paths from that file's folder (), as it reads the imported SVG's links: a raster stays linked, an SVG file imports as vector art, and one that can't be read gets the placeholder, its link and a warning. SVG 2 tells a viewer not to load these links (); reading them is VectorCraft's own rule. The same href in SVG files of two folders names two files. A link that leads back to an SVG file showing it gets the placeholder and a warning, and reading the links of linked SVG files stops at the nesting depth cap and at 1,024 reads and 512 MB per import, the same limits decoded nested SVG images have, counted separately from them. The text of every linked SVG file read counts, and so does every file read for the links of a linked SVG file, raster or SVG; rasters the imported SVG links to don't. Once either limit is passed, no file linked from a linked SVG file is read and its image is left out with a warning, and an SVG file the imported SVG links to is still shown, without the files its own links name, with a warning. Another href to an SVG file already read, spelled differently or in another SVG file, does not read it again, unless a link back or the depth cap changes what it shows there (`crates/svg/tests/it/import_details.rs`, `crates/svg/src/import/files.rs`). - File › Place and a file dropped on the canvas refuse a PNG, JPEG, GIF or WebP file whose pixels can't be read, such as one cut short by a download or a sync still running, and the status bar shows "Couldn't place : the image's pixels can't be read". Paste and Relink refuse such an image too, and Update Links, or Open when it reads the file again, counts it as missing. A JPEG cut short still places when its pixels can be decoded (`file.place`, `crates/engine/src/tests_place.rs`). - On the desktop, Copy and Cut make the system clipboard's formats (text, SVG, PDF, PNG) from a copy of the clipboard on the thread that writes background saves and exports, and write them when they are ready; while that thread has a save, an export, a print or a recovery copy to finish, they are made and written at once. Until they are written, Paste in VectorCraft takes the internal clipboard, and while a text field or the Type tool has the keyboard the app waits for them. Another Copy or Cut, or a copy of text in VectorCraft, drops them. From 850ef83511b7e5613b72ddbbb3ecaeb5925db87d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 13:14:04 -0800 Subject: [PATCH 2/2] SVG import: a kept filter's feImage holds its data URL, not the key The prepass writes a key in place of each data URL before usvg reads the SVG, and the filters kept as Effect > SVG Filters were read from that edited text, so their XML held the key. The effect's check refused it and the filter was dropped with a warning. The filter's XML now has each keyed href put back as it was written. --- crates/svg/src/import.rs | 2 +- crates/svg/src/import/files.rs | 10 ++++++++++ crates/svg/src/import/fx.rs | 35 +++++++++++++++++++++++++++++++--- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 1aa571a6ed..22511ce8ab 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -188,7 +188,7 @@ pub(crate) fn import_finding( symbol_suffixes: HashMap::new(), pattern_suffixes: HashMap::new(), blends: fx::blends(&xml, &scan.markers), - filter_sources: fx::sources(&xml, &scan.markers), + filter_sources: fx::sources(&xml, &scan.markers, |h| files.data_href(h)), files, non_scaling: found.non_scaling, data: found.data, diff --git a/crates/svg/src/import/files.rs b/crates/svg/src/import/files.rs index fe3bf05e8f..8899eccdee 100644 --- a/crates/svg/src/import/files.rs +++ b/crates/svg/src/import/files.rs @@ -102,6 +102,8 @@ struct DataUrls { numbers: HashMap, usize>, /// Each href: a data URL, then what it decoded to, or a link. urls: Vec, + /// Each href as it was written, for the filters kept as written ([`Files::data_href`]). + hrefs: Vec>, } enum DataUrl { @@ -322,6 +324,7 @@ impl Files { let href: Arc = Arc::from(a.value()); let n = data.urls.len(); data.numbers.insert(href.clone(), n); + data.hrefs.push(href.clone()); data.urls.push(if url { DataUrl::Encoded(href) } else { DataUrl::Link(href) }); n } @@ -601,6 +604,13 @@ impl Files { (known && h.len() == DATA_URL_KEY.len() + n.to_string().len()).then_some(n) } + /// The href whose key is `h`, as it was written: a filter kept as an effect holds its + /// `` hrefs as written, not their keys ([`super::fx::sources`]). + pub(in crate::import) fn data_href(&self, h: &str) -> Option> { + let n = self.data_number(h)?; + self.data.lock().ok()?.hrefs.get(n).cloned() + } + /// The image of data URL `n`: decoded the first time ([`Files::decode`]), then kept; the link /// when href `n` is one. fn data_url(&self, n: usize, opts: &usvg::Options) -> Result, Arc> { diff --git a/crates/svg/src/import/fx.rs b/crates/svg/src/import/fx.rs index b760b1b119..4a549552d4 100644 --- a/crates/svg/src/import/fx.rs +++ b/crates/svg/src/import/fx.rs @@ -61,8 +61,10 @@ impl Source { /// Each `` element with an id, as written, by id, with the document's style sheets read /// once for all of them. A source is checked and read by usvg only when art uses it, once. -/// `markers` is the text with its data URLs left out ([`super::depth::prescan`]). -pub(super) fn sources(xml: &roxmltree::Document, markers: &str) -> HashMap { +/// `markers` is the text with its data URLs left out ([`super::depth::prescan`]). `href` gives the +/// href, as written, of each key the prepass wrote in place of an `` href +/// ([`super::files::Files::data_href`]): the source holds that href. +pub(super) fn sources(xml: &roxmltree::Document, markers: &str, href: impl Fn(&str) -> Option>) -> HashMap { if !markers.contains(" HashMap` href that `href` gives a written href for put back as written, XML-escaped. +fn hrefs_as_written(n: roxmltree::Node, mut xml: String, href: &impl Fn(&str) -> Option>) -> String { + // `element_xml` adds namespace declarations to the start tag only, ahead of every child. + let (start, end) = (n.range().start, n.range().end); + let Some(shift) = xml.len().checked_sub(end - start) else { return xml }; + let mut written: Vec<_> = n + .descendants() + .filter(|p| p.is_element() && p.tag_name().name() == "feImage") + .flat_map(|p| p.attributes()) + .filter(|a| a.name() == "href" && matches!(a.namespace(), None | Some("http://www.w3.org/1999/xlink"))) + .filter_map(|a| { + let r = a.range_value(); + // A value written through an entity lies outside the element's text: it stays. + (r.start > start && r.end < end).then_some(())?; + Some((r.start - start + shift..r.end - start + shift, a.value(), href(a.value())?)) + }) + .collect(); + written.sort_by_key(|(r, ..)| std::cmp::Reverse(r.start)); + for (r, key, h) in written { + if xml.get(r.clone()) == Some(key) { + xml.replace_range(r, &crate::xml_escape(&h)); + } + } + xml +} + /// The element name of primitive `k` (`feGaussianBlur`). fn element_name(k: &Kind) -> String { let debug = format!("{k:?}");