diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index f31f942ce..69b2c6b68 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -332,6 +332,14 @@ impl Node { self.visual(Some(symbols)) } + /// The visual box of a group or layer without a clipping path, from the visual boxes of its + /// [`Node::editing_children`]: for a walk that measures the members as well. + pub fn visual_of_members(&self, members: impl Iterator>) -> Option { + // The container's own strokes paint around its members. + let o = self.appearance.outset(); + members.fold(None, vectorcraft_geom::union_opt).map(|b| b.inflate(o, o)) + } + /// The box of [`Node::visual_bounds`], or of [`Node::visual_bounds_with`] when `symbols` is /// given. pub(crate) fn visual(&self, symbols: Option<&SymbolBoxes>) -> Option { @@ -346,11 +354,7 @@ impl Node { } } NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))), - NodeKind::Layer { .. } | NodeKind::Group { .. } => { - // The container's own strokes paint around its members. - let o = self.appearance.outset(); - self.editing_children().iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))).map(|b| b.inflate(o, o)) - } + NodeKind::Layer { .. } | NodeKind::Group { .. } => self.visual_of_members(self.editing_children().iter().map(|c| c.visual(symbols))), NodeKind::Blend { children, spec } => { let b = children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))); let o = crate::live::max_outset(children, symbols); diff --git a/crates/doc/src/node/tests.rs b/crates/doc/src/node/tests.rs index cde4004ae..279221a53 100644 --- a/crates/doc/src/node/tests.rs +++ b/crates/doc/src/node/tests.rs @@ -443,6 +443,20 @@ fn visual_bounds_include_stroke() { assert_eq!(p.visual_bounds(), Some(Rect::new(-0.5, -0.5, 10.5, 10.5))); } +/// A group's box from its members' boxes grows by the group's own strokes, leaves out members +/// without a box, and is `None` when none has one. +#[test] +fn visual_of_members_unions_the_members_and_grows_by_the_group_stroke() { + let mut g = Node::group(NodeId(1), vec![]); + g.appearance = Appearance::default_art().into(); + let (a, b) = (Rect::new(0.0, 0.0, 10.0, 10.0), Rect::new(20.0, 5.0, 30.0, 8.0)); + let o = g.appearance.outset(); + assert!(o > 0.0); + assert_eq!(g.visual_of_members([Some(a), None, Some(b)].into_iter()), Some(Rect::new(0.0, 0.0, 30.0, 10.0).inflate(o, o))); + assert_eq!(g.visual_of_members([None, None].into_iter()), None); + assert_eq!(g.visual_of_members([].into_iter()), None); +} + /// Type's visual bounds grow by how far its character strokes paint past the glyphs: half the /// weight centred, the whole weight outside, nothing inside; scaled with the type. #[test] diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index cfb3d9344..8728b29b2 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -45,6 +45,8 @@ pub use entry::{export, export_with_report, page_areas}; use exporter::{MAX_COORD, writable, xf}; #[cfg(test)] use images::{png_8_bit, png_16_bit}; +#[cfg(test)] +pub(crate) use nodes::MEASURES; pub(crate) use writer::{Sheet, Writer}; pub(crate) struct Exporter<'a> { diff --git a/crates/pdf/src/export/links.rs b/crates/pdf/src/export/links.rs index 5eec2fd83..a40ca6692 100644 --- a/crates/pdf/src/export/links.rs +++ b/crates/pdf/src/export/links.rs @@ -47,8 +47,10 @@ impl Exporter<'_> { { return; } - let bounds = n.visual_bounds_with(&self.symbols).filter(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite())); - if let (Some(uri), Some(b)) = (n.url().filter(|u| opens(u)), bounds) { + // Only a link and a clipping group read the box. + let uri = n.url().filter(|u| opens(u)); + let bounds = (uri.is_some() || n.clips()).then(|| self.measure_for_links(n)).flatten(); + if let (Some(uri), Some(b)) = (uri, bounds) { let shown = b.intersect(within); if shown.width() > 0.0 && shown.height() > 0.0 { let rect = to_page.transform_rect_bbox(shown); @@ -74,6 +76,13 @@ impl Exporter<'_> { self.link_node(child, within, to_page, links, more); } } + + /// The visual box of `n`, when all its coordinates are finite. + fn measure_for_links(&self, n: &Node) -> Option { + #[cfg(test)] + super::MEASURES.with(|m| m.set(m.get() + 1)); + n.visual_bounds_with(&self.symbols).filter(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite())) + } } /// `uri` as a PDF URI (ISO 32000-1 §12.6.4.7: 7-bit ASCII): spaces, control characters and diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index 389552948..e8057236c 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -4,6 +4,20 @@ use super::{ Shape, Stroke, StrokeAlign, StrokeDash, StrokeLayer, Surface, WrittenShape, krilla, stroke, }; +/// An object's visual box for leaving out what is off the page. A plain group or layer also holds +/// the boxes of the members it is measured through ([`Node::editing_children`], in order), so that +/// the walk measures them once. +struct Measured { + bounds: Option, + members: Vec, +} + +#[cfg(test)] +std::thread_local! { + /// The objects this thread has measured for the walk and for the links (tests). + pub(crate) static MEASURES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + impl Exporter<'_> { /// Opacity mask → luminosity soft mask. Outside the art the backdrop is black (clip) or white; /// invert is a white Difference rect on top (luminance is linear, so luma(1 − c) = 1 − luma(c)). @@ -38,9 +52,15 @@ impl Exporter<'_> { /// The children of a group, as the elements of a knockout group when it is one. pub(super) fn children(&mut self, s: &mut Surface, children: &[std::sync::Arc], page: Rect) { + self.measured_children(s, children, page, &[]); + } + + /// [`Self::children`] with the boxes of the last members in `measured`. + fn measured_children(&mut self, s: &mut Surface, children: &[std::sync::Arc], page: Rect, measured: &[Measured]) { if !self.knockout { - for c in children { - self.node(s, c, page, false); + let unmeasured = children.len().saturating_sub(measured.len()); + for (i, c) in children.iter().enumerate() { + self.measured_node(s, c, page, false, i.checked_sub(unmeasured).and_then(|i| measured.get(i))); } return; } @@ -50,23 +70,25 @@ impl Exporter<'_> { self.warn("knockout groups are written as soft-masked groups (same look, but not editable as knockout groups)"); let elements: Vec<_> = Node::knockout_elements(children) .into_iter() - .filter(|c| c.visual_bounds_with(&self.symbols).is_some_and(|b| rects_overlap(b.inflate(1.0, 1.0), page))) + .map(|c| (c, self.measure(c))) + .filter(|(_, m)| m.bounds.is_some_and(|b| rects_overlap(b.inflate(1.0, 1.0), page))) .collect(); - let Some((first, rest)) = elements.split_first() else { return }; - for c in rest.iter().rev() { - let mask = self.knockout_mask(s, c, page); + let Some(((first, measured), rest)) = elements.split_first() else { return }; + for (c, m) in rest.iter().rev() { + let mask = self.knockout_mask(s, c, page, m); s.push_mask(mask); } - self.node(s, first, page, false); - for c in rest { + self.measured_node(s, first, page, false, Some(measured)); + for (c, m) in rest { s.pop(); - self.node(s, c, page, false); + self.measured_node(s, c, page, false, Some(m)); } } - /// A luminosity mask that is 1 − the knockout shape of `c`: white, then black through an alpha - /// mask of `c` (at full object opacity without its own mask, unless those define its shape). - fn knockout_mask(&mut self, s: &mut Surface, c: &Node, page: Rect) -> krilla::mask::Mask { + /// A luminosity mask that is 1 − the knockout shape of `c`, whose box is in `measured`: white, + /// then black through an alpha mask of `c` (at full object opacity without its own mask, unless + /// those define its shape). + fn knockout_mask(&mut self, s: &mut Surface, c: &Node, page: Rect, measured: &Measured) -> krilla::mask::Mask { let shape = if c.knockout_shape { c.clone() } else { @@ -81,7 +103,7 @@ impl Exporter<'_> { let mut ab = ms.stream_builder(); let mut als = ab.surface(); let overprint = std::mem::take(&mut self.overprint); - self.node(&mut als, &shape, page, false); + self.measured_node(&mut als, &shape, page, false, Some(measured)); self.overprint = overprint; als.finish(); krilla::mask::Mask::new(ab.finish(), krilla::mask::MaskType::Alpha) @@ -93,7 +115,23 @@ impl Exporter<'_> { krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity) } + /// The box of `n`, and of the members of a plain group or layer. + fn measure(&self, n: &Node) -> Measured { + if !matches!(n.kind, NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. }) { + #[cfg(test)] + MEASURES.with(|m| m.set(m.get() + 1)); + return Measured { bounds: n.visual_bounds_with(&self.symbols), members: vec![] }; + } + let members: Vec<_> = n.editing_children().iter().map(|c| self.measure(c)).collect(); + Measured { bounds: n.visual_of_members(members.iter().map(|m| m.bounds)), members } + } + pub(super) fn node(&mut self, s: &mut Surface, n: &Node, page: Rect, force: bool) { + self.measured_node(s, n, page, force, None); + } + + /// [`Self::node`] with the box of `n` in `measured`, when its parent measured it. + fn measured_node(&mut self, s: &mut Surface, n: &Node, page: Rect, force: bool, measured: Option<&Measured>) { if self.layers && self.in_layer != address(n) && let NodeKind::Layer { printable, .. } = n.kind @@ -115,9 +153,12 @@ impl Exporter<'_> { { return; } - match n.visual_bounds_with(&self.symbols) { - Some(b) if !force && !rects_overlap(b.inflate(1.0, 1.0), page) => return, - None if !n.is_container() => return, + // A container written whatever its box is has no use for one. + let own = (measured.is_none() && !(force && n.is_container())).then(|| self.measure(n)); + let measured = measured.or(own.as_ref()); + match measured.map(|m| m.bounds) { + Some(Some(b)) if !force && !rects_overlap(b.inflate(1.0, 1.0), page) => return, + Some(None) if !n.is_container() => return, _ => {} } self.warn_raster(&n.appearance.effects, || format!("{} objects", n.kind_label().to_lowercase())); @@ -161,7 +202,9 @@ impl Exporter<'_> { pushes += 1; } match &n.kind { - NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => self.children(s, children, page), + NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { + self.measured_children(s, children, page, measured.map_or(&[], |m| m.members.as_slice())); + } NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { // The region every output clips to; nothing to clip by hides the clipped art. The // clipping path's fill paints behind the clipped art and its stroke over it, unclipped. diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index f8df38500..eb9a4d284 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -30,6 +30,7 @@ mod layers; mod links; pub(super) mod live_text; mod marks; +mod measuring; mod mesh_export; mod output; mod pdf13; diff --git a/crates/pdf/src/tests/measuring.rs b/crates/pdf/src/tests/measuring.rs new file mode 100644 index 000000000..1ed6af95e --- /dev/null +++ b/crates/pdf/src/tests/measuring.rs @@ -0,0 +1,121 @@ +use std::sync::Arc; + +use vectorcraft_doc::{Knockout, Node, NodeId}; + +use super::*; + +/// The objects measured to leave out what is off the page and to write links while `d` is exported +/// (on this thread). +fn measures(d: &Document) -> usize { + crate::export::MEASURES.with(|m| m.set(0)); + export(d, &PdfOptions { created: Some(0), ..PdfOptions::default() }).expect("export"); + crate::export::MEASURES.with(std::cell::Cell::get) +} + +fn square(x: f64) -> Arc { + Arc::new(rect_node(Rect::new(x, 10.0, x + 20.0, 30.0), Color::rgb(0.2, 0.4, 0.6))) +} + +/// An object in groups within a layer is measured once in an export, as an object directly in a +/// layer is. +#[test] +fn an_object_in_nested_groups_is_measured_once() { + let mut d = doc(200.0, 200.0); + add(&mut d, (*square(10.0)).clone()); + assert_eq!(measures(&d), 1); + + let mut d = doc(200.0, 200.0); + let inner = Node::group(NodeId(0), vec![square(10.0), square(40.0)]); + add(&mut d, Node::group(NodeId(0), vec![Arc::new(inner), square(70.0)])); + assert_eq!(measures(&d), 3); +} + +/// A group in PDF layers, where a layer's box is not used, is measured once as well. +#[test] +fn an_object_in_a_pdf_layer_is_measured_once() { + let mut d = doc(200.0, 200.0); + add(&mut d, Node::group(NodeId(0), vec![square(10.0), square(40.0)])); + let mut opts = PdfOptions { created: Some(0), ..PdfOptions::default() }; + opts.settings.create_layers = true; + crate::export::MEASURES.with(|m| m.set(0)); + export(&d, &opts).expect("export"); + assert_eq!(crate::export::MEASURES.with(std::cell::Cell::get), 2); +} + +/// The elements of a knockout group are measured for the group's box and to choose the elements +/// on the page, and not again when each is drawn and when its mask is made. +#[test] +fn an_element_of_a_knockout_group_is_measured_twice() { + let mut d = doc(200.0, 200.0); + let mut group = Node::group(NodeId(0), vec![square(10.0), square(20.0)]); + group.knockout = Knockout::On; + add(&mut d, group); + assert_eq!(measures(&d), 4); +} + +/// The links of a tree with no URLs and no clipping group are found without measuring: the export +/// measures each object once, as the walk that leaves out what is off the page does. +#[test] +fn objects_without_a_url_are_not_measured_for_links() { + let mut d = doc(200.0, 200.0); + let inner = Node::group(NodeId(0), vec![square(10.0), square(40.0)]); + add(&mut d, Node::group(NodeId(0), vec![Arc::new(inner)])); + assert_eq!(measures(&d), 2); +} + +/// Only an object with a URL that opens is measured for its link, and not the groups around it. +#[test] +fn only_an_object_with_a_url_is_measured_for_its_link() { + let mut linked = (*square(10.0)).clone(); + linked.edit_attrs(|a| a.url = "https://example.com".into()); + let mut refused = (*square(40.0)).clone(); + refused.edit_attrs(|a| a.url = "javascript:alert(1)".into()); + let mut d = doc(200.0, 200.0); + add(&mut d, Node::group(NodeId(0), vec![Arc::new(linked), Arc::new(refused)])); + assert_eq!(measures(&d), 3); +} + +/// A clipping group is measured for the links inside it, and what it holds is not: the group for +/// the page and for the links, then the clipping path's fill and the member when they are written. +#[test] +fn a_clipping_group_is_measured_for_the_links_inside_it() { + let mut clip = Node::group(NodeId(0), vec![square(10.0), square(15.0)]); + clip.set_clips(true); + let mut d = doc(200.0, 200.0); + add(&mut d, clip); + assert_eq!(measures(&d), 4); +} + +/// A Shaper group's first member is left out of the group's box, and the boxes found by measuring +/// the group belong to the members after it. An off-page first member is not written, and the +/// member on the page is. +#[test] +fn a_member_left_out_of_the_group_box_measures_itself() { + let on_page = square(10.0); + let off_page = square(5000.0); + + let mut shaped = doc(200.0, 200.0); + let mut group = Node::group(NodeId(0), vec![off_page, on_page.clone()]); + group.extras_mut().shaper = Some(Box::default()); + add(&mut shaped, group); + + let mut plain = doc(200.0, 200.0); + add(&mut plain, Node::group(NodeId(0), vec![on_page])); + + assert_eq!(uncompressed(&shaped), uncompressed(&plain)); +} + +/// A group wholly off the page is not written, with the see-through empty group inside it. +#[test] +fn a_group_off_the_page_writes_nothing() { + let mut empty = Node::group(NodeId(0), vec![]); + empty.opacity = 0.5; + let mut off = doc(200.0, 200.0); + add(&mut off, (*square(10.0)).clone()); + add(&mut off, Node::group(NodeId(0), vec![square(5000.0), Arc::new(empty)])); + + let mut none = doc(200.0, 200.0); + add(&mut none, (*square(10.0)).clone()); + + assert_eq!(uncompressed(&off), uncompressed(&none)); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3da7b29cf..e0ea84890 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1176,6 +1176,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A font family removed while the app runs leaves the font menus when the app scans the font folders again (when its window comes to the front after a font folder changed, or Refresh Font List), also when type had used it: that type then draws in the fallback font and Find Font lists the font as missing, until the font is installed again and the folders are scanned. - **Files (M4):** - PDF export and print composition use the optional shared guarded writer ([pdf-backend.md](pdf-backend.md)); app drawing and output policy and desktop printer submission remain in their existing layers. The shared reader keeps the app-owned import adapter; synthetic tests cover layer and overprint state across windows, long operands, inline images, failed decoding, document limits and incomplete tails. Empty and populated metadata key presence is regression-tested, and CI checks the immutable backend source. + PDF export measures each member of a plain group or layer once to leave out what is off the page: the group or layer is measured through its members (`Node::visual_of_members`), and each member uses that box when it is written. The elements of a knockout group are measured twice, once for the group's box and once to choose the elements on the page (`pdf/src/tests/measuring.rs` counts the measures). A local byte comparison with the previous build found identical output on one open path of 200,000 anchors, 20,000 rectangles in 200 groups of 100, 5,000 point type objects and a document of plain, see-through, clipping and knockout groups. The walk that writes link annotations measures only an object with a URL that opens and a clipping group (`links.rs`), and its output on a document of linked objects in plain and clipping groups is the same byte for byte as when it measured every object. Invisible text in a PDF (rendering mode 3, which carries a scanned page's recognized text over the scan) opens as type with no fill or stroke, a run of its own beside visible text on the same line; text that can't come in as type (opened as outlines, Type 3 fonts) is left out with a warning. PDF export, and printing, write characters with no fill or stroke as real text in rendering mode 3, so they draw nothing but stay selectable and searchable, also in PDF/X-1a and PDF/A-2b (#2675, `pdf/src/tests/import_fidelity.rs`, `pdf/src/tests/realtext.rs`). A PDF, or a `.ai` read through its PDF part, whose spot inks have the same values but different names opens with a spot swatch for each ink. The solid fills and strokes of paths and type, and the stops of gradients on paths, link to the swatch of the ink they are painted in, the points of a gradient mesh in one of these inks keep the ink's color, and PDF export writes each of these inks as its own separation (`pdf/src/tests/import_color.rs`). Paint in these inks inside pattern cells, soft masks and type 3 glyphs, and gradients on type in these inks, still import as RGB. Type in one of these inks can also import as RGB when type in another Separation or DeviceN ink is shown with no path drawn between them. The stops of an axial or radial shading in a DeviceN space that mixes one spot ink with process plates (a spot ink over a constant cyan, for example) link to the ink's swatch at its tint and keep the process plates, and PDF and EPS (Level 3) export write those stops in a DeviceN space of the ink and those plates, so the spot plate is not lost (#3235, `import_color::a_device_n_gradient_of_a_spot_ink_over_a_process_plate_keeps_both_inks`). Solid fills that mix a spot ink with process plates, and stops that mix several spot inks, still import as RGB, with the spot inks kept as swatches. A `.ai` or EPS opened from its editing data keeps both swatches when a pattern's name, or a named color's, is also another swatch's: a pattern's swatch named as a default swatch, a named color or a palette color becomes "Name 2", and a named color named as a gradient or pattern swatch of the palette becomes "Name 2", with the fills, strokes and gradient stops linked to it following it (`ai::tests::a_pattern_swatch_named_as_another_swatch_is_numbered`, `ai::tests::a_color_named_as_a_palette_gradient_or_pattern_is_numbered`).