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.
This commit is contained in:
Dylan M. Taylor
2026-11-02 20:33:10 -05:00
parent 11862f6119
commit 653f178b28
2 changed files with 93 additions and 3 deletions
@@ -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::<u64>(), 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::Path>) -> 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);
}
}
@@ -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<Node>>) -> Arc<Node> {
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)));
}
}