diff --git a/ROADMAP.md b/ROADMAP.md index 93d03dc9d0..57f3b20fb4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -175,6 +175,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-16 (M3, #2739):** Wrapped revision buttons stay above Binding in Document Properties. A synthetic regression checks control bounds and opens a saved revision at two viewport widths. Estimates unchanged. - **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged. - **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged. +- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of the public ISCIII form, with its button, is page 7 of 7). Acrobat Pro shows the form as 7 pages with that page last, as here; when printing, Acrobat keeps that page but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet. Synthetic regression tests. Estimates unchanged. - **2026-10-15 (M8, #2447):** Removing links or comments (Remove Hidden Information, Sanitize) also drops the tagged-PDF structure tree's object references to them, so the full rewrite no longer keeps a removed link's URL or a comment's text. Regression seen to fail without the fix. Estimates unchanged. - **2026-10-15 (M6, CSV tab-containing headers):** CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Synthetic export/import and incremental/full save regressions cover header tabs, quotes and line breaks, plus ordinary TSV controls. Formula-cell policy is unchanged. Estimates unchanged. - **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged. diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index a491d3f493..62d7beedf1 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -232,6 +232,7 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result content: vec![Rect::new(36.0, 36.0, 540.0, 720.0)], items: Vec::new(), occur_max: None, + occur_min: 0, }], }; let mut values = HashMap::new(); @@ -261,6 +262,7 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result if tpl.root.common.presence.occupies() { l.flow_subform(&root_node, None, &ctx, 0, 0)?; } + l.satisfy_page_area_minimums()?; l.check_measures()?; let total = l.pages.len(); let count = total.to_string(); @@ -497,11 +499,28 @@ impl Layouter<'_> { // ───────────────────────────────────────────────────────────────────────── pages fn new_page(&mut self) -> Result<(), XfaError> { + let idx = + (0..self.areas.len()).find(|&i| self.areas[i].occur_max.is_none_or(|m| self.uses[i] < m)).unwrap_or(self.areas.len().saturating_sub(1)); + self.new_page_of(idx) + } + + /// Once the body has been laid out, give every page area that has been used fewer times than + /// its `occur min` the pages it still needs, in template order, after the body's pages. + fn satisfy_page_area_minimums(&mut self) -> Result<(), XfaError> { + let areas = Rc::clone(&self.areas); + for (idx, area) in areas.iter().enumerate() { + while self.uses.get(idx).is_some_and(|u| *u < area.occur_min) { + self.new_page_of(idx)?; + } + } + Ok(()) + } + + /// Start a page from page area `idx`. + fn new_page_of(&mut self, idx: usize) -> Result<(), XfaError> { if self.pages.len() >= MAX_PAGES { return Err(XfaError::TooLarge(format!("the form lays out to more than {MAX_PAGES} pages"))); } - let idx = - (0..self.areas.len()).find(|&i| self.areas[i].occur_max.is_none_or(|m| self.uses[i] < m)).unwrap_or(self.areas.len().saturating_sub(1)); let areas = Rc::clone(&self.areas); let Some(area) = areas.get(idx) else { return Err(XfaError::Malformed("the template has no page area".into())); diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs index f4ef3fabce..572675f958 100644 --- a/crates/xfa/src/model.rs +++ b/crates/xfa/src/model.rs @@ -680,6 +680,9 @@ pub struct PageArea { pub items: Vec, /// How many pages may use this master (`None`: unbounded). pub occur_max: Option, + /// How many pages must use this master: a layout that ends with fewer adds pages for it. + /// Zero without an ``. + pub occur_min: usize, } #[derive(Clone, Debug, Default, PartialEq)] diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index 3384b19a67..4582d59b22 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -529,9 +529,11 @@ fn parse_page_set(n: XmlNode, warnings: &mut Vec, depth: usize) -> PageS content.push(Rect::new(0.0, 0.0, width, height)); } let items = pa.children().filter(|c| c.is_element()).filter_map(|c| parse_node(c, warnings, depth + 1)).collect(); - // A page area with no `` has no limit (a subform's default of one would exhaust it). - let occur_max = child(pa, "occur").and_then(|o| parse_occur(Some(o)).max); - areas.push(PageArea { name: pa.attribute("name").map(str::to_string), width, height, content, items, occur_max }); + // A page area with no `` has no limit and no minimum (a subform's default of one + // would exhaust it, and demand a page for every master). + let occur = child(pa, "occur").map(|o| parse_occur(Some(o))); + let (occur_min, occur_max) = occur.map_or((0, None), |o| (o.min, o.max)); + areas.push(PageArea { name: pa.attribute("name").map(str::to_string), width, height, content, items, occur_max, occur_min }); } // Nested page sets: take their areas too. for ps in children(n, "pageSet") { diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index c661929b12..03eb2c67ad 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -1225,9 +1225,39 @@ fn page_area_without_occur_serves_every_body_page() { ); let form = layout_xml(&xdp).unwrap(); let soms: Vec> = form.pages.iter().map(|p| widgets(p).iter().map(|w| w.som.clone()).collect()).collect(); - assert_eq!(form.pages.len(), 4, "{soms:?}"); - let buttons = soms.iter().flatten().filter(|s| s.contains("PrintButton")).count(); - assert_eq!(buttons, 0, "the single-use page area is not drawn over body pages: {soms:?}"); + let has_button = |page: &Vec| page.iter().any(|s| s.contains("PrintButton")); + // Four body pages, then the once-only page: its button is on that page alone, not over the body. + assert_eq!(form.pages.len(), 5, "{soms:?}"); + assert!(soms[..4].iter().all(|p| !has_button(p)), "the single-use page area is not drawn over body pages: {soms:?}"); + assert_eq!(soms[4].iter().filter(|s| s.contains("PrintButton")).count(), 1, "the required page is laid out once: {soms:?}"); +} + +/// A page area's `occur min` is a page it must have even when the body never reaches it, and a +/// body that already used it does not get another. An area with no `` needs none. +#[test] +fn page_area_minimum_adds_pages_after_the_body_only_when_unused() { + let area = |name: &str, occur: &str| { + format!( + r#"{occur}"# + ) + }; + let pages = |areas: String| { + let xdp = format!( + r##" +"## + ); + layout_xml(&xdp).unwrap().pages.len() + }; + // The body is one page; the required second master adds one, a third with min 2 adds two. + assert_eq!(pages(area("A", "") + &area("B", r#""#)), 2); + assert_eq!(pages(area("A", "") + &area("B", r#""#) + &area("C", r#""#)), 4); + // An area the body already used (it is first, so page 1 is its own) adds nothing. + assert_eq!(pages(area("A", r#""#)), 1); + assert_eq!(pages(area("A", r#""#) + &area("B", "")), 1); + // No `` and `min="0"` need no page. + assert_eq!(pages(area("A", "") + &area("B", r#""#)), 1); } /// Areas are named in SOM expressions but are not data scopes; unnamed subforms and areas have diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ac1deb83f1..14f534dce2 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5221,7 +5221,7 @@ 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/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/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/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/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/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/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"] -notes = "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), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF 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. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, 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. Missing: a page area's occur min (a once-only page area such as a closing instructions page is not laid out as a page of its own, #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)" +notes = "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 shows the public ISCIII form), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF 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. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, 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. Missing: a page area's relevant=\"-print\" (Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #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"