SVG import: read the text once for the prepass's markers, with the data URLs left out

The prepass decided what to look for in the tree by a dozen substring searches over the whole
SVG text (`:not(`, the event attributes, `script`, `transform:` and nine more), and a separate
pass counted the elements' depth before roxmltree. With an embedded picture the text is mostly
its data URL: a 1.35 MB card SVG (a 968 KB PNG) paid about 7 ms of searches and 1 ms of depth
scan per import, on a 64 ms render, and a 54-page PDF export 54 times that.

`depth::prescan` is the one pass: it counts the depth as `xml_too_deep` did (the DOCTYPE entity
count included) and, in the same walk, notes every `data:` attribute value, answering the text
with those values left out, their quotes kept. The prepass and `fx::blends` search that text;
the edits are still made in the source. A data URL can hold no tag, attribute name, selector or
CSS declaration, and everything else, a `style="display:none"` included, is kept, so what is
found is what was found before.

`vectorcraft-cli perf --deep` gains a tracked row, "import SVG, one embedded PNG", beside the
export row it had.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Drew Dibble
2026-10-23 01:01:27 +07:00
committed by bflatastic
co-authored by Claude Fable 5.1
parent 48792842ec
commit 5b919c0529
4 changed files with 131 additions and 33 deletions
+22 -2
View File
@@ -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<Document, String> {
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#"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}"><image width="{w}" height="{h}" href="data:image/png;base64,{data}"/><rect width="100" height="100" style="display:none"/></svg>"#
);
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)?;
+20 -15
View File
@@ -42,14 +42,15 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
// Before anything reads the tree: the XML reader, usvg, and what is made of its tree, all by
// recursion.
let too_deep = || SvgError::TooDeep(depth::MAX_ELEMENT_DEPTH);
if depth::xml_too_deep(svg) {
let scan = depth::prescan(svg);
if scan.too_deep {
return Err(too_deep());
}
let xml = parse(svg)?;
if depth::too_deep(&xml) {
return Err(too_deep());
}
let (svg, found) = prepass(svg, &xml, opts);
let (svg, found) = prepass(svg, &scan.markers, &xml, opts);
// The prepass's edits are in the text: the XML is read again only when it made any.
let xml = match &svg {
Cow::Borrowed(_) => xml,
@@ -86,7 +87,7 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
hidden: found.hidden,
uses: found.uses,
symbols: HashMap::new(),
blends: fx::blends(&xml),
blends: fx::blends(&xml, &scan.markers),
files,
non_scaling: found.non_scaling,
data: found.data,
@@ -590,18 +591,22 @@ impl Edits {
/// * linked files are read ([`files`]);
/// * percentage lengths in a `<symbol>` 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 `<svg:image>`.)
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
&& !["<a", "display", "<use", ":use", "<image", ":image", vector_effect::NON_SCALING, "data-", "groupmode", "script"]
.iter()
.any(|t| svg.contains(t))
.any(|t| markers.contains(t))
{
return (svg.into(), found);
}
@@ -612,7 +617,7 @@ fn prepass<'s>(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
+85 -13
View File
@@ -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"<!DOCTYPE") {
// Up to the `>` 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"</") {
depth.0 = depth.0.saturating_sub(1);
tag_end(at).0
tag_end(at, false).0
} else if at < doctype_end {
// In an entity's value, itself in quotes: up to the tag's `>`.
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::<usize>());
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,{}", "<use>display<script>".repeat(1000));
let text = svg(&format!(
r#"<image href="{picture}"/><image xlink:href='{picture}'/><rect style="display:none" data-x='1' d="M0 0h1"/><a HREF="DATA:text/plain,<g>"><g/></a>"#
));
let scan = prescan(&text);
assert!(!scan.too_deep);
let markers = scan.markers.as_ref();
assert!(!markers.contains("<use") && !markers.contains("script") && !markers.contains("base64"), "{markers}");
assert!(markers.contains(r#"href=""/>"#) && markers.contains("xlink:href=''/>") && markers.contains(r#"HREF="""#), "{markers}");
assert!(markers.contains(r#"style="display:none" data-x='1' d="M0 0h1""#) && markers.contains("<g/></a>"), "{markers}");
assert!(markers.len() < text.len() / 10);
// Without one, the text itself is the markers, unchanged.
let plain = svg("<rect width='1' height='1' style='fill:url(#p)'/>");
assert!(matches!(prescan(&plain).markers, Cow::Borrowed(m) if m == plain));
// A quote inside a comment or character data starts no value.
let odd = svg(r#"<!-- "data:x --><![CDATA["data:y]]><rect/>"#);
assert!(matches!(prescan(&odd).markers, Cow::Borrowed(_)));
// Nor does one in the document type: an entity's value is markup the tree will hold.
let entity = format!(r#"<!DOCTYPE svg [<!ENTITY e "data:<script href='a.js'/>">]>{}"#, svg("<g>&e;</g>"));
assert!(matches!(prescan(&entity).markers, Cow::Borrowed(_)));
// Past the cap, nothing more is read.
assert!(prescan(&svg(&groups(MAX_ELEMENT_DEPTH))).too_deep);
}
#[test]
fn a_chain_of_uses_is_as_deep_as_what_it_copies() {
let chain = |n: usize| {
+4 -3
View File
@@ -21,9 +21,10 @@ use vectorcraft_geom::{Affine, Vec2};
/// (filters composite normally; the mode comes back on import).
pub(crate) const BLEND: &str = "data-vc-blend";
/// The blend modes our export recorded ([`BLEND`]), by filter id.
pub(super) fn blends(xml: &roxmltree::Document) -> HashMap<String, String> {
if !xml.input_text().contains(BLEND) {
/// The blend modes our export recorded ([`BLEND`]), by filter id. `markers` is the text with its
/// data URLs left out ([`super::depth::prescan`]).
pub(super) fn blends(xml: &roxmltree::Document, markers: &str) -> HashMap<String, String> {
if !markers.contains(BLEND) {
return HashMap::new();
}
xml.descendants()