diff --git a/apps/vectorcraft-cli/src/perf.rs b/apps/vectorcraft-cli/src/perf.rs index 3aa1db6a4..ad7eab161 100644 --- a/apps/vectorcraft-cli/src/perf.rs +++ b/apps/vectorcraft-cli/src/perf.rs @@ -23,13 +23,16 @@ impl Rng { } } +/// The synthetic document's page, in points. +const SYNTHETIC_PAGE: (f64, f64) = (1600.0, 1200.0); + /// `n` mixed paths (ellipses with strokes, translucent rectangles, stroked stars) on a 1600×1200 page. pub fn synthetic(n: usize) -> Result { - let mut d = Document::new(1600.0, 1200.0); + let mut d = Document::new(SYNTHETIC_PAGE.0, SYNTHETIC_PAGE.1); let l = d.layers.first().map(|l| l.id); let mut r = Rng(42); for i in 0..n { - let (x, y, s) = (r.next() * 1600.0, r.next() * 1200.0, 3.0 + r.next() * 22.0); + let (x, y, s) = (r.next() * SYNTHETIC_PAGE.0, r.next() * SYNTHETIC_PAGE.1, 3.0 + r.next() * 22.0); let c = Color::rgb(r.next() as f32, r.next() as f32, r.next() as f32); let id = d.alloc_id(); let mut node = match i % 3 { @@ -296,6 +299,23 @@ fn deep(n: usize) -> Result<(), String> { rows.push(("render 4 embedded 24 MP photos with 3 renderers (cold)".into(), ms)); note("after rendering the photos with 3 renderers"); } + // An SVG with one embedded picture opened from its text, as picture-heavy SVGs come: the + // importer's passes over the text before usvg reads it, most of which is the data URL. The + // picture is the synthetic page drawn at 1.25x (about a megabyte as a PNG, the size at which + // the passes were measured); the hidden rectangle is a marker, so the prepass walks the tree + // as it does for a file that has one, instead of returning at its first check. + { + let k = 1.25; + let (w, h) = ((SYNTHETIC_PAGE.0 * k).round() as u32, (SYNTHETIC_PAGE.1 * k).round() as u32); + let photo = Renderer::new().render(&synthetic(3000)?, w, h, Affine::scale(k), &opts).to_png()?; + let data = vectorcraft_svg::base64_encode(&photo); + let svg = format!( + r#""# + ); + let ms = median_ms(5, || drop(vectorcraft_svg::import_with_report(&svg))); + rows.push((format!("import SVG, one embedded {} KB PNG", photo.len() / 1024), ms)); + note("after the import of an embedded picture"); + } // Output hashes, to compare builds: the SVG export and a save → open → save round trip. let fnv = |b: &[u8]| b.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &x| (h ^ u64::from(x)).wrapping_mul(0x100_0000_01b3)); let doc = synthetic(n)?; diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index b98d30724..1491d6e9f 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -42,14 +42,15 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec xml, @@ -86,7 +87,7 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec` with a `viewBox` become numbers ([`symbol_units`]); /// * what style sheet rules with a `:not()` set goes in `style` attributes ([`not_rules`]). -fn prepass<'s>(svg: &'s str, xml: &roxmltree::Document, opts: &ImportOptions) -> (Cow<'s, str>, Found) { +/// +/// Whether each is there at all is read from `markers`: `svg` with its data URLs left out +/// ([`depth::prescan`]), so an embedded picture isn't searched a dozen times. The edits are made +/// in `svg` itself. +fn prepass<'s>(svg: &'s str, markers: &str, xml: &roxmltree::Document, opts: &ImportOptions) -> (Cow<'s, str>, Found) { let mut found = Found::default(); // (`:` for prefixed elements such as ``.) - let css_transform = has_css_transform(svg); - let not = svg.contains(":not("); - let events = SVG_EVENTS.iter().any(|e| svg.contains(e)); + let css_transform = has_css_transform(markers); + let not = markers.contains(":not("); + let events = SVG_EVENTS.iter().any(|e| markers.contains(e)); if !css_transform && !not && !events && !["(svg: &'s str, xml: &roxmltree::Document, opts: &ImportOptions) -> found.links.insert(edits.id(svg, a), url.to_string()); } } - if svg.contains("data-") { + if markers.contains("data-") { for e in elements().filter(|e| OBJECT_ELEMENTS.contains(&e.tag_name().name())) { let data = user_data(e); if !data.is_empty() { @@ -628,7 +633,7 @@ fn prepass<'s>(svg: &'s str, xml: &roxmltree::Document, opts: &ImportOptions) -> } } } - if svg.contains("script") { + if markers.contains("script") { found.scripts = linked_scripts(xml, &mut found.warnings); } found.sublayers = elements().filter(|g| marked_layer(*g)).filter_map(|g| g.attribute("id")).map(str::to_string).collect(); @@ -638,7 +643,7 @@ fn prepass<'s>(svg: &'s str, xml: &roxmltree::Document, opts: &ImportOptions) -> found.uses.insert(edits.id(svg, u), sym.to_string()); } } - let (display, non_scaling) = (svg.contains("display"), svg.contains(vector_effect::NON_SCALING)); + let (display, non_scaling) = (markers.contains("display"), markers.contains(vector_effect::NON_SCALING)); if display || non_scaling || not { let css = css::Styles::new(xml); // First: an undisplayed object shown for usvg is shown after these. @@ -653,7 +658,7 @@ fn prepass<'s>(svg: &'s str, xml: &roxmltree::Document, opts: &ImportOptions) -> } } found.files = files::Files::read(svg, xml, opts, &mut edits, &mut found.warnings); - if !symbols.is_empty() && svg.contains('%') { + if !symbols.is_empty() && markers.contains('%') { edits.list.extend(symbol_units::rewrites(xml)); } if css_transform { @@ -662,9 +667,9 @@ fn prepass<'s>(svg: &'s str, xml: &roxmltree::Document, opts: &ImportOptions) -> (edits.apply(svg), found) } -/// Is there a `transform` CSS declaration in `svg` (`transform` followed by `:`)? -fn has_css_transform(svg: &str) -> bool { - svg.match_indices("transform").any(|(i, t)| svg.get(i + t.len()..).is_some_and(|s| s.trim_start().starts_with(':'))) +/// Is there a `transform` CSS declaration in `markers` (`transform` followed by `:`)? +fn has_css_transform(markers: &str) -> bool { + markers.match_indices("transform").any(|(i, t)| markers.get(i + t.len()..).is_some_and(|s| s.trim_start().starts_with(':'))) } /// usvg's style sheets drop rules with a `:not()` selector (#1557). The properties such rules set diff --git a/crates/svg/src/import/depth.rs b/crates/svg/src/import/depth.rs index a172affe7..422441cd2 100644 --- a/crates/svg/src/import/depth.rs +++ b/crates/svg/src/import/depth.rs @@ -8,6 +8,7 @@ //! (`clip-path: url(#a)` on an element of a clip path makes a chain as deep as there are clip //! paths). So each element's depth is the deepest of what it holds and what it refers to. +use std::borrow::Cow; use std::collections::HashMap; use std::collections::hash_map::Entry; @@ -69,21 +70,51 @@ fn references<'a, 'i>(n: XNode<'a, 'i>, ids: &HashMap<&str, XNode<'a, 'i>>, css: } } -/// Do the elements of the XML text `svg` nest deeper than [`MAX_ELEMENT_DEPTH`]? Read from the -/// text, before any XML reader: roxmltree (ours and usvg's) reads elements by recursion too. Tags -/// are counted as the text has them; markup in the values of a document type's entities counts -/// ten times (how deep entities may refer to one another), on top of the deepest element. +/// What one pass over the XML text `svg` finds before any XML reader: whether its elements nest +/// deeper than [`MAX_ELEMENT_DEPTH`] (roxmltree, ours and usvg's, reads elements by recursion +/// too), and the text the prepass searches for its markers, with every `data:` attribute value +/// left out. An embedded picture is such a value and is most of the text when there is one: with +/// a 1 MB picture, each of the prepass's dozen substring searches was a pass over a megabyte, and +/// no tag, attribute name, selector or CSS declaration can be inside a data URL. +pub(crate) struct Prescan<'s> { + pub too_deep: bool, + /// `svg` with the value of every `data:` attribute left out, its quotes kept. Borrowed when + /// there is none. + pub markers: Cow<'s, str>, +} + +/// [`Prescan::too_deep`] of `svg` alone (a linked file, a filter's definitions). pub(crate) fn xml_too_deep(svg: &str) -> bool { + prescan(svg).too_deep +} + +/// The one pass: tags are counted as the text has them; markup in the values of a document +/// type's entities counts ten times (how deep entities may refer to one another), on top of the +/// deepest element. Past the cap the pass stops, and the markers are the whole text. +pub(crate) fn prescan(svg: &str) -> Prescan<'_> { let b = svg.as_bytes(); let find = |from: usize, what: &[u8]| b.get(from..).and_then(|rest| rest.windows(what.len()).position(|w| w == what)).map(|at| from + at + what.len()); - // The end of the tag starting at `from` (past its `>`), and whether it closes itself. - let tag_end = |from: usize| { + // The `data:` attribute values seen: each from past its opening quote to its closing quote. + let mut data_values: Vec<(usize, usize)> = vec![]; + // The end of the tag starting at `from` (past its `>`), and whether it closes itself. An + // element's `data:` values are noted; the document type's quoted values are entities, whose + // markup becomes elements, so none of them is. + let mut tag_end = |from: usize, element: bool| { let mut quote = None; + let mut data = None; for (i, c) in b.iter().enumerate().skip(from) { match (quote, c) { - (None, b'"' | b'\'') => quote = Some(*c), - (Some(q), c) if q == *c => quote = None, + (None, b'"' | b'\'') => { + quote = Some(*c); + data = b.get(i + 1..i + 6).filter(|v| element && v.eq_ignore_ascii_case(b"data:")).map(|_| i + 1); + } + (Some(q), c) if q == *c => { + quote = None; + if let Some(start) = data.take() { + data_values.push((start, i)); + } + } (None, b'>') => return (i + 1, b.get(i.wrapping_sub(1)) == Some(&b'/')), _ => {} } @@ -105,7 +136,7 @@ pub(crate) fn xml_too_deep(svg: &str) -> bool { find(at, b"?>").unwrap_or(b.len()) } else if rest.starts_with(b"` that ends it: after its internal subset (`[` … `]`) when it has one. - let plain = tag_end(at).0; + let plain = tag_end(at, false).0; let subset = b.get(at..plain).is_some_and(|head| head.contains(&b'[')); doctype_end = if subset { find(at, b"]>").unwrap_or(b.len()) } else { plain }; at + 2 @@ -114,7 +145,7 @@ pub(crate) fn xml_too_deep(svg: &str) -> bool { at + 2 } else if rest.starts_with(b"`. let end = find(at, b">").unwrap_or(b.len()); @@ -122,16 +153,29 @@ pub(crate) fn xml_too_deep(svg: &str) -> bool { depth.0 += usize::from(b.get(end.wrapping_sub(2)) != Some(&b'/')); end } else { - let (end, empty) = tag_end(at); + let (end, empty) = tag_end(at, true); depth.1 = depth.1.max(depth.0 + 1); depth.0 += usize::from(!empty); end }; if elements.1 + 10 * entities.1 > MAX_ELEMENT_DEPTH { - return true; + return Prescan { too_deep: true, markers: Cow::Borrowed(svg) }; } } - false + let markers = if data_values.is_empty() { + Cow::Borrowed(svg) + } else { + let mut text = String::with_capacity(svg.len() - data_values.iter().map(|(start, end)| end - start).sum::()); + let mut from = 0; + for &(start, end) in &data_values { + // Both ends are at a quote, so both are character boundaries. + text.push_str(svg.get(from..start).unwrap_or_default()); + from = end; + } + text.push_str(svg.get(from..).unwrap_or_default()); + Cow::Owned(text) + }; + Prescan { too_deep: false, markers } } /// Does anything in `xml` nest deeper than [`MAX_ELEMENT_DEPTH`], references followed? Without @@ -219,6 +263,34 @@ mod tests { assert!(!xml_too_deep("")); } + /// The prepass searches the markers, not the text: an embedded picture is left out of them, + /// and everything a search may need to find stays, a short `style` value included. + #[test] + fn the_markers_leave_out_every_data_url_and_keep_the_rest() { + let picture = format!("data:image/png;base64,{}", "display