mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 17:56:50 -06:00
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:
committed by
bflatastic
co-authored by
Claude Fable 5.1
parent
48792842ec
commit
5b919c0529
@@ -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
@@ -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
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user