PDF export: members of a plain group or layer are measured once to leave out off-page art

The walk measured every object's visual box to leave out what is off
the page. A plain group or layer is measured as the union of its
members, and each member then measured itself again when it was
written, so an object in plain groups was measured once for each level
it sits at.

Measuring a plain group or layer now keeps its members' boxes in a
tree, and each member uses its box when it is written. A container
written whatever its box is (a PDF layer's group, placed symbol art) is
not measured. A knockout group's elements are measured for the group's
box and again for the list of elements on the page, and that second box
is used for the element and for its mask. Objects in a live container
(Blend, Envelope, Repeat, Intertwine) are still measured with the
container and again after it is expanded. Node::visual_of_members holds
the formula that Node::visual also uses, so the boxes and every decision
are unchanged.

The output is byte for byte the same on one open path of 200,000
anchors, 20,000 rectangles in 200 groups of 100, and 5,000 point type
objects, and on a document of plain, see-through, clipping and knockout
groups on and off the page, each exported with default settings, with
uncompressed text and with PDF layers.
This commit is contained in:
dotCooCoo
2026-11-07 05:15:07 +07:00
committed by bflatastic
parent a17907ee7d
commit a3045845c8
7 changed files with 174 additions and 22 deletions
+9 -5
View File
@@ -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<Item = Option<Rect>>) -> Option<Rect> {
// 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<Rect> {
@@ -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);
+14
View File
@@ -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]
+2
View File
@@ -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> {
+60 -17
View File
@@ -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<Rect>,
members: Vec<Measured>,
}
#[cfg(test)]
std::thread_local! {
/// The objects this thread has measured for the walk (tests).
pub(crate) static MEASURES: std::cell::Cell<usize> = 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<Node>], 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<Node>], 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.
+1
View File
@@ -30,6 +30,7 @@ mod layers;
mod links;
pub(super) mod live_text;
mod marks;
mod measuring;
mod mesh_export;
mod output;
mod pdf13;
+87
View File
@@ -0,0 +1,87 @@
use std::sync::Arc;
use vectorcraft_doc::{Knockout, Node, NodeId};
use super::*;
/// The objects measured to leave out what is off the page 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<Node> {
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);
}
/// 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));
}
+1
View File
@@ -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.
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`).