diff --git a/crates/affinity/README.md b/crates/affinity/README.md index 422d2cfac..8986dae57 100644 --- a/crates/affinity/README.md +++ b/crates/affinity/README.md @@ -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 diff --git a/crates/affinity/src/model.rs b/crates/affinity/src/model.rs index 1e125d2c3..5a1feaab4 100644 --- a/crates/affinity/src/model.rs +++ b/crates/affinity/src/model.rs @@ -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 { diff --git a/crates/affinity/src/overlay.rs b/crates/affinity/src/overlay.rs index 62465fd2e..170c50ec3 100644 --- a/crates/affinity/src/overlay.rs +++ b/crates/affinity/src/overlay.rs @@ -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 = 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> { + 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, Vec) = 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); diff --git a/crates/affinity/src/text.rs b/crates/affinity/src/text.rs index 7b809e139..1a3b6e544 100644 --- a/crates/affinity/src/text.rs +++ b/crates/affinity/src/text.rs @@ -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::()), + _ => 0, + }; + let dash = st.dash.as_ref().map_or(0, |(d, _)| d.len().saturating_mul(std::mem::size_of::())); + 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, diff --git a/crates/affinity/tests/text.rs b/crates/affinity/tests/text.rs index e1fd6bfd9..db984652b 100644 --- a/crates/affinity/tests/text.rs +++ b/crates/affinity/tests/text.rs @@ -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::() <= 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")); +} diff --git a/crates/engine/tests/affinity_features.rs b/crates/engine/tests/affinity_features.rs index 45a2aa796..bb323387d 100644 --- a/crates/engine/tests/affinity_features.rs +++ b/crates/engine/tests/affinity_features.rs @@ -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));