AF.5: nested overlays innermost first; embedded master pages; text outline budget

- Overlays bake child-first: a group's overlay now lies over its children's
  (they used to be applied after it and win). Each one pays out of the
  import's overlay work budget.
- Master page instances inside embedded documents are left out with a warning:
  their indices count the embedded document's masters, which aren't kept, and
  would show the outer document's.
- A text outline's gradient stops and dashes are charged to the text attribute
  budget, like the fill's.

Addresses CodeRabbit's second review on #3548.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
dwetscher
2026-10-22 18:24:57 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent f73a3df974
commit b3ccdf01a7
6 changed files with 157 additions and 19 deletions
+2 -2
View File
@@ -28,8 +28,8 @@ Affinity files.
| Tables (`TxtT`): grid, cell text, merged cells, cell insets and vertical alignment, the line on every cell edge | a group of area type per cell and a stroked line per edge (VectorCraft has no tables) |
| Placed images (`ImgN`): the original JPEG/PNG embedded in the file | embedded images |
| Pixel layers (`Rstr`): RGBA 8/16-bit and CMYK 8-bit tiles, cropped to their content; source-backed RGBA8 JPEG/PNG tiles with matching dimensions and zero origin; the document's CMYK colour profile (`CMYP`) | embedded PNG images; CMYK layers through the document's profile (relative colorimetric, black-point compensation, as Affinity converts them), or, without one, CMYK images that keep their inks |
| Embedded Affinity documents (`EmbN` with an `EmDc` entry), four deep at most | their first page's content, editable (their artboards only clip, they don't become artboards); other embedded documents and symbols as the picture Affinity cached of them |
| Layer effects: drop shadows, outer glows, inner shadows; outlines; colour and gradient overlays in any blend mode | Stylize › Drop Shadow and Outer Glow, Inner Glow at the edge (approximated); one more stroke on shapes (around their own stroke), the characters' stroke on text; overlays baked into fill, stroke, text colours and pixels |
| Embedded Affinity documents (`EmbN` with an `EmDc` entry), four deep at most | their first page's content, editable (their artboards only clip, they don't become artboards; their master pages are left out); other embedded documents and symbols as the picture Affinity cached of them |
| Layer effects: drop shadows, outer glows, inner shadows; outlines; colour and gradient overlays in any blend mode | Stylize › Drop Shadow and Outer Glow, Inner Glow at the edge (approximated); one more stroke on shapes (around their own stroke), the characters' stroke on text; overlays baked into fill, stroke, text colours and pixels (a group's over its children's) |
| Opacity, visibility, lock, names, the blend modes VectorCraft has | the same |
Everything else is reported in the import warnings, one line per kind with a count, never
+20 -1
View File
@@ -1000,7 +1000,26 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
if doc.spreads.len() > 1 {
self.warn("embedded documents with several pages (the first is used)");
}
doc.spreads.into_iter().next().map(|s| s.nodes)
let mut nodes = doc.spreads.into_iter().next()?.nodes;
// Its master page instances count its own masters, which aren't kept: in this document
// they would show other masters.
fn strip_masters(nodes: &mut [Node], depth: usize) -> bool {
let mut found = false;
for n in nodes {
if matches!(n.kind, Kind::MasterInstance { .. }) {
n.kind = Kind::Unsupported;
found = true;
}
if depth < MAX_TREE_DEPTH {
found |= strip_masters(&mut n.children, depth + 1);
}
}
found
}
if strip_masters(&mut nodes, 0) {
self.warn("master pages of embedded documents (left out)");
}
Some(nodes)
}
fn artboard(&mut self, id: ObjId, world: Affine) -> Kind {
+70 -15
View File
@@ -35,12 +35,26 @@ struct Overlay<'a> {
blend: Blend,
}
/// `node` with its colour and gradient overlays baked in (borrowed unchanged when it has none,
/// or when what is left of `work`, see [`MAX_WORK`], can't pay for them). `warn` hears what
/// could only be approximated or was left out.
/// `node` with its colour and gradient overlays baked in, and those of everything in it
/// (borrowed unchanged when `node` has none, or when what is left of `work`, see [`MAX_WORK`],
/// can't pay for copying it). `warn` hears what could only be approximated or was left out.
pub fn apply<'a>(node: &'a Node, work: &mut u64, warn: &mut dyn FnMut(&str)) -> Cow<'a, Node> {
let overlays: Vec<Overlay> = node
.effects
if overlays(&node.effects).is_empty() {
return Cow::Borrowed(node);
}
let Some(left) = work.checked_sub(subtree_cost(node, *work, 0)) else {
warn("overlay effects beyond the work one import may take (left out)");
return Cow::Borrowed(node);
};
*work = left;
let mut out = node.clone();
bake_tree(&mut out, work, 0, warn);
Cow::Owned(out)
}
/// The visible colour and gradient overlays among `effects`.
fn overlays(effects: &[Effect]) -> Vec<Overlay<'_>> {
effects
.iter()
.filter_map(|e| match e {
Effect::ColorOverlay { color, opacity, blend } => Some(Overlay { source: Source::Solid(*color), opacity: *opacity, blend: *blend }),
@@ -50,26 +64,45 @@ pub fn apply<'a>(node: &'a Node, work: &mut u64, warn: &mut dyn FnMut(&str)) ->
_ => None,
})
.filter(|o| o.opacity > 0.0)
.collect();
if overlays.is_empty() {
return Cow::Borrowed(node);
.collect()
}
fn is_overlay(e: &Effect) -> bool {
matches!(e, Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. })
}
/// Bakes the overlays of `node` and its subtree into their paint, innermost first: an overlay
/// lies over what its node shows, its children's overlays included. Each overlay pays for the
/// subtree it is baked into out of `work`; past it, it is left out (and said).
fn bake_tree(node: &mut Node, work: &mut u64, depth: usize, warn: &mut dyn FnMut(&str)) {
if depth > MAX_DEPTH {
return;
}
let copies = u64::try_from(overlays.len()).unwrap_or(u64::MAX).saturating_add(1);
let cost = subtree_cost(node, *work / copies, 0).saturating_mul(copies);
for c in &mut node.children {
bake_tree(c, work, depth + 1, warn);
}
if !node.effects.iter().any(is_overlay) {
return;
}
let (effects, rest): (Vec<Effect>, Vec<Effect>) = std::mem::take(&mut node.effects).into_iter().partition(is_overlay);
node.effects = rest;
let overlays = overlays(&effects);
if overlays.is_empty() {
return;
}
let count = u64::try_from(overlays.len()).unwrap_or(u64::MAX);
let cost = subtree_cost(node, *work / count, 0).saturating_mul(count);
let Some(left) = work.checked_sub(cost) else {
warn("overlay effects beyond the work one import may take (left out)");
return Cow::Borrowed(node);
return;
};
*work = left;
let mut out = node.clone();
out.effects.retain(|e| !matches!(e, Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. }));
for o in &overlays {
if matches!(o.blend, Blend::Erase | Blend::PassThrough) {
warn("overlay effects in a blend mode VectorCraft doesn't have (imported as Normal)");
}
bake_node(&mut out, o, 0, warn);
bake_node(node, o, 0, warn);
}
Cow::Owned(out)
}
/// Nodes and pixels in `node`'s subtree, counted only until they pass `limit`.
@@ -592,6 +625,28 @@ mod tests {
assert_eq!(fills.first(), Some(&Paint::Gradient(g)));
}
#[test]
fn a_group_overlay_lies_over_its_childrens_overlays() {
let red = Color::Rgb { r: 1.0, g: 0.0, b: 0.0, a: 1.0 };
let blue = Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 };
let mut group = solid(red, 0.5, Blend::Normal);
group.kind = Kind::Group;
group.children = vec![solid(blue, 1.0, Blend::Normal)];
// The copy (2 nodes), the child's overlay (1) and the group's (2).
let mut work = 5;
let out = apply(&group, &mut work, &mut |_| {}).into_owned();
assert_eq!(work, 0);
let child = out.children.first().unwrap();
// Blue first, then half of the group's red over it.
assert_eq!(fill(child), Color::Rgb { r: 0.5, g: 0.0, b: 0.5, a: 1.0 });
assert!(out.effects.is_empty() && child.effects.is_empty(), "both overlays are baked");
// With no work left for the group's overlay, the child's still applies.
let mut warnings = Vec::new();
let out = apply(&group, &mut { 4 }, &mut |w| warnings.push(w.to_string())).into_owned();
assert_eq!(fill(out.children.first().unwrap()), blue);
assert!(warnings.iter().any(|w| w.contains("beyond the work")), "{warnings:?}");
}
#[test]
fn overlays_stop_at_the_work_one_import_may_take() {
let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }, 1.0, Blend::Normal);
+12 -1
View File
@@ -362,10 +362,21 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribut
}
}
// Outlined characters: `Objs` 1 is the outline's paint, 2 its line style (as for shapes).
let stroke = match (attribute_budget.is_some(), objects.get(1).copied().flatten(), objects.get(2).copied().flatten()) {
let mut stroke = match (attribute_budget.is_some(), objects.get(1).copied().flatten(), objects.get(2).copied().flatten()) {
(true, Some(pen), Some(style)) => paint::stroke(r, pen, style, Affine::IDENTITY, false),
_ => None,
};
// Its gradient stops and dashes are owned data too, charged like the fill's.
if let Some(st) = &stroke {
let stops = match &st.paint {
Paint::Gradient(g) => g.stops.len().saturating_mul(std::mem::size_of::<paint::Stop>()),
_ => 0,
};
let dash = st.dash.as_ref().map_or(0, |(d, _)| d.len().saturating_mul(std::mem::size_of::<f64>()));
if !charge_attributes(r, attribute_budget, stops.saturating_add(dash)) {
stroke = None;
}
}
// Underline (slot 0) and strikethrough (slot 1): 1 is a single line.
let mut decoration = |i: usize| match slot(ints, i, int) {
Some(1) => true,
+36
View File
@@ -343,3 +343,39 @@ fn shared_gradient_attributes_are_bounded_without_losing_characters() {
assert!(warning(&d, "text attribute budget exceeded"));
assert!(!warning(&d, "text without character attributes"), "budget recovery must not claim source attributes are absent");
}
#[test]
fn shared_gradient_outlines_are_bounded_without_losing_characters() {
let color =
F::Def(43, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct([0.2f32, 0.4, 0.6, 1.0].iter().flat_map(|v| v.to_le_bytes()).collect()))]);
let mut colors = vec![color];
colors.extend((1..1024).map(|_| F::Ref(43)));
let positions = (0..1024).map(|i| [(i as f32) / 1023.0, 0.5].iter().flat_map(|v| v.to_le_bytes()).collect()).collect();
let pen = F::Obj(
tag(b"FilG"),
vec![(tag(b"Grad"), F::Obj(tag(b"Grad"), vec![(tag(b"Cols"), F::Shared(colors)), (tag(b"Posn"), F::Records(8, positions))]))],
);
let style = F::Obj(tag(b"LSty"), vec![(tag(b"Wght"), F::F64(2.0))]);
// `Objs` 1 and 2: the outline's paint and line style, shared by every run.
let attrs = F::Def(42, vec![tag(b"GAtt")], vec![(tag(b"Objs"), F::Shared(vec![solid(1.0), pen, style]))]);
let mut runs = vec![run(1, Some(attrs))];
runs.extend((2..=1000).map(|end| run(end, Some(F::Ref(42)))));
let d = read(vec![block(utf8(&"x".repeat(1000)), runs, &[])]);
let t = text(&d);
assert_eq!(content(t), "x".repeat(1000));
assert_eq!(t.runs.len(), 1000);
let stop_count: usize = t
.runs
.iter()
.filter_map(|r| r.stroke.as_ref())
.map(|s| match &s.paint {
Paint::Gradient(g) => g.stops.len(),
_ => 0,
})
.sum();
assert!(stop_count * std::mem::size_of::<vectorcraft_affinity::paint::Stop>() <= 16 << 20);
let Some(Paint::Gradient(first)) = t.runs[0].stroke.as_ref().map(|s| &s.paint) else { panic!("the first outline must fit") };
assert_eq!(first.stops.len(), 1024);
assert!(t.runs.last().unwrap().stroke.is_none());
assert!(warning(&d, "text attribute budget exceeded"));
}
+17
View File
@@ -374,6 +374,23 @@ fn embedded_documents_nest_only_so_deep() {
assert!(loaded.warnings.iter().any(|w| w.contains("nested more than four deep")), "{:?}", loaded.warnings);
}
/// An embedded document's master pages aren't kept, so its instances of them are left out: in the
/// document it is embedded in they would show that document's masters.
#[test]
fn master_pages_of_embedded_documents_are_left_out() {
use vectorcraft_affinity::model::{Kind, Node as AfNode};
fn instances(nodes: &[AfNode]) -> usize {
nodes.iter().map(|n| usize::from(matches!(n.kind, Kind::MasterInstance { .. })) + instances(&n.children)).sum()
}
let outer = canvas_with(vec![embedded_node(20)], Some(&with_master(1)));
let d = vectorcraft_affinity::read(&outer, vectorcraft_affinity::Limits::default()).unwrap();
assert_eq!(instances(&d.spreads[0].nodes), 0);
assert!(d.warnings.iter().any(|w| w.contains("master pages of embedded documents")), "{:?}", d.warnings);
// Its own objects still come in.
let loaded = open("poster.af", &outer);
named(&loaded.doc, "Page box");
}
#[test]
fn master_pages_show_on_the_pages_that_use_them() {
let loaded = open("master.afpub", &with_master(1));