mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-27 03:36:51 -06:00
Merge pdf-hidden-fields: fix(pdf): form fields, buttons and media that don't print stay out of the PDF (#726)
This commit is contained in:
@@ -128,6 +128,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-13** · IDML export keeps parent item overrides: each page lists its overridden parent items and the local copy keeps its link to the parent item, so a reopened IDML file shows only the local copy (#725). No readiness percentage change.
|
||||
- **2026-10-13** · Reflowable EPUB and HTML export include tables (header and footer rows, merged cells), footnotes (a linked note after the paragraph that refers to it) and endnotes (a linked section at the end of the book). No readiness percentage change.
|
||||
- **2026-10-13** · Word import: table cells read text by the same rules as body text, so deleted text, moved-away text and field codes stay out (#721). Word's Normal comes in as its own paragraph style "Normal" over the document defaults, so text formatted only by Normal keeps its look; it conflicts only with a document's own Normal, and the style map can send it to [Basic Paragraph] (#722). No readiness percentage change.
|
||||
|
||||
@@ -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]) }
|
||||
@@ -621,7 +648,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() {
|
||||
@@ -683,7 +710,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<usize>, 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);
|
||||
@@ -766,7 +793,7 @@ impl Exporter<'_> {
|
||||
|
||||
fn item_guarded(&mut self, s: &mut Surface, it: &Item, parent: Affine, page: Option<usize>) {
|
||||
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.
|
||||
@@ -821,7 +848,7 @@ impl Exporter<'_> {
|
||||
}
|
||||
|
||||
fn item_inner(&mut self, s: &mut Surface, it: &Item, parent: Affine, page: Option<usize>) {
|
||||
if it.hidden || it.nonprinting {
|
||||
if !item_exported(it) {
|
||||
return;
|
||||
}
|
||||
let ds = &it.effects.drop_shadow;
|
||||
|
||||
+4
-10
@@ -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<Vec<u8>>, 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<Item>)> = 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;
|
||||
|
||||
@@ -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::<usize>();
|
||||
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<designcraft_doc::Item>)> =
|
||||
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,
|
||||
|
||||
@@ -658,3 +658,69 @@ fn based_on_parent_items_export_base_first() {
|
||||
assert!(cyan.is_some() && magenta.is_some(), "both parents' items are on the page");
|
||||
assert!(cyan < magenta, "the base parent's item is drawn first");
|
||||
}
|
||||
|
||||
/// 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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user