mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
f73a3df974
commit
b3ccdf01a7
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user