From 653f178b289720a7be60a5baa0481adcc4abddaf Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 20:33:10 -0500 Subject: [PATCH] Affinity import: count path segments and masks in master page copies Master page copies were charged one unit per shape whatever its number of segments, and nothing for attached vector or pixel masks, so a master holding one huge curve or mask could be rebuilt on thousands of pages within the copy allowance. Copy costs now count every subpath and segment of a shape's path and its vector mask, and a pixel mask's pixels. A node's vector mask also skipped the document range check the node's own art passes; its control points are now checked as mapped into the document (non-finite ones included) and an object whose mask reaches beyond the range is left out, as one whose art does. --- .../src/cmd/fileio/affinity/master_copies.rs | 59 ++++++++++++++++++- .../engine/src/cmd/fileio/affinity/nodes.rs | 37 ++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/fileio/affinity/master_copies.rs b/crates/engine/src/cmd/fileio/affinity/master_copies.rs index 91b0ce53c..789f3c774 100644 --- a/crates/engine/src/cmd/fileio/affinity/master_copies.rs +++ b/crates/engine/src/cmd/fileio/affinity/master_copies.rs @@ -1,7 +1,8 @@ use super::nodes::MAX_DEPTH; use super::{Kind, af}; -/// What copying a subtree takes: its nodes, characters and table cells (`work`), and its pixels. +/// What copying a subtree takes: its nodes, characters, table cells and path segments (`work`), +/// and its pixels. #[derive(Clone, Copy, Debug, Default)] pub(super) struct CopyCost { pub(super) work: u64, @@ -40,8 +41,60 @@ pub(super) fn copy_cost(n: &af::Node, depth: usize) -> CopyCost { let own = match &n.kind { Kind::Text(t) => CopyCost { work: 1 + len(&t.runs), pixels: 0 }, Kind::Table(t) => CopyCost { work: 1 + t.cells.iter().map(|c| 1 + len(&c.runs)).sum::(), pixels: 0 }, - Kind::Image(img) => CopyCost { work: 1, pixels: u64::from(img.width) * u64::from(img.height) }, + Kind::Image(img) => CopyCost { work: 1, pixels: pixels(img) }, + // Each copy builds its own path: a curve of many segments costs them all. + Kind::Shape { path, .. } | Kind::Artboard { path, .. } => CopyCost { work: 1 + segments(path), pixels: 0 }, _ => CopyCost { work: 1, pixels: 0 }, }; - n.children.iter().fold(own, |c, ch| c.plus(copy_cost(ch, depth + 1))) + // A vector mask is a path of its own, and a pixel mask an image of its own, in every copy. + let mask = CopyCost { + work: n.mask.as_ref().map_or(0, segments) + u64::from(n.pixel_mask.is_some()), + pixels: n.pixel_mask.as_ref().map_or(0, pixels), + }; + n.children.iter().fold(own.plus(mask), |c, ch| c.plus(copy_cost(ch, depth + 1))) +} + +/// The subpaths and segments of `p`. +fn segments(p: &af::Path) -> u64 { + p.subpaths.iter().map(|s| 1 + s.segments.len() as u64).sum() +} + +fn pixels(img: &af::Image) -> u64 { + u64::from(img.width) * u64::from(img.height) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn node(kind: Kind, mask: Option) -> af::Node { + af::Node { + class: vectorcraft_affinity::stream::Tag::of(b"ShpN"), + name: String::new(), + visible: true, + locked: false, + opacity: 1.0, + blend: af::Blend::Normal, + kind, + mask, + pixel_mask: None, + effects: vec![], + children: vec![], + } + } + + fn path(n: usize) -> af::Path { + let p = af::Point { x: 0.0, y: 0.0 }; + af::Path { subpaths: vec![af::SubPath { start: p, segments: vec![[p; 3]; n], closed: false }] } + } + + /// A curve, and a vector mask, cost every segment each copy rebuilds: a shape of a million + /// segments shown on many pages can't pass as one object. + #[test] + fn paths_and_masks_cost_their_segments() { + let shape = |n| Kind::Shape { path: path(n), fills: vec![], strokes: vec![], even_odd: false }; + assert!(copy_cost(&node(shape(1_000_000), None), 0).work > 1_000_000); + assert!(copy_cost(&node(Kind::Group, Some(path(1_000_000))), 0).work > 1_000_000); + assert!(copy_cost(&node(shape(3), None), 0).work < 10); + } } diff --git a/crates/engine/src/cmd/fileio/affinity/nodes.rs b/crates/engine/src/cmd/fileio/affinity/nodes.rs index 2e9334227..36734dbe3 100644 --- a/crates/engine/src/cmd/fileio/affinity/nodes.rs +++ b/crates/engine/src/cmd/fileio/affinity/nodes.rs @@ -11,6 +11,15 @@ pub(super) fn affine(a: af::Affine) -> Affine { Affine::new(a.0) } +/// Are all of `p`'s points, mapped by `to_doc`, finite and within `MAX_COORD` of the origin? +pub(super) fn path_in_range(p: &af::Path, to_doc: Affine) -> bool { + let ok = |q: &af::Point| { + let d = to_doc * kurbo::Point::new(q.x, q.y); + [d.x, d.y].iter().all(|v| v.is_finite() && v.abs() <= crate::MAX_COORD) + }; + p.subpaths.iter().all(|s| ok(&s.start) && s.segments.iter().flatten().all(ok)) +} + impl Builder { pub(super) fn layer_with(&mut self, name: Option<&str>, children: Vec>) -> Arc { let id = self.doc.alloc_id(); @@ -107,6 +116,12 @@ impl Builder { return None; } } + // So must its vector mask, which becomes a clipping path: every control point, not just + // the finite ones a bounding box takes. + if n.mask.as_ref().is_some_and(|m| !path_in_range(m, to_doc)) { + self.warn("objects beyond the document range VectorCraft supports (left out)"); + return None; + } let n = &*self.overlaid(n); // Register before descending so nested artboards keep their document order. Inside an // embedded document an artboard is just its content's frame. @@ -252,3 +267,25 @@ impl Builder { Some(Arc::new(out)) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn path(points: &[(f64, f64)]) -> af::Path { + let p = |&(x, y): &(f64, f64)| af::Point { x, y }; + let start = p(&points[0]); + let segments = points[1..].iter().map(|q| [start, p(q), p(q)]).collect(); + af::Path { subpaths: vec![af::SubPath { start, segments, closed: true }] } + } + + /// A mask's control points are checked as mapped into the document, NaN ones included. + #[test] + fn mask_paths_must_stay_in_the_document_range() { + let k = Affine::scale(0.75); + assert!(path_in_range(&path(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)]), k)); + assert!(!path_in_range(&path(&[(0.0, 0.0), (1.0e7, 0.0)]), k)); + assert!(!path_in_range(&path(&[(0.0, 0.0), (f64::NAN, 0.0)]), k)); + assert!(!path_in_range(&path(&[(0.0, 0.0), (1.0, 1.0)]), Affine::scale(1.0e9))); + } +}