diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index a768785abf..c2235eca77 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -664,6 +664,29 @@ proptest! { survive(&svg, || vectorcraft_svg::import(&svg).ok())?; } + /// Style sheets of descendant, child, sibling and `:not()` selectors over nested groups and the + /// `` copies of a rectangle inside them, in an SVG and in an SVG image it shows: the import + /// measures their matching before its own cascade and the SVG reader run. + #[test] + fn svg_hostile_selectors_over_copies_never_panic( + sheet in r#"([a-z.#:()>+* ]{1,24}\{fill:red\} ?){1,6}"#, + depth in 1usize..12, + copies in 0usize..4, + ) { + let groups = format!("{}{}", "".repeat(depth), "".repeat(depth)); + let inner = format!( + "{groups}{}", + "".repeat(copies) + ); + let image = format!( + "", + vectorcraft_svg::base64_encode(inner.as_bytes()) + ); + for svg in [inner, image] { + survive(&svg, || vectorcraft_svg::import(&svg).ok())?; + } + } + /// Clip paths made of text, of `` elements showing text, and of both with shapes, each with /// hostile attributes, clipping art that may clip by them in turn. #[test] diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index c17f689922..5267acd279 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -110,6 +110,8 @@ pub(crate) fn svg_filters(svg: &str, room: usize, taken: impl Fn(&str) -> bool) return Err(too_deep()); } let xml = parse(svg)?; + // Before the style cascade of `depth::height`, as `import` measures it. + budget::check(&xml, budget::Cost::LIMITS, depth::MAX_ELEMENT_DEPTH)?; if depth::height(&xml, depth::MAX_ELEMENT_DEPTH).is_none() { return Err(too_deep()); } @@ -139,7 +141,7 @@ pub(crate) fn import_finding( let xml = parse(svg)?; // Before the style cascade of `depth::height` and the prepass, which keeps the declarations of // each element it reads; measured again below on the text usvg reads when that differs. - let styles = budget::check(&xml, budget::MAX_COPIED_STYLES, depth::MAX_ELEMENT_DEPTH)?; + let spent = budget::check(&xml, budget::Cost::LIMITS, depth::MAX_ELEMENT_DEPTH)?; let Some(height) = depth::height(&xml, depth::MAX_ELEMENT_DEPTH) else { return Err(too_deep()); }; @@ -160,13 +162,13 @@ pub(crate) fn import_finding( let text_xml = found.text_source.as_deref().map(parse).transpose()?; let (src, slots) = text::prepare(svg, &xml, text_xml.as_ref(), units.dpi, &mut warnings); // Before usvg copies what ``s show and the style values of each copy: the text usvg reads. - let styles = match &src { - Cow::Borrowed(_) if !rewritten => styles, - Cow::Borrowed(_) => budget::check(&xml, budget::MAX_COPIED_STYLES, usize::MAX)?, - Cow::Owned(prepared) => budget::check(&parse(prepared)?, budget::MAX_COPIED_STYLES, usize::MAX)?, + let spent = match &src { + Cow::Borrowed(_) if !rewritten => spent, + Cow::Borrowed(_) => budget::check(&xml, budget::Cost::LIMITS, usize::MAX)?, + Cow::Owned(prepared) => budget::check(&parse(prepared)?, budget::Cost::LIMITS, usize::MAX)?, }; let files = found.files; - files.copied_styles(styles); + files.spent_by_svg(spent); let tree = { let opt = usvg::Options { dpi: units.dpi as f32, diff --git a/crates/svg/src/import/budget.rs b/crates/svg/src/import/budget.rs index ec4de17eca..2520a8932a 100644 --- a/crates/svg/src/import/budget.rs +++ b/crates/svg/src/import/budget.rs @@ -20,13 +20,23 @@ //! would leave it out. //! //! The style sheets' rules are matched against each element once, as usvg matches them against -//! each copy. +//! each copy. usvg tests a selector against an element without keeping what it found, so a +//! selector whose leftmost part matches nothing can test every set of ancestors in turn: the tests +//! usvg makes for each copy are counted, and an SVG past [`MAX_SELECTOR_TESTS`] of them is refused. +//! A look at an attribute counts one test for every 16 attributes of the element it scans, and one +//! for every 16 bytes of the value it compares. Matching stops once the tests pass what is left of +//! the limit. +//! +//! The import's own style cascade (`css::Styles`) keeps the declarations of the rules that match +//! each element, once per element, and reads selectors usvg drops (`:not()`). What it keeps and the +//! tests it makes count toward the same limits, measured before it runs. +use std::cell::Cell; use std::collections::{HashMap, HashSet}; use usvg::roxmltree; -use super::{MAX_NODES, SVG_NS, XNode}; +use super::{MAX_NODES, SVG_NS, XNode, css}; use crate::{MAX_ELEMENT_DEPTH, SvgError}; /// Most elements usvg may make of an SVG: [`MAX_NODES`], and below the 1,000,000 elements past @@ -37,6 +47,10 @@ pub(crate) const MAX_SVG_ELEMENTS: usize = if MAX_NODES < 999_999 { MAX_NODES } /// shows, all together: VectorCraft's own limit. pub(crate) const MAX_COPIED_STYLES: u64 = 64 << 20; +/// Most tests of a style sheet selector's parts against elements an SVG and the SVG images it +/// shows may take, all together: VectorCraft's own limit. +pub(crate) const MAX_SELECTOR_TESTS: u64 = 50_000_000; + /// What usvg keeps for each value it copies besides its bytes (the count and length of a shared /// string). const COPY: u64 = 16; @@ -116,28 +130,59 @@ fn svg_name<'a>(n: XNode<'a, '_>) -> Option<&'a str> { (n.is_element() && matches!(name.namespace(), None | Some(SVG_NS)) && ELEMENTS.contains(&name.name())).then(|| name.name()) } -/// An element as the style sheets' selectors see it, as usvg matches them. -struct Element<'a, 'i>(XNode<'a, 'i>); +#[cfg(test)] +thread_local! { + /// The selector tests the last measurement on this thread made as usvg matches the style + /// sheets, as far as it got. + static USVG_TESTS: Cell = const { Cell::new(0) }; +} -impl simplecss::Element for Element<'_, '_> { +/// An element as the style sheets' selectors see it, as usvg matches them. Each look at it counts +/// as tests in `tests`; past `most` it has no parent or sibling and matches nothing, so the +/// matching ends. +struct Element<'a, 'i, 't> { + node: XNode<'a, 'i>, + tests: &'t Cell, + most: u64, +} + +impl<'a, 'i, 't> Element<'a, 'i, 't> { + /// Counts `n` tests: false past `most`. + fn test(&self, n: u64) -> bool { + let tests = self.tests.get().saturating_add(n); + self.tests.set(tests); + #[cfg(test)] + USVG_TESTS.set(tests); + tests <= self.most + } + + fn of(&self, node: Option>) -> Option { + node.filter(|_| self.test(1)).map(|node| Element { node, tests: self.tests, most: self.most }) + } +} + +impl simplecss::Element for Element<'_, '_, '_> { fn parent_element(&self) -> Option { - self.0.parent_element().map(Self) + self.of(self.node.parent_element()) } fn prev_sibling_element(&self) -> Option { - self.0.prev_sibling_element().map(Self) + self.of(self.node.prev_sibling_element()) } fn has_local_name(&self, name: &str) -> bool { - self.0.tag_name().name() == name + self.test(1) && self.node.tag_name().name() == name } fn attribute_matches(&self, name: &str, operator: simplecss::AttributeOperator<'_>) -> bool { - self.0.attribute(name).is_some_and(|value| operator.matches(value)) + // The lookup scans the element's attributes, and the match the value. + let value = self.node.attribute(name); + let scanned = css::scan_tests(self.node.attributes().len()).saturating_add(value.map_or(0, |v| css::scan_tests(v.len()))); + self.test(scanned) && value.is_some_and(|value| operator.matches(value)) } fn pseudo_class_matches(&self, class: simplecss::PseudoClass<'_>) -> bool { - matches!(class, simplecss::PseudoClass::FirstChild) && self.0.prev_sibling_element().is_none() + self.test(1) && matches!(class, simplecss::PseudoClass::FirstChild) && self.node.prev_sibling_element().is_none() } } @@ -153,16 +198,34 @@ fn copied(declaration: &simplecss::Declaration) -> u64 { } } -/// What usvg makes of one element: elements (text nodes included) and bytes of copied styles. -#[derive(Clone, Copy, Default)] -struct Cost { - elements: u64, - bytes: u64, +/// What usvg makes of an element or an SVG: elements (text nodes included), bytes of copied +/// styles and selector tests. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct Cost { + pub(crate) elements: u64, + pub(crate) bytes: u64, + pub(crate) tests: u64, } impl Cost { - fn add(self, other: Cost) -> Cost { - Cost { elements: self.elements.saturating_add(other.elements), bytes: self.bytes.saturating_add(other.bytes) } + /// The most an SVG and the SVG images it shows may cost together. + pub(crate) const LIMITS: Cost = Cost { elements: MAX_SVG_ELEMENTS as u64, bytes: MAX_COPIED_STYLES, tests: MAX_SELECTOR_TESTS }; + + pub(crate) fn add(self, other: Cost) -> Cost { + Cost { + elements: self.elements.saturating_add(other.elements), + bytes: self.bytes.saturating_add(other.bytes), + tests: self.tests.saturating_add(other.tests), + } + } + + /// What is left of `self` after `spent`. + pub(crate) fn less(self, spent: Cost) -> Cost { + Cost { + elements: self.elements.saturating_sub(spent.elements), + bytes: self.bytes.saturating_sub(spent.bytes), + tests: self.tests.saturating_sub(spent.tests), + } } } @@ -178,6 +241,14 @@ struct Measure<'a, 'i> { /// The first element of each id. ids: HashMap<&'a str, XNode<'a, 'i>>, sheet: simplecss::StyleSheet<'a>, + /// The selector tests made of `sheet` so far, each element once. + tests: Cell, + /// The most selector tests of `sheet` that are left: matching stops past them. + most_tests: u64, + /// The selector tests the import's own cascade made ([`Measure::cascade`]). + cascade_tests: Cell, + /// The import's own style cascade of the same style sheets. + cascade: css::Styles, /// The elements that hold a `` of themselves. holds_use_of_itself: HashSet, /// The ``s of each element, by the element. @@ -204,7 +275,17 @@ impl<'a, 'i> Measure<'a, 'i> { sheet.parse_more(text); } } - let mut m = Measure { ids, sheet, holds_use_of_itself: HashSet::new(), used_by: HashMap::new(), tref_text: HashMap::new() }; + let mut m = Measure { + ids, + sheet, + tests: Cell::new(0), + most_tests: MAX_SELECTOR_TESTS, + cascade_tests: Cell::new(0), + cascade: css::Styles::new(xml), + holds_use_of_itself: HashSet::new(), + used_by: HashMap::new(), + tref_text: HashMap::new(), + }; for u in xml.descendants().filter(|n| n.has_tag_name((SVG_NS, "use"))) { let Some(target) = m.target(u) else { continue }; if u.ancestors().skip(1).any(|a| a == target) { @@ -241,21 +322,51 @@ impl<'a, 'i> Measure<'a, 'i> { Shows::All(link) } - /// The bytes of style values usvg copies into one copy of element `n`. - fn styles(&self, n: XNode) -> u64 { + /// Element `n` alone, as usvg makes one copy of it: the bytes of style values it copies into + /// it, and the selector tests usvg makes to find them (each rule tested counts as one too). + fn own(&self, n: XNode) -> Cost { + let before = self.tests.get(); let mut bytes = 0u64; - for rule in self.sheet.rules.iter().filter(|r| r.selector.matches(&Element(n))) { - bytes = rule.declarations.iter().map(copied).fold(bytes, u64::saturating_add); + for rule in &self.sheet.rules { + self.tests.set(self.tests.get().saturating_add(1)); + if rule.selector.matches(&Element { node: n, tests: &self.tests, most: self.most_tests }) { + bytes = rule.declarations.iter().map(copied).fold(bytes, u64::saturating_add); + } } if let Some(style) = n.attribute("style") { bytes = simplecss::DeclarationTokenizer::from(style).map(|d| copied(&d)).fold(bytes, u64::saturating_add); } - bytes + Cost { elements: 1, bytes, tests: self.tests.get().saturating_sub(before) } } - /// Element `n` alone. - fn own(&self, n: XNode) -> Cost { - Cost { elements: 1, bytes: self.styles(n) } + /// Have the selector tests made so far passed what is left of them? + fn tests_past(&self) -> bool { + self.tests.get() > self.most_tests + } + + /// What the import's own style cascade keeps for the elements of `xml`, each once, up to + /// `left`: the bytes of the declarations of the rules that match it, and the tests it makes to + /// find them. + /// The tests it makes are kept in `cascade_tests`, as far as it gets. + fn cascade(&self, xml: &roxmltree::Document, left: Cost) -> Result { + let mut cost = Cost::default(); + if !self.cascade.has_rules() { + return Ok(cost); + } + let mut result = Ok(()); + for n in xml.descendants().filter(XNode::is_element) { + let Some(bytes) = self.cascade.matched_bytes(n, COPY, &mut cost.tests, left.tests) else { + result = Err(Past::Tests); + break; + }; + cost.bytes = cost.bytes.saturating_add(bytes); + if cost.bytes > left.bytes { + result = Err(Past::Bytes); + break; + } + } + self.cascade_tests.set(cost.tests); + result.map(|()| cost) } /// `` `t` and what usvg makes of what it holds: at most an element of each element, and @@ -265,11 +376,11 @@ impl<'a, 'i> Measure<'a, 'i> { for d in t.descendants().skip(1) { if d.is_text() { let len = d.text().map_or(0, str::len) as u64; - cost = cost.add(Cost { elements: 1, bytes: COPY + len }); + cost = cost.add(Cost { elements: 1, bytes: COPY + len, tests: 0 }); } else if svg_name(d).is_some() { cost = cost.add(self.own(d)); if svg_name(d) == Some("tref") { - cost = cost.add(Cost { elements: 1, bytes: COPY + self.tref(d) }); + cost = cost.add(Cost { elements: 1, bytes: COPY + self.tref(d), tests: 0 }); } } } @@ -286,16 +397,23 @@ impl<'a, 'i> Measure<'a, 'i> { } } -/// The bytes of style values usvg would copy into the elements of `xml`, when that is no more -/// than `bytes_left` and it makes no more than [`MAX_SVG_ELEMENTS`] elements of it, nested no -/// deeper than `depth` (counted as `depth::height` counts, which finds at least as much). -pub(super) fn check(xml: &roxmltree::Document, bytes_left: u64, depth: usize) -> Result { - match measure(xml, MAX_SVG_ELEMENTS as u64, bytes_left, depth) { - Ok(cost) => Ok(cost.bytes), - Err(Past::Elements) => Err(SvgError::TooLarge(MAX_SVG_ELEMENTS)), - Err(Past::Bytes) => Err(SvgError::StylesTooLarge((MAX_COPIED_STYLES >> 20) as usize)), - Err(Past::Depth) => Err(SvgError::TooDeep(MAX_ELEMENT_DEPTH)), - } +/// What usvg would make of `xml`, with what the import's own style cascade keeps of it and the +/// selector tests it makes, when that is no more than `left` and nested no deeper than `depth` +/// (counted as `depth::height` counts, which finds at least as much). +pub(super) fn check(xml: &roxmltree::Document, left: Cost, depth: usize) -> Result { + check_spending(xml, left, depth).0 +} + +/// [`check`], with the selector tests it made, as far as it got. +pub(super) fn check_spending(xml: &roxmltree::Document, left: Cost, depth: usize) -> (Result, u64) { + let (result, tests) = measure_spending(xml, left, depth); + let result = result.map_err(|past| match past { + Past::Elements => SvgError::TooLarge(MAX_SVG_ELEMENTS), + Past::Bytes => SvgError::StylesTooLarge((MAX_COPIED_STYLES >> 20) as usize), + Past::Tests => SvgError::StylesTooComplex, + Past::Depth => SvgError::TooDeep(MAX_ELEMENT_DEPTH), + }); + (result, tests) } /// The limit [`measure`] stopped at. @@ -303,14 +421,32 @@ pub(super) fn check(xml: &roxmltree::Document, bytes_left: u64, depth: usize) -> enum Past { Elements, Bytes, + Tests, Depth, } -/// What usvg makes of `xml`, up to `elements` elements and `bytes` bytes of copied styles, its -/// elements and the copies ``s show nested up to `depth` deep. Without recursion; each -/// element is measured once. -fn measure(xml: &roxmltree::Document, elements: u64, bytes: u64, depth: usize) -> Result { +/// What usvg makes of `xml` with what the import's own style cascade keeps of it and its tests, +/// up to `left`, its elements and the copies ``s show nested up to `depth` deep. Without +/// recursion; each element is measured once. +#[cfg(test)] +fn measure(xml: &roxmltree::Document, left: Cost, depth: usize) -> Result { + measure_spending(xml, left, depth).0 +} + +/// [`measure`], with the selector tests it made, as far as it got. +fn measure_spending(xml: &roxmltree::Document, left: Cost, depth: usize) -> (Result, u64) { let mut m = Measure::new(xml); + let result = walk(&mut m, xml, left, depth); + (result, m.cascade_tests.get().saturating_add(m.tests.get())) +} + +/// [`measure`] with `m`, made of `xml`. +fn walk<'a, 'i>(m: &mut Measure<'a, 'i>, xml: &'a roxmltree::Document<'i>, left: Cost, depth: usize) -> Result { + // First: its tests are bounded by what it finds, usvg's are not. + let cascade = m.cascade(xml, left)?; + let left = left.less(cascade); + m.most_tests = left.tests; + let (elements, bytes) = (left.elements, left.bytes); let mut costs: HashMap = HashMap::new(); // The elements entered and not yet left: one entered again is a loop. let mut open: HashSet = HashSet::new(); @@ -366,11 +502,15 @@ fn measure(xml: &roxmltree::Document, elements: u64, bytes: u64, depth: usize) - if cost.bytes > bytes { return Err(Past::Bytes); } + if m.tests_past() || cost.tests > left.tests { + return Err(Past::Tests); + } costs.insert(n.id(), cost); } } } - Ok(costs.get(&root.id()).copied().unwrap_or_default()) + let usvg = costs.get(&root.id()).copied().unwrap_or_default(); + Ok(usvg.add(cascade)) } #[cfg(test)] @@ -381,15 +521,17 @@ mod tests { format!(r#"{body}"#) } + const NO_LIMITS: Cost = Cost { elements: u64::MAX, bytes: u64::MAX, tests: u64::MAX }; + /// What [`measure`] makes of `text` with no limits, as (elements, bytes). fn cost(text: &str) -> (u64, u64) { let xml = roxmltree::Document::parse(text).unwrap(); - let c = measure(&xml, u64::MAX, u64::MAX, usize::MAX).unwrap_or_else(|p| panic!("{p:?}")); + let c = measure(&xml, NO_LIMITS, usize::MAX).unwrap_or_else(|p| panic!("{p:?}")); (c.elements, c.bytes) } fn past(text: &str, elements: u64, bytes: u64) -> Option { - measure(&roxmltree::Document::parse(text).unwrap(), elements, bytes, usize::MAX).err() + measure(&roxmltree::Document::parse(text).unwrap(), Cost { elements, bytes, tests: u64::MAX }, usize::MAX).err() } /// Groups `g0` to `g{levels}`: `g0` a rectangle styled `style`, each other two ``s of the @@ -423,9 +565,12 @@ mod tests { #[test] fn style_sheet_rules_count_where_they_match() { let groups = "".repeat(4); - // `g` matches the four groups (16 + 3 each), `.b` none, `svg` the root (16 + 4). + // `g` matches the four groups (16 + 3 each), `.b` none, `svg` the root (16 + 4); the + // import's own cascade keeps the same, names included (16 + 4 + 3 and 16 + 4 + 4). let (_, bytes) = cost(&svg(&format!("{groups}"))); - assert_eq!(bytes, 4 * 19 + 20); + assert_eq!(bytes, 4 * 19 + 20 + 4 * 23 + 24); + // usvg drops a `:not()` rule; the cascade keeps it for each group it matches. + assert_eq!(cost(&svg(&format!("{groups}"))).1, 4 * 23); // A style sheet of another type is not read; the style element itself makes nothing. assert_eq!(cost(&svg(&format!("{groups}"))), (5, 0)); // `marker` sets three properties; `font` resets twelve and sets six from its text. @@ -467,13 +612,13 @@ mod tests { let links: String = (0..3000).map(|i| format!("", i + 1)).collect(); let deep = svg(&format!("{links}")); let xml = roxmltree::Document::parse(&deep).unwrap(); - assert_eq!(measure(&xml, u64::MAX, u64::MAX, MAX_ELEMENT_DEPTH).err(), Some(Past::Depth)); + assert_eq!(measure(&xml, NO_LIMITS, MAX_ELEMENT_DEPTH).err(), Some(Past::Depth)); assert!(super::super::depth::height(&xml, MAX_ELEMENT_DEPTH).is_none()); - assert!(matches!(check(&xml, MAX_COPIED_STYLES, MAX_ELEMENT_DEPTH), Err(SvgError::TooDeep(_)))); + assert!(matches!(check(&xml, Cost::LIMITS, MAX_ELEMENT_DEPTH), Err(SvgError::TooDeep(_)))); for text in [chain(5, ""), svg("")] { let xml = roxmltree::Document::parse(&text).unwrap(); let height = super::super::depth::height(&xml, usize::MAX).unwrap_or(0); - assert!(measure(&xml, u64::MAX, u64::MAX, height).is_ok(), "{height} {text}"); + assert!(measure(&xml, NO_LIMITS, height).is_ok(), "{height} {text}"); } } @@ -492,11 +637,110 @@ mod tests { fn a_long_rule_on_many_elements_is_refused_before_the_cascade_reads_it() { let groups = svg(&format!("{}", "a".repeat(100_000), "".repeat(1000))); let image = svg(&format!(r#""#, crate::base64_encode(groups.as_bytes()))); - for text in [groups, image] { + // usvg drops a `:not()` rule, which the import's own cascade reads. + let not = svg(&format!("{}", "a".repeat(100_000), "".repeat(1000))); + for text in [groups, image, not] { let before = super::super::css::selector_tests(); let err = super::super::import(&text, &crate::ImportOptions::default(), MAX_NODES).err(); assert!(matches!(err, Some(SvgError::StylesTooLarge(64))), "{err:?}"); assert_eq!(super::super::css::selector_tests(), before); } } + + /// A linked SVG file with such a rule, and an `` of its own whose link the import reads, + /// is refused before the cascade reads it too. + #[test] + fn a_linked_svg_with_a_long_rule_is_refused_before_the_cascade_reads_it() { + let linked = + svg(&format!("{}", "a".repeat(100_000), "".repeat(1000))); + let read = |path: &str| path.ends_with("a.svg").then(|| (linked.clone().into_bytes(), vectorcraft_doc::LinkInfo::new(path))); + let folder = std::env::temp_dir().join("vectorcraft-svg-budget"); + let folder = folder.to_string_lossy(); + let opts = crate::ImportOptions { folder: Some(&folder), read: Some(&read) }; + let before = super::super::css::selector_tests(); + let err = super::super::import(&svg(""), &opts, MAX_NODES).err(); + assert!(matches!(err, Some(SvgError::StylesTooLarge(64))), "{err:?}"); + assert_eq!(super::super::css::selector_tests(), before); + } + + /// `depth` groups in one another, each of class `a`, around a rectangle, with `sheet`. + fn nested(depth: usize, sheet: &str) -> String { + svg(&format!("{}{}", "".repeat(depth), "".repeat(depth))) + } + + /// A selector whose leftmost part matches nothing makes usvg test every set of ancestors in + /// turn: the tests stop at the limit and the SVG is refused, before usvg or the cascade runs. + #[test] + fn a_selector_that_never_matches_stops_at_the_test_limit() { + let never = nested(60, &format!(".missing{} {{fill:red}}", " g".repeat(20))); + let xml = roxmltree::Document::parse(&never).unwrap(); + assert_eq!(measure(&xml, Cost::LIMITS, usize::MAX).err(), Some(Past::Tests)); + let before = super::super::css::selector_tests(); + let err = super::super::import(&never, &crate::ImportOptions::default(), MAX_NODES).err(); + assert!(matches!(err, Some(SvgError::StylesTooComplex)), "{err:?}"); + assert_eq!(super::super::css::selector_tests(), before); + // The same selector without its leftmost part matches at once, and imports. + let matching = nested(60, &format!("g{} {{fill:red}}", " g".repeat(19))); + assert!(super::super::import(&matching, &crate::ImportOptions::default(), MAX_NODES).is_ok()); + } + + /// usvg tests the rules against each copy a `` shows: the tests add up as the copies do, + /// and the cascade's tests count once for each element. + #[test] + fn selector_tests_count_for_each_copy() { + let tests = |levels: usize| { + let text = chain(levels, "").replacen("", "", 1); + let xml = roxmltree::Document::parse(&text).unwrap(); + measure(&xml, NO_LIMITS, usize::MAX).unwrap_or_else(|p| panic!("{p:?}")).tests + }; + let (ten, eleven) = (tests(10), tests(11)); + assert!(eleven > 2 * ten - 100, "{ten} {eleven}"); + let text = chain(11, "").replacen("", "", 1); + let xml = roxmltree::Document::parse(&text).unwrap(); + assert_eq!(measure(&xml, Cost { tests: eleven - 1, ..NO_LIMITS }, usize::MAX).err(), Some(Past::Tests)); + assert!(measure(&xml, Cost { tests: eleven, ..NO_LIMITS }, usize::MAX).is_ok()); + } + + /// Each alternative of a `:not()` counts as a test of the cascade, for each element. + #[test] + fn not_alternatives_count_as_tests() { + let alternatives: Vec = (0..1000).map(|i| format!(".m{i}")).collect(); + let text = svg(&format!("{}", alternatives.join(","), "".repeat(1000))); + let xml = roxmltree::Document::parse(&text).unwrap(); + let tests = measure(&xml, NO_LIMITS, usize::MAX).unwrap_or_else(|p| panic!("{p:?}")).tests; + assert!(tests > 1000 * 1000, "{tests}"); + assert_eq!(measure(&xml, Cost { tests: 1000 * 1000, ..NO_LIMITS }, usize::MAX).err(), Some(Past::Tests)); + } + + /// Matching as usvg matches stops once its tests pass what the limits have left, as when an + /// SVG image gets what the SVG showing it left. + #[test] + fn usvg_matching_stops_at_what_is_left() { + let never = nested(60, &format!(".missing{} {{fill:red}}", " g".repeat(20))); + let xml = roxmltree::Document::parse(&never).unwrap(); + let cascade = Measure::new(&xml).cascade(&xml, NO_LIMITS).unwrap_or_else(|p| panic!("{p:?}")).tests; + assert_eq!(measure(&xml, Cost { tests: cascade + 1000, ..NO_LIMITS }, usize::MAX).err(), Some(Past::Tests)); + let made = USVG_TESTS.get(); + assert!((1000..1100).contains(&made), "{made}"); + } + + /// Each class of a compound is looked for in the element's class list, so the tests grow with + /// the list's length: in the import's own cascade (a `:not()` rule, which usvg drops) and as + /// usvg matches. + #[test] + fn class_tests_grow_with_the_class_list() { + let list = (0..1000).map(|i| format!("c{i}")).collect::>().join(" "); + let classes: String = (0..100).map(|i| format!(".c{i}")).collect(); + let scans = 100 * css::scan_tests(list.len()); + let tests = |rule: &str| { + let text = svg(&format!("")); + let xml = roxmltree::Document::parse(&text).unwrap(); + measure(&xml, NO_LIMITS, usize::MAX).unwrap_or_else(|p| panic!("{p:?}")).tests + }; + let cascade = tests(&format!("g:not(.x{classes})")); + assert!(cascade >= scans, "{cascade} {scans}"); + tests(&format!("g{classes}")); + let usvg = USVG_TESTS.get(); + assert!(usvg >= scans, "{usvg} {scans}"); + } } diff --git a/crates/svg/src/import/css.rs b/crates/svg/src/import/css.rs index 7ddfb1bbd5..b5f1d80320 100644 --- a/crates/svg/src/import/css.rs +++ b/crates/svg/src/import/css.rs @@ -103,6 +103,24 @@ impl Compound { && self.attrs.iter().all(|(a, v)| n.attribute(a.as_str()).is_some_and(|x| v.as_deref().is_none_or(|v| x == v))) && !self.nots.iter().any(|c| c.matches(n)) } + + /// The tests [`Compound::matches`] makes of `n` at most, its `:not()`s' included: one for the + /// compound, [`scan_tests`] of `n`'s attributes for each attribute it looks up, and for each + /// of its classes, [`scan_tests`] of `n`'s class list. + fn tests(&self, n: XNode) -> u64 { + let lookups = u64::from(self.id.is_some()) + u64::from(!self.classes.is_empty()) + self.attrs.len() as u64; + let mut tests = lookups.saturating_mul(scan_tests(n.attributes().len())).saturating_add(1); + if !self.classes.is_empty() { + let list = scan_tests(n.attribute("class").map_or(0, str::len)); + tests = tests.saturating_add((self.classes.len() as u64).saturating_mul(list)); + } + self.nots.iter().map(|c| c.tests(n)).fold(tests, u64::saturating_add) + } +} + +/// The tests a scan of `len` attributes or bytes counts as: one, and one more for every 16. +pub(super) fn scan_tests(len: usize) -> u64 { + 1 + len as u64 / 16 } /// A complex selector, rightmost compound first; each compound's flag says whether it must be the @@ -260,14 +278,35 @@ impl Selector { } fn matches(&self, n: XNode) -> bool { + #[cfg(test)] + SELECTOR_TESTS.set(SELECTOR_TESTS.get().saturating_add(1)); + self.matches_counting(n, &mut 0, None) + } + + /// [`Self::matches`], counting the tests of each compound tested against an element in + /// `steps` ([`Compound::tests`]), those of its `:not()`s included. Once `steps` passes `most`, + /// no compound matches any more. + fn matches_within(&self, n: XNode, steps: &mut u64, most: u64) -> bool { + self.matches_counting(n, steps, Some(most)) + } + + /// [`Self::matches_within`] when `most` is given; [`Self::matches`], counting nothing, when not. + fn matches_counting(&self, n: XNode, steps: &mut u64, most: Option) -> bool { // The result of matching a selector suffix from a node depends only on the suffix and // the node, so memoize it: without this, every descendant step retries every matching // ancestor and a selector whose leftmost compound never matches makes the search // enumerate ancestor combinations (#1703). A suffix of `parts` is identified by its // length, so (suffix length, node id) is a complete key and the visited states are // bounded by parts × ancestors. - fn go(parts: &[(Compound, bool)], n: XNode, memo: &mut HashMap<(usize, roxmltree::NodeId), bool>) -> bool { + type Memo = HashMap<(usize, roxmltree::NodeId), bool>; + fn go(parts: &[(Compound, bool)], n: XNode, memo: &mut Memo, steps: &mut u64, most: Option) -> bool { let Some(((c, child), rest)) = parts.split_first() else { return true }; + if let Some(most) = most { + *steps = steps.saturating_add(c.tests(n)); + if *steps > most { + return false; + } + } if !c.matches(n) { return false; } @@ -279,16 +318,14 @@ impl Selector { return hit; } let hit = if *child { - n.parent_element().is_some_and(|p| go(rest, p, memo)) + n.parent_element().is_some_and(|p| go(rest, p, memo, steps, most)) } else { - n.ancestors().skip(1).filter(|a| a.is_element()).any(|a| go(rest, a, memo)) + n.ancestors().skip(1).filter(|a| a.is_element()).any(|a| go(rest, a, memo, steps, most)) }; memo.insert(key, hit); hit } - #[cfg(test)] - SELECTOR_TESTS.set(SELECTOR_TESTS.get().saturating_add(1)); - go(&self.parts, n, &mut HashMap::new()) + go(&self.parts, n, &mut HashMap::new(), steps, most) } } @@ -495,6 +532,24 @@ impl Styles { m } + /// The bytes the declarations of the rules that match `n` take, names and values, each with + /// `per_declaration` more: at least what [`Styles::declared`] keeps of them for `n`. Nothing + /// is kept. `steps` counts the rules and compounds tested; `None` once it passes `most`. + pub(super) fn matched_bytes(&self, n: XNode, per_declaration: u64, steps: &mut u64, most: u64) -> Option { + let mut bytes = 0u64; + for r in self.index.candidates(n).into_iter().filter_map(|i| self.rules.get(i)) { + *steps = steps.saturating_add(1); + if r.selector.matches_within(n, steps, most) { + let size = |(k, v, _): &(String, String, bool)| per_declaration.saturating_add((k.len() + v.len()) as u64); + bytes = r.decls.iter().map(size).fold(bytes, u64::saturating_add); + } + if *steps > most { + return None; + } + } + Some(bytes) + } + /// Are there rules with a `:not()` (which usvg's style sheets drop)? pub(super) fn has_not_rules(&self) -> bool { self.rules.iter().any(|r| r.selector.has_not()) diff --git a/crates/svg/src/import/files.rs b/crates/svg/src/import/files.rs index 691ea950f1..fe3335fb4e 100644 --- a/crates/svg/src/import/files.rs +++ b/crates/svg/src/import/files.rs @@ -74,9 +74,9 @@ 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 bytes of style values usvg copies into the elements of the SVG and of the SVG images - /// read so far ([`budget`]). - styles: AtomicU64, + /// What usvg makes of the SVG and of the SVG images read so far, all together ([`budget`]): + /// elements, bytes of copied style values and selector tests. + spent: Mutex, /// Why an SVG image refused the whole SVG: what usvg would make of it is past [`budget`]'s /// limits. The first one. refused: Mutex>, @@ -134,6 +134,9 @@ struct Reading<'a, 'w> { /// SVG links to aren't counted. svg_reads: usize, svg_text: u64, + /// What the linked SVG files measured for their links make, all together ([`budget`]); of one + /// past what is left, the selector tests it made. + measured: budget::Cost, } impl Reading<'_, '_> { @@ -254,8 +257,17 @@ impl Files { /// relinked. 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 }; + let mut reading = Reading { + opts, + warnings, + placeholder: None, + by_file: HashMap::new(), + active: vec![], + placed: false, + svg_reads: 0, + svg_text: 0, + measured: budget::Cost::default(), + }; files.sources = files.links_of(svg, xml, opts.folder, None, edits, &mut reading); files } @@ -383,6 +395,19 @@ impl Files { // Read from its own handle: the text stays borrowed while `svg` may be given back. let text = svg.text.clone(); let Ok(xml) = super::parse(&text) else { return Outcome::shown(Source::Svg(svg)) }; + // Measured before the style cascade of `depth::height`, as an SVG image is when usvg + // reads it ([`Files::svg_tree`]), which then refuses one past the limits, within what the + // linked SVG files measured before it left: past it, its links aren't read, and the + // selector tests it made still count. + let (result, tests) = budget::check_spending(&xml, budget::Cost::LIMITS.less(reading.measured), left); + match result { + Ok(cost) => reading.measured = reading.measured.add(cost), + Err(e) => { + reading.measured.tests = reading.measured.tests.saturating_add(tests); + reading.placed |= matches!(e, SvgError::TooDeep(_)); + return Outcome::shown(Source::Svg(svg)); + } + } let Some(height) = depth::height(&xml, left) else { reading.placed = true; return Outcome::shown(Source::Svg(svg)); @@ -485,10 +510,20 @@ impl Files { notes } - /// The SVG holding the images copies `bytes` of style values ([`budget::check`]): its images - /// copy at most what is left. - pub(in crate::import) fn copied_styles(&self, bytes: u64) { - self.styles.store(bytes, Ordering::Relaxed); + /// What usvg makes of the SVG holding the images ([`budget::check`]): its images make at most + /// what is left of [`budget::Cost::LIMITS`]. + pub(in crate::import) fn spent_by_svg(&self, spent: budget::Cost) { + self.set_spent(spent); + } + + fn spent(&self) -> budget::Cost { + self.spent.lock().map(|s| *s).unwrap_or(budget::Cost::LIMITS) + } + + fn set_spent(&self, spent: budget::Cost) { + if let Ok(mut s) = self.spent.lock() { + *s = spent; + } } /// The error of the first SVG image past [`budget`]'s limits, which refuses the SVG holding it. @@ -588,12 +623,12 @@ impl Files { } let xml = super::parse(text).ok()?; // What usvg would make of it, measured as the SVG holding it is (`import`), before the - // style cascade of `depth::height`; its copied styles count with theirs while usvg reads - // it, and only when it decodes. Too deep, it is left out; past the other limits, the whole - // SVG is refused. - let spent = self.styles.load(Ordering::Relaxed); - let bytes = match budget::check(&xml, budget::MAX_COPIED_STYLES.saturating_sub(spent), left) { - Ok(bytes) => bytes, + // style cascade of `depth::height`; its elements, copied styles and selector tests count + // with theirs while usvg reads it, and only when it decodes. Too deep, it is left out; + // past the other limits, the whole SVG is refused. + let spent = self.spent(); + let cost = match budget::check(&xml, budget::Cost::LIMITS.less(spent), left) { + Ok(cost) => cost, Err(SvgError::TooDeep(_)) => return leave_out(&self.too_deep, true), Err(e) => { if let Ok(mut refused) = self.refused.lock() { @@ -605,7 +640,7 @@ 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()?; - self.styles.store(spent.saturating_add(bytes), Ordering::Relaxed); + self.set_spent(spent.add(cost)); 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. @@ -631,7 +666,7 @@ impl Files { self.depth.store(used, Ordering::Relaxed); let Ok(tree) = tree else { // Left out: neither it nor the images inside it count. - self.styles.store(spent, Ordering::Relaxed); + self.set_spent(spent); return None; }; self.note_text(text); @@ -935,12 +970,44 @@ mod tests { placed: false, svg_reads: reads, svg_text: text, + measured: budget::Cost::default(), }; let mut files = Files::default(); files.sources = files.links_of(&svg, &xml, opts.folder, None, &mut Edits::default(), &mut reading); (log.into_inner().unwrap(), files) } + /// The linked SVG files measured for their links share the limits: each of these keeps about + /// 40 MB of declarations in the import's own cascade (its `:not()` rule), so the second one is + /// shown without its links, and neither it nor the cascade reads them. + #[test] + fn linked_svg_files_share_the_limits_for_their_links() { + let file = |png: &str| { + let groups = "".repeat(400); + let rule = format!("g:not(.x){{font-family:{}}}", "a".repeat(100_000)); + format!(r#"{groups}"#) + .into_bytes() + }; + let served = [("a.svg", file("a.png")), ("b.svg", file("b.png")), ("a.png", png()), ("b.png", png())]; + let (log, _) = read_links(&["a.svg"], &served, 0, 0); + assert_eq!(log, ["a.svg", "a.png"]); + let (log, _) = read_links(&["a.svg", "b.svg"], &served, 0, 0); + assert_eq!(log, ["a.svg", "a.png", "b.svg"]); + } + + /// A linked SVG file past the limits alone (a `` chain past the element limit) is shown + /// without its links, and the linked SVG files after it still get what it left. + #[test] + fn a_linked_svg_file_past_the_limits_leaves_the_others_their_links() { + let links: String = (1..=20).map(|i| format!(r##""##, i - 1)).collect(); + let chain = format!( + r##"{links}"## + ); + let served = [("a.svg", chain.into_bytes()), ("b.svg", linking(&["b.png"])), ("a.png", png()), ("b.png", png())]; + let (log, _) = read_links(&["a.svg", "b.svg"], &served, 0, 0); + assert_eq!(log, ["a.svg", "b.svg", "b.png"]); + } + fn png() -> Vec { let mut out = vec![]; image::RgbaImage::new(1, 1).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index daf3288920..3272c999fc 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -370,6 +370,10 @@ pub enum SvgError { /// copies and SVG images included, add up to more than this many megabytes. #[error("the SVG's styles expand to more than {0} MB")] StylesTooLarge(usize), + /// Matching the style sheets' selectors against the elements the SVG expands to, `` + /// copies and SVG images included, takes more tests than the reader allows. + #[error("the SVG's style sheets take too long to match")] + StylesTooComplex, } /// Export a document as an SVG string (linked images are not returned: see [`export_full`]). diff --git a/crates/svg/tests/it/import_details.rs b/crates/svg/tests/it/import_details.rs index 6276f5ee77..4d6606eaa5 100644 --- a/crates/svg/tests/it/import_details.rs +++ b/crates/svg/tests/it/import_details.rs @@ -911,6 +911,33 @@ fn svg_images_whose_styles_expand_past_the_limit_refuse_the_svg() { assert!(!art(&d).is_empty()); } +/// The SVG images share the element limit with the SVG showing them: each image's `` chain +/// makes 655,356 elements, so one imports and two together are refused before usvg makes the +/// second. +#[test] +fn svg_images_share_the_element_limit() { + let chain = use_chain(16, ""); + let unshown = chain.replacen(r##""##, "", 1); + assert_ne!(chain, unshown); + let image = image_of("image/svg+xml", &unshown); + let rect = r#""#; + assert!(import_with(&svg(&format!("{rect}{image}")), &ImportOptions::default()).is_ok()); + let err = import_with(&svg(&format!("{rect}{}", image.repeat(2))), &ImportOptions::default()).unwrap_err(); + assert!(matches!(err, SvgError::TooLarge(n) if n == vectorcraft_svg::MAX_NODES.min(999_999)), "{err:?}"); +} + +/// A `:not()` rule, which only the import's own style cascade reads, counts toward the style limit +/// the SVG images share: each image's rule gives 400 groups the long style (about 40 MB), so one +/// imports and two together are refused. +#[test] +fn svg_images_share_the_style_limit_with_not_rules() { + let groups = svg(&format!(r#"{}"#, long_style(), "".repeat(400))); + let image = image_of("image/svg+xml", &groups); + assert!(import_with(&svg(&image), &ImportOptions::default()).is_ok()); + let err = import_with(&svg(&image.repeat(2)), &ImportOptions::default()).unwrap_err(); + assert!(matches!(err, SvgError::StylesTooLarge(64)), "{err:?}"); +} + /// Nested SVG images exceeding `MAX_NESTED_SVG_IMAGES` are left out with a warning note (#3564). #[test] fn nested_svg_images_past_count_cap_are_left_out() { diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index ab50e14a58..4c55b5d90c 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6350,6 +6350,7 @@ digits Malayalam Malaiàlam @msg the spine has no segments l’espina no té segments @msg the stroke has no weight el traç no té gruix @msg the SVG's elements are nested more than {0} deep els elements de l’SVG estan imbricats a més de {0} nivells de profunditat +@msg the SVG's style sheets take too long to match fer coincidir els fulls d’estil de l’SVG triga massa @msg the SVG's styles expand to more than {0} MB els estils de l’SVG s’expandeixen a més de {0} MB @msg the target layer is locked or hidden la capa de destinació està blocada o amagada @msg the thumbnail image is malformed la imatge de la miniatura està mal formada diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3b9c300980..68b49bc86b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -9032,6 +9032,7 @@ breakText Glyphs Glyfy @msg the RTF file is too large to place as type (at most {_1} MB) soubor RTF je příliš velký na umístění jako text (nejvýše {_1} MB) @msg the SVG expands to more than {0} nested SVG images SVG se rozvine na více než {0} vnořených obrázků SVG @msg the SVG expands to more than {0} objects SVG se rozvine na více než {0} objektů +@msg the SVG's style sheets take too long to match porovnání šablon stylů SVG trvá příliš dlouho @msg the SVG's styles expand to more than {0} MB styly SVG se rozvinou na více než {0} MB @msg the SVG file is larger than {MAX_IMPORT_BYTES} bytes soubor SVG je větší než {MAX_IMPORT_BYTES} bajtů @msg the SVG has no drawable objects SVG nemá žádné vykreslitelné objekty diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index ddc01a8016..a114f2cdfe 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -8597,6 +8597,7 @@ breakText Glyphs Glyphen @msg the RTF file is too large to place as type (at most {_1} MB) die RTF-Datei ist zu groß, um als Text platziert zu werden (höchstens {_1} MB) @msg the SVG expands to more than {0} nested SVG images das SVG wird zu mehr als {0} verschachtelten SVG-Bildern erweitert @msg the SVG expands to more than {0} objects das SVG wird zu mehr als {0} Objekten erweitert +@msg the SVG's style sheets take too long to match das Abgleichen der Stylesheets des SVG dauert zu lange @msg the SVG's styles expand to more than {0} MB die Stile des SVG werden auf mehr als {0} MB erweitert @msg the SVG has no drawable objects das SVG hat keine zeichenbaren Objekte @msg the SVGZ file unpacks to more than {_1} MB die SVGZ-Datei wird auf mehr als {_1} MB entpackt diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index a89d3c72e4..945743c4fa 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4820,6 +4820,7 @@ digits Malayalam Μαλαγιάλαμ @msg result would exceed the canvas (coordinates out of range) το αποτέλεσμα θα υπερέβαινε τον καμβά (συντεταγμένες εκτός εύρους) @msg objects can't be nested more than 32 deep τα αντικείμενα δεν μπορούν να έχουν ένθεση μεγαλύτερη των 32 επιπέδων @msg the SVG's elements are nested more than {0} deep τα στοιχεία SVG έχουν ένθεση μεγαλύτερη των {0} επιπέδων +@msg the SVG's style sheets take too long to match η αντιστοίχιση των φύλλων στυλ του SVG διαρκεί πολύ @msg the SVG's styles expand to more than {0} MB τα στυλ του SVG επεκτείνονται σε περισσότερα από {0} MB @msg scale must be a number from {_1} to {_2}, not {_3} το scale πρέπει να είναι αριθμός από {_1} έως {_2}, όχι {_3} @msg scale must be a number from {_1} to {_2}, not {s} το scale πρέπει να είναι αριθμός από {_1} έως {_2}, όχι {s} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4a912f8005..706514b0c6 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4864,6 +4864,7 @@ axis Both Ambos @msg result would exceed the canvas (coordinates out of range) el resultado excedería el lienzo (coordenadas fuera de rango) @msg objects can't be nested more than 32 deep los objetos no pueden anidarse a más de 32 niveles @msg the SVG's elements are nested more than {0} deep los elementos del SVG están anidados a más de {0} niveles +@msg the SVG's style sheets take too long to match la comparación de las hojas de estilo del SVG tarda demasiado @msg the SVG's styles expand to more than {0} MB los estilos del SVG se expanden a más de {0} MB @msg scale must be a number from {_1} to {_2}, not {_3} scale debe ser un número de {_1} a {_2}, no {_3} @msg scale must be a number from {_1} to {_2}, not {s} scale debe ser un número de {_1} a {_2}, no {s} diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 06499d13ea..fe7097a5cd 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5079,6 +5079,7 @@ imageTrace Outline Ääriviiva @msg result would exceed the canvas (coordinates out of range) tulos ylittäisi kankaan (koordinaatit alueen ulkopuolella) @msg objects can't be nested more than 32 deep objekteja ei voi sisäkkäistää yli 32 tason syvyyteen @msg the SVG's elements are nested more than {0} deep elementit ovat SVG-tiedostossa sisäkkäin yli {0} tason syvyydessä +@msg the SVG's style sheets take too long to match SVG-tiedoston tyylisivujen täsmäytys kestää liian kauan @msg the SVG's styles expand to more than {0} MB SVG-tiedoston tyylit laajenevat yli {0} megatavuksi @msg scale must be a number from {_1} to {_2}, not {_3} scale-arvon on oltava luku väliltä {_1}–{_2}, ei {_3} @msg scale must be a number from {_1} to {_2}, not {s} scale-arvon on oltava luku väliltä {_1}–{_2}, ei {s} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 90882cf105..866e557571 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -8893,6 +8893,7 @@ breakText Glyphs Glyphes @msg the RTF file is too large to place as type (at most {_1} MB) le fichier RTF est trop volumineux pour être importé comme texte (au plus {_1} Mo) @msg the SVG expands to more than {0} nested SVG images le SVG se développe en plus de {0} images SVG imbriquées @msg the SVG expands to more than {0} objects le SVG se développe en plus de {0} objets +@msg the SVG's style sheets take too long to match la mise en correspondance des feuilles de style du SVG prend trop de temps @msg the SVG's styles expand to more than {0} MB les styles du SVG se développent en plus de {0} Mo @msg the SVG file is larger than {MAX_IMPORT_BYTES} bytes le fichier SVG dépasse {MAX_IMPORT_BYTES} octets @msg the SVG has no drawable objects le SVG n’a aucun objet dessinable diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 671052e82b..b250d637db 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -8298,6 +8298,7 @@ antiAlias Sharp शार्प @msg the subpath is closed or too short to close सबपैथ बंद है या बंद करने के लिए बहुत कम है @msg the SVG expands to more than {0} nested SVG images SVG {0} नेस्टेड SVG इमेज से अधिक का विस्तार करता है @msg the SVG expands to more than {0} objects SVG {0} ऑब्जेक्ट्स से अधिक का विस्तार करता है +@msg the SVG's style sheets take too long to match SVG की स्टाइल शीट का मिलान करने में बहुत अधिक समय लगता है @msg the SVG's styles expand to more than {0} MB SVG की शैलियाँ {0} MB से अधिक तक विस्तारित होती हैं @msg the SVG has no drawable objects SVG में कोई आकर्षक ऑब्जेक्ट नहीं है @msg the SVG's elements are nested more than {0} deep SVG के तत्वों को {0} से अधिक गहरा घोंसला दिया जाता है diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a218a2ce15..cf8262a48b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -8902,6 +8902,7 @@ breakText Glyphs Glifák @msg the RTF file is too large to place as type (at most {_1} MB) az RTF-fájl túl nagy ahhoz, hogy szövegként elhelyezhető legyen (legfeljebb {_1} MB) @msg the SVG expands to more than {0} nested SVG images az SVG kibontva több mint {0} beágyazott SVG-képet tartalmaz @msg the SVG expands to more than {0} objects az SVG kibontva több mint {0} objektumot tartalmaz +@msg the SVG's style sheets take too long to match az SVG stíluslapjainak illesztése túl sokáig tart @msg the SVG's styles expand to more than {0} MB az SVG stílusai kibontva több mint {0} MB-ot foglalnak @msg the SVG file is larger than {MAX_IMPORT_BYTES} bytes az SVG-fájl nagyobb, mint {MAX_IMPORT_BYTES} bájt @msg the SVG has no drawable objects az SVG-ben nincsenek kirajzolható objektumok diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 2b5a6bf3da..5e1b40cf0c 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -8645,6 +8645,7 @@ breakText Glyphs Glifi @msg the RTF file is too large to place as type (at most {_1} MB) il file RTF è troppo grande per essere inserito come testo (al massimo {_1} MB) @msg the SVG expands to more than {0} nested SVG images l'SVG si espande in più di {0} immagini SVG annidate @msg the SVG expands to more than {0} objects l'SVG si espande in più di {0} oggetti +@msg the SVG's style sheets take too long to match l'abbinamento dei fogli di stile dell'SVG richiede troppo tempo @msg the SVG's styles expand to more than {0} MB gli stili dell'SVG si espandono a oltre {0} MB @msg the SVG has no drawable objects l'SVG non ha oggetti disegnabili @msg the SVGZ file unpacks to more than {_1} MB il file SVGZ si decomprime in più di {_1} MB diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 87ec6c8b05..37753f21a8 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -7853,6 +7853,7 @@ axis Y Axis Y 軸 @msg the RTF file is too large to place as type (at most {_1} MB) RTF ファイルが大きすぎて、文字として配置できません (最大 {_1} MB) @msg the SVG expands to more than {0} nested SVG images SVG を展開すると、入れ子の SVG 画像が {0} 個を超えます @msg the SVG expands to more than {0} objects SVG を展開すると、オブジェクトが {0} 個を超えます +@msg the SVG's style sheets take too long to match SVG のスタイルシートの照合に時間がかかりすぎます @msg the SVG's styles expand to more than {0} MB SVG のスタイルを展開すると {0} MB を超えます @msg the SVG has no drawable objects SVG に描画できるオブジェクトがありません @msg the SVGZ file unpacks to more than {_1} MB SVGZ ファイルを展開すると {_1} MB を超えます diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 8c42eafaf1..c75925d692 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5696,6 +5696,7 @@ imageTrace Outline კონტური @msg object has no weighted stroke ობიექტს ცვლადი სისქის კონტური არ აქვს @msg objects can't be nested more than 32 deep ობიექტების ჩადგმის სიღრმე 32-ს ვერ გადააჭარბებს @msg the SVG's elements are nested more than {0} deep SVG-ის ელემენტების ჩადგმის სიღრმე {0}-ს აჭარბებს +@msg the SVG's style sheets take too long to match SVG-ის სტილის ფურცლების შედარებას ძალიან დიდი დრო სჭირდება @msg the SVG's styles expand to more than {0} MB SVG-ის სტილები გაშლისას {0} MB-ს აჭარბებს @msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode უცნობი control `{name}`: fillStroke, coloring, drawMode ან screenMode @plural agrees with {n} default shortcut|agrees with {n} default shortcuts ემთხვევა ნაგულისხმევ მალსახმობებს: {n}|ემთხვევა ნაგულისხმევ მალსახმობებს: {n} diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 4fd8001ea7..46dcaf82f8 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -8629,6 +8629,7 @@ breakText Glyphs 글리프 @msg the RTF file is too large to place as type (at most {_1} MB) RTF 파일이 너무 커서 문자로 가져올 수 없습니다(최대 {_1} MB) @msg the SVG expands to more than {0} nested SVG images SVG를 확장하면 중첩된 SVG 이미지가 {0}개보다 많아집니다 @msg the SVG expands to more than {0} objects SVG를 확장하면 개체가 {0}개보다 많아집니다 +@msg the SVG's style sheets take too long to match SVG의 스타일 시트를 대조하는 데 시간이 너무 오래 걸립니다 @msg the SVG's styles expand to more than {0} MB SVG의 스타일을 확장하면 {0}MB를 넘습니다 @msg the SVG file is larger than {MAX_IMPORT_BYTES} bytes SVG 파일이 {MAX_IMPORT_BYTES}바이트보다 큽니다 @msg the SVG has no drawable objects SVG에 그릴 수 있는 개체가 없습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 31872f060e..2cddf3ffda 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6975,6 +6975,7 @@ imageTrace Outline Contour @msg the RTF file is too large to place as type (at most {_1} MB) het RTF-bestand is te groot om als tekst te plaatsen (maximaal {_1} MB) @msg the SVG expands to more than {0} nested SVG images de SVG wordt uitgevouwen tot meer dan {0} geneste SVG-afbeeldingen @msg the SVG expands to more than {0} objects de SVG wordt uitgevouwen tot meer dan {0} objecten +@msg the SVG's style sheets take too long to match het vergelijken van de stijlbladen van de SVG duurt te lang @msg the SVG's styles expand to more than {0} MB de stijlen van de SVG worden uitgevouwen tot meer dan {0} MB @msg the SVG file is larger than {MAX_IMPORT_BYTES} bytes het SVG-bestand is groter dan {MAX_IMPORT_BYTES} bytes @msg the SVG has no drawable objects de SVG heeft geen tekenbare objecten diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 77b1425e5f..a7f9dbd2f9 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -8883,6 +8883,7 @@ breakText Glyphs Glify @msg the RTF file is too large to place as type (at most {_1} MB) plik RTF jest zbyt duży, aby umieścić go jako tekst (najwyżej {_1} MB) @msg the SVG expands to more than {0} nested SVG images SVG rozwija się do ponad {0} zagnieżdżonych obrazów SVG @msg the SVG expands to more than {0} objects SVG rozwija się do ponad {0} obiektów +@msg the SVG's style sheets take too long to match dopasowanie arkuszy stylów SVG trwa zbyt długo @msg the SVG's styles expand to more than {0} MB style SVG rozwijają się do ponad {0} MB @msg the SVG has no drawable objects SVG nie zawiera obiektów, które można narysować @msg the SVGZ file unpacks to more than {_1} MB plik SVGZ po rozpakowaniu ma ponad {_1} MB diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index caed79af2a..3cad46980c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -9095,6 +9095,7 @@ swatch Yellow Green Verde-amarelado @msg the RTF file is too large to place as type (at most {_1} MB) o arquivo RTF é grande demais para ser colocado como texto (no máximo {_1} MB) @msg the SVG expands to more than {0} nested SVG images o SVG se expande em mais de {0} imagens SVG aninhadas @msg the SVG expands to more than {0} objects o SVG se expande em mais de {0} objetos +@msg the SVG's style sheets take too long to match a correspondência das folhas de estilo do SVG demora demais @msg the SVG's styles expand to more than {0} MB os estilos do SVG se expandem para mais de {0} MB @msg the SVG file is larger than {MAX_IMPORT_BYTES} bytes o arquivo SVG tem mais de {MAX_IMPORT_BYTES} bytes @msg the SVG has no drawable objects o SVG não tem objetos desenháveis diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 206b209ee2..8fbb3d7901 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4901,6 +4901,7 @@ axis Both Оба @msg result would exceed the canvas (coordinates out of range) результат превысил бы холст (координаты вне диапазона) @msg objects can't be nested more than 32 deep объекты нельзя вкладывать глубже 32 уровней @msg the SVG's elements are nested more than {0} deep элементы SVG вложены глубже {0} уровней +@msg the SVG's style sheets take too long to match сопоставление таблиц стилей SVG занимает слишком много времени @msg the SVG's styles expand to more than {0} MB после развёртывания стили SVG занимают более {0} МБ @msg scale must be a number from {_1} to {_2}, not {_3} scale должен быть числом от {_1} до {_2}, а не {_3} @msg scale must be a number from {_1} to {_2}, not {s} scale должен быть числом от {_1} до {_2}, а не {s} diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 82e63d1667..af6baf860f 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -8668,6 +8668,7 @@ digits Hindi Hindi @msg the subpath has no ends to extend (it is closed or a single point) delbanan har inga ändar att förlänga (den är stängd eller en enkel punkt) @msg the SVG expands to more than {0} nested SVG images SVG-filen expanderar till fler än {0} kapslade SVG-bilder @msg the SVG expands to more than {0} objects SVG-filen expanderar till fler än {0} objekt +@msg the SVG's style sheets take too long to match matchningen av SVG-filens formatmallar tar för lång tid @msg the SVG's styles expand to more than {0} MB SVG-filens stilar expanderar till mer än {0} MB @msg the SVG has no drawable objects SVG-filen har inga ritbara objekt @msg the SVGZ file unpacks to more than {_1} MB SVGZ-filen packas upp till mer än {_1} MB diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 237694bff5..2b9138c682 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4634,6 +4634,7 @@ axis Both Her İkisi @msg result would exceed the canvas (coordinates out of range) sonuç tuvali aşacaktır (koordinatlar aralık dışındadır) @msg objects can't be nested more than 32 deep nesneler 32'den fazla derinliğe yerleştirilemez @msg the SVG's elements are nested more than {0} deep SVG'nin öğeleri {0} derinliğinden daha fazla yuvalanmıştır +@msg the SVG's style sheets take too long to match SVG'nin stil sayfalarının eşleştirilmesi çok uzun sürüyor @msg the SVG's styles expand to more than {0} MB SVG'nin stilleri genişletildiğinde {0} MB'tan fazla yer kaplıyor @msg scale must be a number from {_1} to {_2}, not {_3} scale {_1} ile {_2} arasında bir sayı olmalıdır, {_3} değil @msg scale must be a number from {_1} to {_2}, not {s} scale {_1} ile {_2} arasında bir sayı olmalıdır, {s} değil diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a74a03529d..066254ca53 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -8627,6 +8627,7 @@ breakText Glyphs Гліфи @msg the RTF file is too large to place as type (at most {_1} MB) файл RTF завеликий, щоб помістити його як текст (не більше {_1} МБ) @msg the SVG expands to more than {0} nested SVG images SVG розгортається у понад {0} вкладених зображень SVG @msg the SVG expands to more than {0} objects SVG розгортається у понад {0} об’єктів +@msg the SVG's style sheets take too long to match зіставлення таблиць стилів SVG триває надто довго @msg the SVG's styles expand to more than {0} MB стилі SVG розгортаються у понад {0} МБ @msg the SVG file is larger than {MAX_IMPORT_BYTES} bytes файл SVG більший за {MAX_IMPORT_BYTES} байтів @msg the SVG has no drawable objects SVG не містить об’єктів, які можна намалювати diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 16c613874c..695371ee7c 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5275,6 +5275,7 @@ theme Appearance Giao diện @msg result would exceed the canvas (coordinates out of range) kết quả sẽ vượt quá vùng vẽ (tọa độ ngoài phạm vi) @msg objects can't be nested more than 32 deep không thể lồng các đối tượng sâu quá 32 cấp @msg the SVG's elements are nested more than {0} deep các phần tử SVG lồng nhau sâu quá {0} cấp +@msg the SVG's style sheets take too long to match việc so khớp các biểu định kiểu của SVG mất quá nhiều thời gian @msg the SVG's styles expand to more than {0} MB các kiểu của SVG khi mở rộng chiếm hơn {0} MB @msg Save for Web: the image is empty Lưu cho Web: ảnh trống @msg Save writes {_1}, not {_2}: use document.export for other formats Lưu ghi {_1}, không phải {_2}: dùng document.export cho các định dạng khác diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b83bee650f..4308586ff5 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -8869,6 +8869,7 @@ breakText Glyphs 字形 @msg the RTF file is too large to place as type (at most {_1} MB) RTF 文件过大,无法作为文字置入 (最多 {_1} MB) @msg the SVG expands to more than {0} nested SVG images SVG 展开后的嵌套 SVG 图像超过 {0} 个 @msg the SVG expands to more than {0} objects SVG 展开后的对象超过 {0} 个 +@msg the SVG's style sheets take too long to match 匹配 SVG 的样式表耗时过长 @msg the SVG's styles expand to more than {0} MB SVG 的样式展开后超过 {0} MB @msg the SVG has no drawable objects SVG 中没有可绘制的对象 @msg the SVGZ file unpacks to more than {_1} MB SVGZ 文件解压后超过 {_1} MB diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 4637ecb811..655bea5a27 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -8797,6 +8797,7 @@ breakText Glyphs 字符 @msg the RTF file is too large to place as type (at most {_1} MB) RTF 檔案過大,無法置入為文字 (最多 {_1} MB) @msg the SVG expands to more than {0} nested SVG images SVG 展開後超過 {0} 個巢狀 SVG 影像 @msg the SVG expands to more than {0} objects SVG 展開後超過 {0} 個物件 +@msg the SVG's style sheets take too long to match 比對 SVG 的樣式表耗時過長 @msg the SVG's styles expand to more than {0} MB SVG 的樣式展開後超過 {0} MB @msg the SVG has no drawable objects SVG 中沒有可繪製的物件 @msg the SVGZ file unpacks to more than {_1} MB SVGZ 檔案解壓縮後超過 {_1} MB diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c6972468cc..ad103bfdf6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1864,7 +1864,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - 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`). - An image that SVG import stores again, as PNG or as a CMYK TIFF, keeps the resolution its PNG, JPEG, TIFF, WebP or BMP file declares, as Open and Place keep it: in a PNG `pHYs` chunk () or the TIFF's resolution tags, with the horizontal and vertical values swapped when the image turns a quarter. An image that declares no resolution is stored without one (`a_turned_raster_keeps_its_resolution`, `an_upright_tiff_keeps_its_resolution` and `a_cmyk_tiff_keeps_its_resolution_turned_or_not` in `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 measures what an SVG expands to before the SVG reader builds it: every copy a `` shows (), chains of ``s included, and the style values each copy gets from its `style` attribute and from the style sheet rules that match it. An SVG that expands to more than 999,999 elements (500,000 on the web) or to more than 64 MB of copied style values is refused with an error, and so is a loop of `` references that would be copied without end; both limits are VectorCraft's own. An SVG shown as an image counts toward the same 64 MB as the SVG showing it, and one past either limit refuses the whole import (`crates/svg/src/import/budget.rs`, `crates/svg/tests/it/import_details.rs`). + - SVG import measures what an SVG expands to before the SVG reader builds it: every copy a `` shows (), chains of ``s included, and the style values each copy gets from its `style` attribute and from the style sheet rules that match it. An SVG that expands to more than 999,999 elements (500,000 on the web) or to more than 64 MB of copied style values is refused with an error, and so is a loop of `` references that would be copied without end. The declarations the import's own style cascade keeps count toward the 64 MB too, once for each element a rule matches, including those of rules the reader drops, such as ones with `:not()`. Matching the style sheets' selectors against those elements may take at most 50,000,000 tests; past that the SVG is refused, so a selector that never matches can't keep the import busy. These limits are VectorCraft's own. An SVG shown as an image, from a data URL or a linked SVG file, counts toward the same limits as the SVG showing it, and one past any of them refuses the whole import (`crates/svg/src/import/budget.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.