From e72147c210e4901e09201e523327a6c5ce39cba5 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:12:42 -0400 Subject: [PATCH 1/5] M12: screen-only and print-only XFA objects are shown and printed as the form says --- crates/engine/src/tests.rs | 39 +++++++ crates/xfa/src/layout.rs | 109 ++++++++++++++++-- crates/xfa/src/model.rs | 45 ++++++++ crates/xfa/src/parse.rs | 23 ++++ crates/xfa/src/pdf.rs | 218 ++++++++++++++++++++++++++++++++++- crates/xfa/src/tests.rs | 186 ++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 7 files changed, 612 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ed4e6efa1c..ce69daf5a0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2593,6 +2593,45 @@ fn printing_lays_out_sheets_that_render() { assert_eq!(s4.print_pdf(id4, &print::Settings { pages: vec![0], ..Default::default() }), Err(EditError::NotPermitted("printing"))); } +/// A dynamic XFA form's screen-only and print-only objects (`relevant`): on screen, the +/// screen-only field and draw show and the print-only ones don't; printed, the screen-only field +/// is left off and the print-only one is on paper. (Draws follow the layers' state on screen +/// when printed until printing applies the layers' print usage; other viewers read it from the +/// file.) +#[test] +fn xfa_screen_only_and_print_only_objects_show_and_print_where_the_form_says() { + let field = |name: &str, attrs: &str| { + format!( + r#"{name} value"# + ) + }; + let draw = |name: &str, attrs: &str| { + format!(r#"{name} text"#) + }; + let xdp = format!( + r##" +"##, + field("screenField", r#"relevant="-print""#), + field("paperField", r#"presence="invisible" relevant="+print""#), + draw("screenDraw", r#"relevant="-print""#), + draw("paperDraw", r#"presence="invisible" relevant="+print""#), + ); + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&xdp)), None).expect("opens"); + let screen = page_texts(&s, id).join("\n"); + assert!(screen.contains("screenField value") && screen.contains("screenDraw text"), "on screen: {screen}"); + assert!(!screen.contains("paperField value") && !screen.contains("paperDraw text"), "on screen: {screen}"); + let sheet = s.print_pdf(id, &print::Settings { pages: vec![0], ..Default::default() }).unwrap(); + let mut out = Session::new(); + let printed = out.open("print.pdf", None, Arc::new(sheet), None).unwrap(); + let paper = page_texts(&out, printed).join("\n"); + assert!(!paper.contains("screenField value"), "printed: {paper}"); + assert!(paper.contains("paperField value"), "printed: {paper}"); +} + /// A one-page document whose red square is in a layer; `off` switches the layer off by default. fn red_layer_pdf(off: bool) -> Vec { let content = "/OC /L1 BDC 1 0 0 rg 0 0 100 100 re f EMC"; diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 23342168e7..c5f2449bd0 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -39,11 +39,30 @@ const PAGE_COUNT_MARK: char = '\u{E000}'; #[derive(Clone, Debug, PartialEq)] pub enum Item { - Fill { rect: Rect, color: [f64; 3] }, - Line { from: (f64, f64), to: (f64, f64), width: f64, color: [f64; 3], dashed: bool }, + Fill { + rect: Rect, + color: [f64; 3], + }, + Line { + from: (f64, f64), + to: (f64, f64), + width: f64, + color: [f64; 3], + dashed: bool, + }, Text(Span), - Image { rect: Rect, data: Arc>, content_type: String }, + Image { + rect: Rect, + data: Arc>, + content_type: String, + }, Widget(Box), + /// An item drawn only on screen or only in print (XFA `relevant`, `presence`): written in an + /// optional content group whose usage says so. + Only { + views: Views, + item: Box, + }, } #[derive(Clone, Debug, PartialEq)] @@ -126,6 +145,8 @@ pub struct Widget { pub action: Option, /// Check boxes: the on and off values of the data; radio buttons: the on value. pub items: Vec, + /// Where the field is shown: on screen, in print, or both (its annotation flags). + pub views: Views, } #[derive(Clone, Debug, Default, PartialEq)] @@ -211,6 +232,8 @@ struct Layouter<'t> { /// The fields and draws of the block being placed: (first item, one past the last, top, /// bottom) on the last page. A page break keeps each whole (see `split_block`). leaves: Vec<(usize, usize, f64, f64)>, + /// Where what is being drawn shows, as the objects it is inside say ([`Common::views`]). + views: Views, } /// How many instances of a repeating subform to lay out: what the data holds once it holds @@ -256,6 +279,7 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result measured: RefCell::new(HashMap::new()), measures: Cell::new(0), leaves: Vec::new(), + views: Views::ALL, }; let ctx = Ctx { font: Font::default(), para: Para::default(), som: String::new() }; l.new_page()?; @@ -335,12 +359,19 @@ fn solid_extent(item: &Item) -> Option<(f64, f64)> { Item::Image { rect, .. } => Some((rect.y, rect.bottom())), Item::Widget(w) => Some((w.rect.y, w.rect.bottom())), Item::Fill { .. } | Item::Line { .. } => None, + Item::Only { item, .. } => solid_extent(item), } } /// `item` as the part above `cut` and the part below: solid items go whole to the side their /// top is on, fills and lines crossing the cut are cut in two. fn divide(item: Item, cut: f64) -> (Option, Option) { + if let Item::Only { views, item } = item { + // Each part shows where the whole did. + let wrap = |i: Item| Item::Only { views, item: Box::new(i) }; + let (a, b) = divide(*item, cut); + return (a.map(wrap), b.map(wrap)); + } if let Some((top, _)) = solid_extent(&item) { return if top < cut - 0.01 { (Some(item), None) } else { (None, Some(item)) }; } @@ -375,6 +406,9 @@ fn divide(item: Item, cut: f64) -> (Option, Option) { /// `item` moved by (`dx`, `dy`). fn moved(mut item: Item, dx: f64, dy: f64) -> Item { + if let Item::Only { views, item: inner } = item { + return Item::Only { views, item: Box::new(moved(*inner, dx, dy)) }; + } match &mut item { Item::Fill { rect, .. } | Item::Image { rect, .. } => { rect.x += dx; @@ -392,6 +426,8 @@ fn moved(mut item: Item, dx: f64, dy: f64) -> Item { w.rect.x += dx; w.rect.y += dy; } + // Taken apart above. + Item::Only { .. } => {} } item } @@ -505,12 +541,25 @@ impl Layouter<'_> { Ok(()) } + /// Put `item` on the last page, shown where what it is inside is shown ([`Self::views`]): a + /// field's widget carries that itself, anything else drawn only on screen or only in print + /// goes in an [`Item::Only`], and what shows nowhere isn't put at all. fn push(&mut self, item: Item) -> Result<(), XfaError> { self.check_measures()?; self.items += 1; if self.items > MAX_ITEMS { return Err(XfaError::TooLarge("the form has too many objects to lay out".into())); } + let views = self.views; + let item = match item { + Item::Widget(mut w) => { + w.views = views; + Item::Widget(w) + } + other if views == Views::ALL => other, + _ if !views.any() => return Ok(()), + other => Item::Only { views, item: Box::new(other) }, + }; if let Some(p) = self.pages.last_mut() { p.items.push(item); } @@ -570,10 +619,12 @@ impl Layouter<'_> { let ctx = Ctx { font: Font::default(), para: Para::default(), som: area.name.clone().map(|n| format!("{n}[0]")).unwrap_or_default() }; let page_rect = Rect::new(0.0, 0.0, area.width, area.height); let sibs = SomIndices::of(&area.items); - for (i, item) in area.items.iter().enumerate() { - self.draw_positioned_child(item, page_rect, &ctx, 1, sibs.at(i))?; - } - Ok(()) + // The master page's own objects: shown where they say, whatever the body was drawing + // when it needed this page. + let body = std::mem::replace(&mut self.views, Views::ALL); + let drawn = area.items.iter().enumerate().try_for_each(|(i, item)| self.draw_positioned_child(item, page_rect, &ctx, 1, sibs.at(i))); + self.views = body; + drawn } /// Move to the next content area, or the next page. @@ -766,7 +817,18 @@ impl Layouter<'_> { // ───────────────────────────────────────────────────────────────────────── flow /// Flow `node` (a subform; anything else is ignored) into the content areas. + /// Flow subform `node` (each instance the data asks for) into the content areas, shown where + /// it and what it is inside say ([`Common::views`]); hidden from both, it still takes its room. fn flow_subform(&mut self, node: &Node, avail: Option<(f64, f64)>, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { + let views = self.views.and(node.common().views()); + let outer = std::mem::replace(&mut self.views, views); + let flowed = self.flow_subform_in(node, avail, ctx, depth, sib); + self.views = outer; + flowed + } + + /// [`Self::flow_subform`] once where the subform shows is set. + fn flow_subform_in(&mut self, node: &Node, avail: Option<(f64, f64)>, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { if depth > MAX_DEPTH { return Ok(()); } @@ -1265,10 +1327,24 @@ impl Layouter<'_> { // ───────────────────────────────────────────────────────────────────────── drawing + /// Draw table row `row` at `rect` in columns `cols`, shown where it and what it is inside say + /// ([`Common::views`]). fn draw_row(&mut self, row: &Subform, cols: &[f64], rect: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { if depth > MAX_DEPTH { return Ok(()); } + let views = self.views.and(row.common.views()); + if !views.any() { + return Ok(()); + } + let outer = std::mem::replace(&mut self.views, views); + let drawn = self.draw_row_cells(row, cols, rect, ctx, depth, sib); + self.views = outer; + drawn + } + + /// [`Self::draw_row`]'s border and cells, once where the row shows is set. + fn draw_row_cells(&mut self, row: &Subform, cols: &[f64], rect: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { if let Some(b) = &row.common.border { self.draw_border(b, rect)?; } @@ -1295,14 +1371,29 @@ impl Layouter<'_> { self.draw_node(k, Rect::new(inner.x + x, inner.y + y, w, h), ctx, depth + 1, sib) } + /// Draw `node` at `rect`, shown where it and what it is inside say ([`Common::views`]): an + /// invisible object that `relevant` prints is drawn for print only, and one shown nowhere + /// draws nothing. fn draw_node(&mut self, node: &Node, rect: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { if depth > MAX_DEPTH { return Ok(()); } let c = node.common(); - if c.presence == Presence::Invisible { + if let Some(other) = &c.relevant_other { + self.warn_once(format!("the form limits some objects to views other than print (relevant \"{other}\"); they are drawn as if it didn't")); + } + let views = self.views.and(c.views()); + if !views.any() { return Ok(()); } + let outer = std::mem::replace(&mut self.views, views); + let drawn = self.draw_node_in(node, rect, ctx, depth, sib); + self.views = outer; + drawn + } + + /// [`Self::draw_node`] once where the node shows is set. + fn draw_node_in(&mut self, node: &Node, rect: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { match node { Node::Draw(d) => { let start = self.item_count(); @@ -1804,6 +1895,8 @@ impl Layouter<'_> { default: template_default, action, items, + // `push` sets where it shows. + views: Views::ALL, }))) } } diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs index b60180b7fa..4c645dcab1 100644 --- a/crates/xfa/src/model.rs +++ b/crates/xfa/src/model.rs @@ -306,6 +306,37 @@ impl Para { } } +/// Where an object is drawn: on screen, when the form is printed, or both (see +/// [`Common::views`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Views { + pub screen: bool, + pub print: bool, +} + +impl Views { + /// On screen and in print: what an object with nothing to say is. + pub const ALL: Views = Views { screen: true, print: true }; + + /// Where an object inside a container drawn in `self` is drawn, when it alone would be drawn + /// in `other`: only where both are. + pub fn and(self, other: Views) -> Views { + Views { screen: self.screen && other.screen, print: self.print && other.print } + } + + /// Drawn somewhere. + pub fn any(self) -> bool { + self.screen || self.print + } +} + +impl Default for Views { + /// On screen and in print. + fn default() -> Self { + Views::ALL + } +} + /// What every container and leaf shares: name, box, presence, margins, border, text defaults. #[derive(Clone, Debug, Default, PartialEq)] pub struct Common { @@ -321,6 +352,12 @@ pub struct Common { pub min_h: Option, pub max_h: Option, pub presence: Presence, + /// What `relevant` says about printing: `Some(true)` for `+print` (printed even when its + /// presence hides it on screen), `Some(false)` for `-print` (never printed), `None` when it + /// says nothing (printed as it is shown). + pub relevant: Option, + /// The first `relevant` token about a view other than print, which is left aside. + pub relevant_other: Option, pub margin: Margin, pub border: Option, pub font: FontSpec, @@ -339,6 +376,14 @@ pub enum Node { } impl Common { + /// Where the object itself is drawn, before what its containers say: on screen when it is + /// visible, and in print when `relevant` says so (`+print` even when hidden or invisible, + /// `-print` never), else when it is visible. + pub fn views(&self) -> Views { + let screen = self.presence == Presence::Visible; + Views { screen, print: self.relevant.unwrap_or(screen) } + } + /// The top-left corner of a positioned box of `w` by `h`, from `x`, `y` and the anchor. pub fn top_left(&self, w: f64, h: f64) -> (f64, f64) { (self.x.unwrap_or(0.0) - w * self.anchor.h, self.y.unwrap_or(0.0) - h * self.anchor.v) diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index fc9ad1a245..27c1c4c35a 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -178,7 +178,28 @@ fn parse_para(n: Option) -> ParaSpec { } } +/// `relevant`: a space-separated list of views, each `+name` (or `name`) or `-name`. What it says +/// about printing (`Some(true)` for `+print`, `Some(false)` for `-print`; the last one counts), and +/// the first token about any other view, which is left aside. +fn parse_relevant(s: Option<&str>) -> (Option, Option) { + let (mut print, mut other) = (None, None); + for token in s.unwrap_or_default().split_whitespace().take(64) { + match token { + "+print" | "print" => print = Some(true), + "-print" => print = Some(false), + _ => { + if other.is_none() { + other = Some(token.chars().take(64).collect()); + } + } + } + } + (print, other) +} + +/// What element `n` shares with every container and leaf ([`Common`]). fn parse_common(n: XmlNode) -> Common { + let (relevant, relevant_other) = parse_relevant(n.attribute("relevant")); Common { name: n.attribute("name").map(str::to_string).filter(|s| !s.is_empty()), id: n.attribute("id").map(str::to_string), @@ -192,6 +213,8 @@ fn parse_common(n: XmlNode) -> Common { min_h: attr_measure(n, "minH"), max_h: attr_measure(n, "maxH"), presence: Presence::parse(n.attribute("presence")), + relevant, + relevant_other, margin: parse_margin(child(n, "margin")), border: parse_border(child(n, "border")), font: parse_font(child(n, "font")), diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 8e0c47d888..7b9981962b 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -160,6 +160,15 @@ pub struct Written { /// layer reads this key). pub const IMAGE_FIELD_KEY: &[u8] = b"PCXfaImage"; +/// On an optional content group PdfCraft made for what a form shows only on screen (`/Screen`) +/// or only in print (`/Print`): a later layout of the form finds and reuses it. +pub const RELEVANT_KEY: &[u8] = b"PCXfaRelevant"; + +/// The optional content groups of [`RELEVANT_KEY`], by index: what the key says, the group's +/// resource name on the pages, its `/Name`, and its view and print states. +const GROUPS: [(&str, &str, &str, &str, &str); 2] = + [("Screen", "PCXfaScreen", "Form: screen only", "ON", "OFF"), ("Print", "PCXfaPrint", "Form: print only", "OFF", "ON")]; + /// On an image XObject written for a picture: the SHA-256 (hex) of the picture's bytes. A /// re-layout (after every script event) finds the XObjects on the pages it replaces by this, /// and reuses them instead of decoding and embedding the same picture again. Nothing is kept @@ -182,6 +191,11 @@ struct Emitter<'a> { warnings: Vec, /// The signatures the file holds, by the SOM path of the field signed. signed: HashMap, + /// The screen-only and print-only optional content groups ([`GROUPS`]): an earlier layout's, + /// or made when first needed. + groups: [Option; 2], + /// Which of [`Self::groups`] this layout drew in. + used: [bool; 2], } /// A signature field of the file that has been signed. @@ -244,13 +258,23 @@ impl Emitter<'_> { format!("/{} {} Tf {} rg", face.resource_name(), n(face.size), rgb(face.color)).into_bytes() } + /// Write laid-out widget `w` on `page` as an AcroForm widget annotation (and its field), its + /// flags saying whether it shows on screen and in print, and return the annotation. fn widget(&mut self, w: &Widget, page: &Page, page_ref: ObjRef) -> Result, XfaError> { let r = w.rect; let (x0, y0, x1, y1) = (r.x, page.height - r.bottom(), r.right(), page.height - r.y); let mut d = Dict::new(); d.set(b"Type".to_vec(), Object::name("Annot")); d.set(b"Subtype".to_vec(), Object::name("Widget")); - d.set(b"F".to_vec(), Object::Int(4)); + // Print (4) when it prints, NoView (32) when it isn't shown on screen, Hidden (2) when + // it is shown nowhere. + let flags = match (w.views.screen, w.views.print) { + (true, true) => 4, + (true, false) => 0, + (false, true) => 4 | 32, + (false, false) => 2, + }; + d.set(b"F".to_vec(), Object::Int(flags)); d.set(b"P".to_vec(), Object::Ref(page_ref)); d.set(b"Rect".to_vec(), Object::Array([x0, y0, x1, y1].iter().map(|v| Object::Real(*v)).collect())); d.set(b"DA".to_vec(), Object::String(PdfString::literal(Self::da(&w.face)))); @@ -573,6 +597,37 @@ impl Emitter<'_> { } } + /// Optional content group `g` of [`GROUPS`] (0 screen only, 1 print only), marked as drawn in: + /// an earlier layout's, or a new one whose `/Usage` says where it shows. + fn group(&mut self, g: usize) -> ObjRef { + if let Some(u) = self.used.get_mut(g) { + *u = true; + } + if let Some(r) = self.groups.get(g).copied().flatten() { + return r; + } + let (key, _, name, view, print) = GROUPS.get(g).copied().unwrap_or(GROUPS[0]); + let state = |event: &str, key: &str, value: &str| { + let mut s = Dict::new(); + s.set(key.as_bytes().to_vec(), Object::name(value)); + (event.as_bytes().to_vec(), Object::Dict(s)) + }; + let mut usage = Dict::new(); + for (k, v) in [state("View", "ViewState", view), state("Print", "PrintState", print)] { + usage.set(k, v); + } + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("OCG")); + d.set(b"Name".to_vec(), Object::String(PdfString::text(name))); + d.set(b"Usage".to_vec(), Object::Dict(usage)); + d.set(RELEVANT_KEY.to_vec(), Object::name(key)); + let r = self.doc.add(Object::Dict(d)); + if let Some(slot) = self.groups.get_mut(g) { + *slot = Some(r); + } + r + } + /// Write one laid-out page under `pages_ref`: its fills, lines, text and pictures in a /// content stream, the fonts and images it uses as resources, and its widgets as /// annotations. Returns the page object. @@ -583,7 +638,25 @@ impl Emitter<'_> { let mut xobjects = Dict::new(); let mut annots = Vec::new(); let h = page.height; + // The group whose marked content is open, and the groups the page uses. + let mut open: Option = None; + let mut properties = Dict::new(); for (i, item) in page.items.iter().enumerate() { + let (group, item) = match item { + Item::Only { views, item } => (Some(if views.screen { 0 } else { 1 }), item.as_ref()), + other => (None, other), + }; + // A field's widget is an annotation, not page content: the marked content runs on. + if !matches!(item, Item::Widget(_)) && group != open { + if open.is_some() { + content.extend_from_slice(b"EMC\n"); + } + if let Some((g, (_, resource, ..))) = group.and_then(|g| GROUPS.get(g).map(|k| (g, k))) { + properties.set(resource.as_bytes().to_vec(), Object::Ref(self.group(g))); + content.extend(format!("/OC /{resource} BDC\n").bytes()); + } + open = group; + } match item { Item::Fill { rect, color } => { content.extend(format!("{} rg {} {} {} {} re f\n", rgb(*color), n(rect.x), n(h - rect.bottom()), n(rect.w), n(rect.h)).bytes()); @@ -641,8 +714,13 @@ impl Emitter<'_> { annots.push(Object::Ref(r)); } } + // Taken apart above: an item is never wrapped twice. + Item::Only { .. } => {} } } + if open.is_some() { + content.extend_from_slice(b"EMC\n"); + } let mut font_dict = Dict::new(); for (name, base) in fonts { font_dict.set(name.as_bytes().to_vec(), standard_font(base)); @@ -652,6 +730,9 @@ impl Emitter<'_> { if !xobjects.is_empty() { res.set(b"XObject".to_vec(), Object::Dict(xobjects)); } + if !properties.is_empty() { + res.set(b"Properties".to_vec(), Object::Dict(properties)); + } let mut cd = Dict::new(); cd.set(b"Type".to_vec(), Object::name("XObject")); let contents = self.doc.add(Object::Stream(Stream::flate(Dict::new(), &content))); @@ -714,6 +795,7 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result // Pictures embedded by earlier passes, from the pages this pass replaces. let images = earlier_images(doc, pages_ref); let signed = signed_fields(doc, &catalog); + let groups = earlier_groups(doc, &catalog); let mut em = Emitter { doc, images, @@ -723,6 +805,8 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result field_count: 0, warnings: Vec::new(), signed, + groups, + used: [false; 2], }; let mut kids = Vec::new(); for page in &form.pages { @@ -738,7 +822,11 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result } })?; } - let Emitter { doc, fields, field_count, warnings, .. } = em; + let Emitter { doc, fields, field_count, warnings, groups, used, .. } = em; + let drawn: Vec = groups.iter().zip(used).filter(|(_, u)| *u).filter_map(|(g, _)| *g).collect(); + if !drawn.is_empty() { + catalog_changed |= register_groups(doc, &mut catalog, &groups, used)?; + } let count = kids.len(); doc.update_dict(pages_ref, |d| { d.set(b"Type".to_vec(), Object::name("Pages")); @@ -792,6 +880,126 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result Ok(Written { pages: count, fields: field_count, warnings }) } +/// The screen-only and print-only groups an earlier layout of the form made ([`RELEVANT_KEY`]), +/// found among the document's optional content groups. +fn earlier_groups(doc: &Document, catalog: &Dict) -> [Option; 2] { + let mut out = [None; 2]; + let ocgs = catalog + .get(b"OCProperties") + .map(|p| doc.resolve(p)) + .and_then(|p| p.as_dict().and_then(|p| p.get(b"OCGs").map(|o| doc.resolve(o)))) + .and_then(|o| o.as_array().cloned()) + .unwrap_or_default(); + for r in ocgs.iter().filter_map(Object::as_ref).take(MAX_GROUPS_SEARCHED) { + let key = doc.get(r).as_dict().and_then(|d| d.name(RELEVANT_KEY).map(<[u8]>::to_vec)); + if let Some(g) = key.and_then(|k| GROUPS.iter().position(|(name, ..)| name.as_bytes() == k.as_slice())) + && let Some(slot) = out.get_mut(g) + && slot.is_none() + { + *slot = Some(r); + } + } + out +} + +/// Most optional content groups looked at for an earlier layout's. +const MAX_GROUPS_SEARCHED: usize = 10_000; + +/// Add the screen-only and print-only `groups` this layout drew in (`used`) to the document's +/// optional content properties, keeping its own layers as they are: each goes in `/OCGs`; in the +/// default configuration the print-only one starts off (the screen-only one starts on when the +/// base state is off); and both join `/AS` usage applications for the View and Print events (an +/// existing one for that event and category, or a new one), so a viewer shows each where it +/// belongs. Nothing already there is added again. Returns whether `catalog` itself changed (a +/// direct `/OCProperties`); an indirect one is updated where it is. +fn register_groups(doc: &mut Document, catalog: &mut Dict, groups: &[Option; 2], used: [bool; 2]) -> Result { + let mine: Vec<(usize, ObjRef)> = + groups.iter().zip(used).enumerate().filter(|(_, (_, u))| *u).filter_map(|(i, (g, _))| g.map(|r| (i, r))).collect(); + let indirect = catalog.get(b"OCProperties").and_then(Object::as_ref); + let mut props = catalog.get(b"OCProperties").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()).unwrap_or_default(); + // A list in `dict` under `key` (direct or not), with `add` appended where missing. + let add_refs = |doc: &mut Document, dict: &mut Dict, key: &[u8], add: &[ObjRef]| -> Result<(), XfaError> { + let at = dict.get(key).and_then(Object::as_ref); + let mut list = dict.get(key).map(|l| doc.resolve(l)).and_then(|l| l.as_array().cloned()).unwrap_or_default(); + for r in add { + if !list.iter().any(|o| o.as_ref() == Some(*r)) { + list.push(Object::Ref(*r)); + } + } + match at { + Some(a) => doc.set(a, Object::Array(list)), + None => dict.set(key.to_vec(), Object::Array(list)), + } + Ok(()) + }; + let all: Vec = mine.iter().map(|(_, r)| *r).collect(); + add_refs(doc, &mut props, b"OCGs", &all)?; + let d_at = props.get(b"D").and_then(Object::as_ref); + let mut d = props.get(b"D").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + let base_off = d.name(b"BaseState") == Some(&b"OFF"[..]); + for (g, r) in &mine { + match (*g, base_off) { + (1, false) => add_refs(doc, &mut d, b"OFF", &[*r])?, + (0, true) => add_refs(doc, &mut d, b"ON", &[*r])?, + _ => {} + } + } + // The usage applications: one per event, with its own category. + let as_at = d.get(b"AS").and_then(Object::as_ref); + let mut apps = d.get(b"AS").map(|x| doc.resolve(x)).and_then(|x| x.as_array().cloned()).unwrap_or_default(); + for event in ["View", "Print"] { + let matches = |doc: &Document, o: &Object| { + doc.resolve(o).as_dict().is_some_and(|a| { + a.name(b"Event") == Some(event.as_bytes()) + && a.get(b"Category") + .map(|c| doc.resolve(c)) + .and_then(|c| c.as_array().cloned()) + .is_some_and(|c| c.len() == 1 && c.first().and_then(Object::as_name).is_some_and(|n| n == event.as_bytes())) + }) + }; + match apps.iter().position(|o| matches(doc, o)) { + Some(i) => { + let entry = apps.get(i).cloned().unwrap_or(Object::Null); + let mut app = doc.resolve(&entry).as_dict().cloned().unwrap_or_default(); + add_refs(doc, &mut app, b"OCGs", &all)?; + match entry.as_ref() { + Some(r) => doc.set(r, Object::Dict(app)), + None => { + if let Some(slot) = apps.get_mut(i) { + *slot = Object::Dict(app); + } + } + } + } + None => { + let mut app = Dict::new(); + app.set(b"Event".to_vec(), Object::name(event)); + app.set(b"OCGs".to_vec(), Object::Array(all.iter().map(|r| Object::Ref(*r)).collect())); + app.set(b"Category".to_vec(), Object::Array(vec![Object::name(event)])); + apps.push(Object::Dict(app)); + } + } + } + match as_at { + Some(a) => doc.set(a, Object::Array(apps)), + None => d.set(b"AS".to_vec(), Object::Array(apps)), + } + match d_at { + Some(a) => doc.set(a, Object::Dict(d)), + None => props.set(b"D".to_vec(), Object::Dict(d)), + } + match indirect { + Some(r) => { + doc.set(r, Object::Dict(props)); + Ok(false) + } + None => { + catalog.set(b"OCProperties".to_vec(), Object::Dict(props)); + Ok(true) + } + } +} + /// What [`strip`] took out, for the user to be told (the packets held more than the fields). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Stripped { @@ -975,6 +1183,12 @@ pub fn strip(doc: &mut Document) -> Result { None => catalog.set(b"AcroForm".to_vec(), Object::Dict(acro)), } catalog.remove(b"NeedsRendering"); + // The screen-only and print-only layers stay, as the pages use them; only their key goes. + for r in earlier_groups(doc, &catalog).into_iter().flatten() { + doc.update_dict(r, |d| { + d.remove(RELEVANT_KEY); + })?; + } // Reader usage rights are void once the form changed: Reader says so on opening the file // and drops them, so they go now, with a note. if let Some(perms) = catalog.get(b"Perms").map(|p| doc.resolve(p)) diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 411529b177..899b667edc 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -350,6 +350,192 @@ fn form_text_the_standard_fonts_cannot_draw_is_reported() { assert!(!render_into(&mut doc).unwrap().warnings.iter().any(|w| w.contains("standard fonts"))); } +/// A template whose page shows objects only on screen (`relevant="-print"`), only in print +/// (`presence="invisible" relevant="+print"`) and everywhere, as fields and as draws, some of +/// them inside a screen-only subform. `others` is put in the catalog (its own layers). +fn relevant_template() -> String { + let field = |name: &str, attrs: &str| { + format!(r#"{name}"#) + }; + let draw = |name: &str, attrs: &str| format!(r#"{name} text"#); + format!( + r##" +"##, + field("everywhere", ""), + field("screenOnly", r#"relevant="-print""#), + field("printOnly", r#"presence="invisible" relevant="+print""#), + field("nowhere", r#"presence="invisible""#), + draw("shown", ""), + draw("screenDraw", r#"relevant="-print""#), + draw("paperDraw", r#"presence="invisible" relevant="+print""#), + draw("otherView", r#"relevant="-screen""#), + field("inScreenBox", ""), + field("printInScreenBox", r#"relevant="+print""#), + ) +} + +/// `relevant` decides printing (`+print` prints even what its presence hides on screen, `-print` +/// never prints) and presence decides the screen, inside containers too: a field's widget says +/// where it shows, and a draw shown only on screen or only in print is wrapped as such. A token +/// about another view is left aside with one warning. +#[test] +fn relevant_and_presence_say_where_fields_and_draws_show() { + let form = layout_xml(&relevant_template()).unwrap(); + let page = &form.pages[0]; + let views_of = |name: &str| widgets(page).iter().find(|w| w.name == name).map(|w| w.views); + let (screen, print) = (Views { screen: true, print: false }, Views { screen: false, print: true }); + assert_eq!(views_of("everywhere"), Some(Views::ALL)); + assert_eq!(views_of("screenOnly"), Some(screen)); + assert_eq!(views_of("printOnly"), Some(print), "an invisible field that +print prints is drawn for print only"); + assert_eq!(views_of("nowhere"), None, "an invisible field is still drawn nowhere"); + assert_eq!(views_of("inScreenBox"), Some(screen), "inside a screen-only subform"); + assert_eq!(views_of("printInScreenBox"), Some(screen), "+print can't print what its container keeps off paper"); + // Draws: wrapped where they show only on screen or only in print. + let text = |want: &str| { + page.items.iter().find_map(|i| match i { + Item::Text(s) if s.text.contains(want) => Some(Views::ALL), + Item::Only { views, item } => matches!(item.as_ref(), Item::Text(s) if s.text.contains(want)).then_some(*views), + _ => None, + }) + }; + assert_eq!(text("shown text"), Some(Views::ALL)); + assert_eq!(text("screenDraw text"), Some(screen)); + assert_eq!(text("paperDraw text"), Some(print)); + assert_eq!(text("otherView text"), Some(Views::ALL), "a view other than print is left aside"); + let warned: Vec<&String> = form.warnings.iter().filter(|w| w.contains("relevant")).collect(); + assert_eq!(warned.len(), 1, "{:?}", form.warnings); + assert!(warned[0].contains("-screen"), "{warned:?}"); +} + +/// A master page's own objects show where they say, even when the page was started by a +/// screen-only subform that ran onto it: its footer isn't made screen-only too. +#[test] +fn a_master_page_started_by_a_screen_only_subform_keeps_its_own_objects_everywhere() { + let rows: String = (0..30).map(|i| format!(r#"Row {i}"#)).collect(); + let xdp = format!( + r##" +"## + ); + let form = layout_xml(&xdp).unwrap(); + assert!(form.pages.len() >= 2, "{} pages", form.pages.len()); + for (i, page) in form.pages.iter().enumerate() { + let footer = page.items.iter().any(|it| matches!(it, Item::Text(s) if s.text.contains("Footer"))); + assert!(footer, "page {i}'s footer shows everywhere, not wrapped as screen-only"); + assert!(page.items.iter().any(|it| matches!(it, Item::Only { .. })), "page {i} has the screen-only rows"); + } +} + +/// The widgets' annotation flags, the draws' optional content and the catalog's layers: shown +/// everywhere prints (`/F 4`), screen only doesn't print (`/F 0`), print only is NoView too +/// (`/F 36`); screen-only and print-only draws are marked content in groups whose `/Usage` and the +/// default configuration's `/AS` say where they show, the print-only one off on screen; the +/// document's own layer stays as it was; and laying the form out again adds nothing twice. +#[test] +fn screen_only_and_print_only_objects_are_written_with_flags_and_layers() { + let xdp = relevant_template(); + let mut bytes = shell(&xdp); + // A layer of the document's own, off, listed in /Order. + let s = String::from_utf8_lossy(&bytes).into_owned(); + let s = s.replacen( + "/AcroForm << /XFA 5 0 R /Fields [] >> >>", + "/AcroForm << /XFA 5 0 R /Fields [] >> /OCProperties << /OCGs [<< /Type /OCG /Name (Mine) >>] /D << /Order [] /OFF [] >> >> >>", + 1, + ); + assert_ne!(s.as_bytes(), bytes.as_slice(), "the shell's catalog was found"); + bytes = s.into_bytes(); + let mut doc = Document::open(Arc::new(bytes)).unwrap(); + render_into(&mut doc).unwrap(); + let check = |doc: &Document| { + // Widget flags by field name. + let catalog = doc.get(doc.root().unwrap()); + let acro = doc.resolve(catalog.as_dict().unwrap().get(b"AcroForm").unwrap()); + let fields = doc.resolve(acro.as_dict().unwrap().get(b"Fields").unwrap()).as_array().unwrap().clone(); + let flags: std::collections::BTreeMap = fields + .iter() + .map(|f| doc.resolve(f)) + .filter_map(|f| { + let d = f.as_dict()?; + Some((d.get(b"T").and_then(Object::as_string)?.to_text(), d.int(b"F").unwrap_or(-1))) + }) + .collect(); + assert_eq!(flags.get("everywhere"), Some(&4)); + assert_eq!(flags.get("screenOnly"), Some(&0)); + assert_eq!(flags.get("printOnly"), Some(&36)); + // The groups, the configuration and the usage applications. + let props = doc.resolve(catalog.as_dict().unwrap().get(b"OCProperties").unwrap()).as_dict().unwrap().clone(); + let ocgs = doc.resolve(props.get(b"OCGs").unwrap()).as_array().unwrap().clone(); + assert_eq!(ocgs.len(), 3, "the document's own layer and the two groups, each once"); + let mine = doc.resolve(ocgs.first().unwrap()); + assert_eq!(mine.as_dict().unwrap().get(b"Name").and_then(Object::as_string).map(|s| s.to_text()).as_deref(), Some("Mine")); + let group = |key: &[u8]| { + ocgs.iter() + .filter_map(Object::as_ref) + .find(|r| doc.get(*r).as_dict().is_some_and(|d| d.name(crate::pdf::RELEVANT_KEY) == Some(key))) + .unwrap() + }; + let (screen, print) = (group(b"Screen"), group(b"Print")); + let state = |r: pdfcraft_cos::ObjRef, event: &[u8], key: &[u8]| { + let g = doc.get(r); + let usage = g.as_dict().unwrap().get(b"Usage").unwrap().as_dict().unwrap().clone(); + usage.get(event).unwrap().as_dict().unwrap().name(key).unwrap().to_vec() + }; + assert_eq!((state(screen, b"View", b"ViewState"), state(screen, b"Print", b"PrintState")), (b"ON".to_vec(), b"OFF".to_vec())); + assert_eq!((state(print, b"View", b"ViewState"), state(print, b"Print", b"PrintState")), (b"OFF".to_vec(), b"ON".to_vec())); + let d = doc.resolve(props.get(b"D").unwrap()).as_dict().unwrap().clone(); + let refs = |o: Option<&Object>| doc.resolve(o.unwrap()).as_array().unwrap().iter().filter_map(Object::as_ref).collect::>(); + assert_eq!(refs(d.get(b"OFF")), [print], "only the print-only group starts off"); + assert!(refs(d.get(b"Order")).is_empty(), "the document's /Order is left as it was"); + let apps = doc.resolve(d.get(b"AS").unwrap()).as_array().unwrap().clone(); + assert_eq!(apps.len(), 2, "one usage application per event"); + for (app, event) in apps.iter().zip([&b"View"[..], b"Print"]) { + let app = doc.resolve(app).as_dict().unwrap().clone(); + assert_eq!(app.name(b"Event"), Some(event)); + assert_eq!(refs(app.get(b"OCGs")), [screen, print]); + } + // The page's marked content and resources. + let kids = doc + .resolve(doc.resolve(catalog.as_dict().unwrap().get(b"Pages").unwrap()).as_dict().unwrap().get(b"Kids").unwrap()) + .as_array() + .unwrap() + .clone(); + let page = doc.resolve(kids.first().unwrap()).as_dict().unwrap().clone(); + let res = doc.resolve(page.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + let properties = res.get(b"Properties").unwrap().as_dict().unwrap().clone(); + assert_eq!((properties.reference(b"PCXfaScreen"), properties.reference(b"PCXfaPrint")), (Some(screen), Some(print))); + let Object::Stream(c) = &*doc.resolve(page.get(b"Contents").unwrap()) else { panic!("a content stream") }; + let ops = String::from_utf8_lossy(&c.decoded().unwrap()).into_owned(); + let inside = |group: &str, text: &str| { + let start = ops.find(&format!("/OC /{group} BDC")).unwrap_or(usize::MAX); + let at = ops.find(text).unwrap_or(0); + let end = ops.get(start.min(ops.len())..).and_then(|rest| rest.find("EMC")).map(|e| start + e).unwrap_or(0); + start < at && at < end + }; + assert!(inside("PCXfaScreen", "(screenDraw text)") && inside("PCXfaPrint", "(paperDraw text)"), "{ops}"); + // Shown everywhere: outside any marked content (here, before the first). + assert!(ops.find("(shown text)") < ops.find("/OC /"), "{ops}"); + }; + check(&doc); + // Laid out again: the same groups, nothing added twice. + let (tpl, _) = parse(&xdp).unwrap(); + rerender(&mut doc, &tpl).unwrap(); + check(&doc); + // Stripping the XFA keeps the layers the pages use, without PdfCraft's key. + strip(&mut doc).unwrap(); + let catalog = doc.get(doc.root().unwrap()); + let props = doc.resolve(catalog.as_dict().unwrap().get(b"OCProperties").unwrap()).as_dict().unwrap().clone(); + let ocgs = doc.resolve(props.get(b"OCGs").unwrap()).as_array().unwrap().clone(); + assert_eq!(ocgs.len(), 3); + assert!(ocgs.iter().all(|g| !doc.resolve(g).as_dict().unwrap().contains(crate::pdf::RELEVANT_KEY))); +} + #[test] fn documents_without_xfa_are_not_touched() { let mut doc = Document::new_empty(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4ec39848c5..9e388d0f7b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5249,8 +5249,8 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] -evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn"] -notes = "Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" +evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/xfa/src/tests.rs::relevant_and_presence_say_where_fields_and_draws_show", "crates/xfa/src/tests.rs::screen_only_and_print_only_objects_are_written_with_flags_and_layers", "crates/xfa/src/tests.rs::a_master_page_started_by_a_screen_only_subform_keeps_its_own_objects_everywhere", "crates/engine/src/tests.rs::xfa_screen_only_and_print_only_objects_show_and_print_where_the_form_says"] +notes = "Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, captions, objects shown only on screen or only in print (relevant -print and +print, inside containers too: fields by their annotation flags, draws in optional content groups whose view and print usage say so; PdfCraft's own printing doesn't yet apply a layer's print usage, so a screen-only or print-only draw prints as it shows on screen, and a hidden object relevant prints isn't laid out), rich text, JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] id = "form.xfa-formcalc" From c65ede1d85921251911f0d1247306edc76c8dd5c Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:09:48 -0400 Subject: [PATCH 2/5] M12: fill in page counts inside screen- or print-only text, and warn about other views on flowed subforms and rows --- crates/xfa/src/layout.rs | 30 ++++++++++++++++++++++-------- crates/xfa/src/tests.rs | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index c5f2449bd0..25db6e2e0c 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -292,16 +292,22 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result let count = total.to_string(); for p in &mut l.pages { for it in &mut p.items { - if let Item::Text(s) = it - && s.text.contains(PAGE_COUNT_MARK) - { - s.text = s.text.replace(PAGE_COUNT_MARK, &count); - } + fill_page_count(it, &count); } } Ok(Form { pages: l.pages, fields: l.fields, warnings: l.warnings }) } +/// Put the page count `count` where text `it` holds [`PAGE_COUNT_MARK`], text drawn only on +/// screen or only in print ([`Item::Only`]) included. +fn fill_page_count(it: &mut Item, count: &str) { + match it { + Item::Text(s) if s.text.contains(PAGE_COUNT_MARK) => s.text = s.text.replace(PAGE_COUNT_MARK, count), + Item::Only { item, .. } => fill_page_count(item, count), + _ => {} + } +} + // Bytes of embedded field values copied out on this thread, for tests that bound layout work. #[cfg(test)] thread_local! { @@ -516,6 +522,14 @@ impl Layouter<'_> { } } + /// Say once when `c`'s `relevant` names a view other than print, which is drawn as if it + /// didn't: for drawn objects, flowed subforms and table rows alike. + fn warn_other_views(&mut self, c: &Common) { + if let Some(other) = &c.relevant_other { + self.warn_once(format!("the form limits some objects to views other than print (relevant \"{other}\"); they are drawn as if it didn't")); + } + } + fn page_no(&self) -> usize { self.pages.len() } @@ -820,6 +834,7 @@ impl Layouter<'_> { /// Flow subform `node` (each instance the data asks for) into the content areas, shown where /// it and what it is inside say ([`Common::views`]); hidden from both, it still takes its room. fn flow_subform(&mut self, node: &Node, avail: Option<(f64, f64)>, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { + self.warn_other_views(node.common()); let views = self.views.and(node.common().views()); let outer = std::mem::replace(&mut self.views, views); let flowed = self.flow_subform_in(node, avail, ctx, depth, sib); @@ -1333,6 +1348,7 @@ impl Layouter<'_> { if depth > MAX_DEPTH { return Ok(()); } + self.warn_other_views(&row.common); let views = self.views.and(row.common.views()); if !views.any() { return Ok(()); @@ -1379,9 +1395,7 @@ impl Layouter<'_> { return Ok(()); } let c = node.common(); - if let Some(other) = &c.relevant_other { - self.warn_once(format!("the form limits some objects to views other than print (relevant \"{other}\"); they are drawn as if it didn't")); - } + self.warn_other_views(c); let views = self.views.and(c.views()); if !views.any() { return Ok(()); diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 899b667edc..e6cad95523 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -411,6 +411,46 @@ fn relevant_and_presence_say_where_fields_and_draws_show() { assert!(warned[0].contains("-screen"), "{warned:?}"); } +/// A page count shown only on screen or only in print is filled in like any other, and a view +/// other than print named on a flowed subform or a table row is warned about as on a draw. +#[test] +fn restricted_page_counts_are_filled_in_and_other_views_warned_about_everywhere() { + let xfa_ns = r#"xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/""#; + let body = format!( + r##" + +

Pages:

+
+ + Flowed + Cell +
"## + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + let texts: Vec = form.pages[0] + .items + .iter() + .filter_map(|i| match i { + Item::Only { item, .. } => match item.as_ref() { + Item::Text(s) => Some(s.text.clone()), + _ => None, + }, + _ => None, + }) + .collect(); + assert!(texts.iter().any(|t| t.contains("Pages:") && t.contains('1')), "{texts:?}"); + assert!( + form.pages + .iter() + .flat_map(|p| &p.items) + .all(|i| !matches!(i, Item::Only { item, .. } if matches!(item.as_ref(), Item::Text(s) if s.text.contains('\u{E000}')))), + "no page-count mark left" + ); + for token in ["-screen", "-preview"] { + assert!(form.warnings.iter().any(|w| w.contains(token)), "{token}: {:?}", form.warnings); + } +} + /// A master page's own objects show where they say, even when the page was started by a /// screen-only subform that ran onto it: its footer isn't made screen-only too. #[test] From 214a5b5978271a45ccf9631b17059c2b0f11c760 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:20:49 -0400 Subject: [PATCH 3/5] M12: an exclusion group's buttons show where each one's relevant and presence say --- crates/xfa/src/layout.rs | 14 +++++++++++++- crates/xfa/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 25db6e2e0c..f85bc56cb3 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -1588,6 +1588,8 @@ impl Layouter<'_> { } } + /// Draw exclusion group `g` at `rect`: its border, then each of its buttons where its layout + /// puts it, as one radio group, the one its data selects turned on. fn draw_group(&mut self, g: &ExclGroup, rect: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { if let Some(b) = &g.common.border { self.draw_border(b, rect)?; @@ -1629,10 +1631,20 @@ impl Layouter<'_> { let sibs = SomIndices::of(&nodes); for (i, r) in placed { if let Some(f) = g.fields.get(i) { + // Each button shows where it and the group say ([`Common::views`]), as any field + // drawn through [`Self::draw_node`] does. + self.warn_other_views(&f.common); + let views = self.views.and(f.common.views()); + if !views.any() { + continue; + } let on = f.items.first().cloned().filter(|s| !s.trim().is_empty()).unwrap_or_else(|| (i + 1).to_string()); let tip = f.tooltip.clone().or_else(|| g.tooltip.clone()); let chosen = selected.as_deref() == Some(on.as_str()); - self.draw_field(f, Rect::new(inner.x + r.x, inner.y + r.y, r.w, r.h), &ctx, Some((&group, &on, tip, chosen)), sibs.at(i))?; + let outer = std::mem::replace(&mut self.views, views); + let drawn = self.draw_field(f, Rect::new(inner.x + r.x, inner.y + r.y, r.w, r.h), &ctx, Some((&group, &on, tip, chosen)), sibs.at(i)); + self.views = outer; + drawn?; } } Ok(()) diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index e6cad95523..5972381015 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -411,6 +411,38 @@ fn relevant_and_presence_say_where_fields_and_draws_show() { assert!(warned[0].contains("-screen"), "{warned:?}"); } +/// The buttons of an exclusion group show where each one's own `relevant` and presence say, +/// inside what the group says, as other fields do; a view other than print is warned about. +#[test] +fn exclusion_group_buttons_show_where_each_says() { + let button = |name: &str, on: &str, attrs: &str| { + format!(r#"{on}"#) + }; + let body = format!( + r#" + {}{}{}{}{} + {} + "#, + button("plain", "1", ""), + button("screenOnly", "2", r#"relevant="-print""#), + button("printOnly", "3", r#"presence="invisible" relevant="+print""#), + button("nowhere", "4", r#"presence="invisible""#), + button("otherView", "5", r#"relevant="-preview""#), + button("printInScreenGroup", "1", r#"relevant="+print""#), + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + let views_of = |name: &str| widgets(&form.pages[0]).iter().find(|w| w.som.ends_with(&format!(".{name}[0]"))).map(|w| w.views); + let (screen, print) = (Views { screen: true, print: false }, Views { screen: false, print: true }); + assert_eq!(views_of("plain"), Some(Views::ALL)); + assert_eq!(views_of("screenOnly"), Some(screen)); + assert_eq!(views_of("printOnly"), Some(print), "an invisible button that +print prints is drawn for print only"); + assert_eq!(views_of("nowhere"), None, "an invisible button is drawn nowhere"); + assert_eq!(views_of("otherView"), Some(Views::ALL), "a view other than print is left aside"); + assert_eq!(views_of("printInScreenGroup"), Some(screen), "+print can't print what its group keeps off paper"); + let warned: Vec<&String> = form.warnings.iter().filter(|w| w.contains("relevant")).collect(); + assert!(warned.len() == 1 && warned[0].contains("-preview"), "{:?}", form.warnings); +} + /// A page count shown only on screen or only in print is filled in like any other, and a view /// other than print named on a flowed subform or a table row is warned about as on a draw. #[test] From a7d51786dab2d2f2020f550d7a74249411ac0cbc Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:59:54 -0400 Subject: [PATCH 4/5] M12: links in screen-only and print-only XFA text work only where the text shows, and their pieces still join --- crates/xfa/src/pdf.rs | 18 +++++++++++++++- crates/xfa/src/tests.rs | 42 ++++++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 2 +- 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 889b6f5653..ba33736942 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -748,12 +748,14 @@ impl Emitter<'_> { content.extend(format!("/OC /{resource} BDC\n").bytes()); } open = group; + // A link's pieces join only within one group: what is in another shows elsewhere. + last_link = None; } // A link's pieces join only across text and the underline drawn just before it: a fill, // picture, field or any other line (a rule) between them ends the link there. let keeps_link = match item { Item::Text(_) => true, - Item::Line { from, to, .. } => matches!(page.items.get(i + 1), Some(Item::Text(s)) + Item::Line { from, to, .. } => matches!(page.items.get(i + 1).map(bare), Some(Item::Text(s)) if (from.1 - to.1).abs() < 0.01 && (from.0 - s.x).abs() < 0.01 && (from.1 - (s.baseline + s.face.size * crate::layout::UNDERLINE_DROP)).abs() < 0.01), @@ -809,6 +811,12 @@ impl Emitter<'_> { content.extend(literal(&win_ansi(&s.text))); content.extend_from_slice(format!(" Tj{close}\n").as_bytes()); if let Some(r) = self.text_link(s, h, page_ref, &mut last_link)? { + // A link works only where its text shows: one in print-only text is + // NoView (32), so it can't be clicked on screen, and one in screen-only + // text doesn't print, as a field's widget there. + if let Some(g) = group { + self.doc.update_dict(r, |d| d.set(b"F".to_vec(), Object::Int(if g == 0 { 0 } else { 4 | 32 })))?; + } annots.push(Object::Ref(r)); } } @@ -921,6 +929,14 @@ impl Emitter<'_> { } } +/// `item`, or what it holds when it is shown only on screen or only in print ([`Item::Only`]). +fn bare(item: &Item) -> &Item { + match item { + Item::Only { item, .. } => item, + other => other, + } +} + /// The link annotation a page's text last ended in: the next piece of the same link on the same /// line widens it. struct LinkPiece { diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index f0ba0e467a..e85146e1b0 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -870,6 +870,48 @@ fn links_to_anything_but_web_and_email_addresses_stay_plain_text() { assert!(warned[0].contains("http, https") && warned[0].contains("mailto"), "{warned:?}"); } +/// A link in text shown only on screen or only in print works only where its text shows: in +/// print-only text it is NoView (`/F 36`), so it can't be clicked on screen over nothing, and in +/// screen-only text it doesn't print (`/F 0`). Its pieces still join into one link across a change +/// of style and the underline drawn with them. +#[test] +fn links_in_screen_only_and_print_only_text_work_where_their_text_shows() { + let draw = |name: &str, attrs: &str| { + format!( + r#"

forms@example.com

"# + ) + }; + let xdp = format!( + r##" +"##, + draw("everywhere", ""), + draw("screen", r#"relevant="-print""#), + draw("paper", r#"presence="invisible" relevant="+print""#), + ); + let mut doc = Document::open(Arc::new(shell(&xdp))).unwrap(); + render_into(&mut doc).unwrap(); + // Each link annotation's address and flags. + let catalog = doc.get(doc.root().unwrap()); + let pages = doc.resolve(catalog.as_dict().unwrap().get(b"Pages").unwrap()); + let kids = doc.resolve(pages.as_dict().unwrap().get(b"Kids").unwrap()).as_array().unwrap().clone(); + let page = doc.resolve(kids.first().unwrap()); + let annots = doc.resolve(page.as_dict().unwrap().get(b"Annots").unwrap()).as_array().unwrap().clone(); + let links: Vec<(String, i64)> = annots + .iter() + .map(|a| doc.resolve(a).as_dict().unwrap().clone()) + .filter(|d| d.name(b"Subtype") == Some(&b"Link"[..])) + .map(|d| { + let action = doc.resolve(d.get(b"A").unwrap()).as_dict().unwrap().clone(); + (String::from_utf8_lossy(&action.get(b"URI").and_then(Object::as_string).unwrap().bytes).into_owned(), d.int(b"F").unwrap()) + }) + .collect(); + let expected = [("https://example.com/everywhere", 4), ("https://example.com/screen", 0), ("https://example.com/paper", 36)]; + assert_eq!(links, expected.map(|(u, f)| (u.to_string(), f)), "one link each, with flags where its text shows"); +} + #[test] fn documents_without_xfa_are_not_touched() { let mut doc = Document::new_empty(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 44ba9595f9..9765515e77 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5284,7 +5284,7 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] -evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::relevant_and_presence_say_where_fields_and_draws_show", "crates/xfa/src/tests.rs::screen_only_and_print_only_objects_are_written_with_flags_and_layers", "crates/xfa/src/tests.rs::a_master_page_started_by_a_screen_only_subform_keeps_its_own_objects_everywhere", "crates/engine/src/tests.rs::xfa_screen_only_and_print_only_objects_show_and_print_where_the_form_says"] +evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::relevant_and_presence_say_where_fields_and_draws_show", "crates/xfa/src/tests.rs::screen_only_and_print_only_objects_are_written_with_flags_and_layers", "crates/xfa/src/tests.rs::a_master_page_started_by_a_screen_only_subform_keeps_its_own_objects_everywhere", "crates/engine/src/tests.rs::xfa_screen_only_and_print_only_objects_show_and_print_where_the_form_says", "crates/xfa/src/tests.rs::links_in_screen_only_and_print_only_text_work_where_their_text_shows"] notes = "Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, objects shown only on screen or only in print (relevant -print and +print, inside containers too: fields by their annotation flags, draws in optional content groups whose view and print usage say so; PdfCraft's own printing doesn't yet apply a layer's print usage, so a screen-only or print-only draw prints as it shows on screen, and a hidden object relevant prints isn't laid out), rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] From 0e3835d2d16f595571afbc199c97fd3c9ff8a855 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:00:49 -0400 Subject: [PATCH 5/5] M12: one summary for flow_subform's docstring after the merge --- crates/xfa/src/layout.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index c7251f50da..e5b15e900b 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -1038,9 +1038,9 @@ impl Layouter<'_> { // ───────────────────────────────────────────────────────────────────────── flow - /// Flow `node` (a subform; anything else is ignored) into the content areas. - /// Flow subform `node` (each instance the data asks for) into the content areas, shown where - /// it and what it is inside say ([`Common::views`]); hidden from both, it still takes its room. + /// Flow subform `node` (each instance the data asks for; anything else is ignored) into the + /// content areas, shown where it and what it is inside say ([`Common::views`]); hidden from + /// both, it still takes its room. fn flow_subform(&mut self, node: &Node, avail: Option<(f64, f64)>, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { self.warn_other_views(node.common()); let views = self.views.and(node.common().views());