Merge pull request #5302 from storytold/fix/sec-affinity-master-copy-cost

Affinity import: count path segments and masks in master page copies; range-check vector masks
This commit is contained in:
Brandon Thomas
2026-11-03 00:19:26 -05:00
committed by GitHub
2 changed files with 91 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,58 @@ 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)));
}
}