diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 94867f98e5..5e485799b4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3790,6 +3790,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 e98ca1f2df..93da598912 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -16,7 +16,7 @@ use crate::XfaError; use crate::data::{DataNode, DataPath, data_date_letters, data_path, data_to_iso, date_letters, is_on, iso_to_pattern}; use crate::model::{ Access, Area, Border, Common, Draw, ExclGroup, Field, Font, HAlign, Layout, Margin, Node, Occur, PageArea, PageRole, Para, Placement, Presence, - Rect, Script, SomIndices, Stroke, Subform, Template, Ui, VAlign, Value, + Rect, Script, SomIndices, Stroke, Subform, Template, Ui, VAlign, Value, Views, }; use crate::text::{self, Block, Face, Span}; @@ -180,6 +180,8 @@ struct Layouter<'t> { splitting: Option, /// The items `draw_field` drew for the border of the field drawn last: (first, one past the last). field_border: Option<(usize, usize)>, + /// Where what is being drawn shows, as the objects it is inside say ([`Common::views`]). + views: Views, /// The flowed subforms being laid out, outermost first: a break between their children /// closes their parts on one content area and opens new ones on the next. frames: Vec, @@ -230,6 +232,7 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result leaves: Vec::new(), splitting: None, field_border: None, + views: Views::ALL, frames: Vec::new(), }; let ctx = Ctx { font: Font::default(), para: Para::default(), som: String::new() }; @@ -255,6 +258,7 @@ fn fill_page_count(items: &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::Turned(t) => fill_page_count(&mut t.items, count), + Item::Only { item, .. } => fill_page_count(std::slice::from_mut(item.as_mut()), count), _ => {} } } diff --git a/crates/xfa/src/layout/common.rs b/crates/xfa/src/layout/common.rs index 83fd395d40..cee2b99e4e 100644 --- a/crates/xfa/src/layout/common.rs +++ b/crates/xfa/src/layout/common.rs @@ -88,12 +88,19 @@ pub(super) fn solid_extent(item: &Item) -> Option<(f64, f64)> { Item::Widget(w) => Some((w.rect.y, w.rect.bottom())), Item::Turned(t) => Some(t.extent), Item::Fill { .. } | Item::Line { .. } | Item::Rounded(_) => 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. pub(super) 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)) }; } @@ -198,6 +205,9 @@ pub(super) fn widget_box(rect: Rect, border: &WidgetBorder) -> Vec { /// `item` moved by (`dx`, `dy`). pub(super) 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; @@ -224,6 +234,8 @@ pub(super) fn moved(mut item: Item, dx: f64, dy: f64) -> Item { t.extent = (t.extent.0 + dy, t.extent.1 + dy); t.items = std::mem::take(&mut t.items).into_iter().map(|i| moved(i, dx, dy)).collect(); } + // Taken apart above. + Item::Only { .. } => {} } item } diff --git a/crates/xfa/src/layout/draw.rs b/crates/xfa/src/layout/draw.rs index 616fe5213a..cdf5c81446 100644 --- a/crates/xfa/src/layout/draw.rs +++ b/crates/xfa/src/layout/draw.rs @@ -1,17 +1,32 @@ use super::common::clean_name; use super::items::{Item, Rounded, Turned}; use super::{ - Arc, Border, Ctx, Draw, ExclGroup, Face, Font, Layout, Layouter, MAX_DEPTH, MAX_IMAGE_BYTES, Node, Para, Presence, Rect, SomIndices, Stroke, - Subform, Value, XfaError, data_path, text, + Arc, Border, Ctx, Draw, ExclGroup, Face, Font, Layout, Layouter, MAX_DEPTH, MAX_IMAGE_BYTES, Node, Para, Rect, SomIndices, Stroke, Subform, + Value, XfaError, data_path, text, }; impl Layouter<'_> { // ───────────────────────────────────────────────────────────────────────── drawing + /// Draw table row `row` at `rect` in columns `cols`, shown where it and what it is inside say + /// ([`Common::views`]). pub(super) 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(()); } + self.warn_other_views(&row.common); + 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. + pub(super) 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)?; } @@ -58,14 +73,27 @@ impl Layouter<'_> { } } + /// 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. pub(super) 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 { + self.warn_other_views(c); + 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. + pub(super) 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(); @@ -301,6 +329,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)?; @@ -342,10 +372,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/layout/field.rs b/crates/xfa/src/layout/field.rs index 8218affe2c..27ef7a8946 100644 --- a/crates/xfa/src/layout/field.rs +++ b/crates/xfa/src/layout/field.rs @@ -1,8 +1,8 @@ use super::common::{button_action, date_pattern}; use super::items::{BorderShape, Item, Widget, WidgetBorder, WidgetKind}; use super::{ - Access, Border, Ctx, Engine, Face, Field, HAlign, Layouter, MAX_DATA_IMAGE, Margin, Para, Placement, Presence, Rect, Ui, VAlign, Value, XfaError, - data_date_letters, data_path, data_to_iso, is_on, iso_to_pattern, text, + Access, Border, Ctx, Engine, Face, Field, HAlign, Layouter, MAX_DATA_IMAGE, Margin, Para, Placement, Presence, Rect, Ui, VAlign, Value, Views, + XfaError, data_date_letters, data_path, data_to_iso, is_on, iso_to_pattern, text, }; impl Layouter<'_> { @@ -265,6 +265,8 @@ impl Layouter<'_> { action, items, no_scroll: f.no_scroll, + // `push` sets where it shows. + views: Views::ALL, }))) } } diff --git a/crates/xfa/src/layout/flow.rs b/crates/xfa/src/layout/flow.rs index 7fadabf53b..f479885c06 100644 --- a/crates/xfa/src/layout/flow.rs +++ b/crates/xfa/src/layout/flow.rs @@ -305,8 +305,20 @@ impl Layouter<'_> { // ───────────────────────────────────────────────────────────────────────── flow - /// Flow `node` (a subform; anything else is ignored) into the content areas. + /// 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. pub(super) 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); + self.views = outer; + flowed + } + + /// [`Self::flow_subform`] once where the subform shows is set. + pub(super) 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(()); } diff --git a/crates/xfa/src/layout/items.rs b/crates/xfa/src/layout/items.rs index be9499cb84..faea3aa014 100644 --- a/crates/xfa/src/layout/items.rs +++ b/crates/xfa/src/layout/items.rs @@ -1,4 +1,4 @@ -use super::{Arc, Face, HAlign, Rect, Span}; +use super::{Arc, Face, HAlign, Rect, Span, Views}; /// How far below its baseline an underline is drawn, in the text's size: an underline is the /// level line pushed just before its span, this far below it (the PDF writer tells it apart from @@ -27,6 +27,12 @@ pub enum Item { 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, + }, /// A draw turned about its anchor. Turned(Box), } @@ -144,6 +150,8 @@ pub struct Widget { pub items: Vec, /// A text field that doesn't scroll: it takes no more text than it shows (`DoNotScroll`). pub no_scroll: bool, + /// Where the field is shown: on screen, in print, or both (its annotation flags). + pub views: Views, } #[derive(Clone, Debug, Default, PartialEq)] diff --git a/crates/xfa/src/layout/pages.rs b/crates/xfa/src/layout/pages.rs index 940c76d246..9f6c00ae75 100644 --- a/crates/xfa/src/layout/pages.rs +++ b/crates/xfa/src/layout/pages.rs @@ -2,8 +2,8 @@ use super::EMBED_COPIED; use super::items::{Item, Page, UNDERLINE_DROP}; use super::{ - Block, Border, Ctx, Cursor, FlowMark, Font, Frame, Layouter, MAX_DRAW_CHARS, MAX_ITEMS, MAX_MEASURES, MAX_PAGES, PAGE_COUNT_MARK, PageRole, Para, - Rc, Rect, SomIndices, XfaError, data_path, text, + Block, Border, Common, Ctx, Cursor, FlowMark, Font, Frame, Layouter, MAX_DRAW_CHARS, MAX_ITEMS, MAX_MEASURES, MAX_PAGES, PAGE_COUNT_MARK, + PageRole, Para, Rc, Rect, SomIndices, Views, XfaError, data_path, text, }; impl Layouter<'_> { @@ -14,6 +14,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. + pub(super) 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() } @@ -65,12 +73,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. pub(super) 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); } @@ -130,10 +151,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. diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs index 51fca2f8eb..8b09f2c1a9 100644 --- a/crates/xfa/src/model.rs +++ b/crates/xfa/src/model.rs @@ -326,6 +326,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 { @@ -344,6 +375,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, @@ -362,6 +399,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 ac8639f98f..d4c473dfe4 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -202,6 +202,25 @@ 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) +} + /// `rotate` as quarter turns (XFA 3.3: degrees counter-clockwise, a multiple of 90, `deg` optional): /// anything else leaves the object as it stands. fn quarter_turns(rotate: Option<&str>) -> u8 { @@ -212,7 +231,9 @@ fn quarter_turns(rotate: Option<&str>) -> u8 { turns.filter(|t| t.fract() == 0.0).map_or(0, |t| t as u8 % 4) } +/// 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), @@ -227,6 +248,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 4e87da5a7a..5450d20ddc 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -22,6 +22,7 @@ use common::{ }; pub(crate) use strip::USAGE_RIGHTS_REMOVED; use strip::drop_usage_rights; +use strip::earlier_groups; pub use strip::{Stripped, XfaShape, existing_layout, strip, xfa_shape}; /// Private key on generated fields: the template's SOM path, so data can be put back. @@ -53,6 +54,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 a check box or radio button generated for a round XFA check button (`shape="round"`): its /// background and border are drawn as a circle, not the widget's rectangle (the forms layer /// reads this key). @@ -80,6 +90,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], /// The full names of the fields the file came with, which a generated name must not repeat. taken: HashSet, /// The fields above the signature fields named by their SOM paths, by the path they name: @@ -368,14 +383,23 @@ impl Emitter<'_> { } /// Write laid-out widget `w` on `page` as an AcroForm widget annotation (and its field), with - /// its border, background and kind, and return the annotation's object. + /// its border, background and kind, its flags saying whether it shows on screen and in print, + /// and return the annotation's object. 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)))); @@ -563,6 +587,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 and the links /// in its text as annotations. Returns the page object. @@ -573,6 +628,9 @@ 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(); // The link the text before ended in, which a next piece of it on the line widens. let mut last_link: Option = None; let steps = steps(&page.items); @@ -580,7 +638,12 @@ impl Emitter<'_> { for (i, step) in steps.iter().enumerate() { let item = match step { Step::Item(item) => *item, + // A turned draw's `q`/`Q` and the marked content nest, never cross: an open group + // is closed first, and opened again inside or after as its items need. Step::Turn(t) => { + if open.take().is_some() { + content.extend_from_slice(b"EMC\n"); + } let matrix = turn_matrix(t, h); turn = Some(matrix); last_link = None; @@ -588,17 +651,37 @@ impl Emitter<'_> { continue; } Step::EndTurn => { + if open.take().is_some() { + content.extend_from_slice(b"EMC\n"); + } turn = None; last_link = None; content.extend_from_slice(b"Q\n"); continue; } }; + 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; + // 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!(steps.get(i + 1), Some(Step::Item(Item::Text(s))) + Item::Line { from, to, .. } => matches!(steps.get(i + 1).and_then(Step::item).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), @@ -654,6 +737,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, turn)? { + // 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)); } } @@ -670,8 +759,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)); @@ -681,6 +775,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))); @@ -806,6 +903,14 @@ fn write_button_action(action: Option<&Action>, d: &mut Dict) { } } +/// `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, + } +} + /// What a page's content is written from: its items, with a turned draw opened out. enum Step<'a> { Item(&'a Item), @@ -813,6 +918,16 @@ enum Step<'a> { EndTurn, } +impl<'a> Step<'a> { + /// The item, when the step is one. + fn item(&self) -> Option<&'a Item> { + match self { + Step::Item(item) => Some(item), + _ => None, + } + } +} + fn steps(items: &[Item]) -> Vec> { let mut out = Vec::with_capacity(items.len()); for item in items { @@ -933,6 +1048,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, taken) = existing_fields(doc, &catalog); + let groups = earlier_groups(doc, &catalog); let mut em = Emitter { doc, images, @@ -942,6 +1058,8 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result field_count: 0, warnings: Vec::new(), signed, + groups, + used: [false; 2], taken, nodes: HashMap::new(), }; @@ -971,7 +1089,11 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result for (node, kids) in nodes { em.doc.update_dict(node, |d| d.set(b"Kids".to_vec(), Object::Array(kids.iter().map(|r| Object::Ref(*r)).collect())))?; } - 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")); @@ -1034,6 +1156,101 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result Ok(Written { pages: count, fields: field_count, warnings, usage_rights }) } +/// 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) + } + } +} + /// The rectangle of a widget in PDF user space (for tests). pub fn pdf_rect(r: Rect, page_height: f64) -> [f64; 4] { [r.x, page_height - r.bottom(), r.right(), page_height - r.y] diff --git a/crates/xfa/src/pdf/strip.rs b/crates/xfa/src/pdf/strip.rs index 93f1a65307..7051f36559 100644 --- a/crates/xfa/src/pdf/strip.rs +++ b/crates/xfa/src/pdf/strip.rs @@ -1,9 +1,37 @@ -use super::{CLICK_KEY, DATE_KEY, Dict, Document, IMAGE_FIELD_KEY, LAYOUT_KEY, NODE_KEY, ObjRef, Object, SOM_KEY, XfaError}; +use super::{ + CLICK_KEY, DATE_KEY, Dict, Document, GROUPS, IMAGE_FIELD_KEY, LAYOUT_KEY, NODE_KEY, ObjRef, Object, RELEVANT_KEY, SOM_KEY, + XfaError, +}; /// What laying a form out says when it took the usage rights off (see [`drop_usage_rights`]). pub(crate) const USAGE_RIGHTS_REMOVED: &str = "the form's usage rights were removed: laying it out voids them, and a form with void usage rights can open with every field locked"; +/// The screen-only and print-only groups an earlier layout of the form made ([`RELEVANT_KEY`]), +/// found among the document's optional content groups. +pub(super) 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; + /// 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 { @@ -193,6 +221,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); + })?; + } out.usage_rights = drop_usage_rights(doc, &mut catalog); doc.set(root, Object::Dict(catalog)); // The packet streams are referenced from nowhere else: freed, so an incremental save diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 7227501ebe..588cfebc56 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -561,6 +561,264 @@ 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:?}"); +} + +/// 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] +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] +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))); +} + /// A field whose ui doesn't scroll in the direction its text runs out (`hScrollPolicy="off"` for /// one line, `vScrollPolicy="off"` for several) is written with DoNotScroll (`/Ff` bit 24), so it /// takes no more text than it shows; the other direction's policy, or the default, doesn't. @@ -817,6 +1075,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(); @@ -4771,6 +5071,49 @@ fn a_draw_turns_counter_clockwise_about_its_anchor() { } } +/// A turned draw that only prints is written in the print-only group inside its turn: the marked +/// content and the turn's `q`/`Q` nest, never cross, and the draw still turns as one shown everywhere. +#[test] +fn a_turned_print_only_draw_nests_its_group_inside_the_turn() { + let draws = [ + line_draw("1in", "0.95in", r#"presence="invisible" relevant="+print""#), + line_draw("3in", "0.95in", r#"rotate="90" presence="invisible" relevant="+print""#), + line_draw("5in", "0.95in", ""), + ] + .concat(); + let (doc, page) = written(&form_of_draws("", &draws)); + let content = page_content(&doc, &page); + let mut open = Vec::new(); + let mut turned_groups = 0; + for token in content.split_whitespace() { + match token { + "q" | "BDC" => { + if token == "BDC" && open.contains(&"cm") { + turned_groups += 1; + } + open.push(token); + } + "cm" if open.last() == Some(&"q") => open.push("cm"), + "Q" => { + if open.last() == Some(&"cm") { + open.pop(); + } + assert_eq!(open.pop(), Some("q"), "a Q closes a q: {content}"); + } + "EMC" => assert_eq!(open.pop(), Some("BDC"), "an EMC closes a BDC: {content}"), + _ => {} + } + } + assert!(open.is_empty(), "{content}"); + assert_eq!(turned_groups, 1, "the turned draw is in a group of its own inside the turn: {content}"); + let lines = stroked_lines(&content); + assert_eq!(lines.len(), 3, "{lines:?}"); + let top = 792.0 - 18.0 - 0.95 * 72.0; + let [from, to] = lines[1]; + assert!((from.0 - 234.0).abs() < 0.01 && (from.1 - top).abs() < 0.01, "{from:?}"); + assert!((to.0 - from.0 - 28.8).abs() < 0.01 && (to.1 - from.1 - 50.4).abs() < 0.01, "{to:?}"); +} + /// The anchor is the point it turns about, whichever corner that is: a box anchored at its bottom /// right stands up and to the left of it, not where a top-left anchor would turn it. #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index cf77e8caa3..db4c9bca81 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5447,8 +5447,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::a_draw_turns_counter_clockwise_about_its_anchor", "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::a_check_box_sits_where_its_margins_caption_and_alignment_put_it", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_takes_the_rest_of_the_field", "crates/xfa/src/tests.rs::a_radio_button_sits_where_a_check_box_would", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_is_measured_as_it_is_drawn", "crates/js/src/tests.rs::message_box_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_in_an_xfa_script_reads_app_alerts_arguments", "crates/engine/src/tests.rs::xfa_message_boxes_keep_their_title_icon_and_buttons", "crates/xfa/src/tests.rs::signature_fields_are_named_by_their_som_path", "crates/xfa/src/tests.rs::a_signature_on_a_laid_out_box_survives_a_new_layout", "crates/xfa/src/tests.rs::the_template_knows_its_floating_fields_and_sheet_roles", "crates/engine/src/tests.rs::text_shows_the_values_scripts_give_its_floating_fields", "crates/js/src/tests.rs::the_sheet_is_the_page_on_screen", "crates/xfa/src/tests.rs::embedded_text_shows_each_instances_data", "crates/xfa/src/tests.rs::an_embedded_sheet_number_counts_the_pages", "crates/engine/src/tests.rs::floating_fields_follow_their_data_whoever_writes_it", "crates/forms/src/af/tests.rs::xfa_date_fields_keep_and_show_what_their_pattern_does_not_read", "crates/automation/tests/it/automation.rs::xfa_date_fields_keep_typed_dates_as_another_viewer_does"] -notes = "A draw placed by position turns about its anchor (rotate); fields, subforms and draws in a flow don't. 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, 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 (a date one of the edit picture's patterns reads is kept in its first, and in the data as an ISO date; text none of them reads, ISO text included, is kept and shown as typed rather than refused, as another viewer keeps it on a synthetic form; a display picture that differs from the edit picture isn't used to show the date yet), check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields (each named by its SOM path, under a generated field for each step above it, so that another viewer, which lays the form out again, finds a signature made here, as seen on a synthetic form, and a box signed here keeps its signature, and that name, when a script lays the form out again; a box whose path, or a part of it, the file already gives another field keeps its own name, and its signature isn't found there yet), 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). xfa.host.messageBox keeps its title, icon and buttons, and app.alert in an XFA script reads its own argument order; every message box still answers OK (1) (#4291). Text that embeds a field (a floating field, XFA 3.3 p. 1222) shows the field's value from the data, so a value an initialize, click or calculate script gives it shows, and an event that changes one lays the form out again when it ends; xfa.layout.sheet (from 0) and sheetCount treat each page as a sheet, and an embedded sheet number or count shows as the page number or count does. A repeating row shows its own instance's value, a page area's field the value written under the area, and a typed value or a reset lays the text out again. Missing: floating fields set by layout:ready scripts (current date and time), as those scripts are not run yet, and fields inside an exclusion group (#6160)." +evidence = ["crates/xfa/src/tests.rs::a_draw_turns_counter_clockwise_about_its_anchor", "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::a_check_box_sits_where_its_margins_caption_and_alignment_put_it", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_takes_the_rest_of_the_field", "crates/xfa/src/tests.rs::a_radio_button_sits_where_a_check_box_would", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_is_measured_as_it_is_drawn", "crates/js/src/tests.rs::message_box_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_in_an_xfa_script_reads_app_alerts_arguments", "crates/engine/src/tests.rs::xfa_message_boxes_keep_their_title_icon_and_buttons", "crates/xfa/src/tests.rs::signature_fields_are_named_by_their_som_path", "crates/xfa/src/tests.rs::a_signature_on_a_laid_out_box_survives_a_new_layout", "crates/xfa/src/tests.rs::the_template_knows_its_floating_fields_and_sheet_roles", "crates/engine/src/tests.rs::text_shows_the_values_scripts_give_its_floating_fields", "crates/js/src/tests.rs::the_sheet_is_the_page_on_screen", "crates/xfa/src/tests.rs::embedded_text_shows_each_instances_data", "crates/xfa/src/tests.rs::an_embedded_sheet_number_counts_the_pages", "crates/engine/src/tests.rs::floating_fields_follow_their_data_whoever_writes_it", "crates/forms/src/af/tests.rs::xfa_date_fields_keep_and_show_what_their_pattern_does_not_read", "crates/automation/tests/it/automation.rs::xfa_date_fields_keep_typed_dates_as_another_viewer_does", "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 = "A draw placed by position turns about its anchor (rotate); fields, subforms and draws in a flow don't. 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 (a date one of the edit picture's patterns reads is kept in its first, and in the data as an ISO date; text none of them reads, ISO text included, is kept and shown as typed rather than refused, as another viewer keeps it on a synthetic form; a display picture that differs from the edit picture isn't used to show the date yet), check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields (each named by its SOM path, under a generated field for each step above it, so that another viewer, which lays the form out again, finds a signature made here, as seen on a synthetic form, and a box signed here keeps its signature, and that name, when a script lays the form out again; a box whose path, or a part of it, the file already gives another field keeps its own name, and its signature isn't found there yet), 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). xfa.host.messageBox keeps its title, icon and buttons, and app.alert in an XFA script reads its own argument order; every message box still answers OK (1) (#4291). Text that embeds a field (a floating field, XFA 3.3 p. 1222) shows the field's value from the data, so a value an initialize, click or calculate script gives it shows, and an event that changes one lays the form out again when it ends; xfa.layout.sheet (from 0) and sheetCount treat each page as a sheet, and an embedded sheet number or count shows as the page number or count does. A repeating row shows its own instance's value, a page area's field the value written under the area, and a typed value or a reset lays the text out again. Missing: floating fields set by layout:ready scripts (current date and time), as those scripts are not run yet, and fields inside an exclusion group (#6160)." [[feature]] id = "form.xfa-formcalc"