From e3d7b44f4c259cc957bf333a87a75655e9835253 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 14:18:35 +0000 Subject: [PATCH] fix(pdf): form fields, buttons and media that don't print stay out of the PDF (#726) Fields, buttons and video or sound are collected with the page content's inclusion rule: the item's layer is visible and printable, and neither the item nor any group around it is hidden or non-printing. --- ROADMAP.md | 1 + crates/pdf/src/export.rs | 35 ++++++++++++++++++--- crates/pdf/src/forms.rs | 14 +++------ crates/pdf/src/links.rs | 8 +---- crates/pdf/src/tests.rs | 66 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 103 insertions(+), 21 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..494cd6983 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · PDF export leaves out form fields, buttons and video or sound that don't print: inside a hidden group, on a hidden or non-printing layer, or marked non-printing. Page content and interactive elements follow one inclusion rule. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index dcf8abb83..88f33e98e 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -39,6 +39,33 @@ pub(crate) struct Sheet { pub(crate) const MARK_LEN: f64 = 18.0; +/// Whether a layer's items go into the PDF. +pub(crate) fn layer_exported(layer: &designcraft_doc::Layer) -> bool { + layer.visible && layer.printable +} + +/// Whether an item and everything inside it go into the PDF, its layer aside. +pub(crate) fn item_exported(it: &Item) -> bool { + !it.hidden && !it.nonprinting +} + +/// Every item of spread `si` that goes into the PDF, with its parent's transform: the items on +/// exported layers, leaving out hidden and non-printing items with everything inside them. +pub(crate) fn exported_items(doc: &Document, si: usize) -> Vec<(Affine, &Item)> { + let Some(sp) = doc.spreads.get(si) else { return Vec::new() }; + let mut stack: Vec<(Affine, &Item)> = + sp.items.iter().filter(|it| doc.layer(it.layer).is_some_and(layer_exported)).map(|it| (Affine::IDENTITY, &**it)).collect(); + let mut out = Vec::new(); + while let Some((xf, it)) = stack.pop() { + if !item_exported(it) { + continue; + } + stack.extend(it.shown_children().map(|c| (xf * it.xf, &**c))); + out.push((xf, it)); + } + out +} + fn bleed_lr(side: PageSide, b: [f64; 4]) -> (f64, f64) { // Bleed is top, bottom, inside, outside; single-sided pages read inside as left. if side == PageSide::Left { (b[3], b[2]) } else { (b[2], b[3]) } @@ -620,7 +647,7 @@ impl Exporter<'_> { let Some(sp) = doc.spreads.get(si) else { return }; let first = doc.first_page_of_spread(si); for layer in doc.layers.iter().rev() { - if !layer.visible || !layer.printable { + if !layer_exported(layer) { continue; } for (pi, page) in sp.pages.iter().enumerate() { @@ -679,7 +706,7 @@ impl Exporter<'_> { /// A spread or parent item, inside its structure tag when tagging. fn top_item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page: Option, on_parent: bool) { - if !self.opts.tagged || it.hidden || it.nonprinting { + if !self.opts.tagged || !item_exported(it) { return self.item(s, it, parent, page); } let story = it.text_frame().map(|t| t.story); @@ -752,7 +779,7 @@ impl Exporter<'_> { fn item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page: Option) { let gf = &it.effects.gradient_feather; - if !gf.on || it.hidden || it.nonprinting { + if !gf.on || !item_exported(it) { return self.item_inner(s, it, parent, page); } // Gradient feather: a luminosity mask (white = opaque) over the object. @@ -808,7 +835,7 @@ impl Exporter<'_> { } fn item_inner(&mut self, s: &mut Surface, it: &Item, parent: Affine, page: Option) { - if it.hidden || it.nonprinting { + if !item_exported(it) { return; } let ds = &it.effects.drop_shadow; diff --git a/crates/pdf/src/forms.rs b/crates/pdf/src/forms.rs index d02f93516..39aa597ec 100644 --- a/crates/pdf/src/forms.rs +++ b/crates/pdf/src/forms.rs @@ -5,8 +5,7 @@ use std::fmt::Write as _; -use designcraft_doc::{Document, FieldKind, FormField, Item, MediaOptions, SpreadRef}; -use designcraft_geom::Affine; +use designcraft_doc::{Document, FieldKind, FormField, Item, MediaOptions}; use crate::export::Sheet; use crate::transitions::{int_after, object, rfind}; @@ -18,16 +17,11 @@ pub(crate) type Placed = (usize, [f64; 4], FormField); /// bytes, options). pub(crate) type PlacedMedia = (usize, [f64; 4], String, String, std::sync::Arc>, MediaOptions); -/// Every shown item of the exported pages with its PDF rectangle (origin bottom left). +/// Every item of the exported pages that goes into the PDF, with its PDF rectangle (origin bottom +/// left). fn visit(doc: &Document, sheets: &[Sheet], mut f: impl FnMut(usize, [f64; 4], &Item)) { for (pi, sh) in sheets.iter().enumerate() { - let Some(sp) = doc.spread(SpreadRef::Doc(sh.spread)) else { continue }; - let mut stack: Vec<(Affine, &std::sync::Arc)> = sp.items.iter().map(|it| (Affine::IDENTITY, it)).collect(); - while let Some((xf, it)) = stack.pop() { - stack.extend(it.shown_children().map(|c| (xf * it.xf, c))); - if it.hidden { - continue; - } + for (xf, it) in crate::export::exported_items(doc, sh.spread) { let r = xf.transform_rect_bbox(it.bounds()).intersect(sh.bleed); if r.width() <= 0.0 || r.height() <= 0.0 { continue; diff --git a/crates/pdf/src/links.rs b/crates/pdf/src/links.rs index ce81287a1..5ad35d7de 100644 --- a/crates/pdf/src/links.rs +++ b/crates/pdf/src/links.rs @@ -81,14 +81,8 @@ pub(crate) fn annotations(doc: &Document, cache: &designcraft_compose::Cache, sh let first = doc.spreads[..sh.spread].iter().map(|s| s.pages.len()).sum::(); sp.pages.iter().position(|p| p.bounds().intersect(sh.trim).area() > 0.0).map(|i| first + i) }); - let mut stack: Vec<(Affine, &std::sync::Arc)> = - doc.spreads.get(sh.spread).map(|sp| sp.items.iter().map(|it| (Affine::IDENTITY, it)).collect()).unwrap_or_default(); - while let Some((xf, it)) = stack.pop() { - stack.extend(it.shown_children().map(|c| (xf * it.xf, c))); + for (xf, it) in crate::export::exported_items(doc, sh.spread) { let Some(action) = &it.button else { continue }; - if it.hidden { - continue; - } let r = xf.transform_rect_bbox(it.bounds()).intersect(sh.bleed); let rect = krilla::geom::Rect::from_ltrb( (r.x0 - sh.media.x0) as f32, diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 9ac53d057..991d799db 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -446,3 +446,69 @@ fn column_rules_are_drawn() { assert!((xs[0] - 214.0).abs() < 0.01 && (xs[1] - 398.0).abs() < 0.01, "{xs:?}"); assert!(rules(false).is_empty()); } + +/// Form fields and buttons that don't print stay out of the PDF: inside a hidden group, on a +/// hidden or non-printing layer, or non-printing themselves (#726). +#[test] +fn only_printed_fields_and_buttons_export() { + use designcraft_doc::{ButtonAction, Content, FieldKind, FormField, LayerId, Shape}; + use designcraft_geom::shapes::rectangle; + fn control(d: &mut Document, layer: LayerId, y: f64, name: &str, button: bool) -> Item { + let r = Rect::new(if button { 300.0 } else { 72.0 }, y, if button { 400.0 } else { 250.0 }, y + 24.0); + let mut it = Item::new(ItemId(d.alloc()), layer, Shape::Rectangle, rectangle(r)); + if button { + it.button = Some(ButtonAction::GoToUrl { url: format!("https://example.com/{name}") }); + } else { + it.form_field = Some(FormField { + kind: FieldKind::TextField, + name: name.into(), + value: String::new(), + options: Vec::new(), + multiline: false, + required: false, + font_size: 0.0, + }); + } + it + } + let export = |unprinted: bool| { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + for b in [false, true] { + let it = control(&mut d, lid, 72.0, "shown", b); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + } + if unprinted { + let mut layers = Vec::new(); + for (visible, printable) in [(false, true), (true, false)] { + let mut l = d.layers[0].clone(); + l.id = LayerId(d.alloc()); + l.visible = visible; + l.printable = printable; + d.layers.push(l.clone()); + layers.push(l.id); + } + for (i, layer) in layers.into_iter().enumerate() { + for b in [false, true] { + let it = control(&mut d, layer, 120.0 + 48.0 * i as f64, &format!("unprinted-layer-{i}"), b); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + } + } + for b in [false, true] { + let mut it = control(&mut d, lid, 240.0, "unprinted-item", b); + it.nonprinting = true; + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + } + let children: Vec<_> = [false, true].map(|b| std::sync::Arc::new(control(&mut d, lid, 288.0, "unprinted-group", b))).into(); + let mut group = Item::new(ItemId(d.alloc()), lid, Shape::Group, rectangle(Rect::new(72.0, 288.0, 400.0, 312.0))); + group.content = Content::Group { items: children }; + group.hidden = true; + d.insert_item(SpreadRef::Doc(0), group, None).unwrap(); + } + uncompressed(&d, PdfOptions::default()) + }; + let (shown, all) = (export(false), export(true)); + assert_eq!(all.matches("/Subtype/Widget").count(), 1, "one field"); + assert!(all.contains("(shown)") && !all.contains("unprinted"), "only the shown field and button"); + assert_eq!(all.matches("/Link").count(), shown.matches("/Link").count(), "only the shown button"); +}