diff --git a/crates/cad/src/import.rs b/crates/cad/src/import.rs index b7967093..f1f41048 100644 --- a/crates/cad/src/import.rs +++ b/crates/cad/src/import.rs @@ -214,6 +214,7 @@ pub fn import(bytes: &[u8], o: &ImportOptions) -> Result { let (mut document, mut warnings) = build::Build::new(&d, o).run(layout, natural, ratio)?; document.units = if o.fit { unit } else { o.unit.unwrap_or(unit) }; warnings.extend(damaged); + document.limit_depth_noting(&mut warnings); Ok(Imported { document, warnings }) } diff --git a/crates/doc/src/clipnest.rs b/crates/doc/src/clipnest.rs index 64d5c3f4..f77f9e01 100644 --- a/crates/doc/src/clipnest.rs +++ b/crates/doc/src/clipnest.rs @@ -24,8 +24,10 @@ pub struct Clip { } /// Deepest nesting of clips, and of groups, an importer records ([`nest`] goes one level deeper -/// for each): deeper ones are left out. -pub const MAX_NEST: usize = 128; +/// for each): deeper ones are left out. As deep as a document's objects may nest +/// ([`crate::MAX_DEPTH`]) under a layer, around the art; clips in groups that deep together are +/// merged by [`Document::limit_depth`]. +pub const MAX_NEST: usize = crate::MAX_DEPTH - 2; /// The warning for clips left out for nesting deeper than [`MAX_NEST`]. pub fn deep_clips() -> String { diff --git a/crates/doc/src/depth.rs b/crates/doc/src/depth.rs new file mode 100644 index 00000000..464eaf9d --- /dev/null +++ b/crates/doc/src/depth.rs @@ -0,0 +1,588 @@ +//! How deep objects may nest. +//! +//! The tree is walked by recursion everywhere (drawing, bounds, hit tests, saving, exporting), so +//! a tree of unbounded depth overflows the stack: an abort no guard catches. No document deeper +//! than [`MAX_DEPTH`] is ever kept, nor one whose symbols and patterns draw deeper than +//! [`MAX_DRAWN_DEPTH`] (a symbol holding an instance of itself, or a pattern painting its own +//! tile, draws without end): importers and the native reader merge deeper objects into the group +//! around them and leave such instances and paints out +//! ([`Document::limit_depth`], with [`merged_note`] and [`symbols_note`] as their warnings), and +//! the engine refuses an edit that would nest deeper ([`Document::too_deep`]). + +use std::collections::HashMap; +use std::sync::Arc; + +use vectorcraft_color::Paint; + +use crate::{Document, Node, NodeKind}; + +/// The deepest an object may be: a top-level layer is 1 deep, what it holds 2, and so on (the art +/// of an opacity mask is one deeper than the object it masks). A symbol's or pattern's art counts +/// from 1 again. +/// +/// A native file writes three levels of JSON for each level of objects, and its reader stops at +/// 128 levels: 32 leaves the deepest object 29 of them for its own data, so every document that +/// can be made can be saved and opened again. +pub const MAX_DEPTH: usize = 32; + +/// The deepest an object may be drawn: as [`MAX_DEPTH`], the art of a symbol counted under its +/// instance and a pattern's tile under the object it paints (and what those use, under them). +/// Symbols a few objects deep nest in one another dozens deep within it. +pub const MAX_DRAWN_DEPTH: usize = 4 * MAX_DEPTH; + +/// The warning of a file whose objects were nested deeper than [`MAX_DEPTH`]. +pub fn merged_note() -> String { + format!("objects nested more than {MAX_DEPTH} deep were merged into the group around them") +} + +/// The warning of a file with symbols or patterns that use themselves or are drawn deeper than +/// [`MAX_DRAWN_DEPTH`]. +pub fn symbols_note() -> String { + format!( + "symbol instances and pattern paints inside their own symbol or pattern, or drawn more than {MAX_DRAWN_DEPTH} objects deep, were left out" + ) +} + +/// Run `f` where recursion has room: on a thread of its own with a 16 MB stack (the main thread +/// has 1 MB on Windows), or here where there are no threads (the web) or none can be started. For +/// readers whose recursion is capped but heavy; a panic in `f` goes on to the caller. +pub fn with_deep_stack(f: impl Fn() -> T + Sync) -> T { + #[cfg(not(target_arch = "wasm32"))] + { + let threaded = std::thread::scope(|s| { + let thread = std::thread::Builder::new().stack_size(16 << 20).spawn_scoped(s, &f).ok()?; + Some(thread.join().unwrap_or_else(|panic| std::panic::resume_unwind(panic))) + }); + if let Some(done) = threaded { + return done; + } + } + f() +} + +/// Is anything in `now` (each `depth` deep) deeper than [`MAX_DEPTH`], or `over` (given its +/// depth)? Without recursion: the trees may be of any depth. With `before` (the same list before +/// an edit, within the caps), only what the edit changed is looked at: a subtree still shared with +/// it, in the same place, is as it was. +fn deeper(now: &[Arc], before: Option<&[Arc]>, depth: usize, over: &dyn Fn(&Node, usize) -> bool) -> bool { + type Pending<'a> = Vec<(&'a Node, Option<&'a Node>, usize)>; + fn push<'a>(stack: &mut Pending<'a>, now: &'a [Arc], before: Option<&'a [Arc]>, depth: usize) { + for (i, n) in now.iter().enumerate() { + let was = before.and_then(|b| b.get(i)); + if !was.is_some_and(|w| Arc::ptr_eq(w, n)) { + stack.push((n, was.map(|w| &**w), depth)); + } + } + } + let mut stack = vec![]; + push(&mut stack, now, before, depth); + while let Some((n, was, d)) = stack.pop() { + if d > MAX_DEPTH || over(n, d) { + return true; + } + if let Some(ch) = n.children() { + push(&mut stack, ch, was.and_then(Node::children).map(Vec::as_slice), d + 1); + } + if let Some(m) = &n.extras().mask { + let was = was.and_then(|w| w.extras().mask.as_ref()).map(|m| std::slice::from_ref(&m.art)); + push(&mut stack, std::slice::from_ref(&m.art), was, d + 1); + } + } + false +} + +/// [`deeper`] for the nesting alone. +fn nested_deeper(now: &[Arc], depth: usize) -> bool { + deeper(now, None, depth, &|_, _| false) +} + +/// Merge what is deeper than [`MAX_DEPTH`] in `nodes` (each `depth` deep) into the group around +/// it: a container at the cap is replaced by what it holds (a clipping group's clipping path and +/// an opacity mask too deep are left out); what it held stays hidden or locked as it was. Returns +/// whether anything changed. Recurses no deeper than the cap. +fn limit(nodes: &mut Vec>, depth: usize) -> bool { + let mut changed = false; + let mut i = 0; + while let Some(n) = nodes.get_mut(i) { + if !nested_deeper(std::slice::from_ref(n), depth) { + i += 1; + continue; + } + changed = true; + let n = Arc::make_mut(n); + if n.extras().mask.as_ref().is_some_and(|m| nested_deeper(std::slice::from_ref(&m.art), depth + 1)) { + n.extras_mut().mask = None; + } + if depth < MAX_DEPTH { + if let Some(ch) = n.children_mut() { + limit(ch, depth + 1); + } + i += 1; + continue; + } + let clip = matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. }); + let (visible, locked) = (n.visible, n.locked); + let mut held = n.children_mut().map(std::mem::take).unwrap_or_default(); + if clip && !held.is_empty() { + held.remove(0); + } + for c in &mut held { + if (!visible && c.visible) || (locked && !c.locked) { + let c = Arc::make_mut(c); + c.visible &= visible; + c.locked |= locked; + } + } + // What it held takes its place, and is looked at in its turn. + nodes.splice(i..=i, held); + } + changed +} + +/// A symbol (`false`) or a pattern (`true`) of the document, by name: what art draws by name, an +/// instance its symbol's art and a pattern paint its tile's. +type Def<'a> = (bool, &'a str); + +/// How deep each symbol's and pattern's art is drawn, the art of the symbols and patterns it uses +/// included (drawn under the object that uses them): `None` for one that uses itself, in its art +/// or through what that uses, or is drawn deeper than [`MAX_DRAWN_DEPTH`]. +type Heights<'a> = HashMap, Option>; + +/// What `n` itself draws by name: the symbol it is an instance of, the patterns that paint it. +fn uses(n: &Node) -> impl Iterator> { + let symbol = match &n.kind { + NodeKind::SymbolInstance { symbol, .. } => Some((false, symbol.as_str())), + _ => None, + }; + let patterns = crate::swatches::node_paints(n).filter_map(|p| match p { + Paint::Pattern { pattern, .. } => Some((true, pattern.as_str())), + _ => None, + }); + symbol.into_iter().chain(patterns) +} + +/// [`Heights`] of the document's symbols and patterns. Without recursion: they may use one +/// another in a chain of any length. Their own art is within [`MAX_DEPTH`]. +fn heights(doc: &Document) -> Heights<'_> { + // Each one's own depth, and what its art uses with the deepest use of each. + type Own<'a> = (usize, Vec<(Def<'a>, usize)>); + let arts = (doc.symbols.iter().map(|s| ((false, s.name.as_str()), std::slice::from_ref(&s.art)))) + .chain(doc.patterns.iter().map(|p| ((true, p.name.as_str()), &p.art[..]))); + let mut own: HashMap = HashMap::new(); + for (def, art) in arts { + let (mut deepest, mut used) = (0, HashMap::::new()); + let mut stack: Vec<(&Node, usize)> = art.iter().map(|n| (&**n, 1)).collect(); + while let Some((n, d)) = stack.pop() { + deepest = deepest.max(d); + for u in uses(n) { + let at = used.entry(u).or_default(); + *at = (*at).max(d); + } + stack.extend(n.children().into_iter().flatten().chain(n.extras().mask.as_ref().map(|m| &m.art)).map(|c| (&**c, d + 1))); + } + // (The first of two of one name is the one drawn.) + own.entry(def).or_insert((deepest, used.into_iter().collect())); + } + // Depth first over what uses what. One met again while it is being worked out (`None` in its + // entry) uses itself. + let mut found: HashMap>> = HashMap::new(); + let starts: Vec = own.keys().copied().collect(); + for start in starts { + if found.contains_key(&start) { + continue; + } + // (which, the next of its uses to look at, the deepest found so far: `None` once it can't be drawn) + let mut path: Vec<(Def, usize, Option)> = vec![(start, 0, own.get(&start).map(|o| o.0))]; + found.insert(start, None); + while let Some(&(def, next, deepest)) = path.last() { + let used = own.get(&def).map_or(&[][..], |o| &o.1); + let Some(&(other, at)) = used.get(next) else { + // Everything it uses is worked out. + let deepest = deepest.filter(|d| *d <= MAX_DRAWN_DEPTH); + found.insert(def, Some(deepest)); + path.pop(); + if let Some((user, next, held)) = path.last_mut() { + let at = own.get(user).and_then(|o| o.1.get(next.wrapping_sub(1))).map_or(1, |u| u.1); + *held = held.zip(deepest).map(|(h, d)| h.max(at + d)); + } + continue; + }; + let known = found.get(&other).copied(); + if known.is_none() && own.contains_key(&other) { + found.insert(other, None); + } + let Some((_, next, deepest)) = path.last_mut() else { break }; + *next += 1; + match known { + // Not in the document: it draws nothing. + None if !own.contains_key(&other) => {} + None => path.push((other, 0, own.get(&other).map(|o| o.0))), + Some(Some(Some(d))) => *deepest = deepest.map(|h| h.max(at + d)), + // It uses itself (it is on the way down here), or can't be drawn. + Some(None | Some(None)) => *deepest = None, + } + } + } + found.into_iter().map(|(def, h)| (def, h.flatten())).collect() +} + +/// Can't `used`, by an object `depth` deep, be drawn there? +fn undrawable(heights: &Heights, used: Def, depth: usize) -> bool { + heights.get(&used).is_some_and(|h| h.is_none_or(|h| depth + h > MAX_DRAWN_DEPTH)) +} + +/// Take out of `nodes` (each `depth` deep) the uses `bad` picks (given the depth of the object): +/// an instance goes, a pattern paint becomes no paint. Returns whether anything changed. For trees +/// within [`MAX_DEPTH`]. +fn strip(nodes: &mut Vec>, depth: usize, bad: &dyn Fn(Def, usize) -> bool) -> bool { + let any = |n: &Node, d: usize| uses(n).any(|u| bad(u, d)); + let before = nodes.len(); + nodes.retain(|n| !matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if bad((false, symbol.as_str()), depth))); + let mut changed = nodes.len() != before; + for n in nodes.iter_mut() { + if !deeper(std::slice::from_ref(n), None, depth, &any) { + continue; + } + changed = true; + let n = Arc::make_mut(n); + if uses(n).any(|u| bad(u, depth)) { + for p in crate::swatches::node_paints_mut(n) { + if matches!(p, Paint::Pattern { pattern, .. } if bad((true, pattern.as_str()), depth)) { + *p = Paint::None; + } + } + } + // An opacity mask whose art is such an instance goes with it. + let mut mask = n.extras().mask.as_ref().map(|m| vec![m.art.clone()]); + if let Some(art) = &mut mask + && strip(art, depth + 1, bad) + { + match art.pop() { + Some(art) => { + if let Some(m) = n.extras_mut().mask.as_mut() { + m.art = art; + } + } + None => n.extras_mut().mask = None, + } + } + if let Some(ch) = n.children_mut() { + strip(ch, depth + 1, bad); + } + } + changed +} + +impl Document { + /// Is anything nested deeper than [`MAX_DEPTH`], or drawn deeper than [`MAX_DRAWN_DEPTH`] + /// (a symbol or pattern that uses itself is)? With `before` (this document before an edit, + /// itself within the caps), only what the edit changed is looked at. + pub fn too_deep(&self, before: Option<&Document>) -> bool { + fn symbol(d: &Document, i: usize) -> Option<&[Arc]> { + d.symbols.get(i).map(|s| std::slice::from_ref(&s.art)) + } + fn pattern(d: &Document, i: usize) -> Option<&[Arc]> { + d.patterns.get(i).map(|p| &p.art[..]) + } + let nested = |_: &Node, _: usize| false; + if (0..self.symbols.len()).any(|i| symbol(self, i).is_some_and(|art| deeper(art, before.and_then(|b| symbol(b, i)), 1, &nested))) + || (0..self.patterns.len()).any(|i| pattern(self, i).is_some_and(|art| deeper(art, before.and_then(|b| pattern(b, i)), 1, &nested))) + { + return true; + } + if self.symbols.is_empty() && self.patterns.is_empty() { + return deeper(&self.layers, before.map(|b| &b.layers[..]), 1, &nested); + } + let heights = heights(self); + if heights.values().any(Option::is_none) { + return true; + } + // Symbols and patterns draw as deep as before when none changed; else every use of them + // is looked at again. + let same = |x: &[Arc], y: &[Arc]| x.len() == y.len() && x.iter().zip(y).all(|(x, y)| Arc::ptr_eq(x, y)); + let before = before.filter(|b| { + b.symbols.len() == self.symbols.len() + && b.patterns.len() == self.patterns.len() + && b.symbols.iter().zip(&self.symbols).all(|(x, y)| x.name == y.name && Arc::ptr_eq(&x.art, &y.art)) + && b.patterns.iter().zip(&self.patterns).all(|(x, y)| x.name == y.name && same(&x.art, &y.art)) + }); + let over = |n: &Node, d: usize| uses(n).any(|u| undrawable(&heights, u, d)); + deeper(&self.layers, before.map(|b| &b.layers[..]), 1, &over) + } + + /// [`Self::limit_depth`] → whether objects were merged, and whether uses of symbols and + /// patterns were left out. + fn limit_depths(&mut self) -> (bool, bool) { + let mut merged = limit(&mut self.layers, 1); + for s in &mut self.symbols { + // The art itself is 1 deep, so it stays the one object it is. + let mut art = vec![s.art.clone()]; + if limit(&mut art, 1) + && let Some(one) = art.pop() + { + s.art = one; + merged = true; + } + } + for p in &mut self.patterns { + merged |= limit(&mut p.art, 1); + } + if self.symbols.is_empty() && self.patterns.is_empty() { + return (merged, false); + } + // The symbols and patterns that can't be drawn lose what their art uses: each is then as + // deep as its own art, and what uses it can be drawn. + let (symbols, patterns): (Vec, Vec) = { + let heights = heights(self); + let bad = |def: Def| heights.get(&def).is_some_and(Option::is_none); + ( + (0..self.symbols.len()).filter(|i| self.symbols.get(*i).is_some_and(|s| bad((false, s.name.as_str())))).collect(), + (0..self.patterns.len()).filter(|i| self.patterns.get(*i).is_some_and(|p| bad((true, p.name.as_str())))).collect(), + ) + }; + let mut left_out = !(symbols.is_empty() && patterns.is_empty()); + let all = |_: Def, _: usize| true; + for i in symbols { + let Some(s) = self.symbols.get_mut(i) else { continue }; + let mut art = vec![s.art.clone()]; + strip(&mut art, 1, &all); + // (An instance alone was the whole art: an empty group stands in.) + s.art = art.pop().unwrap_or_else(|| Arc::new(Node::group(s.art.id, vec![]))); + } + for i in patterns { + if let Some(p) = self.patterns.get_mut(i) { + strip(&mut p.art, 1, &all); + } + } + // Then the uses drawn too deep where they are. + let mut layers = self.layers.clone(); + left_out |= { + let heights = heights(self); + strip(&mut layers, 1, &|used, depth| undrawable(&heights, used, depth)) + }; + self.layers = layers; + (merged, left_out) + } + + /// Bring the document within the caps: merge the objects nested deeper than [`MAX_DEPTH`] + /// into the group around them, and leave out the uses of symbols and patterns that can't be + /// drawn (inside their own symbol or pattern, or deeper than [`MAX_DRAWN_DEPTH`]). What an + /// importer does to what it read. Returns whether anything changed. + pub fn limit_depth(&mut self) -> bool { + let (merged, left_out) = self.limit_depths(); + merged || left_out + } + + /// [`Self::limit_depth`], adding [`merged_note`] and [`symbols_note`] to an importer's + /// `warnings` for what it did. + pub fn limit_depth_noting(&mut self, warnings: &mut Vec) { + let (merged, left_out) = self.limit_depths(); + warnings.extend(merged.then(merged_note)); + warnings.extend(left_out.then(symbols_note)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{NodeId, OpacityMask}; + + /// One path in `levels` groups, each holding a second path beside the next group. + fn nested(levels: usize) -> Arc { + let mut id = 0; + let mut next = || { + id += 1; + NodeId(id) + }; + let path = |id| Arc::new(Node::path(id, Default::default(), Default::default())); + let mut inner = path(next()); + for _ in 0..levels { + inner = Arc::new(Node::group(next(), vec![inner, path(next())])); + } + inner + } + + fn doc(art: Arc) -> Document { + let mut d = Document::new(100.0, 100.0); + if let Some(ch) = d.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) { + ch.push(art); + } + d + } + + #[test] + fn a_document_at_the_cap_is_left_alone() { + // The layer, the groups and the path in them: MAX_DEPTH deep. + let mut d = doc(nested(MAX_DEPTH - 2)); + let before = d.clone(); + assert!(!d.too_deep(None)); + assert!(!d.limit_depth()); + assert_eq!(d, before); + } + + #[test] + fn deeper_objects_are_merged_into_the_group_around_them() { + for levels in [MAX_DEPTH - 1, MAX_DEPTH + 5, 100_000] { + let mut d = doc(nested(levels)); + assert!(d.too_deep(None), "{levels}"); + assert!(d.limit_depth(), "{levels}"); + assert!(!d.too_deep(None), "{levels}"); + // Every path is kept, under the layer and the groups down to the cap. + assert_eq!(d.node_count(), 1 + (MAX_DEPTH - 2) + (levels + 1), "{levels}"); + assert!(!d.limit_depth(), "nothing more to merge"); + } + } + + #[test] + fn a_clipping_path_and_a_mask_too_deep_are_left_out_and_hidden_art_stays_hidden() { + let mut inner = Node::new(NodeId(9000), NodeKind::Group { children: vec![nested(0), nested(0)], clip: true }); + inner.visible = false; + let mut art = Arc::new(inner); + for i in 0..MAX_DEPTH { + art = Arc::new(Node::group(NodeId(9001 + i as u64), vec![art])); + } + let mut masked = Node::group(NodeId(8000), vec![]); + masked.extras_mut().mask = Some(Box::new(OpacityMask::new((*nested(MAX_DEPTH)).clone(), true))); + let mut d = doc(art); + if let Some(ch) = d.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) { + ch.push(Arc::new(masked)); + } + assert!(d.limit_depth()); + assert!(!d.too_deep(None)); + let mut leaves = vec![]; + d.walk(|n| { + if n.children().is_none() { + leaves.push(n.visible); + } + }); + assert_eq!(leaves, [false], "the clipping path is gone, what it clipped is still hidden"); + assert!(d.node(NodeId(8000)).is_some_and(|n| n.extras().mask.is_none())); + } + + #[test] + fn an_edit_is_checked_by_what_it_changed() { + let before = doc(nested(MAX_DEPTH - 2)); + let mut wrapped = before.clone(); + if let Some(ch) = wrapped.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) { + let all = std::mem::take(ch); + ch.push(Arc::new(Node::group(NodeId(7000), all))); + } + assert!(wrapped.too_deep(Some(&before)), "the same art, one deeper"); + let mut beside = before.clone(); + if let Some(ch) = beside.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) { + ch.push(nested(3)); + } + assert!(!beside.too_deep(Some(&before))); + assert!(!before.too_deep(Some(&before))); + } + + #[test] + fn symbols_and_patterns_are_limited_too() { + let mut d = Document::new(10.0, 10.0); + d.symbols.push(crate::Symbol { name: "s".into(), art: nested(MAX_DEPTH + 3) }); + assert!(d.too_deep(None)); + assert!(d.limit_depth()); + assert!(!d.too_deep(None)); + assert!(d.symbols.first().is_some_and(|s| s.art.children().is_some()), "still one object"); + } + + fn instance(id: u64, of: &str) -> Arc { + Arc::new(Node::new(NodeId(id), NodeKind::SymbolInstance { symbol: of.into(), xf: Default::default() })) + } + + /// `count` symbols, each `levels` groups around an instance of the next; the last one's of `last`. + fn symbols(count: usize, levels: usize, last: &str) -> Document { + let mut d = doc(instance(1, "s0")); + for i in 0..count { + let mut art = instance(2, &if i + 1 < count { format!("s{}", i + 1) } else { last.to_string() }); + for _ in 0..levels { + art = Arc::new(Node::group(NodeId(3), vec![art])); + } + d.symbols.push(crate::Symbol { name: format!("s{i}"), art }); + } + d + } + + fn instances(d: &Document) -> usize { + let mut n = 0; + let mut count = |x: &Node| n += usize::from(matches!(x.kind, NodeKind::SymbolInstance { .. })); + d.walk(&mut count); + d.symbols.iter().for_each(|s| s.art.walk(&mut count)); + n + } + + #[test] + fn symbols_in_symbols_are_drawn_only_so_deep() { + // Three objects deep each (two groups and the instance), under the instance on the layer. + let fits = MAX_DRAWN_DEPTH / 3 - 1; + let mut d = symbols(fits, 2, "none"); + assert!(!d.too_deep(None) && !d.limit_depth()); + let mut d = symbols(fits + 2, 2, "none"); + assert!(d.too_deep(None)); + let before = instances(&d); + assert!(d.limit_depth() && !d.too_deep(None)); + assert!(instances(&d) < before && instances(&d) > 0, "only the instances drawn too deep go"); + // A chain of any length is measured, not walked by recursion. + let mut d = symbols(100_000, 0, "none"); + assert!(d.too_deep(None) && d.limit_depth() && !d.too_deep(None)); + } + + #[test] + fn a_symbol_holding_itself_loses_the_instance() { + for (count, last) in [(1, "s0"), (3, "s0"), (3, "s1")] { + let mut d = symbols(count, 1, last); + assert!(d.too_deep(None), "{count} {last}"); + let mut warnings = vec![]; + d.limit_depth_noting(&mut warnings); + assert_eq!(warnings, [symbols_note()]); + assert!(!d.too_deep(None), "{count} {last}"); + assert!(d.symbols.iter().all(|s| s.art.children().is_some()), "the art is still a group"); + } + // An edit that makes one hold itself is seen, whatever else stayed as it was. + let before = symbols(2, 1, "none"); + let mut after = before.clone(); + after.symbols[1].art = Arc::new(Node::group(NodeId(9), vec![instance(8, "s0")])); + assert!(!before.too_deep(None) && after.too_deep(Some(&before))); + } + + /// A path painted with the pattern named `with`. + fn painted(id: u64, with: &str) -> Arc { + let fill = Paint::Pattern { pattern: with.into(), xf: Default::default() }; + Arc::new(Node::path(NodeId(id), Default::default(), crate::Appearance::basic(fill, Paint::None, 0.0))) + } + + fn pattern_fills(d: &Document) -> usize { + let mut n = 0; + d.visit_paints(&mut |p| n += usize::from(matches!(p, Paint::Pattern { .. }))); + n + } + + #[test] + fn a_pattern_painting_its_own_tile_loses_the_paint() { + // One that paints its own tile; two that paint each other's; one in a symbol it holds. + let itself = |d: &mut Document| d.patterns.push(crate::PatternDef::new("p", vec![painted(2, "p")])); + let each_other = |d: &mut Document| { + d.patterns.push(crate::PatternDef::new("p", vec![painted(2, "q")])); + d.patterns.push(crate::PatternDef::new("q", vec![painted(3, "p")])); + }; + let through_a_symbol = |d: &mut Document| { + d.patterns.push(crate::PatternDef::new("p", vec![instance(2, "s")])); + d.symbols.push(crate::Symbol { name: "s".into(), art: painted(3, "p") }); + }; + for make in [&itself as &dyn Fn(&mut Document), &each_other, &through_a_symbol] { + let mut d = doc(painted(1, "p")); + make(&mut d); + assert!(d.too_deep(None)); + let mut warnings = vec![]; + d.limit_depth_noting(&mut warnings); + assert_eq!(warnings, [symbols_note()]); + assert!(!d.too_deep(None)); + assert_eq!(pattern_fills(&d), 1, "the object on the layer keeps its pattern"); + } + // A pattern whose tile is painted with another is fine. + let mut d = doc(painted(1, "p")); + d.patterns.push(crate::PatternDef::new("p", vec![painted(2, "q")])); + d.patterns.push(crate::PatternDef::new("q", vec![painted(3, "none")])); + assert!(!d.too_deep(None) && !d.limit_depth()); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 1d4805ec..54af60a9 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -11,6 +11,7 @@ pub mod blend; pub mod clipnest; pub mod cmyk; pub mod corners; +pub mod depth; pub mod graph; pub mod hit; mod id_index; @@ -79,6 +80,7 @@ pub use appearance::{ }; pub use assets::ExportAsset; pub use corners::LiveCorners; +pub use depth::MAX_DEPTH; pub use graph::{ GraphDesign, GraphKind, GraphSpec, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, PiePosition, PieSort, SeriesPaint, TickLength, ValueAxisSide, diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index ab888d7f..bda2f47a 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -224,7 +224,7 @@ fn item_paint_mut(it: &mut AppearanceItem) -> &mut Paint { } /// The paints a node holds itself (not its children): appearance items, then text runs. -fn node_paints(n: &Node) -> impl Iterator { +pub(crate) fn node_paints(n: &Node) -> impl Iterator { let runs = match &n.kind { NodeKind::Text(t) => t.runs.as_slice(), _ => &[], @@ -233,7 +233,7 @@ fn node_paints(n: &Node) -> impl Iterator { } /// Mutable [`node_paints`], in the same order. -fn node_paints_mut(n: &mut Node) -> impl Iterator { +pub(crate) fn node_paints_mut(n: &mut Node) -> impl Iterator { let runs = match &mut n.kind { NodeKind::Text(t) => t.runs.as_mut_slice(), _ => &mut [], diff --git a/crates/engine/src/cmd/fileio/affinity.rs b/crates/engine/src/cmd/fileio/affinity.rs index 62311ed2..0a5a2f03 100644 --- a/crates/engine/src/cmd/fileio/affinity.rs +++ b/crates/engine/src/cmd/fileio/affinity.rs @@ -32,6 +32,7 @@ pub(super) fn import(name: &str, bytes: &[u8]) -> Result { Ok(d) => { let mut b = Builder { doc: Document::new(0.0, 0.0), warnings: Vec::new(), k: 72.0 / d.dpi, layer: 0 }; b.document(&d)?; + b.doc.limit_depth_noting(&mut b.warnings); let mut warnings = d.warnings.iter().map(|w| format!("Affinity: not imported or approximated: {w}.")).collect::>(); warnings.extend(b.warnings.iter().map(|w| format!("Affinity: {w}."))); Ok(Imported { doc: b.doc, warnings, preview_only: false }) diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index f69f4c54..1ac08df1 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -139,7 +139,8 @@ fn restore_or_import( /// (with a warning for each that can't be used). pub(crate) fn native_file(bytes: &[u8]) -> Result<(Document, vectorcraft_format::FileInfo, Vec)> { let f = vectorcraft_format::load_file(bytes).map_err(err)?; - let warnings = super::native::install_profiles(f.profiles); + let mut warnings = super::native::install_profiles(f.profiles); + warnings.extend(f.depth_notes); Ok((f.doc, f.info, warnings)) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b7c789ab..0157f169 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -583,6 +583,10 @@ pub(crate) fn is_guide_path(d: &Document, id: NodeId) -> bool { d.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Path { guide: true, .. })) } +/// The error of an edit that would nest objects deeper than [`vectorcraft_doc::MAX_DEPTH`]. +pub const TOO_DEEP: &str = "objects can't be nested more than 32 deep"; +const _: () = assert!(vectorcraft_doc::MAX_DEPTH == 32, "TOO_DEEP (and its catalog rows) names the cap"); + /// Cheap sanity check after an edit: artboards and the objects just touched (the selection) must /// have finite, in-range geometry, so saved files always reload and renderers never see NaN/∞. fn doc_sane(d: &Document, sel: &Selection) -> bool { @@ -1245,6 +1249,9 @@ impl Session { } /// Add a document and make it active. pub fn add_document(&mut self, mut doc: Document, path: Option) -> usize { + // Every edit is checked against the document before it, so the first one is within the cap + // (importers and the native reader have seen to theirs already, with a warning). + doc.limit_depth(); Self::refresh_text_bounds(&mut doc); // Guides from files saved before guides had layers go on a layer. doc.adopt_guides(); @@ -1262,6 +1269,7 @@ impl Session { if index >= self.docs.len() { return false; } + doc.limit_depth(); Self::refresh_text_bounds(&mut doc); doc.adopt_guides(); if self.active == Some(index) { @@ -1476,7 +1484,10 @@ impl Session { if !st.doc.variables.bindings.is_empty() { Arc::make_mut(&mut st.doc).prune_variable_bindings(); } - if doc_sane(&st.doc, &st.selection) { + if st.doc.too_deep(Some(&before)) { + // Deeper trees overflow the stack of what walks them. + Err(EngineError::Other(TOO_DEEP.into())) + } else if doc_sane(&st.doc, &st.selection) { Ok(v) } else { Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into())) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 7d7c9d62..84a9cc85 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2440,3 +2440,345 @@ proptest! { checked("mutated script", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes), "name": "sample.jsx"}))?; } } + +// ---------- deep nesting ---------- +// +// The tree is walked by recursion, so a file nested deeply enough overflowed the stack: an abort, +// which nothing catches. Every reader caps what it reads, and no document is deeper than +// `MAX_DEPTH`. These run on a thread with the stack of a Windows main thread (1 MB), where an +// overflow ends the test run. + +use vectorcraft_doc::MAX_DEPTH; + +/// `f` on a thread with a 1 MB stack. +fn on_a_small_stack(f: impl FnOnce() + Send + 'static) { + std::thread::Builder::new().stack_size(1 << 20).spawn(f).unwrap().join().unwrap(); +} + +/// Open `bytes` as the file `name`: an error, or a document within the cap that renders and +/// exports. Returns the document and the import's warnings. +fn open_deep(name: &str, bytes: &[u8]) -> Option<(Document, Vec)> { + let loaded = vectorcraft_engine::cmd::fileio::load(name, bytes).ok()?; + assert!(!loaded.doc.too_deep(None), "{name}: deeper than the cap"); + let doc = loaded.doc.clone(); + survive(name, move || Some(doc)).unwrap(); + Some((loaded.doc, loaded.warnings)) +} + +fn merged(warnings: &[String]) -> bool { + warnings.iter().any(|w| w.contains("nested more than")) +} + +fn paths_in(doc: &Document) -> usize { + let mut paths = 0; + doc.walk(|n| paths += usize::from(n.path_data().is_some())); + paths +} + +const SVG_NS: &str = "xmlns=\"http://www.w3.org/2000/svg\""; + +/// `n` groups in one another, each named (so it is kept) and with a rectangle beside the next. +fn nested_svg(n: usize) -> String { + let open: String = (0..n).map(|i| format!("")).collect(); + format!("{open}{}", "".repeat(n)) +} + +#[test] +fn deeply_nested_svg_is_refused_or_merged() { + on_a_small_stack(|| { + // Within what the reader takes: opened, the groups past the cap merged into the last. + let (doc, warnings) = open_deep("deep.svg", nested_svg(40).as_bytes()).expect("40 groups open"); + assert!(merged(&warnings), "{warnings:?}"); + assert_eq!(paths_in(&doc), 41, "no art is lost"); + let (_, warnings) = open_deep("shallow.svg", nested_svg(20).as_bytes()).expect("20 groups open"); + assert!(!merged(&warnings), "{warnings:?}"); + // As deep as the reader goes (on a stack of its own): opened and merged the same. + let most = vectorcraft_svg::MAX_ELEMENT_DEPTH; + let (doc, warnings) = open_deep("deepest.svg", nested_svg(most - 2).as_bytes()).expect("opens at the reader's cap"); + assert!(merged(&warnings), "{warnings:?}"); + assert_eq!(paths_in(&doc), most - 1, "no art is lost"); + // Past it: an error that says why, whatever the depth. + for n in [most, 2 * most, 200_000] { + let e = vectorcraft_engine::cmd::fileio::load("deep.svg", nested_svg(n).as_bytes()).err().expect("refused").to_string(); + assert!(e.contains(&format!("nested more than {most} deep")), "{n}: {e}"); + } + // Unnamed groups of one object aren't kept, but usvg walks them all the same. + let plain = format!( + "{}{}", + "".repeat(2 * most), + "".repeat(2 * most) + ); + assert!(open_deep("plain.svg", plain.as_bytes()).is_none()); + // A chain of ``: shallow XML, a deep tree. + let links: String = (0..3000).map(|i| format!("", i + 1)).collect(); + let chain = format!( + "{links}" + ); + assert!(open_deep("chain.svg", chain.as_bytes()).is_none()); + // Placed and pasted, as opened. + let mut s = vectorcraft_engine::Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); + for n in [40, 2 * most] { + let p = json!({"name": "deep.svg", "dataBase64": vectorcraft_format::base64_encode(nested_svg(n).as_bytes())}); + let _ = no_panic(s.execute("file.place", &p)); + let _ = no_panic(s.execute("clipboard.importSvg", &json!({"svg": nested_svg(n)}))); + let _ = no_panic(s.execute("edit.paste", &json!({}))); + assert!(!s.doc().unwrap().doc.too_deep(None)); + } + }); +} + +/// A page whose content is `content`, with `resources`. +fn deep_pdf(content: &str, resources: &str) -> Vec { + handmade_pdf(content, resources, "[0 0 100 100]") +} + +/// An EPS whose program is `body`. +fn deep_eps(body: &str) -> Vec { + format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{body}\nshowpage\n%%EOF\n").into_bytes() +} + +/// A test for each file: it opens as an error or as a document within the cap, on a small stack. +macro_rules! deep_files { + ($($test:ident: $name:literal, $bytes:expr;)*) => {$( + #[test] + fn $test() { + on_a_small_stack(|| { + open_deep($name, &$bytes); + }); + } + )*}; +} + +deep_files! { + // PDF: clips inside clips (saved states never restored); arrays in arrays as an operand and as + // an object; dictionaries in dictionaries as a resource and inline; marked content never ended. + pdf_clips_in_clips: "clips.pdf", deep_pdf(&format!("{} 0 0 5 5 re f", "q 0 0 50 50 re W n ".repeat(20_000)), "<< >>"); + pdf_arrays_in_an_operand: "arrays.pdf", deep_pdf(&format!("BT {}{} TJ ET 0 0 5 5 re f", "[".repeat(100_000), "]".repeat(100_000)), "<< >>"); + pdf_arrays_in_an_object: "boxes.pdf", handmade_pdf("0 0 5 5 re f", "<< >>", &format!("{}0 0 100 100{}", "[".repeat(100_000), "]".repeat(100_000))); + pdf_dictionaries_in_resources: "dicts.pdf", deep_pdf("0 0 5 5 re f", &format!("{}{}", "<< /A ".repeat(50_000), ">> ".repeat(50_000))); + pdf_dictionaries_in_content: "inline.pdf", deep_pdf(&format!("/P {}{} DP 0 0 5 5 re f", "<< /A ".repeat(50_000), ">> ".repeat(50_000)), "<< >>"); + pdf_marked_content_never_ended: "marked.pdf", deep_pdf(&format!("{} 0 0 5 5 re f", "/Span << /MCID 0 >> BDC ".repeat(50_000)), "<< >>"); + // PostScript: clips inside clips; procedures in procedures; arrays in arrays; a procedure that + // calls itself; dictionaries never ended. + eps_clips_in_clips: "clips.eps", deep_eps(&format!("{} 0 0 5 5 rectfill", "gsave 0 0 50 50 rectclip ".repeat(20_000))); + eps_procedures_in_procedures: "procs.eps", deep_eps(&format!("{}{} 0 0 5 5 rectfill", "{".repeat(100_000), "}".repeat(100_000))); + eps_arrays_in_arrays: "arrays.eps", deep_eps(&format!("{}{} pop 0 0 5 5 rectfill", "[".repeat(100_000), "]".repeat(100_000))); + eps_a_procedure_calling_itself: "runaway.eps", deep_eps("/f { 0 0 5 5 rectfill f } def f"); + eps_dictionaries_never_ended: "dicts.eps", deep_eps(&format!("{} 0 0 5 5 rectfill", "1 dict begin ".repeat(100_000))); +} + +#[test] +fn deeply_nested_dxf_blocks_are_read_within_the_cap() { + on_a_small_stack(|| { + // Block `B0` inserts `B1`, which inserts `B2`, and so on; the last one holds a line. + let pair = |c: i32, v: &str| (c, v.to_string()); + let mut pairs = vec![pair(0, "SECTION"), pair(2, "BLOCKS")]; + for i in 0..2_000 { + pairs.extend([pair(0, "BLOCK"), pair(2, &format!("B{i}")), pair(10, "0"), pair(20, "0")]); + if i + 1 < 2_000 { + pairs.extend([pair(0, "INSERT"), pair(2, &format!("B{}", i + 1)), pair(10, "1"), pair(20, "1")]); + } else { + pairs.extend([pair(0, "LINE"), pair(10, "0"), pair(20, "0"), pair(11, "5"), pair(21, "5")]); + } + pairs.push(pair(0, "ENDBLK")); + } + pairs.extend([pair(0, "ENDSEC"), pair(0, "SECTION"), pair(2, "ENTITIES"), pair(0, "INSERT"), pair(2, "B0"), pair(10, "0"), pair(20, "0")]); + pairs.extend([pair(0, "LINE"), pair(10, "0"), pair(20, "0"), pair(11, "9"), pair(21, "9"), pair(0, "ENDSEC"), pair(0, "EOF")]); + open_deep("blocks.dxf", dxf_text(&pairs).as_bytes()).expect("opens"); + }); +} + +/// A layer holding `levels` groups in one another around a rectangle, each with a second +/// rectangle beside the next: `levels + 2` deep. +fn nested_document(levels: usize) -> Document { + use vectorcraft_doc::Node; + use vectorcraft_testkit::fixtures::DocBuilder; + let mut b = DocBuilder::new(100.0, 100.0); + let rect = + |b: &mut DocBuilder, x: f64| b.rect_node(vectorcraft_geom::Rect::new(x, 0.0, x + 10.0, 10.0), vectorcraft_color::Color::rgb8(200, 0, 0)); + let mut inner = rect(&mut b, 0.0); + for _ in 0..levels { + let beside = rect(&mut b, 20.0); + inner = Node::group(b.alloc(), vec![std::sync::Arc::new(inner), std::sync::Arc::new(beside)]); + inner.opacity = 0.9; + } + b.add(inner); + b.build() +} + +#[test] +fn a_document_at_the_cap_saves_opens_and_exports() { + on_a_small_stack(|| { + let doc = nested_document(MAX_DEPTH - 2); + assert!(!doc.too_deep(None)); + let back = vectorcraft_format::load_file(&vectorcraft_format::save_file(&doc)).expect("what can be made opens again"); + assert!(back.depth_notes.is_empty()); + assert_eq!(back.doc.layers, doc.layers); + survive("at the cap", || Some(doc.clone())).unwrap(); + // Every format that keeps the nesting reads its own file back within the cap. + let mut s = vectorcraft_engine::Session::new(); + s.add_document(doc, None); + for format in ["svg", "pdf", "eps", "ai"] { + let r = s.execute("document.export", &json!({"format": format})).unwrap(); + let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + open_deep(&format!("cap.{format}"), &bytes).unwrap_or_else(|| panic!("{format} opens")); + } + for cmd in ["select.all", "document.inspect", "document.info", "edit.copy", "edit.paste", "object.ungroup", "edit.undo"] { + no_panic(s.execute(cmd, &json!({}))).unwrap_or_else(|e| panic!("{cmd}: {e}")); + } + }); +} + +#[test] +fn a_native_file_nested_too_deep_is_merged_or_refused() { + on_a_small_stack(|| { + // Saved before the cap by a version that could still open it: opened, the deepest merged. + let old = vectorcraft_format::save_file(&nested_document(MAX_DEPTH + 2)); + let (doc, warnings) = open_deep("old.vectorcraft", &old).expect("opens"); + assert!(merged(&warnings), "{warnings:?}"); + assert_eq!(paths_in(&doc), MAX_DEPTH + 3, "no art is lost"); + // Deeper than any version could open, and a hostile file: an error that says why. Written + // by hand: a tree this deep can't be built and saved. + let text = String::from_utf8(vectorcraft_format::save(&nested_document(0), false)).unwrap(); + let (open, close) = ("{\"id\":900000,\"kind\":{\"type\":\"group\",\"children\":[", "]}}"); + let at = text.find("\"children\":[").expect("the layer's children") + "\"children\":[".len(); + for levels in [100, 50_000] { + let deep = format!("{}{}{},{}", &text[..at], open.repeat(levels), close.repeat(levels), &text[at..]); + let e = vectorcraft_engine::cmd::fileio::load("deep.vectorcraft", deep.as_bytes()).err().expect("refused").to_string(); + assert!(e.contains("nested too deep"), "{levels}: {e}"); + } + }); +} + +#[test] +fn commands_cannot_nest_deeper_than_the_cap() { + on_a_small_stack(|| { + let mut s = vectorcraft_engine::Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + let mut grouped = 0; + for _ in 0..10_000 { + match no_panic(s.execute("object.group", &json!({}))) { + Ok(_) => grouped += 1, + Err(e) => assert!(e.to_string().contains(vectorcraft_engine::TOO_DEEP), "after {grouped} groups: {e}"), + } + } + // The layer, the groups and the rectangle. + assert_eq!(grouped, MAX_DEPTH - 2); + assert!(!s.doc().unwrap().doc.too_deep(None)); + // Other ways down: a clipping mask, a copy pasted, a symbol made of it. + for (cmd, p) in [ + ("object.clippingMask.make", json!({})), + ("edit.copy", json!({})), + ("edit.pasteInFront", json!({})), + ("symbol.new", json!({"name": "deep"})), + ] { + let _ = no_panic(s.execute(cmd, &p)); + assert!(!s.doc().unwrap().doc.too_deep(None), "{cmd}"); + } + survive("grouped to the cap", || Some((*s.doc().unwrap().doc).clone())).unwrap(); + }); +} + +/// A document whose one object is an instance of the first of `count` symbols: each symbol's art +/// is `levels` groups around an instance of the next symbol, the last one of `last`. +fn symbols_document(count: usize, levels: usize, last: &str) -> Document { + use vectorcraft_doc::{Node, NodeKind, Symbol}; + let instance = + |doc: &mut Document, of: String| Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: of, xf: vectorcraft_geom::Affine::IDENTITY }); + let mut doc = nested_document(1); + for i in 0..count { + let mut art = instance(&mut doc, if i + 1 < count { format!("s{}", i + 1) } else { last.to_string() }); + for _ in 0..levels { + art = Node::group(doc.alloc_id(), vec![std::sync::Arc::new(art)]); + } + doc.symbols.push(Symbol { name: format!("s{i}"), art: std::sync::Arc::new(art) }); + } + let first = instance(&mut doc, "s0".into()); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, first).unwrap(); + doc +} + +/// `doc` saved (as an older version, or a hostile file, could have) and opened again: within the +/// caps (`kept`: as it was), drawn, exported and edited. +fn symbols_survive(what: &str, doc: Document, kept: bool) { + let (back, warnings) = open_deep("symbols.vectorcraft", &vectorcraft_format::save_file(&doc)).expect("opens"); + assert_eq!(!warnings.iter().any(|w| w.contains("symbol instances")), kept, "{what}: {warnings:?}"); + let mut s = vectorcraft_engine::Session::new(); + s.add_document(back, None); + for cmd in [ + "select.all", + "document.inspect", + "document.info", + "object.expand", + "symbol.breakLink", + "object.flattenTransparency", + "edit.copy", + "edit.paste", + ] { + let _ = no_panic(s.execute(cmd, &json!({}))); + assert!(!s.doc().unwrap().doc.too_deep(None), "{what}: {cmd}"); + } + for format in ["svg", "pdf", "eps", "png"] { + let _ = no_panic(s.execute("document.export", &json!({"format": format}))); + } +} + +#[test] +fn a_symbol_holding_itself_opens_without_the_instance() { + on_a_small_stack(|| { + symbols_survive("a symbol holding itself", symbols_document(1, 1, "s0"), false); + symbols_survive("symbols holding one another", symbols_document(3, 2, "s0"), false); + }); +} + +#[test] +fn symbols_in_symbols_are_drawn_only_so_deep() { + on_a_small_stack(|| { + // As deep as symbols draw: each one's art as deep as art goes. + symbols_survive("symbols at the cap", symbols_document(3, MAX_DEPTH - 2, "none"), true); + symbols_survive("symbols in symbols", symbols_document(40, MAX_DEPTH - 1, "none"), false); + // A symbol can't be made to hold itself, or one that holds it, by editing either. + let mut s = vectorcraft_engine::Session::new(); + s.add_document(symbols_document(2, 1, "none"), None); + s.execute("select.all", &json!({})).unwrap(); + for (cmd, p) in [ + ("symbol.new", json!({"name": "s1"})), + ("symbol.update", json!({"name": "s1"})), + ("symbol.update", json!({"name": "s0"})), + ("symbol.replace", json!({"name": "s1"})), + ] { + let _ = no_panic(s.execute(cmd, &p)); + assert!(!s.doc().unwrap().doc.too_deep(None), "{cmd} {p}"); + } + }); +} + +#[test] +fn a_pattern_painting_its_own_tile_opens_without_the_paint() { + on_a_small_stack(|| { + use vectorcraft_color::Paint; + use vectorcraft_doc::{Appearance, PatternDef}; + let painted = |doc: &mut Document, with: &str| { + let fill = Paint::Pattern { pattern: with.into(), xf: vectorcraft_geom::Affine::IDENTITY }; + let square = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 20.0, 20.0)); + vectorcraft_doc::Node::path(doc.alloc_id(), square, Appearance::basic(fill, Paint::None, 0.0)) + }; + // One pattern painting its own tile, and two painting each other's. + for names in [["p", "p"], ["p", "q"]] { + let mut doc = nested_document(1); + let (first, second) = (painted(&mut doc, names[1]), painted(&mut doc, names[0])); + doc.patterns.push(PatternDef::new(names[0], vec![std::sync::Arc::new(first)])); + if names[0] != names[1] { + doc.patterns.push(PatternDef::new(names[1], vec![std::sync::Arc::new(second)])); + } + let on_layer = painted(&mut doc, "p"); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, on_layer).unwrap(); + symbols_survive("a pattern painting its own tile", doc, false); + } + }); +} diff --git a/crates/eps/src/import/interp.rs b/crates/eps/src/import/interp.rs index 6104d1b5..f74b5df0 100644 --- a/crates/eps/src/import/interp.rs +++ b/crates/eps/src/import/interp.rs @@ -4,7 +4,6 @@ //! Every operation counts against a budget and procedures nest only so deep, so a looping or //! recursing program ends with [`PsError::Limit`] instead of hanging. -use std::cell::RefCell; use std::rc::Rc; use vectorcraft_geom::Affine; @@ -86,7 +85,7 @@ struct SaveMark { } fn new_dict() -> DictRef { - Rc::new(RefCell::new(Dict::new())) + DictRef::new(Dict::new()) } /// An encoding vector: 256 `/.notdef` names (type is read as Latin-1 whatever the encoding). diff --git a/crates/eps/src/import/mod.rs b/crates/eps/src/import/mod.rs index 3be867a7..276cc981 100644 --- a/crates/eps/src/import/mod.rs +++ b/crates/eps/src/import/mod.rs @@ -268,5 +268,7 @@ fn finish_with(out: Out, layers: Vec>) -> Imported { } } doc.layers = layers; - Imported { document: doc, warnings: out.warnings, preview: false } + let mut warnings = out.warnings; + doc.limit_depth_noting(&mut warnings); + Imported { document: doc, warnings, preview: false } } diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index a4355a76..7a979de4 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -993,6 +993,7 @@ fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool, tr } None => unmark(&mut doc.layers, true, &mut vec![]), } + doc.limit_depth_noting(&mut notes); Ok((doc, notes)) } diff --git a/crates/eps/src/import/obj.rs b/crates/eps/src/import/obj.rs index 220f79dc..ae8f7a8e 100644 --- a/crates/eps/src/import/obj.rs +++ b/crates/eps/src/import/obj.rs @@ -12,7 +12,73 @@ use super::graphics::GState; pub(crate) type Dict = HashMap; /// A shared, mutable dictionary. -pub(crate) type DictRef = Rc>; +#[derive(Clone, Debug)] +pub(crate) struct DictRef(Rc>); + +impl DictRef { + pub fn new(d: Dict) -> Self { + Self(Rc::new(RefCell::new(d))) + } +} + +impl std::ops::Deref for DictRef { + type Target = Rc>; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Drop for DictRef { + fn drop(&mut self) { + if Rc::strong_count(&self.0) == 1 + && let Ok(mut d) = self.0.try_borrow_mut() + { + drop_held(d.drain().map(|(_, v)| v)); + } + } +} + +thread_local! { + /// What the composite objects being dropped held, waiting for their turn (while any is). + static HELD: RefCell>> = const { RefCell::new(None) }; +} + +/// Drop what a composite object held, now that nothing else refers to it, without recursion: a +/// program nests arrays and dictionaries as deep as it likes (`[ exch ]` in a loop), and dropping +/// them one inside the other overflows the stack. What `held` holds in its turn waits in [`HELD`] +/// for the outermost drop, which works through it. +fn drop_held(held: impl Iterator) { + let holds = |o: &Obj| matches!(o, Obj::Array { .. } | Obj::Dict(_) | Obj::File { .. } | Obj::GState(_)); + // (Without the list, when the thread is ending: dropped as they come.) + let outermost = HELD.try_with(|h| { + let Ok(mut h) = h.try_borrow_mut() else { return false }; + let outermost = h.is_none(); + h.get_or_insert_with(Vec::new).extend(held.filter(holds)); + outermost + }); + if !matches!(outermost, Ok(true)) { + return; + } + // Each drop here puts what it held on the list instead of dropping it. + while let Ok(Some(next)) = HELD.try_with(|h| h.try_borrow_mut().ok().and_then(|mut h| h.as_mut().and_then(Vec::pop))) { + drop(next); + } + let _ = HELD.try_with(|h| h.try_borrow_mut().map(|mut h| *h = None)); // Nothing to do when the thread is ending. +} + +/// What [`Shared`] values may be: bytes, or objects (which may hold more of themselves). +pub(crate) trait Held: Sized { + /// Drop the values of storage nothing refers to any more. + fn release(_values: Vec) {} +} + +impl Held for u8 {} + +impl Held for Obj { + fn release(values: Vec) { + drop_held(values.into_iter()); + } +} /// A dictionary key. #[derive(Clone, Debug, PartialEq, Eq, Hash)] @@ -72,7 +138,7 @@ impl Obj { } pub fn dict(d: Dict) -> Self { - Self::Dict(Rc::new(RefCell::new(d))) + Self::Dict(DictRef::new(d)) } /// The number this is (integers and reals). @@ -135,13 +201,23 @@ impl Obj { /// The values of a string or an array: a run of storage that other strings or arrays may share /// (`getinterval` makes a new object over part of the same values, as PostScript does). #[derive(Clone, Debug)] -pub(crate) struct Shared { +pub(crate) struct Shared { buf: Rc>>, at: usize, len: usize, } -impl Shared { +impl Drop for Shared { + fn drop(&mut self) { + if Rc::strong_count(&self.buf) == 1 + && let Ok(mut values) = self.buf.try_borrow_mut() + { + T::release(std::mem::take(&mut *values)); + } + } +} + +impl Shared { pub fn new(v: Vec) -> Self { Self { len: v.len(), buf: Rc::new(RefCell::new(v)), at: 0 } } @@ -198,7 +274,7 @@ impl Shared { } /// Hashes what [`Shared::same`] compares: the storage and the run of it. -impl Hash for Shared { +impl Hash for Shared { fn hash(&self, state: &mut H) { Rc::as_ptr(&self.buf).hash(state); (self.at, self.len).hash(state); diff --git a/crates/eps/src/import/text.rs b/crates/eps/src/import/text.rs index 9c8cd673..248f011b 100644 --- a/crates/eps/src/import/text.rs +++ b/crates/eps/src/import/text.rs @@ -389,7 +389,7 @@ impl Interp<'_> { d.insert(Key::name("FontMatrix"), matrix_obj(Affine::scale(0.001))); d.insert(Key::name("FontBBox"), Obj::array(vec![Obj::Int(0), Obj::Int(-250), Obj::Int(1000), Obj::Int(1000)])); d.insert(Key::name("Encoding"), Obj::array(vec![Obj::name(".notdef"); 256])); - let font = Rc::new(std::cell::RefCell::new(d)); + let font = DictRef::new(d); self.fonts.borrow_mut().insert(key, Obj::Dict(font.clone())); Ok(font) } @@ -489,7 +489,7 @@ fn transformed(font: &DictRef, m: Affine) -> DictRef { let mut d = font.borrow().clone(); let fm = d.get(&Key::name("FontMatrix")).and_then(|o| o.items().and_then(|i| matrix_of(&i.borrow()))).unwrap_or(Affine::scale(0.001)); d.insert(Key::name("FontMatrix"), matrix_obj(m * fm)); - Rc::new(std::cell::RefCell::new(d)) + DictRef::new(d) } /// The character a glyph name stands for (`A`, `space`, `uni20AC`), else a bullet. diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index e6891e1e..4811b8ca 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -482,6 +482,9 @@ pub fn load(bytes: &[u8]) -> Result { #[derive(Debug)] pub struct NativeFile { pub doc: Document, + /// What was done to bring the document within the nesting caps + /// ([`Document::limit_depth_noting`]: a file saved before them, or a damaged one). + pub depth_notes: Vec, pub info: FileInfo, /// ICC files by profile name (saved with Embed ICC Profiles; undecodable ones are left out). pub profiles: BTreeMap>, @@ -511,7 +514,11 @@ pub fn load_info(bytes: &[u8]) -> Result<(Document, FileInfo), FormatError> { /// [`load`], with everything else the file carries. pub fn load_file(bytes: &[u8]) -> Result { let file = unpack::Unpacked::new(bytes)?; - let f: File = serde_json::from_slice(file.json()).map_err(|e| FormatError::NotVectorcraft(e.to_string()))?; + // The JSON reader stops at 128 levels (a document's objects use three each). + let f: File = serde_json::from_slice(file.json()).map_err(|e| { + let why = if e.to_string().contains("recursion limit") { format!("its objects are nested too deep ({e})") } else { e.to_string() }; + FormatError::NotVectorcraft(why) + })?; if f.format != EXTENSION && f.format != LEGACY_EXTENSION { return Err(FormatError::NotVectorcraft(format!("format is `{}`", f.format))); } @@ -545,8 +552,10 @@ pub fn load_file(bytes: &[u8]) -> Result { // Untrusted: capped, named and pointing at objects the file has. doc.tidy_saved_selections(); doc.tidy_graph_designs(); + let mut depth_notes = vec![]; + doc.limit_depth_noting(&mut depth_notes); let profiles = f.profiles.into_iter().filter_map(|(name, icc)| Some((name, bytes_of(&icc)?))).collect(); - Ok(NativeFile { doc, info: FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }, profiles }) + Ok(NativeFile { doc, depth_notes, info: FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }, profiles }) } /// The members of a native file besides its document (which is skipped, not decoded). diff --git a/crates/metafile/src/lib.rs b/crates/metafile/src/lib.rs index b1ab24fc..f51c2c8e 100644 --- a/crates/metafile/src/lib.rs +++ b/crates/metafile/src/lib.rs @@ -102,7 +102,9 @@ pub struct Imported { /// Read an EMF or WMF file (with or without a placeable header) into a document whose one /// artboard is the picture's frame. pub fn import(bytes: &[u8]) -> Result { - import::import(bytes) + let mut i = import::import(bytes)?; + i.document.limit_depth_noting(&mut i.warnings); + Ok(i) } #[cfg(test)] diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index 1c4ca1b3..d3116ce6 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -149,6 +149,7 @@ pub const PAST_PAGE_NOTE: &str = "art reaching past the page was clipped to it, pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result { let original = crate::pages::open(bytes, opts.password.as_deref())?; let picked = crate::pages::picked(opts, original.pages().len())?; + picked.iter().filter_map(|&n| original.pages().get(n)).try_for_each(crate::pages::content_too_deep)?; let mut ocgs = Ocgs::read(&original); let route = opts.layers && !ocgs.list.is_empty(); // Content that is off imports as hidden layers: an update of the file turns every group on @@ -166,6 +167,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result>> = Arc::default(); let sink2 = sink.clone(); @@ -417,6 +419,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result bool { bytes.starts_with(b"%!PS") || bytes.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6]) } +/// The deepest a file's arrays and dictionaries may nest. The PDF reader (hayro) reads them by +/// recursion, without a cap of its own: a file nested deeply enough overflows the stack, which is +/// an abort, not an error. Far beyond what a PDF writer makes. +const MAX_OBJECT_NESTING: usize = 256; + +fn too_deep() -> PdfError { + PdfError::Parse(format!("its arrays and dictionaries are nested more than {MAX_OBJECT_NESTING} deep")) +} + +/// Do the arrays and dictionaries in the text of `pdf` nest deeper than [`MAX_OBJECT_NESTING`]? +/// Read as tokens (strings and comments hold none), an object at a time. The data of a stream +/// with a filter is skipped: it isn't PDF text until decoded, which this doesn't do, so nesting +/// inside compressed streams is not seen here. +fn nests_too_deep(pdf: &[u8]) -> bool { + let after = + |from: usize, what: &[u8]| pdf.get(from..).and_then(|rest| rest.windows(what.len()).position(|w| w == what)).map(|at| from + at + what.len()); + let word = |at: usize, w: &[u8]| pdf.get(at..).is_some_and(|rest| rest.starts_with(w)); + let (mut depth, mut filtered) = (0usize, false); + let mut i = 0; + while let Some(&c) = pdf.get(i) { + i += 1; + match c { + b'%' => i += pdf.get(i..).and_then(|rest| rest.iter().position(|c| matches!(c, b'\n' | b'\r'))).unwrap_or(pdf.len()), + b'(' => { + // A string: parentheses nest in it, a backslash takes the next byte. + let mut open = 1usize; + while open > 0 { + match pdf.get(i) { + None => return false, + Some(b'\\') => i += 1, + Some(b'(') => open += 1, + Some(b')') => open -= 1, + Some(_) => {} + } + i += 1; + } + } + b'[' => depth += 1, + b']' => depth = depth.saturating_sub(1), + b'<' if pdf.get(i) == Some(&b'<') => (depth, i) = (depth + 1, i + 1), + b'>' if pdf.get(i) == Some(&b'>') => (depth, i) = (depth.saturating_sub(1), i + 1), + // A string in hexadecimal. + b'<' => i = after(i, b">").unwrap_or(pdf.len()), + b'/' if word(i - 1, b"/Filter") => filtered = true, + b'e' if word(i - 1, b"endobj") => (depth, filtered) = (0, false), + b'e' if word(i - 1, b"endstream") => i += 8, + b's' if word(i - 1, b"stream") => { + depth = 0; + if std::mem::take(&mut filtered) { + i = after(i, b"endstream").unwrap_or(pdf.len()); + } + } + _ => {} + } + if depth > MAX_OBJECT_NESTING { + return true; + } + } + false +} + /// Read a PDF, decrypting it with `password` (none: the empty user password). The permissions /// password opens a file too. pub(crate) fn open(bytes: &[u8], password: Option<&str>) -> Result { if is_postscript(bytes) { return Err(PdfError::PostScript); } + if nests_too_deep(bytes) { + return Err(too_deep()); + } let password = password.unwrap_or_default(); let read = |pw: &str| Pdf::new_with_password(bytes.to_vec(), pw); read(password) @@ -57,6 +121,12 @@ pub(crate) fn open(bytes: &[u8], password: Option<&str>) -> Result) -> Result<(), PdfError> { + if page.page_stream().is_some_and(nests_too_deep) { Err(too_deep()) } else { Ok(()) } +} + /// The pages and their boxes. An encrypted PDF needs its `password` /// ([`PdfError::NeedsPassword`] / [`PdfError::WrongPassword`]). pub fn info(bytes: &[u8], password: Option<&str>) -> Result { diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 323621e1..dc65646e 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -18,10 +18,13 @@ use crate::{ImportOptions, SvgError, fnv1a}; use css::XNode; use text::TextSlots; +pub use depth::MAX_ELEMENT_DEPTH; +pub(crate) use depth::xml_too_deep; pub(crate) use fx::BLEND; mod css; mod css_transform; +mod depth; mod files; mod fx; mod spread; @@ -29,11 +32,30 @@ mod symbol_units; mod text; mod vector_effect; +/// The XML of `svg`. +fn parse(svg: &str) -> Result, SvgError> { + roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) + .map_err(|e| SvgError::Parse(e.to_string())) +} + pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec), SvgError> { - let (svg, found) = prepass(svg, opts); + // 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) { + return Err(too_deep()); + } + let xml = parse(svg)?; + if depth::too_deep(&xml) { + return Err(too_deep()); + } + let (svg, found) = prepass(svg, &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, + Cow::Owned(edited) => parse(edited)?, + }; let svg = svg.as_ref(); - let xml = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) - .map_err(|e| SvgError::Parse(e.to_string()))?; let units = RootUnits::of(xml.root_element()); let root_name = root_layer_name(xml.root_element()); let mut warnings = found.warnings; @@ -165,6 +187,7 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec` with a `viewBox` become numbers ([`symbol_units`]); /// * what style sheet rules with a `:not()` set goes in `style` attributes ([`not_rules`]). -fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) { +fn prepass<'s>(svg: &'s str, xml: &roxmltree::Document, opts: &ImportOptions) -> (Cow<'s, str>, Found) { let mut found = Found::default(); // (`:` for prefixed elements such as ``.) let css_transform = has_css_transform(svg); @@ -565,9 +588,6 @@ fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) { { return (svg.into(), found); } - let Ok(xml) = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) else { - return (svg.into(), found); - }; let mut edits = Edits::default(); let elements = || xml.descendants().filter(|n| n.is_element()); for a in elements().filter(|n| n.tag_name().name() == "a") { @@ -592,24 +612,24 @@ fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) { } let (display, non_scaling) = (svg.contains("display"), svg.contains(vector_effect::NON_SCALING)); if display || non_scaling || not { - let css = css::Styles::new(&xml); + let css = css::Styles::new(xml); // First: an undisplayed object shown for usvg is shown after these. if not && css.has_not_rules() { - not_rules(svg, &xml, &css, &mut edits); + not_rules(svg, xml, &css, &mut edits); } if display { - hidden_objects(svg, &xml, &css, &mut edits, &mut found.hidden); + hidden_objects(svg, xml, &css, &mut edits, &mut found.hidden); } if non_scaling { - vector_effect::find(svg, &xml, &css, &mut edits, &mut found.non_scaling, &mut found.warnings); + vector_effect::find(svg, xml, &css, &mut edits, &mut found.non_scaling, &mut found.warnings); } } - found.files = files::Files::read(svg, &xml, opts, &mut edits, &mut found.warnings); + found.files = files::Files::read(svg, xml, opts, &mut edits, &mut found.warnings); if !symbols.is_empty() && svg.contains('%') { - edits.list.extend(symbol_units::rewrites(&xml)); + edits.list.extend(symbol_units::rewrites(xml)); } if css_transform { - edits.list.extend(css_transform::rewrites(svg, &xml)); + edits.list.extend(css_transform::rewrites(svg, xml)); } (edits.apply(svg), found) } diff --git a/crates/svg/src/import/css.rs b/crates/svg/src/import/css.rs index 8f508a7f..86eb6767 100644 --- a/crates/svg/src/import/css.rs +++ b/crates/svg/src/import/css.rs @@ -17,7 +17,7 @@ pub(super) type XNode<'a, 'i> = roxmltree::Node<'a, 'i>; /// `name: value` declarations of a block or `style` attribute, with their `!important` flag. The /// `font` shorthand becomes its longhands, as usvg reads it for the rest of the drawing (#1557). -fn parse_decls(s: &str) -> Vec<(String, String, bool)> { +pub(super) fn parse_decls(s: &str) -> Vec<(String, String, bool)> { s.split(';') .filter_map(|d| { let (k, v) = d.split_once(':')?; @@ -371,8 +371,13 @@ impl Styles { Self { rules, index, cache: RefCell::default() } } + /// Do the document's style sheets have any rules? + pub(super) fn has_rules(&self) -> bool { + !self.rules.is_empty() + } + /// Properties set on `n` by rules and its `style` attribute, after the cascade. - fn declared(&self, n: XNode) -> Rc> { + pub(super) fn declared(&self, n: XNode) -> Rc> { if let Some(m) = self.cache.borrow().get(&n.id()) { return m.clone(); } diff --git a/crates/svg/src/import/depth.rs b/crates/svg/src/import/depth.rs new file mode 100644 index 00000000..3febdd96 --- /dev/null +++ b/crates/svg/src/import/depth.rs @@ -0,0 +1,264 @@ +//! How deep an SVG's elements nest, found in the XML before usvg runs: usvg converts the tree by +//! recursion, a few stack frames an element, so a file nested deeply enough overflows the stack +//! (an abort, not an error). Such a file is refused: its nesting can't be merged before usvg has +//! read it, and usvg is what can't read it. +//! +//! What counts is the tree usvg walks, not the XML alone: a `` holds a copy of what it +//! refers to, and a clip path, mask, pattern, marker or filter is converted where it is used +//! (`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::collections::HashMap; +use std::collections::hash_map::Entry; + +use usvg::roxmltree; + +use super::css::{Styles, XNode, parse_decls}; + +/// The deepest an SVG's elements may nest, references followed. Far beyond what drawing apps +/// write, and deeper than a document's objects may nest ([`vectorcraft_doc::MAX_DEPTH`]: wrappers +/// that hold one object aren't kept, and what is still deeper is merged into the group around +/// it). usvg uses about 15 kB of stack for each level: the importer runs on a thread with 16 MB +/// ([`vectorcraft_doc::depth::with_deep_stack`]), except on the web, where there is the one +/// 1 MB stack. +pub const MAX_ELEMENT_DEPTH: usize = if cfg!(target_arch = "wasm32") { 48 } else { 256 }; + +/// Properties whose value an element's descendants use too: the paint server they name is +/// converted at each descendant, under everything between. +const INHERITED: [&str; 6] = ["fill", "stroke", "marker", "marker-start", "marker-mid", "marker-end"]; + +/// The ids of the `url(#id)` references in a property value. +fn urls(value: &str) -> impl Iterator { + value.split("url(").skip(1).filter_map(|rest| { + let id = rest.trim_start().trim_start_matches(['"', '\'']).strip_prefix('#')?; + id.split([')', '"', '\'']).next().map(str::trim).filter(|id| !id.is_empty()) + }) +} + +/// The elements `n` refers to, each with whether `n`'s descendants use it too. +fn references<'a, 'i>(n: XNode<'a, 'i>, ids: &HashMap<&str, XNode<'a, 'i>>, css: &Styles, out: &mut Vec<(XNode<'a, 'i>, bool)>) { + let add = |out: &mut Vec<(XNode<'a, 'i>, bool)>, name: &str, value: &str| { + let inherited = INHERITED.contains(&name); + out.extend(urls(value).filter_map(|id| ids.get(id)).map(|t| (*t, inherited))); + }; + for a in n.attributes() { + match a.name() { + "href" => out.extend(a.value().trim().strip_prefix('#').and_then(|id| ids.get(id)).map(|t| (*t, false))), + // Read with the style sheets' rules below. + "style" if css.has_rules() => {} + "style" => parse_decls(a.value()).iter().for_each(|(k, v, _)| add(out, k, v)), + name => add(out, name, a.value()), + } + } + if css.has_rules() { + css.declared(n).iter().for_each(|(k, v)| add(out, k, v)); + } +} + +/// 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. +pub(crate) fn xml_too_deep(svg: &str) -> bool { + 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| { + let mut quote = 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'>') => return (i + 1, b.get(i.wrapping_sub(1)) == Some(&b'/')), + _ => {} + } + } + (b.len(), false) + }; + // (now, deepest) of the elements, and of the markup inside the document type. + let (mut elements, mut entities) = ((0usize, 0usize), (0usize, 0usize)); + let mut doctype_end = 0; + let mut i = 0; + while let Some(at) = b.get(i..).and_then(|rest| rest.iter().position(|c| *c == b'<')).map(|at| i + at) { + let rest = b.get(at..).unwrap_or_default(); + let depth = if at < doctype_end { &mut entities } else { &mut elements }; + i = if rest.starts_with(b"").unwrap_or(b.len()) + } else if rest.starts_with(b"").unwrap_or(b.len()) + } else if rest.starts_with(b"").unwrap_or(b.len()) + } else if rest.starts_with(b"` that ends it: after its internal subset (`[` … `]`) when it has one. + let plain = tag_end(at).0; + let 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 + } else if rest.starts_with(b"`. + let end = find(at, b">").unwrap_or(b.len()); + depth.1 = depth.1.max(depth.0 + 1); + depth.0 += usize::from(b.get(end.wrapping_sub(2)) != Some(&b'/')); + end + } else { + let (end, empty) = tag_end(at); + 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; + } + } + false +} + +/// Does anything in `xml` nest deeper than [`MAX_ELEMENT_DEPTH`], references followed? Without +/// recursion. +pub(super) fn too_deep(xml: &roxmltree::Document) -> bool { + let elements = || xml.descendants().filter(|n| n.is_element()); + let ids: HashMap<&str, XNode> = elements().filter_map(|n| Some((n.attribute("id")?, n))).collect(); + let css = Styles::new(xml); + // How deep each element's own tree goes, itself included; 0 while it is being worked out: a + // reference back to it then is a loop, which usvg leaves out. + let mut heights: HashMap = HashMap::new(); + enum Step<'a, 'i> { + Enter(XNode<'a, 'i>), + Leave(XNode<'a, 'i>, Vec<(XNode<'a, 'i>, bool)>), + } + let mut stack = vec![Step::Enter(xml.root_element())]; + while let Some(step) = stack.pop() { + match step { + Step::Enter(n) => { + match heights.entry(n.id()) { + Entry::Occupied(_) => continue, + Entry::Vacant(v) => v.insert(0), + }; + let mut refs = vec![]; + references(n, &ids, &css, &mut refs); + let targets: Vec = refs.iter().map(|(t, _)| *t).collect(); + stack.push(Step::Leave(n, refs)); + stack.extend(n.children().filter(|c| c.is_element()).chain(targets).map(Step::Enter)); + } + Step::Leave(n, refs) => { + let of = |n: XNode| heights.get(&n.id()).copied().unwrap_or(0); + let held = n.children().filter(|c| c.is_element()).map(of).max().unwrap_or(0); + let used = |inherited: bool| refs.iter().filter(|(_, i)| *i == inherited).map(|(t, _)| of(*t)).max().unwrap_or(0); + let height = 1 + held.max(used(false)) + used(true); + if height > MAX_ELEMENT_DEPTH { + return true; + } + heights.insert(n.id(), height); + } + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + fn deep(svg: &str) -> bool { + too_deep(&roxmltree::Document::parse(svg).unwrap()) + } + + fn svg(body: &str) -> String { + format!(r#"{body}"#) + } + + /// `n` groups in one another around a rectangle. + fn groups(n: usize) -> String { + format!("{}{}", "".repeat(n), "".repeat(n)) + } + + #[test] + fn elements_nest_up_to_the_cap() { + // The root, the groups and the rectangle. + let (at, past) = (svg(&groups(MAX_ELEMENT_DEPTH - 2)), svg(&groups(MAX_ELEMENT_DEPTH - 1))); + assert!(!deep(&at) && !xml_too_deep(&at)); + assert!(deep(&past) && xml_too_deep(&past)); + assert!(xml_too_deep(&svg(&groups(200_000))), "any depth is measured"); + } + + #[test] + fn the_text_is_measured_as_xml_reads_it() { + let many = r#""#.repeat(500); + assert!(!xml_too_deep(&svg(&many)), "side by side, with `>` and tags in attribute values"); + let hidden = format!("", "".repeat(500)); + assert!(!xml_too_deep(&svg(&hidden)), "comments, character data and instructions hold no elements"); + assert!(xml_too_deep(&format!("{}", "".repeat(MAX_ELEMENT_DEPTH))), "never closed"); + assert!(!xml_too_deep(&format!("{}", "".repeat(2 * MAX_ELEMENT_DEPTH))), "ends without beginnings"); + // Entities: namespaces as drawing apps declare them, and markup that would be read ten deep. + let names = r#" ]>"#; + assert!(!xml_too_deep(&format!("{names}{}", svg(&groups(30))))); + let tenth = MAX_ELEMENT_DEPTH / 10 + 1; + let bomb = format!(" ]>", "".repeat(tenth), "".repeat(tenth)); + assert!(xml_too_deep(&format!("{bomb}&a;"))); + assert!(!xml_too_deep("")); + } + + #[test] + fn a_chain_of_uses_is_as_deep_as_what_it_copies() { + let chain = |n: usize| { + let defs: String = (0..n).map(|i| format!("", i + 1)).collect(); + svg(&format!("{defs}")) + }; + assert!(!deep(&chain(10))); + assert!(deep(&chain(MAX_ELEMENT_DEPTH / 2)), "two elements a link"); + assert!(deep(&chain(5000))); + } + + #[test] + fn chains_of_clips_masks_and_patterns_count_by_attribute_style_and_style_sheet() { + let by_attribute = |n: usize| -> String { + (0..n).map(|i| format!("", i + 1)).collect() + }; + let by_style = |n: usize| -> String { + (0..n).map(|i| format!("", i + 1)).collect() + }; + let by_sheet = |n: usize| -> String { + let rules: String = (0..n).map(|i| format!(".p{i} {{ fill: url(#c{}) }}", i + 1)).collect(); + let patterns: String = (0..n) + .map(|i| format!("")) + .collect(); + format!("{patterns}") + }; + for chain in [&by_attribute as &dyn Fn(usize) -> String, &by_style, &by_sheet] { + let file = + |n| svg(&format!("{}", chain(n))); + assert!(!deep(&file(5))); + assert!(deep(&file(MAX_ELEMENT_DEPTH))); + } + } + + #[test] + fn many_paint_servers_used_side_by_side_are_not_a_chain() { + // As drawing apps write gradients: a class each, used by objects that aren't inside one another. + let rules: String = (0..300).map(|i| format!(".c{i}{{fill:url(#g{i})}}")).collect(); + let defs: String = (0..300).map(|i| format!("")).collect(); + let art: String = (0..300).map(|i| format!("")).collect(); + assert!(!deep(&svg(&format!("{defs}{art}")))); + } + + #[test] + fn a_loop_of_references_is_not_deep() { + assert!(!deep(&svg(""))); + } + + #[test] + fn an_inherited_paint_is_converted_under_everything_between() { + // Neither the pattern nor the art is too deep; the pattern under the art's last object is. + let half = MAX_ELEMENT_DEPTH / 2; + let pattern = format!("{}", groups(half)); + assert!(!deep(&svg(&format!("{pattern}{}", groups(half / 2))))); + assert!(deep(&svg(&format!("{pattern}{}", groups(half))))); + } +} diff --git a/crates/svg/src/import/files.rs b/crates/svg/src/import/files.rs index bbe33d84..88607277 100644 --- a/crates/svg/src/import/files.rs +++ b/crates/svg/src/import/files.rs @@ -203,12 +203,16 @@ impl Files { ImageHrefResolver { resolve_data: Box::new(move |mime, bytes, opts| { if mime == "image/svg+xml" { + if nested_too_deep(&bytes) { + return None; + } self.note_text(&bytes); } data(mime, bytes, opts) }), resolve_string: Box::new(move |h, opts| match self.sources.get(h).or_else(|| self.sources.get(h.trim()))? { Source::Raster(kind, bytes) => Some(kind(bytes.clone())), + Source::Svg(bytes) if nested_too_deep(bytes) => None, Source::Svg(bytes) => { self.note_text(bytes); usvg::Tree::from_data_nested(bytes, opts).ok().map(ImageKind::SVG) @@ -218,6 +222,12 @@ impl Files { } } +/// Is the SVG image `bytes` nested too deep for usvg to read (as the SVG holding it would be)? +/// It is then left out, as an image usvg can't read. +fn nested_too_deep(bytes: &[u8]) -> bool { + crate::text_of(bytes).is_ok_and(|text| super::xml_too_deep(&text)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 7cbc8531..2f1f3c5e 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -312,6 +312,9 @@ pub struct LinkedImage { pub enum SvgError { #[error("SVG parse error: {0}")] Parse(String), + /// Its elements nest (references followed) deeper than the reader goes. + #[error("the SVG's elements are nested more than {0} deep")] + TooDeep(usize), } /// Export a document as an SVG string (linked images are not returned: see [`export_full`]). @@ -347,7 +350,8 @@ pub struct Editing { /// The native document an SVG carries, if any, and whether the SVG was edited since. pub fn editing(svg: &str) -> Option { - if !svg.contains(EDITING_NS) { + // (An SVG nested too deep for the XML reader carries none: it isn't one of ours.) + if !svg.contains(EDITING_NS) || import::xml_too_deep(svg) { return None; } let opts = usvg::roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }; @@ -500,14 +504,18 @@ pub fn import_with_report(svg: &str) -> Result<(Document, Vec), SvgError import_with(svg, &ImportOptions::default()) } +pub use import::MAX_ELEMENT_DEPTH; + /// Import an SVG document reading the files it links to as `opts` says, also returning warnings. pub fn import_with(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec), SvgError> { - import::import(svg, opts) + // usvg converts the tree by recursion, in large stack frames: the nesting it is given is + // capped (`import::depth`), and it gets room for that cap whatever thread asks. + vectorcraft_doc::depth::with_deep_stack(|| import::import(svg, opts)) } /// Reads a linked file: its bytes and the link to it (absolute path, size, time and hash), or /// `None` when there is no readable file at the (absolute) path. -pub type ReadFile<'a> = &'a dyn Fn(&str) -> Option<(Vec, vectorcraft_doc::LinkInfo)>; +pub type ReadFile<'a> = &'a (dyn Fn(&str) -> Option<(Vec, vectorcraft_doc::LinkInfo)> + Sync); /// How [`import_with`] reads the files an SVG's `` elements link to. /// diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9a58680f..522b6fce 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4099,6 +4099,8 @@ imageTrace Outline Obrys @msg recovery copy `{file}`: {e} kopie pro obnovení `{file}`: {e} @msg resolutions must be between 1 and 2400 ppi rozlišení musí být mezi 1 a 2400 ppi @msg result would exceed the canvas (coordinates out of range) výsledek by přesáhl plátno (souřadnice mimo rozsah) +@msg objects can't be nested more than 32 deep objekty nelze vnořit hlouběji než do 32 úrovní +@msg the SVG's elements are nested more than {0} deep prvky SVG jsou vnořeny hlouběji než do {0} úrovní @msg scale must be a number from {_1} to {_2}, not {_3} měřítko musí být číslo od {_1} do {_2}, ne {_3} @msg scale must be a number from {_1} to {_2}, not {s} měřítko musí být číslo od {_1} do {_2}, ne {s} @msg select (or target) objects whose appearance isn't basic vyberte (nebo zacilte) objekty, jejichž vzhled není základní diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 24af8f75..bda4f451 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3705,6 +3705,8 @@ theme Appearance Erscheinungsbild @msg recovery copy `{file}`: {e} Wiederherstellungskopie `{file}`: {e} @msg resolutions must be between 1 and 2400 ppi Auflösungen müssen zwischen 1 und 2400 ppi liegen @msg result would exceed the canvas (coordinates out of range) das Ergebnis würde die Arbeitsfläche überschreiten (Koordinaten außerhalb des Bereichs) +@msg objects can't be nested more than 32 deep Objekte können nicht tiefer als 32 Ebenen verschachtelt werden +@msg the SVG's elements are nested more than {0} deep die Elemente der SVG-Datei sind tiefer als {0} Ebenen verschachtelt @msg scale must be a number from {_1} to {_2}, not {_3} scale muss eine Zahl von {_1} bis {_2} sein, nicht {_3} @msg scale must be a number from {_1} to {_2}, not {s} scale muss eine Zahl von {_1} bis {_2} sein, nicht {s} @msg select (or target) objects whose appearance isn't basic wählen Sie Objekte aus (oder als Ziel), deren Aussehen nicht einfach ist diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 91c49142..c9c36d04 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3804,6 +3804,8 @@ axis Both Ambos @msg recovery copy `{file}`: {e} copia de recuperación `{file}`: {e} @msg resolutions must be between 1 and 2400 ppi las resoluciones deben estar entre 1 y 2400 ppi @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 scale must be a number from {_1} to {_2}, not {_3} la escala debe ser un número de {_1} a {_2}, no {_3} @msg scale must be a number from {_1} to {_2}, not {s} la escala debe ser un número de {_1} a {_2}, no {s} @msg select (or target) objects whose appearance isn't basic selecciona (o apunta a) objetos cuya apariencia no sea básica diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 3226266a..de2b62f6 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3801,6 +3801,8 @@ axis Both Les deux @msg recovery copy `{file}`: {e} copie de récupération `{file}` : {e} @msg resolutions must be between 1 and 2400 ppi les résolutions doivent être comprises entre 1 et 2400 ppp @msg result would exceed the canvas (coordinates out of range) le résultat dépasserait la zone de travail (coordonnées hors limites) +@msg objects can't be nested more than 32 deep les objets ne peuvent pas être imbriqués sur plus de 32 niveaux +@msg the SVG's elements are nested more than {0} deep les éléments du SVG sont imbriqués sur plus de {0} niveaux @msg scale must be a number from {_1} to {_2}, not {_3} l’échelle doit être un nombre compris entre {_1} et {_2}, pas {_3} @msg scale must be a number from {_1} to {_2}, not {s} l’échelle doit être un nombre compris entre {_1} et {_2}, pas {s} @msg select (or target) objects whose appearance isn't basic sélectionnez (ou ciblez) des objets dont l’aspect n’est pas basique diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 961caaa0..488b5a9f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4441,6 +4441,8 @@ imageTrace Outline Körvonal @msg resolutions must be between 1 and 2400 ppi a felbontás 1 és 2400 ppi között lehet @msg Restored {_1} recovered document(s): save them to keep them Helyreállított dokumentumok: {_1}; mentsd őket, ha meg akarod tartani @msg result would exceed the canvas (coordinates out of range) az eredmény túllépné a vásznat (a koordináták tartományon kívül esnek) +@msg objects can't be nested more than 32 deep az objektumok legfeljebb 32 szint mélyen ágyazhatók egymásba +@msg the SVG's elements are nested more than {0} deep az SVG elemei több mint {0} szint mélyen vannak egymásba ágyazva @msg Save for Web: the image is empty Mentés webre: a kép üres @msg Save writes {_1}, not {_2}: use document.export for other formats a Mentés ezt írja: {_1}, ezt nem: {_2}; más formátumhoz használd a document.export parancsot @msg saved panel layout is invalid; reset the workspace first a mentett panelelrendezés érvénytelen; előbb állítsd vissza a munkaterületet diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3353c5bc..1facacf4 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3804,6 +3804,8 @@ axis Both Entrambi @msg recovery copy `{file}`: {e} copia di recupero `{file}`: {e} @msg resolutions must be between 1 and 2400 ppi le risoluzioni devono essere comprese tra 1 e 2400 ppi @msg result would exceed the canvas (coordinates out of range) il risultato supererebbe l'area di disegno (coordinate fuori intervallo) +@msg objects can't be nested more than 32 deep gli oggetti non possono essere nidificati oltre 32 livelli +@msg the SVG's elements are nested more than {0} deep gli elementi dell'SVG sono nidificati oltre {0} livelli @msg scale must be a number from {_1} to {_2}, not {_3} la scala deve essere un numero da {_1} a {_2}, non {_3} @msg scale must be a number from {_1} to {_2}, not {s} la scala deve essere un numero da {_1} a {_2}, non {s} @msg select (or target) objects whose appearance isn't basic seleziona (o imposta come destinazione) oggetti con un aspetto non di base diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b2361baf..8eef3ae6 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3730,6 +3730,8 @@ theme Appearance 外観 @msg no interaction in progress 進行中の操作がありません @msg no such object {0} オブジェクト {0} が見つかりません @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 unknown command `{0}` `{0}` は不明なコマンドです @msg `{what}` closes nothing `{what}` に対応する開始部分がありません @msg `{what}` closes something it didn't open `{what}` が対応しない開始部分を閉じています diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index d964a99e..2b944e13 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3708,6 +3708,8 @@ theme Appearance 외관 @msg no interaction in progress 진행 중인 상호작용이 없습니다 @msg no such object {0} 개체 {0}이(가) 없습니다 @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 unknown command `{0}` 알 수 없는 명령 `{0}` @msg `{what}` closes nothing `{what}`이(가) 닫는 항목이 없습니다 @msg `{what}` closes something it didn't open `{what}`이(가) 열지 않은 항목을 닫습니다 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 5a8df065..4c26ef01 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3965,6 +3965,8 @@ imageTrace Outline Kontur @msg recovery copy `{file}`: {e} kopia odzyskiwania `{file}`: {e} @msg resolutions must be between 1 and 2400 ppi rozdzielczości muszą mieścić się w zakresie od 1 do 2400 ppi @msg result would exceed the canvas (coordinates out of range) wynik wykroczyłby poza płótno (współrzędne poza zakresem) +@msg objects can't be nested more than 32 deep obiektów nie można zagnieżdżać głębiej niż na 32 poziomy +@msg the SVG's elements are nested more than {0} deep elementy SVG są zagnieżdżone głębiej niż na {0} poziomów @msg scale must be a number from {_1} to {_2}, not {_3} skala musi być liczbą od {_1} do {_2}, nie {_3} @msg scale must be a number from {_1} to {_2}, not {s} skala musi być liczbą od {_1} do {_2}, nie {s} @msg select (or target) objects whose appearance isn't basic zaznacz (lub wskaż) obiekty, których wygląd nie jest podstawowy diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 75a81dca..a5c87a70 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3449,6 +3449,8 @@ axis Both Ambos @msg recovery copy `{file}`: {e} cópia de recuperação `{file}`: {e} @msg resolutions must be between 1 and 2400 ppi as resoluções devem estar entre 1 e 2400 ppi @msg result would exceed the canvas (coordinates out of range) o resultado excederia a tela (coordenadas fora do intervalo) +@msg objects can't be nested more than 32 deep os objetos não podem ser aninhados em mais de 32 níveis +@msg the SVG's elements are nested more than {0} deep os elementos do SVG estão aninhados em mais de {0} níveis @msg scale must be a number from {_1} to {_2}, not {_3} a escala deve ser um número de {_1} a {_2}, não {_3} @msg scale must be a number from {_1} to {_2}, not {s} a escala deve ser um número de {_1} a {_2}, não {s} @msg select (or target) objects whose appearance isn't basic selecione (ou direcione) objetos cuja aparência não seja básica diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 367f5f1b..80d8d8c8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3800,6 +3800,8 @@ axis Both Оба @msg recovery copy `{file}`: {e} копия восстановления `{file}`: {e} @msg resolutions must be between 1 and 2400 ppi разрешения должны быть от 1 до 2400 ppi @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 scale must be a number from {_1} to {_2}, not {_3} масштаб должен быть числом от {_1} до {_2}, а не {_3} @msg scale must be a number from {_1} to {_2}, not {s} масштаб должен быть числом от {_1} до {_2}, а не {s} @msg select (or target) objects whose appearance isn't basic выделите (или укажите как объект) объекты, оформление которых не является основным diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 1791565a..82efe7e1 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3788,6 +3788,8 @@ axis Both Обидві @msg recovery copy `{file}`: {e} копія відновлення `{file}`: {e} @msg resolutions must be between 1 and 2400 ppi дозволи мають бути від 1 до 2400 ppi @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 scale must be a number from {_1} to {_2}, not {_3} масштаб має бути числом від {_1} до {_2}, а не {_3} @msg scale must be a number from {_1} to {_2}, not {s} масштаб має бути числом від {_1} до {_2}, а не {s} @msg select (or target) objects whose appearance isn't basic виділіть (або вкажіть як об’єкт) об’єкти, оформлення яких не є основним diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index d2e8d3ea..a9c00b35 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4071,6 +4071,8 @@ theme Appearance Giao diện @msg resolutions must be between 1 and 2400 ppi độ phân giải phải nằm trong khoảng 1 đến 2400 ppi @msg Restored {_1} recovered document(s): save them to keep them Đã phục hồi {_1} tài liệu: hãy lưu để giữ lại @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 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 @msg saved panel layout is invalid; reset the workspace first bố cục bảng đã lưu không hợp lệ; hãy đặt lại không gian làm việc trước