From afdb36805113fd93abf2fce9408bd1ace4d44334 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:02:18 +0800 Subject: [PATCH 1/6] M12: XFA repeating subforms lay out more than 50 rows A repeating subform laid out at most 50 instances, so rows 51 and on of a long list or table (from the data, or added by a button's addInstance) were left out. XFA 3.3, "The occur element" (p. 786): max -1 sets no upper boundary. The per-subform bound is now 1,000: the largest occur count the parser reads and the most rows one script run may add (MAX_ADDED). The layout budgets still bound the whole form. Opening a form with 1,000 one-field rows takes about 3 s in a debug build and 2,000 rows about 13 s, so a much larger bound would let one list stall the app. Rows past the occur max or the bound are still reported, and the warning now names the layout's bound when that is what cut them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC --- crates/engine/src/tests.rs | 6 +- crates/engine/tests/it/main.rs | 1 + crates/engine/tests/it/xfa_repeat.rs | 54 ++++++++++++++ crates/xfa/README.md | 4 +- crates/xfa/src/layout.rs | 13 +++- crates/xfa/src/layout/flow.rs | 21 ++++-- crates/xfa/src/lib.rs | 3 + crates/xfa/src/repeat_tests.rs | 102 +++++++++++++++++++++++++++ crates/xfa/src/tests.rs | 5 +- parity/acrobat-features.toml | 4 +- 10 files changed, 196 insertions(+), 17 deletions(-) create mode 100644 crates/engine/tests/it/xfa_repeat.rs create mode 100644 crates/xfa/src/repeat_tests.rs diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6a68916dff..c7184d5ac9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6190,12 +6190,12 @@ fn xfa_data_import_adds_and_removes_rows_of_a_dynamic_form() { assert_eq!(names(&s).iter().filter(|n| n.starts_with("amount")).count(), 3, "{:?}", names(&s)); assert_eq!(value(&s, "total"), vec!["15".to_string()], "qty 3 × price 5, calculated after the import: {:?}", s.take_js_output(id)); assert_eq!(value(&s, "grand"), vec!["42.5".to_string()]); - // More rows than the layout shows: the document says so. - let rows: String = (0..60).map(|i| format!("r{i}1")).collect(); + // More rows than the layout shows (1 000 for one repeating subform): the document says so. + let rows: String = (0..1010).map(|i| format!("r{i}1")).collect(); let file = format!("
1{rows}
"); s.apply(id, Edit::ImportData { name: "many.xml".into(), bytes: Arc::new(file.into_bytes()) }).unwrap(); let out = s.take_js_output(id); - assert!(out.errors.iter().chain(out.console.iter()).any(|l| l.contains("60 rows")), "{out:?}"); + assert!(out.errors.iter().chain(out.console.iter()).any(|l| l.contains("1010 rows") && l.contains("first 1000")), "{out:?}"); // A file with one row takes the others away. let file = "
1only7
"; s.apply(id, Edit::ImportData { name: "one.xml".into(), bytes: Arc::new(file.as_bytes().to_vec()) }).unwrap(); diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 8d9026600d..d669c395e3 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -11,3 +11,4 @@ mod ocr_edit; mod office_export_images; mod private_image_resize; mod private_text_glyphs; +mod xfa_repeat; diff --git a/crates/engine/tests/it/xfa_repeat.rs b/crates/engine/tests/it/xfa_repeat.rs new file mode 100644 index 0000000000..57cc54cf76 --- /dev/null +++ b/crates/engine/tests/it/xfa_repeat.rs @@ -0,0 +1,54 @@ +//! A dynamic XFA form whose button adds rows past fifty lays every row out and adds the pages +//! they need (XFA 3.3, "The occur element", p. 786: `max="-1"` sets no upper boundary). + +use std::sync::Arc; + +use pdfcraft_engine::Session; + +/// A list whose `item` row (20pt, 36 to a page) repeats without bound, `data` rows in its +/// datasets, and a button that adds `add` rows in one click. +fn form(add: usize, data_rows: usize) -> Vec { + let data: String = (1..=data_rows).map(|i| format!("row {i}")).collect(); + let datasets = if data_rows == 0 { + String::new() + } else { + format!( + r#"
{data}
"# + ) + }; + let xdp = format!( + r#" + +{datasets} +"# + ); + pdfcraft_xfa::fixtures::shell(&xdp) +} + +fn rows(s: &Session, id: pdfcraft_engine::DocId) -> usize { + s.get(id).map_or(0, |d| d.form.iter().filter(|f| f.name.starts_with("qty")).count()) +} + +#[test] +fn a_button_adding_rows_past_fifty_lays_them_all_out() { + let mut s = Session::new(); + let id = s.open("rows.pdf", None, Arc::new(form(59, 0)), None).expect("opens"); + assert_eq!(rows(&s, id), 1); + assert_eq!(s.get(id).map(|d| d.info.pages.len()), Some(1)); + let o = s.run_javascript(id, "", Some("more")).expect("the click runs"); + assert_eq!(o.error, None); + // The button row plus 60 rows of 20pt: 61 rows at 36 a page is two pages. + assert_eq!(rows(&s, id), 60); + assert_eq!(s.get(id).map(|d| d.info.pages.len()), Some(2)); + let out = s.take_js_output(id); + assert!(!out.console.iter().any(|l| l.contains("only the first")), "no row is cut: {:?}", out.console); +} diff --git a/crates/xfa/README.md b/crates/xfa/README.md index 2d08a74bb2..423e07c26d 100644 --- a/crates/xfa/README.md +++ b/crates/xfa/README.md @@ -48,7 +48,9 @@ that warning and keeps the existing layout and accepted widget values. - **Data** (`data`): the `datasets` packet. On layout, a field takes its value from the data node at its data path: its SOM path without areas, which are named in SOM expressions but are not data scopes (`data_path`, as Adobe's viewers bind) (dates in ISO form, check and radio states by their on values), and a - repeating subform or row gets as many instances as the data has. `write_datasets` merges the + repeating subform or row gets as many instances as the data has, up to its `occur max` and at + most 1 000 (`MAX_INSTANCES`, a work bound; the rows past it are left out with a layout + warning). `write_datasets` merges the AcroForm fields' values (by `/PCSom`, or by the Designer field names of a static form) into the existing `xfa:data`: only the bound nodes' text changes and missing nodes are added, so unbound data, other namespaces, attributes and comments stay byte for byte. Values it can't diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 73c6bde90f..4c66361c85 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -43,8 +43,17 @@ const MAX_DEPTH: usize = 64; /// Most size measurements of one layout. Measurements of containers are memoized, so a real /// form stays far below this; it bounds the work a hostile template can cause. const MAX_MEASURES: usize = 2_000_000; -/// Most initial instances of one repeating subform. -const MAX_INSTANCES: usize = 50; +/// Most instances of one repeating subform laid out (a row the data adds, or the template's +/// initial count). `occur max="-1"` sets no upper boundary (XFA 3.3, "The occur element", +/// p. 786), so this only bounds the work of a hostile or runaway form: a long list or table +/// is laid out whole up to here, and the rows past it are reported ([`Layouter::warn_truncated`]). +/// It matches the largest `occur` count the parser reads and the most rows one script run may +/// add (`MAX_ADDED` in pdfcraft-js). Laying out and listing the fields grows faster than the +/// row count (one text field a row: 1,000 rows open in about 3 s in a debug build, 2,000 in +/// about 13 s), so a much larger bound would let one list stall the app. The layout budgets +/// ([`MAX_MEASURES`], [`MAX_ITEMS`], [`MAX_PAGES`]) still bound the form as a whole, however +/// its repeats nest. +pub(crate) const MAX_INSTANCES: usize = 1_000; /// Largest picture taken from the data (decoded bytes), as for template pictures. const MAX_DATA_IMAGE: usize = 32 << 20; /// Most picture bytes one layout keeps, all copies together. Each laid-out picture holds its diff --git a/crates/xfa/src/layout/flow.rs b/crates/xfa/src/layout/flow.rs index 8f656d7eaf..36a2af9d3b 100644 --- a/crates/xfa/src/layout/flow.rs +++ b/crates/xfa/src/layout/flow.rs @@ -1,6 +1,6 @@ use super::common::{clamp_opt, divide, is_row, moved, solid_extent, splittable}; use super::items::Item; -use super::{Ctx, Frame, Layout, Layouter, MAX_DEPTH, Node, Rect, SomIndices, Subform, XfaError, data_path, instance_count}; +use super::{Ctx, Frame, Layout, Layouter, MAX_DEPTH, MAX_INSTANCES, Node, Rect, SomIndices, Subform, XfaError, data_path, instance_count}; impl Layouter<'_> { /// Place `node` as one block of size (`w`, `h`) at the cursor: in this content area when it fits, @@ -293,18 +293,25 @@ impl Layouter<'_> { } /// The data holds more instances of `node` than `laid_out`: say so, since calculations - /// then see only those. + /// then see only those, and say why. (The template's own counts can't ask for more: the + /// parser reads `initial` and `max` up to [`MAX_INSTANCES`].) fn warn_truncated(&mut self, node: &Node, parent_som: &str, laid_out: usize) { let Node::Subform(sf) = node else { return }; let from_data = match (&sf.common.name, self.data) { (Some(name), Some(d)) if sf.occur.max != Some(1) => d.count(&data_path(self.tpl, parent_som), name), _ => 0, }; - if from_data > laid_out { - self.warn_once(format!( - "{}: the data holds {from_data} rows; only the first {laid_out} are laid out (calculations see those)", - sf.common.name.as_deref().unwrap_or("a subform") - )); + if from_data <= laid_out { + return; } + let name = sf.common.name.as_deref().unwrap_or("a subform"); + // The template's own `occur max` is the form's rule: past it, the extra data is ignored + // (XFA 3.3, p. 336). Only the layout's own limit needs naming as a limit. + let why = if sf.occur.max.is_some_and(|m| m <= laid_out) { + String::new() + } else { + format!("; {MAX_INSTANCES} is the most one repeating subform lays out") + }; + self.warn_once(format!("{name}: the data holds {from_data} rows; only the first {laid_out} are laid out (calculations see those){why}")); } } diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs index 7e63218c90..d00f45d73b 100644 --- a/crates/xfa/src/lib.rs +++ b/crates/xfa/src/lib.rs @@ -174,5 +174,8 @@ pub fn render_into(doc: &mut Document) -> Result { Ok(Report { pages: written.pages, fields: written.fields, template_bytes: packets.xdp.len(), warnings }) } +#[cfg(test)] +mod repeat_tests; + #[cfg(test)] mod tests; diff --git a/crates/xfa/src/repeat_tests.rs b/crates/xfa/src/repeat_tests.rs new file mode 100644 index 0000000000..810847c30b --- /dev/null +++ b/crates/xfa/src/repeat_tests.rs @@ -0,0 +1,102 @@ +//! Repeating subforms with many instances: every row the data holds is laid out, up to the +//! template's `occur max` and the layout's instance limit, past which the rows left out are +//! reported (XFA 3.3, "The occur element", p. 786; Chapter 9, p. 336). + +use super::*; +use crate::layout::MAX_INSTANCES; +use crate::model::Occur; + +/// A form with a flowed list (`table` false) or a table (`table` true) whose `item` row +/// repeats with `occur max`, and data holding `rows` rows, each 20pt tall: 36 to a page. +fn rows_form(max: &str, rows: usize, table: bool) -> String { + let (list, row) = if table { (r#"layout="table" columnWidths="200pt""#, r#"layout="row""#) } else { (r#"layout="tb""#, r#"layout="tb""#) }; + let data: String = (1..=rows).map(|i| format!("row {i}")).collect(); + format!( + r#" + +
{data}
+
"# + ) +} + +fn qty_widgets(pages: &[Page]) -> Vec<&Widget> { + pages.iter().flat_map(|p| p.items.iter()).filter_map(|it| if let Item::Widget(w) = it { Some(w.as_ref()) } else { None }).collect() +} + +/// The values of the laid-out `qty` fields, in order. +fn values(form: &Form) -> Vec { + qty_widgets(&form.pages).iter().map(|w| w.value.clone().unwrap_or_default()).collect() +} + +fn expected(n: usize) -> Vec { + (1..=n).map(|i| format!("row {i}")).collect() +} + +fn cut_warning(form: &Form) -> Option<&String> { + form.warnings.iter().find(|w| w.contains("rows; only the first")) +} + +#[test] +fn every_data_row_past_fifty_is_laid_out_and_paginated() { + for table in [false, true] { + for (rows, pages) in [(60, 2), (300, 9)] { + let form = layout_xml(&rows_form("-1", rows, table)).unwrap(); + assert_eq!(values(&form), expected(rows), "table {table}, {rows} rows"); + assert_eq!(form.pages.len(), pages, "table {table}, {rows} rows at 36 a page"); + assert_eq!(cut_warning(&form), None, "nothing is cut: {:?}", form.warnings); + // Rows stay inside the content area of their page. + for w in qty_widgets(&form.pages) { + assert!(w.rect.y >= 36.0 - 0.01 && w.rect.bottom() <= 756.0 + 0.01, "{} at {:?}", w.name, w.rect); + } + } + } +} + +#[test] +fn occur_max_still_stops_the_rows_and_says_so() { + for table in [false, true] { + let form = layout_xml(&rows_form("5", 60, table)).unwrap(); + assert_eq!(values(&form), expected(5), "table {table}"); + let w = cut_warning(&form).unwrap_or_else(|| panic!("the 55 rows past occur max are reported: {:?}", form.warnings)); + assert!(w.contains("holds 60 rows") && w.contains("first 5") && !w.contains("the most"), "occur max is the form's own rule: {w}"); + } +} + +#[test] +fn rows_past_the_instance_limit_are_cut_with_a_warning() { + for table in [false, true] { + // Exactly at the limit: all laid out, nothing reported. + let form = layout_xml(&rows_form("-1", MAX_INSTANCES, table)).unwrap(); + assert_eq!(values(&form).len(), MAX_INSTANCES, "table {table}"); + assert_eq!(cut_warning(&form), None, "{:?}", form.warnings); + // One past it: the last row is left out, and the warning says so. + let form = layout_xml(&rows_form("-1", MAX_INSTANCES + 1, table)).unwrap(); + assert_eq!(values(&form), expected(MAX_INSTANCES), "table {table}"); + let w = cut_warning(&form).unwrap_or_else(|| panic!("rows past the limit are reported: {:?}", form.warnings)); + assert!(w.contains(&format!("holds {} rows", MAX_INSTANCES + 1)) && w.contains(&format!("first {MAX_INSTANCES}")), "{w}"); + assert!(w.contains(&format!("{MAX_INSTANCES} is the most one repeating subform lays out")), "the limit is named: {w}"); + } +} + +#[test] +fn an_initial_count_lays_out_whole_up_to_the_limit() { + // `initial` comes from the template, not the data: past fifty it lays out whole, and a + // hostile count stops at the limit (the parser reads it up to there). + let tpl = |initial: &str| { + rows_form("-1", 0, false).replace(r#""#, &format!(r#""#)) + }; + let form = layout_xml(&tpl("300")).unwrap(); + assert_eq!(qty_widgets(&form.pages).len(), 300); + assert_eq!(form.pages.len(), 9); + let form = layout_xml(&tpl("1000000000")).unwrap(); + assert_eq!(qty_widgets(&form.pages).len(), MAX_INSTANCES); + assert_eq!(crate::layout::instance_count(&Occur { initial: usize::MAX, ..Occur::default() }, 0), 1, "max defaults to 1"); + let unbounded = Occur { initial: usize::MAX, max: None, ..Occur::default() }; + assert_eq!(crate::layout::instance_count(&unbounded, 0), MAX_INSTANCES); + assert_eq!(crate::layout::instance_count(&unbounded, usize::MAX), MAX_INSTANCES); +} diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index e424be5441..cea352c8ee 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -3527,7 +3527,8 @@ fn imported_data_is_written_with_the_packets_own_prefix_and_never_leaves_it_unre assert!(import_data(&mut doc, &deepish, Some("form"), None).is_err()); assert_eq!(crate::data_of(&doc).unwrap().text_at(&som_to_path("form[0].page1[0].country[0]")), Some("CA"), "untouched"); // The data holds more rows than are laid out: said, not hidden. - let rows: String = (0..60).map(|i| format!("r{i}1")).collect(); + let n = crate::layout::MAX_INSTANCES + 10; + let rows: String = (0..n).map(|i| format!("r{i}1")).collect(); let many = scripted_template().replace( "", &format!( @@ -3535,7 +3536,7 @@ fn imported_data_is_written_with_the_packets_own_prefix_and_never_leaves_it_unre ), ); let form = layout_xml(&many).unwrap(); - assert!(form.warnings.iter().any(|w| w.contains("60 rows") && w.contains("first 50")), "{:?}", form.warnings); + assert!(form.warnings.iter().any(|w| w.contains(&format!("{n} rows")) && w.contains(&format!("first {}", n - 10))), "{:?}", form.warnings); } #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3b8dba920c..fbbe3a2dde 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5332,8 +5332,8 @@ milestone = "M12" status = "partial" commands = ["file.open", "file.save", "form.export_data", "form.import_data"] tools = ["doc_open", "form_fill", "doc_save", "doc_export_data", "doc_import_data", "form_set_image"] -evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::a_date_is_read_from_the_data_in_its_data_picture", "crates/xfa/src/tests.rs::scripts_read_a_date_as_iso_whatever_its_data_picture_writes", "crates/engine/src/tests.rs::xfa_dates_are_written_and_read_back_in_their_data_picture", "crates/engine/src/tests.rs::each_xfa_date_field_is_written_in_its_own_data_picture_and_a_partial_one_keeps_iso", "crates/xfa/src/tests.rs::som_paths_name_the_template_field_their_numbers_count_to", "crates/xfa/src/tests.rs::a_refreshed_field_takes_its_value_from_its_own_template_field", "crates/xfa/src/tests.rs::a_date_its_data_picture_cant_hold_is_written_as_iso", "crates/xfa/src/tests.rs::data_fills_the_fields_and_adds_rows", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/xfa/src/tests.rs::data_paths_leave_areas_out", "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::data_exports_as_datasets_xml_or_an_xdp_and_imports_from_any_of_them", "crates/xfa/src/tests.rs::a_picture_chosen_for_an_image_field_is_kept_in_the_data_with_its_content_type", "crates/engine/src/tests.rs::xfa_data_exports_imports_and_pictures_round_trip_through_the_session", "crates/engine/src/tests.rs::xfa_data_import_adds_and_removes_rows_of_a_dynamic_form", "crates/automation/tests/it/automation.rs::xfa_data_files_export_and_import_through_tools"] -notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, or in the field's data picture (``, such as date{YYYYMMDD}) when it has one holding a whole date (one without a day, such as date{YYYYMM}, keeps ISO, as does a date the picture can't hold, such as 2050 in a two-digit year), each field its own even beside same-named siblings, which saving writes them in too, while scripts still read the ISO date as rawValue, as another viewer gives it, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom), with areas left out of data paths as Adobe binds them. Export Data writes it as XML (the data alone, rooted at the form's root data node, as Acrobat does) or an XDP with the whole datasets and a reference to the PDF; Import Data takes an XDP, datasets or data XML file (or a bare data document) as the form's whole data, keeping data descriptions, lays a dynamic form out again from it (rows added and removed, more than 50 rows reported) and runs its calculations, undoable; a static form's fields take the data and the rest are emptied; the file's data is re-serialized with the packet's own prefix and every packet rewrite is parsed back before it is written; XFDF/FDF/CSV still go the AcroForm way. Files are UTF-8, UTF-16 or ISO-8859-1, at most 16 MB and 64 levels deep; pictures at most 8 MB (PNG, JPEG, GIF, BMP, TIFF). Pictures chosen for image fields are kept in the data as base64 with xfa:contentType, as Designer saves them. Missing: explicit bind refs, data descriptions are kept but not checked, no data connection (WSDL/ODBC)" +evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::a_date_is_read_from_the_data_in_its_data_picture", "crates/xfa/src/tests.rs::scripts_read_a_date_as_iso_whatever_its_data_picture_writes", "crates/engine/src/tests.rs::xfa_dates_are_written_and_read_back_in_their_data_picture", "crates/engine/src/tests.rs::each_xfa_date_field_is_written_in_its_own_data_picture_and_a_partial_one_keeps_iso", "crates/xfa/src/tests.rs::som_paths_name_the_template_field_their_numbers_count_to", "crates/xfa/src/tests.rs::a_refreshed_field_takes_its_value_from_its_own_template_field", "crates/xfa/src/tests.rs::a_date_its_data_picture_cant_hold_is_written_as_iso", "crates/xfa/src/tests.rs::data_fills_the_fields_and_adds_rows", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/xfa/src/tests.rs::data_paths_leave_areas_out", "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::data_exports_as_datasets_xml_or_an_xdp_and_imports_from_any_of_them", "crates/xfa/src/tests.rs::a_picture_chosen_for_an_image_field_is_kept_in_the_data_with_its_content_type", "crates/engine/src/tests.rs::xfa_data_exports_imports_and_pictures_round_trip_through_the_session", "crates/engine/src/tests.rs::xfa_data_import_adds_and_removes_rows_of_a_dynamic_form", "crates/automation/tests/it/automation.rs::xfa_data_files_export_and_import_through_tools", "crates/xfa/src/repeat_tests.rs::every_data_row_past_fifty_is_laid_out_and_paginated", "crates/xfa/src/repeat_tests.rs::occur_max_still_stops_the_rows_and_says_so", "crates/xfa/src/repeat_tests.rs::rows_past_the_instance_limit_are_cut_with_a_warning", "crates/xfa/src/repeat_tests.rs::an_initial_count_lays_out_whole_up_to_the_limit", "crates/engine/tests/it/xfa_repeat.rs::a_button_adding_rows_past_fifty_lays_them_all_out"] +notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, or in the field's data picture (``, such as date{YYYYMMDD}) when it has one holding a whole date (one without a day, such as date{YYYYMM}, keeps ISO, as does a date the picture can't hold, such as 2050 in a two-digit year), each field its own even beside same-named siblings, which saving writes them in too, while scripts still read the ISO date as rawValue, as another viewer gives it, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom), with areas left out of data paths as Adobe binds them. Export Data writes it as XML (the data alone, rooted at the form's root data node, as Acrobat does) or an XDP with the whole datasets and a reference to the PDF; Import Data takes an XDP, datasets or data XML file (or a bare data document) as the form's whole data, keeping data descriptions, lays a dynamic form out again from it (rows added and removed: a repeating subform lays out every row up to its occur max, or up to 1,000 when it has none, and the rows past that are reported) and runs its calculations, undoable; a static form's fields take the data and the rest are emptied; the file's data is re-serialized with the packet's own prefix and every packet rewrite is parsed back before it is written; XFDF/FDF/CSV still go the AcroForm way. Files are UTF-8, UTF-16 or ISO-8859-1, at most 16 MB and 64 levels deep; pictures at most 8 MB (PNG, JPEG, GIF, BMP, TIFF). Pictures chosen for image fields are kept in the data as base64 with xfa:contentType, as Designer saves them. Missing: explicit bind refs, data descriptions are kept but not checked, no data connection (WSDL/ODBC)" [[feature]] id = "form.xfa-flatten" From 4d467e1ba6c271aeefc65ad2a0ec179248313e32 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:29:38 +0800 Subject: [PATCH 2/6] M12: report rows cut from repeats in positioned containers A repeating subform drawn inside a positioned container (a tb, lr-tb or rl-tb subform placed on a positioned page) was cut at its occur max or MAX_INSTANCES like a flowed one, but the rows past the cut were dropped without the layout warning that flowed subforms and tables give. Both drawing paths now call warn_truncated, so the README, the MAX_INSTANCES doc and the form.xfa-data note are true for these layouts too (XFA 3.3, "The occur element", p. 786). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC --- crates/xfa/src/layout/draw.rs | 13 ++++++++++--- crates/xfa/src/layout/flow.rs | 2 +- crates/xfa/src/repeat_tests.rs | 18 ++++++++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/crates/xfa/src/layout/draw.rs b/crates/xfa/src/layout/draw.rs index f6520c1628..cc8ff5b670 100644 --- a/crates/xfa/src/layout/draw.rs +++ b/crates/xfa/src/layout/draw.rs @@ -97,8 +97,11 @@ impl Layouter<'_> { } let (w, h) = self.size_of(k, inner.w, &ctx, depth + 1); // A repeating subform here (a table in an area of a positioned page, - // say) gets its instances, as in a flowed one, one under another. - for instance in 0..self.instances(k, &ctx.som) { + // say) gets its instances, as in a flowed one, one under another, + // and the rows past them are reported as there. + let instances = self.instances(k, &ctx.som); + self.warn_truncated(k, &ctx.som, instances); + for instance in 0..instances { self.draw_node(k, Rect::new(inner.x, y, w, h), &ctx, depth + 1, sibs.at(i) + instance)?; y += h; } @@ -106,7 +109,11 @@ impl Layouter<'_> { } Layout::LrTb | Layout::RlTb => { // A repeating subform here (rows of fields under a heading) gets its - // instances, as in the other layouts. + // instances, as in the other layouts, and the rows past them are reported. + for k in s.children.iter().filter(|k| k.common().presence.occupies()) { + let instances = self.instances(k, &ctx.som); + self.warn_truncated(k, &ctx.som, instances); + } let (placed, _) = self.pack_instances(&s.children, inner.w, &ctx, depth, &ctx.som); for (i, instance, r) in placed { if let Some(k) = s.children.get(i) { diff --git a/crates/xfa/src/layout/flow.rs b/crates/xfa/src/layout/flow.rs index 36a2af9d3b..797ec6aa6c 100644 --- a/crates/xfa/src/layout/flow.rs +++ b/crates/xfa/src/layout/flow.rs @@ -295,7 +295,7 @@ impl Layouter<'_> { /// The data holds more instances of `node` than `laid_out`: say so, since calculations /// then see only those, and say why. (The template's own counts can't ask for more: the /// parser reads `initial` and `max` up to [`MAX_INSTANCES`].) - fn warn_truncated(&mut self, node: &Node, parent_som: &str, laid_out: usize) { + pub(super) fn warn_truncated(&mut self, node: &Node, parent_som: &str, laid_out: usize) { let Node::Subform(sf) = node else { return }; let from_data = match (&sf.common.name, self.data) { (Some(name), Some(d)) if sf.occur.max != Some(1) => d.count(&data_path(self.tpl, parent_som), name), diff --git a/crates/xfa/src/repeat_tests.rs b/crates/xfa/src/repeat_tests.rs index 810847c30b..bce2051838 100644 --- a/crates/xfa/src/repeat_tests.rs +++ b/crates/xfa/src/repeat_tests.rs @@ -100,3 +100,21 @@ fn an_initial_count_lays_out_whole_up_to_the_limit() { assert_eq!(crate::layout::instance_count(&unbounded, 0), MAX_INSTANCES); assert_eq!(crate::layout::instance_count(&unbounded, usize::MAX), MAX_INSTANCES); } + +#[test] +fn rows_cut_in_a_positioned_container_are_reported_too() { + // The list sits in a positioned subform, so it is drawn rather than flowed: its `tb`, + // `lr-tb` and `rl-tb` repeats are cut at the same bounds and must say so as a flowed one does. + for layout in ["tb", "lr-tb", "rl-tb"] { + let xml = rows_form("5", 60, false) + .replace(r#""#, r#""#) + .replace("", "") + .replace(r#""#, &format!(r#""#)) + .replace("
", "") + .replace("", "
"); + let form = layout_xml(&xml).unwrap(); + assert_eq!(values(&form), expected(5), "{layout}"); + let w = cut_warning(&form).unwrap_or_else(|| panic!("{layout}: the rows past occur max are reported: {:?}", form.warnings)); + assert!(w.contains("holds 60 rows") && w.contains("first 5"), "{layout}: {w}"); + } +} From 7e0f635856fb980905f60505cd02dd5ffefbc712 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:52:31 +0800 Subject: [PATCH 3/6] M12: XFA table rows repeat in positioned containers A table subform (layout="table") placed in positioned content was drawn through draw_node, which drew each row subform once: the rows the data holds past the first, and an occur initial count, were dropped with no warning, while the same table in flowed content repeated them (XFA 3.3, "Tables", p. 327; "Repeating Elements using Occurrence Limits", p. 263; Chapter 9, p. 336). The positioned table now draws each row once per instance, one under another with its own SOM index, and is measured with them, so what follows it in a top-to-bottom container moves down. The count and the warning for the rows cut come from one helper, laid_out_instances, now used by every layout that repeats a subform (flowed subforms, flowed tables, positioned top-to-bottom, wrapped and table layouts). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC --- crates/engine/tests/it/xfa_repeat.rs | 29 +++++ crates/xfa/README.md | 3 +- crates/xfa/src/layout/draw.rs | 15 ++- crates/xfa/src/layout/flow.rs | 15 ++- crates/xfa/src/layout/measure.rs | 5 +- crates/xfa/src/lib.rs | 3 + crates/xfa/src/table_repeat_tests.rs | 188 +++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 8 files changed, 248 insertions(+), 14 deletions(-) create mode 100644 crates/xfa/src/table_repeat_tests.rs diff --git a/crates/engine/tests/it/xfa_repeat.rs b/crates/engine/tests/it/xfa_repeat.rs index 57cc54cf76..70c05f85f8 100644 --- a/crates/engine/tests/it/xfa_repeat.rs +++ b/crates/engine/tests/it/xfa_repeat.rs @@ -52,3 +52,32 @@ fn a_button_adding_rows_past_fifty_lays_them_all_out() { let out = s.take_js_output(id); assert!(!out.console.iter().any(|l| l.contains("only the first")), "no row is cut: {:?}", out.console); } + +/// A table (`layout="table"`) placed at its own position in a positioned subform, its `item` row +/// repeating with `occur max` `max`, and five rows in its data (XFA 3.3, "Tables", p. 327). +fn positioned_table(max: &str) -> Vec { + let data: String = (1..=5).map(|i| format!("row {i}")).collect(); + let xdp = format!( + r#" + +
{data}
+
"# + ); + pdfcraft_xfa::fixtures::shell(&xdp) +} + +#[test] +fn a_table_in_a_positioned_subform_gets_a_row_per_data_row_up_to_its_max() { + let mut s = Session::new(); + for (max, want) in [("-1", 5), ("3", 3)] { + let id = s.open("table.pdf", None, Arc::new(positioned_table(max)), None).expect("opens"); + assert_eq!(rows(&s, id), want, "occur max {max}"); + } +} diff --git a/crates/xfa/README.md b/crates/xfa/README.md index 423e07c26d..3aef875681 100644 --- a/crates/xfa/README.md +++ b/crates/xfa/README.md @@ -48,7 +48,8 @@ that warning and keeps the existing layout and accepted widget values. - **Data** (`data`): the `datasets` packet. On layout, a field takes its value from the data node at its data path: its SOM path without areas, which are named in SOM expressions but are not data scopes (`data_path`, as Adobe's viewers bind) (dates in ISO form, check and radio states by their on values), and a - repeating subform or row gets as many instances as the data has, up to its `occur max` and at + repeating subform or table row gets as many instances as the data has (in flowed and positioned + containers alike), up to its `occur max` and at most 1 000 (`MAX_INSTANCES`, a work bound; the rows past it are left out with a layout warning). `write_datasets` merges the AcroForm fields' values (by `/PCSom`, or by the Designer field names of a static form) into diff --git a/crates/xfa/src/layout/draw.rs b/crates/xfa/src/layout/draw.rs index cc8ff5b670..2b3ccd0971 100644 --- a/crates/xfa/src/layout/draw.rs +++ b/crates/xfa/src/layout/draw.rs @@ -99,8 +99,7 @@ impl Layouter<'_> { // A repeating subform here (a table in an area of a positioned page, // say) gets its instances, as in a flowed one, one under another, // and the rows past them are reported as there. - let instances = self.instances(k, &ctx.som); - self.warn_truncated(k, &ctx.som, instances); + let instances = self.laid_out_instances(k, &ctx.som); for instance in 0..instances { self.draw_node(k, Rect::new(inner.x, y, w, h), &ctx, depth + 1, sibs.at(i) + instance)?; y += h; @@ -111,8 +110,7 @@ impl Layouter<'_> { // A repeating subform here (rows of fields under a heading) gets its // instances, as in the other layouts, and the rows past them are reported. for k in s.children.iter().filter(|k| k.common().presence.occupies()) { - let instances = self.instances(k, &ctx.som); - self.warn_truncated(k, &ctx.som, instances); + self.laid_out_instances(k, &ctx.som); } let (placed, _) = self.pack_instances(&s.children, inner.w, &ctx, depth, &ctx.som); for (i, instance, r) in placed { @@ -133,9 +131,14 @@ impl Layouter<'_> { if let Node::Subform(r) = k && r.layout == Layout::Row { + // A repeating row (a table in a positioned subform, say) gets its + // instances one under another, as in a flowed table (XFA 3.3, + // "Tables", p. 327), and the rows past them are reported. let h = self.row_height(r, &cols, inner.w, &ctx, depth + 1); - self.draw_row(r, &cols, Rect::new(inner.x, y, inner.w, h), &ctx, depth + 1, csib)?; - y += h; + for instance in 0..self.laid_out_instances(k, &ctx.som) { + self.draw_row(r, &cols, Rect::new(inner.x, y, inner.w, h), &ctx, depth + 1, csib + instance)?; + y += h; + } } else { let (w, h) = self.size_of(k, inner.w, &ctx, depth + 1); self.draw_node(k, Rect::new(inner.x, y, w, h), &ctx, depth + 1, csib)?; diff --git a/crates/xfa/src/layout/flow.rs b/crates/xfa/src/layout/flow.rs index 797ec6aa6c..422902b61b 100644 --- a/crates/xfa/src/layout/flow.rs +++ b/crates/xfa/src/layout/flow.rs @@ -158,8 +158,7 @@ impl Layouter<'_> { self.break_frames(0.0, true)?; } // Data adds instances of a repeating subform (rows added in another viewer). - let instances = self.instances(node, &ctx.som); - self.warn_truncated(node, &ctx.som, instances); + let instances = self.laid_out_instances(node, &ctx.som); let ctx_parent = ctx; for i in 0..instances { // Every instance spends budget, so nested repeats that place nothing still stop. @@ -246,8 +245,7 @@ impl Layouter<'_> { && row.layout == Layout::Row { // Rows added as data (in another viewer) repeat the row. - let instances = self.instances(child, &ctx.som); - self.warn_truncated(child, &ctx.som, instances); + let instances = self.laid_out_instances(child, &ctx.som); for inst in 0..instances { let h = self.row_height(row, &cols, inner_w, ctx, depth + 1); let led = lead.filter(|(li, _)| *li != ci); @@ -292,6 +290,15 @@ impl Layouter<'_> { instance_count(&sf.occur, from_data) } + /// [`Self::instances`] for laying `node` out: the count every layout that repeats a subform + /// draws (flowed or positioned, top to bottom, wrapped or a table's rows), with the data rows + /// past it reported ([`Self::warn_truncated`]). + pub(super) fn laid_out_instances(&mut self, node: &Node, parent_som: &str) -> usize { + let instances = self.instances(node, parent_som); + self.warn_truncated(node, parent_som, instances); + instances + } + /// The data holds more instances of `node` than `laid_out`: say so, since calculations /// then see only those, and say why. (The template's own counts can't ask for more: the /// parser reads `initial` and `max` up to [`MAX_INSTANCES`].) diff --git a/crates/xfa/src/layout/measure.rs b/crates/xfa/src/layout/measure.rs index 2fffb3dfa0..2ab5f66727 100644 --- a/crates/xfa/src/layout/measure.rs +++ b/crates/xfa/src/layout/measure.rs @@ -94,7 +94,10 @@ impl Layouter<'_> { .iter() .filter(|k| k.common().presence.occupies()) .map(|k| match k { - Node::Subform(r) if r.layout == Layout::Row => self.row_height(r, &cols, inner_w, &ctx, depth + 1), + // A repeating row takes its height once per instance, as drawn. + Node::Subform(r) if r.layout == Layout::Row => { + self.row_height(r, &cols, inner_w, &ctx, depth + 1) * self.instances(k, &som) as f64 + } _ => self.size_of(k, inner_w, &ctx, depth + 1).1, }) .sum() diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs index d00f45d73b..c27c3a8e90 100644 --- a/crates/xfa/src/lib.rs +++ b/crates/xfa/src/lib.rs @@ -177,5 +177,8 @@ pub fn render_into(doc: &mut Document) -> Result { #[cfg(test)] mod repeat_tests; +#[cfg(test)] +mod table_repeat_tests; + #[cfg(test)] mod tests; diff --git a/crates/xfa/src/table_repeat_tests.rs b/crates/xfa/src/table_repeat_tests.rs new file mode 100644 index 0000000000..5897cc6ca5 --- /dev/null +++ b/crates/xfa/src/table_repeat_tests.rs @@ -0,0 +1,188 @@ +//! A table (`layout="table"`, its rows `layout="row"`) placed in positioned content repeats a +//! row subform once per instance, as a table in flowed content does: the template's `occur` +//! and the data's rows decide how many (XFA 3.3, "Tables", p. 327; "Repeating Elements using +//! Occurrence Limits", p. 263; Chapter 9, p. 336), and the data rows past `occur max` or the +//! layout's own limit are reported, not dropped silently. + +use super::*; +use crate::layout::Item; +use crate::model::Rect; + +/// Where the table sits. +#[derive(Clone, Copy, Debug)] +enum Place { + /// At its own `x`/`y` in a positioned subform. + Positioned, + /// In a top-to-bottom subform at a position in a positioned subform, with a field after it. + PositionedFlow, + /// In the flowed body, with a field after it. + Flowed, +} + +/// A form with a table whose header row (`head`, shown once) and footer row (`foot`) frame a +/// repeating `item` row with `occur` attributes `occur`, and data holding `rows` items (and two +/// header rows, which the template shows once). Each row is 20pt tall. +fn table_form(place: Place, occur: &str, rows: usize) -> String { + let items: String = (1..=rows).map(|i| format!("row {i}")).collect(); + let table = r#" + + + + "# + .replace("OCCUR", occur); + let after = r#""#; + let (page, data) = match place { + Place::Positioned => { + (format!(r#"{}"#, table.replace("POS", r#"x="36pt" y="72pt""#)), "page1") + } + Place::PositionedFlow => ( + format!( + r#"{}{after}"#, + table.replace("POS", "") + ), + "page1> (format!(r#"{}{after}"#, table.replace("POS", "")), "page1"), + }; + let close: String = data.split("><").collect::>().iter().rev().map(|n| format!("")).collect(); + format!( + r#" + +
<{data}>{items}T
{close}
+
"# + ) +} + +/// A laid-out widget: (field name, SOM, value, rect). +type Laid = (String, String, Option, Rect); + +/// The laid-out widgets, in order. +fn laid_out(form: &Form) -> Vec { + form.pages + .iter() + .flat_map(|p| p.items.iter()) + .filter_map(|it| if let Item::Widget(w) = it { Some((w.name.clone(), w.som.clone(), w.value.clone(), w.rect)) } else { None }) + .collect() +} + +fn named<'a>(all: &'a [Laid], field: &str) -> Vec<&'a Laid> { + all.iter().filter(|w| w.1.ends_with(&format!(".{field}[0]"))).collect() +} + +fn qty_values(all: &[Laid]) -> Vec { + named(all, "qty").iter().map(|w| w.2.clone().unwrap_or_default()).collect() +} + +fn expected(n: usize) -> Vec { + (1..=n).map(|i| format!("row {i}")).collect() +} + +fn cut_warning(form: &Form) -> Option<&String> { + form.warnings.iter().find(|w| w.contains("rows; only the first")) +} + +/// Header, the rows and footer one under another, 20pt apart, from the header's top. +fn assert_stacked(all: &[Laid], rows: usize, what: &str) { + let head = named(all, "label"); + assert_eq!(head.len(), 1, "{what}: the header row is shown once: {head:?}"); + let top = head[0].3.y; + let qty = named(all, "qty"); + assert_eq!(qty.len(), rows, "{what}"); + for (i, w) in qty.iter().enumerate() { + assert!((w.3.y - (top + 20.0 * (i as f64 + 1.0))).abs() < 0.01, "{what}: row {i} at {:?}, header at {top}", w.3); + } + let foot = named(all, "total"); + assert_eq!(foot.len(), 1, "{what}: the footer row is shown once"); + assert!((foot[0].3.y - (top + 20.0 * (rows as f64 + 1.0))).abs() < 0.01, "{what}: footer at {:?} after {rows} rows", foot[0].3); +} + +#[test] +fn a_table_in_positioned_content_gets_a_row_per_data_row() { + for place in [Place::Positioned, Place::PositionedFlow] { + let form = layout_xml(&table_form(place, r#"min="1" max="-1""#, 5)).unwrap(); + let all = laid_out(&form); + assert_eq!(qty_values(&all), expected(5), "{place:?}"); + let soms: Vec<&str> = named(&all, "qty").iter().map(|w| w.1.as_str()).collect(); + let prefix = if matches!(place, Place::Positioned) { "form[0].page1[0].table[0]" } else { "form[0].page1[0].wrap[0].table[0]" }; + let want: Vec = (0..5).map(|i| format!("{prefix}.item[{i}].qty[0]")).collect(); + assert_eq!(soms, want, "{place:?}: each row with its own SOM index"); + assert_stacked(&all, 5, &format!("{place:?}")); + assert_eq!(named(&all, "label")[0].2.as_deref(), Some("H"), "{place:?}: the header takes the first header data row"); + assert!(cut_warning(&form).is_none(), "{place:?}: {:?}", form.warnings); + } +} + +#[test] +fn a_table_in_positioned_content_is_measured_with_its_rows() { + // What follows the table in a top-to-bottom container comes after the footer, not after one + // row: the table is measured with every instance. + let form = layout_xml(&table_form(Place::PositionedFlow, r#"min="1" max="-1""#, 5)).unwrap(); + let all = laid_out(&form); + let foot = named(&all, "total")[0].3; + let after = named(&all, "after")[0].3; + assert!((after.y - foot.bottom()).abs() < 0.01, "after {after:?}, footer {foot:?}"); +} + +#[test] +fn occur_max_bounds_a_positioned_table_and_the_rows_cut_are_reported() { + for place in [Place::Positioned, Place::PositionedFlow, Place::Flowed] { + let form = layout_xml(&table_form(place, r#"min="1" max="3""#, 5)).unwrap(); + let all = laid_out(&form); + assert_eq!(qty_values(&all), expected(3), "{place:?}"); + assert_stacked(&all, 3, &format!("{place:?}")); + let warning = cut_warning(&form).unwrap_or_else(|| panic!("{place:?}: {:?}", form.warnings)); + assert!(warning.contains("item") && warning.contains("5 rows") && warning.contains("first 3"), "{place:?}: {warning}"); + } +} + +#[test] +fn without_data_rows_a_positioned_table_takes_its_initial_count() { + let form = layout_xml(&table_form(Place::Positioned, r#"min="1" max="-1" initial="2""#, 0)).unwrap(); + let all = laid_out(&form); + assert_stacked(&all, 2, "initial 2"); + assert!(cut_warning(&form).is_none(), "{:?}", form.warnings); +} + +#[test] +fn a_positioned_table_lays_out_the_rows_a_flowed_one_does() { + // One rule for both: the same rows, values, spacing and warnings, wherever the table sits. + for (occur, rows) in [(r#"min="1" max="-1""#, 5), (r#"min="2" max="4""#, 7), (r#"min="1" max="-1""#, 0), (r#"min="3""#, 1)] { + let flowed = layout_xml(&table_form(Place::Flowed, occur, rows)).unwrap(); + let positioned = layout_xml(&table_form(Place::Positioned, occur, rows)).unwrap(); + let (f, p) = (laid_out(&flowed), laid_out(&positioned)); + assert_eq!(qty_values(&p), qty_values(&f), "{occur} with {rows} rows"); + let offsets = |all: &[Laid]| -> Vec { + let top = named(all, "label").first().map_or(0.0, |w| w.3.y); + all.iter().filter(|w| w.0 != "after").map(|w| w.3.y - top).collect() + }; + assert_eq!(offsets(&p), offsets(&f), "{occur} with {rows} rows"); + assert_eq!(cut_warning(&positioned).is_some(), cut_warning(&flowed).is_some(), "{occur} with {rows} rows"); + } +} + +#[test] +fn hostile_occur_values_and_data_floods_lay_a_positioned_table_out_within_bounds() { + for occur in [ + r#"min="-3" max="-7" initial="99999999999999999999""#, + r#"min="abc" max="1e9" initial="-1""#, + r#"min="999999" max="999999" initial="999999""#, + r#"max="0""#, + r#"min="0" max="0" initial="0""#, + ] { + for rows in [0, 4] { + let form = layout_xml(&table_form(Place::Positioned, occur, rows)).unwrap(); + let flowed = layout_xml(&table_form(Place::Flowed, occur, rows)).unwrap(); + assert_eq!(qty_values(&laid_out(&form)).len(), qty_values(&laid_out(&flowed)).len(), "{occur} with {rows} rows"); + } + } + // More data rows than the layout lays out: bounded, and said. + let rows = 1_200; + let form = layout_xml(&table_form(Place::Positioned, r#"min="1" max="-1""#, rows)).unwrap(); + let n = qty_values(&laid_out(&form)).len(); + assert!((1..rows).contains(&n), "{n} of {rows} rows"); + let warning = cut_warning(&form).unwrap_or_else(|| panic!("{:?}", form.warnings)); + assert!(warning.contains(&format!("{rows} rows")) && warning.contains(&format!("first {n}")), "{warning}"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index fbbe3a2dde..f8dbdc6256 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5332,8 +5332,8 @@ milestone = "M12" status = "partial" commands = ["file.open", "file.save", "form.export_data", "form.import_data"] tools = ["doc_open", "form_fill", "doc_save", "doc_export_data", "doc_import_data", "form_set_image"] -evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::a_date_is_read_from_the_data_in_its_data_picture", "crates/xfa/src/tests.rs::scripts_read_a_date_as_iso_whatever_its_data_picture_writes", "crates/engine/src/tests.rs::xfa_dates_are_written_and_read_back_in_their_data_picture", "crates/engine/src/tests.rs::each_xfa_date_field_is_written_in_its_own_data_picture_and_a_partial_one_keeps_iso", "crates/xfa/src/tests.rs::som_paths_name_the_template_field_their_numbers_count_to", "crates/xfa/src/tests.rs::a_refreshed_field_takes_its_value_from_its_own_template_field", "crates/xfa/src/tests.rs::a_date_its_data_picture_cant_hold_is_written_as_iso", "crates/xfa/src/tests.rs::data_fills_the_fields_and_adds_rows", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/xfa/src/tests.rs::data_paths_leave_areas_out", "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::data_exports_as_datasets_xml_or_an_xdp_and_imports_from_any_of_them", "crates/xfa/src/tests.rs::a_picture_chosen_for_an_image_field_is_kept_in_the_data_with_its_content_type", "crates/engine/src/tests.rs::xfa_data_exports_imports_and_pictures_round_trip_through_the_session", "crates/engine/src/tests.rs::xfa_data_import_adds_and_removes_rows_of_a_dynamic_form", "crates/automation/tests/it/automation.rs::xfa_data_files_export_and_import_through_tools", "crates/xfa/src/repeat_tests.rs::every_data_row_past_fifty_is_laid_out_and_paginated", "crates/xfa/src/repeat_tests.rs::occur_max_still_stops_the_rows_and_says_so", "crates/xfa/src/repeat_tests.rs::rows_past_the_instance_limit_are_cut_with_a_warning", "crates/xfa/src/repeat_tests.rs::an_initial_count_lays_out_whole_up_to_the_limit", "crates/engine/tests/it/xfa_repeat.rs::a_button_adding_rows_past_fifty_lays_them_all_out"] -notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, or in the field's data picture (``, such as date{YYYYMMDD}) when it has one holding a whole date (one without a day, such as date{YYYYMM}, keeps ISO, as does a date the picture can't hold, such as 2050 in a two-digit year), each field its own even beside same-named siblings, which saving writes them in too, while scripts still read the ISO date as rawValue, as another viewer gives it, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom), with areas left out of data paths as Adobe binds them. Export Data writes it as XML (the data alone, rooted at the form's root data node, as Acrobat does) or an XDP with the whole datasets and a reference to the PDF; Import Data takes an XDP, datasets or data XML file (or a bare data document) as the form's whole data, keeping data descriptions, lays a dynamic form out again from it (rows added and removed: a repeating subform lays out every row up to its occur max, or up to 1,000 when it has none, and the rows past that are reported) and runs its calculations, undoable; a static form's fields take the data and the rest are emptied; the file's data is re-serialized with the packet's own prefix and every packet rewrite is parsed back before it is written; XFDF/FDF/CSV still go the AcroForm way. Files are UTF-8, UTF-16 or ISO-8859-1, at most 16 MB and 64 levels deep; pictures at most 8 MB (PNG, JPEG, GIF, BMP, TIFF). Pictures chosen for image fields are kept in the data as base64 with xfa:contentType, as Designer saves them. Missing: explicit bind refs, data descriptions are kept but not checked, no data connection (WSDL/ODBC)" +evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::a_date_is_read_from_the_data_in_its_data_picture", "crates/xfa/src/tests.rs::scripts_read_a_date_as_iso_whatever_its_data_picture_writes", "crates/engine/src/tests.rs::xfa_dates_are_written_and_read_back_in_their_data_picture", "crates/engine/src/tests.rs::each_xfa_date_field_is_written_in_its_own_data_picture_and_a_partial_one_keeps_iso", "crates/xfa/src/tests.rs::som_paths_name_the_template_field_their_numbers_count_to", "crates/xfa/src/tests.rs::a_refreshed_field_takes_its_value_from_its_own_template_field", "crates/xfa/src/tests.rs::a_date_its_data_picture_cant_hold_is_written_as_iso", "crates/xfa/src/tests.rs::data_fills_the_fields_and_adds_rows", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/xfa/src/tests.rs::data_paths_leave_areas_out", "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::data_exports_as_datasets_xml_or_an_xdp_and_imports_from_any_of_them", "crates/xfa/src/tests.rs::a_picture_chosen_for_an_image_field_is_kept_in_the_data_with_its_content_type", "crates/engine/src/tests.rs::xfa_data_exports_imports_and_pictures_round_trip_through_the_session", "crates/engine/src/tests.rs::xfa_data_import_adds_and_removes_rows_of_a_dynamic_form", "crates/automation/tests/it/automation.rs::xfa_data_files_export_and_import_through_tools", "crates/xfa/src/repeat_tests.rs::every_data_row_past_fifty_is_laid_out_and_paginated", "crates/xfa/src/repeat_tests.rs::occur_max_still_stops_the_rows_and_says_so", "crates/xfa/src/repeat_tests.rs::rows_past_the_instance_limit_are_cut_with_a_warning", "crates/xfa/src/repeat_tests.rs::an_initial_count_lays_out_whole_up_to_the_limit", "crates/engine/tests/it/xfa_repeat.rs::a_button_adding_rows_past_fifty_lays_them_all_out", "crates/xfa/src/table_repeat_tests.rs::a_table_in_positioned_content_gets_a_row_per_data_row", "crates/xfa/src/table_repeat_tests.rs::occur_max_bounds_a_positioned_table_and_the_rows_cut_are_reported", "crates/xfa/src/table_repeat_tests.rs::a_positioned_table_lays_out_the_rows_a_flowed_one_does", "crates/engine/tests/it/xfa_repeat.rs::a_table_in_a_positioned_subform_gets_a_row_per_data_row_up_to_its_max"] +notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, or in the field's data picture (``, such as date{YYYYMMDD}) when it has one holding a whole date (one without a day, such as date{YYYYMM}, keeps ISO, as does a date the picture can't hold, such as 2050 in a two-digit year), each field its own even beside same-named siblings, which saving writes them in too, while scripts still read the ISO date as rawValue, as another viewer gives it, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom), with areas left out of data paths as Adobe binds them. Export Data writes it as XML (the data alone, rooted at the form's root data node, as Acrobat does) or an XDP with the whole datasets and a reference to the PDF; Import Data takes an XDP, datasets or data XML file (or a bare data document) as the form's whole data, keeping data descriptions, lays a dynamic form out again from it (rows added and removed: a repeating subform or table row, in a flowed or a positioned container, lays out every row up to its occur max, or up to 1,000 when it has none, and the rows past that are reported) and runs its calculations, undoable; a static form's fields take the data and the rest are emptied; the file's data is re-serialized with the packet's own prefix and every packet rewrite is parsed back before it is written; XFDF/FDF/CSV still go the AcroForm way. Files are UTF-8, UTF-16 or ISO-8859-1, at most 16 MB and 64 levels deep; pictures at most 8 MB (PNG, JPEG, GIF, BMP, TIFF). Pictures chosen for image fields are kept in the data as base64 with xfa:contentType, as Designer saves them. Missing: explicit bind refs, data descriptions are kept but not checked, no data connection (WSDL/ODBC)" [[feature]] id = "form.xfa-flatten" From a4264efbb24661c16947ce83eabfb83bdc706df1 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:21:03 +0800 Subject: [PATCH 4/6] M12: measure each instance of a repeating XFA container with its own rows Instances of one repeating subform can hold different numbers of rows in the data (XFA 3.3, "Tables", p. 327; Chapter 9, p. 336). Measurement used instance 0 for all of them and cached by template node only, so a later instance with more rows ran into what followed it. - size_at measures instance `index` under its own SOM path, and the measurement cache keys on that path when data drives the layout. - Top-to-bottom, left-to-right, table and positioned measurement and drawing take each instance's own size. - A repeating subform that isn't split across content areas (a positioned one) in flowed content now gets one block per instance, as in positioned content (XFA 3.3, "Repeating Elements using Occurrence Limits", p. 263), with the rows past the limit reported. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC --- crates/xfa/src/layout.rs | 5 +- crates/xfa/src/layout/draw.rs | 12 ++-- crates/xfa/src/layout/flow.rs | 24 +++++-- crates/xfa/src/layout/measure.rs | 103 ++++++++++++++++----------- crates/xfa/src/table_repeat_tests.rs | 61 ++++++++++++++++ parity/acrobat-features.toml | 4 +- 6 files changed, 155 insertions(+), 54 deletions(-) diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 4c66361c85..595849dcc5 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -90,8 +90,9 @@ impl Ctx { } } -/// (address of a subform or area in the template, available width bits, depth). -type MeasureKey = (usize, u64, usize); +/// (address of a subform or area in the template, available width bits, depth, SOM path of the +/// instance measured when data drives the layout). +type MeasureKey = (usize, u64, usize, String); /// A cached size, with the inherited text defaults it was measured with. struct Measured { diff --git a/crates/xfa/src/layout/draw.rs b/crates/xfa/src/layout/draw.rs index 2b3ccd0971..1cd7fa1771 100644 --- a/crates/xfa/src/layout/draw.rs +++ b/crates/xfa/src/layout/draw.rs @@ -33,7 +33,7 @@ impl Layouter<'_> { if !c.presence.occupies() { return Ok(()); } - let (w, h) = self.size_of(k, (inner.w - c.x.unwrap_or(0.0)).max(1.0), ctx, depth + 1); + let (w, h) = self.size_at(k, (inner.w - c.x.unwrap_or(0.0)).max(1.0), ctx, depth + 1, sib); let (x, y) = c.top_left(w, h); self.draw_node(k, Rect::new(inner.x + x, inner.y + y, w, h), ctx, depth + 1, sib) } @@ -95,13 +95,15 @@ impl Layouter<'_> { if !k.common().presence.occupies() { continue; } - let (w, h) = self.size_of(k, inner.w, &ctx, depth + 1); // A repeating subform here (a table in an area of a positioned page, // say) gets its instances, as in a flowed one, one under another, - // and the rows past them are reported as there. + // each as tall as its own content, and the rows past them are + // reported as there. let instances = self.laid_out_instances(k, &ctx.som); for instance in 0..instances { - self.draw_node(k, Rect::new(inner.x, y, w, h), &ctx, depth + 1, sibs.at(i) + instance)?; + let sib = sibs.at(i).saturating_add(instance); + let (w, h) = self.size_at(k, inner.w, &ctx, depth + 1, sib); + self.draw_node(k, Rect::new(inner.x, y, w, h), &ctx, depth + 1, sib)?; y += h; } } @@ -140,7 +142,7 @@ impl Layouter<'_> { y += h; } } else { - let (w, h) = self.size_of(k, inner.w, &ctx, depth + 1); + let (w, h) = self.size_at(k, inner.w, &ctx, depth + 1, csib); self.draw_node(k, Rect::new(inner.x, y, w, h), &ctx, depth + 1, csib)?; y += h; } diff --git a/crates/xfa/src/layout/flow.rs b/crates/xfa/src/layout/flow.rs index 422902b61b..1ec19849d8 100644 --- a/crates/xfa/src/layout/flow.rs +++ b/crates/xfa/src/layout/flow.rs @@ -170,7 +170,7 @@ impl Layouter<'_> { let w = clamp_opt(sf.common.w.unwrap_or(w), sf.common.min_w, sf.common.max_w).min(w.max(1.0)); if !matches!(sf.layout, Layout::Tb | Layout::Table) || sf.common.h.is_some() { // Not splittable: one block. - let (_, h) = self.size_of(node, w, ctx_parent, depth); + let (_, h) = self.size_at(node, w, ctx_parent, depth, sib + i); self.flow_block(node, x, (w, h), ctx_parent, depth, sib + i)?; continue; } @@ -201,8 +201,7 @@ impl Layouter<'_> { self.flow_subform(child, Some((inner_x, inner_w)), &ctx, depth + 1, csib)?; continue; } - let (cw, ch) = self.size_of(child, inner_w, &ctx, depth + 1); - self.flow_block(child, inner_x, (cw, ch), &ctx, depth + 1, csib)?; + self.flow_instances(child, inner_x, inner_w, &ctx, depth + 1, csib)?; } } } @@ -268,8 +267,23 @@ impl Layouter<'_> { self.flow_subform(child, Some((x, inner_w)), ctx, depth + 1, csib)?; continue; } - let (cw, ch) = self.size_of(child, inner_w, ctx, depth + 1); - self.flow_block(child, x, (cw, ch), ctx, depth + 1, csib)?; + self.flow_instances(child, x, inner_w, ctx, depth + 1, csib)?; + } + Ok(()) + } + + /// Flow `node`, which isn't split across content areas, as one block per instance (a + /// repeating positioned subform gets one for each row the data or its `occur` gives it, XFA + /// 3.3, "Repeating Elements using Occurrence Limits", p. 263), each as tall as its own + /// content; the data rows past them are reported. + fn flow_instances(&mut self, node: &Node, x: f64, inner_w: f64, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { + for instance in 0..self.laid_out_instances(node, &ctx.som) { + // Every instance spends budget, so nested repeats that place nothing still stop. + self.measures.set(self.measures.get().saturating_add(1)); + self.check_measures()?; + let sib = sib.saturating_add(instance); + let size = self.size_at(node, inner_w, ctx, depth, sib); + self.flow_block(node, x, size, ctx, depth, sib)?; } Ok(()) } diff --git a/crates/xfa/src/layout/measure.rs b/crates/xfa/src/layout/measure.rs index 2ab5f66727..5c2ca82b32 100644 --- a/crates/xfa/src/layout/measure.rs +++ b/crates/xfa/src/layout/measure.rs @@ -1,36 +1,47 @@ use super::common::clamp_opt; use super::field::auto_check_reserve; use super::{ - Access, Area, Ctx, ExclGroup, Face, Field, Font, Layout, Layouter, MAX_DEPTH, MAX_MEASURES, Measured, Node, Para, Placement, Rect, Subform, Ui, - Value, data_path, text, + Access, Area, Ctx, ExclGroup, Face, Field, Font, Layout, Layouter, MAX_DEPTH, MAX_MEASURES, Measured, Node, Para, Placement, Rect, SomIndices, + Subform, Ui, Value, data_path, text, }; impl Layouter<'_> { - /// The width and height `node` takes in `avail_w`: its own size, else what its content needs - /// (containers are measured once per width and depth, and measuring stops at its caps). + /// [`Self::size_at`] for the first instance of `node` (one that doesn't repeat). pub(super) fn size_of(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> (f64, f64) { + self.size_at(node, avail_w, ctx, depth, 0) + } + + /// The width and height instance `index` of `node` under `ctx` takes in `avail_w`: its own + /// size, else what its content needs (containers are measured once per width, depth and + /// instance, and measuring stops at its caps). Instances differ when the data gives each its + /// own rows (XFA 3.3, "Tables", p. 327; Chapter 9, p. 336), so with data each is measured + /// under its own SOM path. + pub(super) fn size_at(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize, index: usize) -> (f64, f64) { let c = node.common(); let spent = self.measures.get().saturating_add(1); self.measures.set(spent); if depth > MAX_DEPTH || spent > MAX_MEASURES { return (c.w.unwrap_or(avail_w), c.h.unwrap_or(0.0)); } + let own = ctx.with_som(c, index); // Only containers recurse; their addresses are stable for the whole layout (the - // template is borrowed, the root and page areas are held by the layouter). + // template is borrowed, the root and page areas are held by the layouter). Without data + // every instance is alike, so one measurement serves them all. + let som = || if self.data.is_some() { own.som.clone() } else { String::new() }; let key = match node { - Node::Subform(s) => Some((&**s as *const Subform as usize, avail_w.to_bits(), depth)), - Node::Area(a) => Some((&**a as *const Area as usize, avail_w.to_bits(), depth)), + Node::Subform(s) => Some((&**s as *const Subform as usize, avail_w.to_bits(), depth, som())), + Node::Area(a) => Some((&**a as *const Area as usize, avail_w.to_bits(), depth, som())), _ => None, }; - if let Some(key) = key + if let Some(key) = &key && let Ok(cache) = self.measured.try_borrow() - && let Some(m) = cache.get(&key) + && let Some(m) = cache.get(key) && m.font == ctx.font && m.para == ctx.para { return m.size; } - let size = self.measure(node, avail_w, ctx, depth); + let size = self.measure(node, avail_w, &own, depth); if let Some(key) = key && let Ok(mut cache) = self.measured.try_borrow_mut() { @@ -39,15 +50,13 @@ impl Layouter<'_> { size } - /// The size (width, height) of `node` within `avail_w`, uncached ([`Self::size_of`] caches it). + /// The size (width, height) of `node` within `avail_w`, uncached ([`Self::size_at`] caches it). /// Without a width, a container, a text draw or a button is as wide as its content (at most /// `avail_w`) and anything else takes `avail_w`; without a height, it is as tall as its content. + /// `ctx` holds the SOM path of the instance measured: its repeating children count their + /// instances under it, and a growing text field finds its value at it. fn measure(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> (f64, f64) { let c = node.common(); - // The node's own SOM: its repeating children count their instances under it, and a - // growing text field finds its value at it. Measurements are cached per template node, - // so instance 0 stands for all of them. - let ctx = ctx.with_som(c, 0); let som = ctx.som.clone(); // A container without a width is as wide as its content (a radio group beside its // question, say), and so is a text label or a button (as wide as its caption), within @@ -57,7 +66,7 @@ impl Layouter<'_> { None => match node { Node::Subform(_) | Node::ExclGroup(_) | Node::Area(_) => { let inner = (avail_w - c.margin.horizontal()).max(1.0); - (self.content_width(node, inner, &ctx, depth) + c.margin.horizontal()).min(avail_w) + (self.content_width(node, inner, ctx, depth) + c.margin.horizontal()).min(avail_w) } Node::Draw(d) => self.natural_width(&d.value, &ctx.font, &ctx.para).map_or(avail_w, |t| (t + c.margin.horizontal()).min(avail_w)), Node::Field(f) if f.ui == Ui::Button => f @@ -74,35 +83,40 @@ impl Layouter<'_> { None => { let inner_w = (w - c.margin.horizontal()).max(1.0); let content = match node { - Node::Draw(d) => self.value_height(&d.value, inner_w, &ctx), - Node::Field(f) => self.field_height(f, inner_w, &ctx), - Node::ExclGroup(g) => self.group_height(g, inner_w, &ctx, depth), - Node::Area(a) => self.positioned_height(&a.children, inner_w, &ctx, depth), + Node::Draw(d) => self.value_height(&d.value, inner_w, ctx), + Node::Field(f) => self.field_height(f, inner_w, ctx), + Node::ExclGroup(g) => self.group_height(g, inner_w, ctx, depth), + Node::Area(a) => self.positioned_height(&a.children, inner_w, ctx, depth), Node::Subform(s) => match s.layout { - Layout::Positioned => self.positioned_height(&s.children, inner_w, &ctx, depth), - // Each instance of a repeating child takes its height again. - Layout::Tb => s - .children - .iter() - .filter(|k| k.common().presence.occupies()) - .map(|k| self.size_of(k, inner_w, &ctx, depth + 1).1 * self.instances(k, &som) as f64) - .sum(), - Layout::LrTb | Layout::RlTb => self.pack_instances(&s.children, inner_w, &ctx, depth, &som).1, - Layout::Table => { - let cols = self.columns(s, inner_w); + Layout::Positioned => self.positioned_height(&s.children, inner_w, ctx, depth), + // Each instance of a repeating child takes its own height. + Layout::Tb => { + let sibs = SomIndices::of(&s.children); s.children .iter() - .filter(|k| k.common().presence.occupies()) - .map(|k| match k { + .enumerate() + .filter(|(_, k)| k.common().presence.occupies()) + .map(|(i, k)| self.instances_height(k, inner_w, ctx, depth + 1, &som, sibs.at(i))) + .sum() + } + Layout::LrTb | Layout::RlTb => self.pack_instances(&s.children, inner_w, ctx, depth, &som).1, + Layout::Table => { + let cols = self.columns(s, inner_w); + let sibs = SomIndices::of(&s.children); + s.children + .iter() + .enumerate() + .filter(|(_, k)| k.common().presence.occupies()) + .map(|(i, k)| match k { // A repeating row takes its height once per instance, as drawn. Node::Subform(r) if r.layout == Layout::Row => { - self.row_height(r, &cols, inner_w, &ctx, depth + 1) * self.instances(k, &som) as f64 + self.row_height(r, &cols, inner_w, ctx, depth + 1) * self.instances(k, &som) as f64 } - _ => self.size_of(k, inner_w, &ctx, depth + 1).1, + _ => self.size_at(k, inner_w, ctx, depth + 1, sibs.at(i)).1, }) .sum() } - Layout::Row => self.row_height(s, &self.columns(s, inner_w), inner_w, &ctx, depth + 1), + Layout::Row => self.row_height(s, &self.columns(s, inner_w), inner_w, ctx, depth + 1), }, }; content + c.margin.vertical() @@ -117,6 +131,12 @@ impl Layouter<'_> { (w, clamp_opt(h, min_h, c.max_h)) } + /// The height `k` takes with all its instances under the container at `som`, one under + /// another, the first at SOM index `sib` among its siblings: each instance takes its own. + fn instances_height(&self, k: &Node, inner_w: f64, ctx: &Ctx, depth: usize, som: &str, sib: usize) -> f64 { + (0..self.instances(k, som)).map(|i| self.size_at(k, inner_w, ctx, depth, sib.saturating_add(i)).1).sum() + } + /// The natural width of a container's content within `avail_w`. fn content_width(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> f64 { let group: Vec; @@ -294,12 +314,14 @@ impl Layouter<'_> { } fn positioned_height(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { + let sibs = SomIndices::of(children); children .iter() - .filter(|k| k.common().presence.occupies()) - .map(|k| { + .enumerate() + .filter(|(_, k)| k.common().presence.occupies()) + .map(|(i, k)| { let c = k.common(); - let (w, h) = self.size_of(k, (inner_w - c.x.unwrap_or(0.0)).max(1.0), ctx, depth + 1); + let (w, h) = self.size_at(k, (inner_w - c.x.unwrap_or(0.0)).max(1.0), ctx, depth + 1, sibs.at(i)); c.top_left(w, h).1 + h }) .fold(0.0, f64::max) @@ -310,13 +332,14 @@ impl Layouter<'_> { /// container at `som`. pub(super) fn pack_instances(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize, som: &str) -> (Vec<(usize, usize, Rect)>, f64) { let mut out = Vec::new(); + let sibs = SomIndices::of(children); let (mut x, mut y, mut line_h) = (0.0, 0.0, 0.0); for (i, k) in children.iter().enumerate() { if !k.common().presence.occupies() { continue; } - let (w, h) = self.size_of(k, inner_w, ctx, depth + 1); for instance in 0..self.instances(k, som) { + let (w, h) = self.size_at(k, inner_w, ctx, depth + 1, sibs.at(i).saturating_add(instance)); if x > 0.0 && x + w > inner_w + 0.05 { y += line_h; x = 0.0; diff --git a/crates/xfa/src/table_repeat_tests.rs b/crates/xfa/src/table_repeat_tests.rs index 5897cc6ca5..22c3cad708 100644 --- a/crates/xfa/src/table_repeat_tests.rs +++ b/crates/xfa/src/table_repeat_tests.rs @@ -186,3 +186,64 @@ fn hostile_occur_values_and_data_floods_lay_a_positioned_table_out_within_bounds let warning = cut_warning(&form).unwrap_or_else(|| panic!("{:?}", form.warnings)); assert!(warning.contains(&format!("{rows} rows")) && warning.contains(&format!("first {n}")), "{warning}"); } + +/// A repeating positioned `entry` subform, each instance holding a positioned table whose `item` +/// rows repeat, with a field after the entries; the data gives entry `i` `rows[i]` rows. +fn nested_form(place: Place, rows: &[usize]) -> String { + let entries: String = rows + .iter() + .enumerate() + .map(|(e, n)| { + let items: String = (1..=*n).map(|i| format!("{e}.{i}")).collect(); + format!("{items}
") + }) + .collect(); + let entry = r#" + + + "#; + let after = r#""#; + let (page, open, close) = match place { + Place::PositionedFlow => ( + format!( + r#"{entry}{after}"# + ), + "", + "", + ), + _ => (format!(r#"{entry}{after}"#), "", ""), + }; + format!( + r#" + +
{open}{entries}{close}
+
"# + ) +} + +#[test] +fn each_instance_of_a_repeating_container_is_measured_with_its_own_rows() { + // Instances of one repeating subform hold different numbers of rows in the data (XFA 3.3, + // "Tables", p. 327; Chapter 9, p. 336): each takes the height of its own rows, so the rows + // of one never run into the next instance or what follows. + for place in [Place::Flowed, Place::PositionedFlow] { + for rows in [[1, 5], [5, 1], [2, 3]] { + let what = format!("{place:?} {rows:?}"); + let form = layout_xml(&nested_form(place, &rows)).unwrap(); + let all = laid_out(&form); + let qty = named(&all, "qty"); + let want: Vec = rows.iter().enumerate().flat_map(|(e, n)| (1..=*n).map(move |i| format!("{e}.{i}"))).collect(); + assert_eq!(qty.iter().map(|w| w.2.clone().unwrap_or_default()).collect::>(), want, "{what}"); + let top = qty[0].3.y; + for (i, w) in qty.iter().enumerate() { + assert!((w.3.y - (top + 20.0 * i as f64)).abs() < 0.01, "{what}: row {i} at {:?}, first at {top}", w.3); + } + let after = named(&all, "after")[0].3; + let last = qty[qty.len() - 1].3; + assert!((after.y - last.bottom()).abs() < 0.01, "{what}: after {after:?}, last row {last:?}"); + } + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index f8dbdc6256..ff3daa069d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5332,8 +5332,8 @@ milestone = "M12" status = "partial" commands = ["file.open", "file.save", "form.export_data", "form.import_data"] tools = ["doc_open", "form_fill", "doc_save", "doc_export_data", "doc_import_data", "form_set_image"] -evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::a_date_is_read_from_the_data_in_its_data_picture", "crates/xfa/src/tests.rs::scripts_read_a_date_as_iso_whatever_its_data_picture_writes", "crates/engine/src/tests.rs::xfa_dates_are_written_and_read_back_in_their_data_picture", "crates/engine/src/tests.rs::each_xfa_date_field_is_written_in_its_own_data_picture_and_a_partial_one_keeps_iso", "crates/xfa/src/tests.rs::som_paths_name_the_template_field_their_numbers_count_to", "crates/xfa/src/tests.rs::a_refreshed_field_takes_its_value_from_its_own_template_field", "crates/xfa/src/tests.rs::a_date_its_data_picture_cant_hold_is_written_as_iso", "crates/xfa/src/tests.rs::data_fills_the_fields_and_adds_rows", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/xfa/src/tests.rs::data_paths_leave_areas_out", "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::data_exports_as_datasets_xml_or_an_xdp_and_imports_from_any_of_them", "crates/xfa/src/tests.rs::a_picture_chosen_for_an_image_field_is_kept_in_the_data_with_its_content_type", "crates/engine/src/tests.rs::xfa_data_exports_imports_and_pictures_round_trip_through_the_session", "crates/engine/src/tests.rs::xfa_data_import_adds_and_removes_rows_of_a_dynamic_form", "crates/automation/tests/it/automation.rs::xfa_data_files_export_and_import_through_tools", "crates/xfa/src/repeat_tests.rs::every_data_row_past_fifty_is_laid_out_and_paginated", "crates/xfa/src/repeat_tests.rs::occur_max_still_stops_the_rows_and_says_so", "crates/xfa/src/repeat_tests.rs::rows_past_the_instance_limit_are_cut_with_a_warning", "crates/xfa/src/repeat_tests.rs::an_initial_count_lays_out_whole_up_to_the_limit", "crates/engine/tests/it/xfa_repeat.rs::a_button_adding_rows_past_fifty_lays_them_all_out", "crates/xfa/src/table_repeat_tests.rs::a_table_in_positioned_content_gets_a_row_per_data_row", "crates/xfa/src/table_repeat_tests.rs::occur_max_bounds_a_positioned_table_and_the_rows_cut_are_reported", "crates/xfa/src/table_repeat_tests.rs::a_positioned_table_lays_out_the_rows_a_flowed_one_does", "crates/engine/tests/it/xfa_repeat.rs::a_table_in_a_positioned_subform_gets_a_row_per_data_row_up_to_its_max"] -notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, or in the field's data picture (``, such as date{YYYYMMDD}) when it has one holding a whole date (one without a day, such as date{YYYYMM}, keeps ISO, as does a date the picture can't hold, such as 2050 in a two-digit year), each field its own even beside same-named siblings, which saving writes them in too, while scripts still read the ISO date as rawValue, as another viewer gives it, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom), with areas left out of data paths as Adobe binds them. Export Data writes it as XML (the data alone, rooted at the form's root data node, as Acrobat does) or an XDP with the whole datasets and a reference to the PDF; Import Data takes an XDP, datasets or data XML file (or a bare data document) as the form's whole data, keeping data descriptions, lays a dynamic form out again from it (rows added and removed: a repeating subform or table row, in a flowed or a positioned container, lays out every row up to its occur max, or up to 1,000 when it has none, and the rows past that are reported) and runs its calculations, undoable; a static form's fields take the data and the rest are emptied; the file's data is re-serialized with the packet's own prefix and every packet rewrite is parsed back before it is written; XFDF/FDF/CSV still go the AcroForm way. Files are UTF-8, UTF-16 or ISO-8859-1, at most 16 MB and 64 levels deep; pictures at most 8 MB (PNG, JPEG, GIF, BMP, TIFF). Pictures chosen for image fields are kept in the data as base64 with xfa:contentType, as Designer saves them. Missing: explicit bind refs, data descriptions are kept but not checked, no data connection (WSDL/ODBC)" +evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::a_date_is_read_from_the_data_in_its_data_picture", "crates/xfa/src/tests.rs::scripts_read_a_date_as_iso_whatever_its_data_picture_writes", "crates/engine/src/tests.rs::xfa_dates_are_written_and_read_back_in_their_data_picture", "crates/engine/src/tests.rs::each_xfa_date_field_is_written_in_its_own_data_picture_and_a_partial_one_keeps_iso", "crates/xfa/src/tests.rs::som_paths_name_the_template_field_their_numbers_count_to", "crates/xfa/src/tests.rs::a_refreshed_field_takes_its_value_from_its_own_template_field", "crates/xfa/src/tests.rs::a_date_its_data_picture_cant_hold_is_written_as_iso", "crates/xfa/src/tests.rs::data_fills_the_fields_and_adds_rows", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/xfa/src/tests.rs::data_paths_leave_areas_out", "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::data_exports_as_datasets_xml_or_an_xdp_and_imports_from_any_of_them", "crates/xfa/src/tests.rs::a_picture_chosen_for_an_image_field_is_kept_in_the_data_with_its_content_type", "crates/engine/src/tests.rs::xfa_data_exports_imports_and_pictures_round_trip_through_the_session", "crates/engine/src/tests.rs::xfa_data_import_adds_and_removes_rows_of_a_dynamic_form", "crates/automation/tests/it/automation.rs::xfa_data_files_export_and_import_through_tools", "crates/xfa/src/repeat_tests.rs::every_data_row_past_fifty_is_laid_out_and_paginated", "crates/xfa/src/repeat_tests.rs::occur_max_still_stops_the_rows_and_says_so", "crates/xfa/src/repeat_tests.rs::rows_past_the_instance_limit_are_cut_with_a_warning", "crates/xfa/src/repeat_tests.rs::an_initial_count_lays_out_whole_up_to_the_limit", "crates/engine/tests/it/xfa_repeat.rs::a_button_adding_rows_past_fifty_lays_them_all_out", "crates/xfa/src/table_repeat_tests.rs::a_table_in_positioned_content_gets_a_row_per_data_row", "crates/xfa/src/table_repeat_tests.rs::occur_max_bounds_a_positioned_table_and_the_rows_cut_are_reported", "crates/xfa/src/table_repeat_tests.rs::a_positioned_table_lays_out_the_rows_a_flowed_one_does", "crates/engine/tests/it/xfa_repeat.rs::a_table_in_a_positioned_subform_gets_a_row_per_data_row_up_to_its_max", "crates/xfa/src/table_repeat_tests.rs::each_instance_of_a_repeating_container_is_measured_with_its_own_rows"] +notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, or in the field's data picture (``, such as date{YYYYMMDD}) when it has one holding a whole date (one without a day, such as date{YYYYMM}, keeps ISO, as does a date the picture can't hold, such as 2050 in a two-digit year), each field its own even beside same-named siblings, which saving writes them in too, while scripts still read the ISO date as rawValue, as another viewer gives it, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom), with areas left out of data paths as Adobe binds them. Export Data writes it as XML (the data alone, rooted at the form's root data node, as Acrobat does) or an XDP with the whole datasets and a reference to the PDF; Import Data takes an XDP, datasets or data XML file (or a bare data document) as the form's whole data, keeping data descriptions, lays a dynamic form out again from it (rows added and removed: a repeating subform or table row, in a flowed or a positioned container, lays out every row up to its occur max, each instance of a repeating container as tall as its own rows,, or up to 1,000 when it has none, and the rows past that are reported) and runs its calculations, undoable; a static form's fields take the data and the rest are emptied; the file's data is re-serialized with the packet's own prefix and every packet rewrite is parsed back before it is written; XFDF/FDF/CSV still go the AcroForm way. Files are UTF-8, UTF-16 or ISO-8859-1, at most 16 MB and 64 levels deep; pictures at most 8 MB (PNG, JPEG, GIF, BMP, TIFF). Pictures chosen for image fields are kept in the data as base64 with xfa:contentType, as Designer saves them. Missing: explicit bind refs, data descriptions are kept but not checked, no data connection (WSDL/ODBC)" [[feature]] id = "form.xfa-flatten" From 5172a1b7ea965b61a96404b10cf555e616a74754 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:44:36 +0800 Subject: [PATCH 5/6] M12: measure each XFA table row instance under its own SOM path A table row's height was measured under the table's SOM path, without the row instance, while draw_row draws its cells under row[n]. A cell that grows with its text (a multi-line field without a height) then found no value: every instance took the empty height, and a long value was drawn in a short slot over the rows after it (XFA 3.3, "Tables", p. 327; "Growable Containers", p. 275). row_height now takes the instance index and measures under that instance's path, as draw_row does. Table measurement sums each instance's own height, and flowed and positioned tables size each instance (and the overflow leader) on its own. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC --- crates/xfa/src/layout/draw.rs | 2 +- crates/xfa/src/layout/flow.rs | 4 ++-- crates/xfa/src/layout/measure.rs | 18 ++++++++++------ crates/xfa/src/table_repeat_tests.rs | 32 ++++++++++++++++++++++++++++ 4 files changed, 46 insertions(+), 10 deletions(-) diff --git a/crates/xfa/src/layout/draw.rs b/crates/xfa/src/layout/draw.rs index 1cd7fa1771..69309aa0e0 100644 --- a/crates/xfa/src/layout/draw.rs +++ b/crates/xfa/src/layout/draw.rs @@ -136,8 +136,8 @@ impl Layouter<'_> { // A repeating row (a table in a positioned subform, say) gets its // instances one under another, as in a flowed table (XFA 3.3, // "Tables", p. 327), and the rows past them are reported. - let h = self.row_height(r, &cols, inner.w, &ctx, depth + 1); for instance in 0..self.laid_out_instances(k, &ctx.som) { + let h = self.row_height(r, &cols, inner.w, &ctx, depth + 1, csib.saturating_add(instance)); self.draw_row(r, &cols, Rect::new(inner.x, y, inner.w, h), &ctx, depth + 1, csib + instance)?; y += h; } diff --git a/crates/xfa/src/layout/flow.rs b/crates/xfa/src/layout/flow.rs index 1ec19849d8..52a19c60e8 100644 --- a/crates/xfa/src/layout/flow.rs +++ b/crates/xfa/src/layout/flow.rs @@ -233,8 +233,8 @@ impl Layouter<'_> { Some(Node::Subform(l)) => Some((li, l.as_ref())), _ => None, }); - let lead_h = lead.map_or(0.0, |(_, l)| self.row_height(l, &cols, inner_w, ctx, depth + 1)); let sibs = SomIndices::of(&sf.children); + let lead_h = lead.map_or(0.0, |(li, l)| self.row_height(l, &cols, inner_w, ctx, depth + 1, sibs.at(li))); for (ci, child) in sf.children.iter().enumerate() { if !child.common().presence.occupies() { continue; @@ -246,7 +246,7 @@ impl Layouter<'_> { // Rows added as data (in another viewer) repeat the row. let instances = self.laid_out_instances(child, &ctx.som); for inst in 0..instances { - let h = self.row_height(row, &cols, inner_w, ctx, depth + 1); + let h = self.row_height(row, &cols, inner_w, ctx, depth + 1, csib.saturating_add(inst)); let led = lead.filter(|(li, _)| *li != ci); if self.make_room(h, led.map_or(0.0, |_| lead_h))? && let Some((li, l)) = led diff --git a/crates/xfa/src/layout/measure.rs b/crates/xfa/src/layout/measure.rs index 5c2ca82b32..78c1ca10ec 100644 --- a/crates/xfa/src/layout/measure.rs +++ b/crates/xfa/src/layout/measure.rs @@ -108,15 +108,16 @@ impl Layouter<'_> { .enumerate() .filter(|(_, k)| k.common().presence.occupies()) .map(|(i, k)| match k { - // A repeating row takes its height once per instance, as drawn. - Node::Subform(r) if r.layout == Layout::Row => { - self.row_height(r, &cols, inner_w, ctx, depth + 1) * self.instances(k, &som) as f64 - } + // Each instance of a repeating row takes its own height, as drawn. + Node::Subform(r) if r.layout == Layout::Row => (0..self.instances(k, &som)) + .map(|n| self.row_height(r, &cols, inner_w, ctx, depth + 1, sibs.at(i).saturating_add(n))) + .sum(), _ => self.size_at(k, inner_w, ctx, depth + 1, sibs.at(i)).1, }) .sum() } - Layout::Row => self.row_height(s, &self.columns(s, inner_w), inner_w, ctx, depth + 1), + // `ctx` is already this row instance's own. + Layout::Row => self.cells(s, &self.columns(s, inner_w), inner_w, ctx, depth + 1).1, }, }; content + c.margin.vertical() @@ -389,7 +390,10 @@ impl Layouter<'_> { (out, clamp_opt(h, row.common.min_h, row.common.max_h)) } - pub(super) fn row_height(&self, row: &Subform, cols: &[f64], inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { - self.cells(row, cols, inner_w, ctx, depth).1 + /// The height of instance `sib` of `row` in the table at `ctx`, measured as [`Self::draw_row`] + /// draws it: under the instance's own SOM path, so a cell that grows with its text takes + /// that instance's value (XFA 3.3, "Tables", p. 327; "Growable Containers", p. 275). + pub(super) fn row_height(&self, row: &Subform, cols: &[f64], inner_w: f64, ctx: &Ctx, depth: usize, sib: usize) -> f64 { + self.cells(row, cols, inner_w, &ctx.with_som(&row.common, sib), depth).1 } } diff --git a/crates/xfa/src/table_repeat_tests.rs b/crates/xfa/src/table_repeat_tests.rs index 22c3cad708..68c36023f3 100644 --- a/crates/xfa/src/table_repeat_tests.rs +++ b/crates/xfa/src/table_repeat_tests.rs @@ -247,3 +247,35 @@ fn each_instance_of_a_repeating_container_is_measured_with_its_own_rows() { } } } + +#[test] +fn each_row_instance_takes_the_height_of_its_own_growing_text() { + // A repeating row whose cell grows with its text (a multi-line field without a height) is + // as tall as its own instance's value (XFA 3.3, "Tables", p. 327; "Growable Containers", + // p. 271): a long value makes its row taller and pushes the rows after it down, wherever + // the table sits. + let long = "a long value that wraps over several lines of the cell ".repeat(6); + for place in [Place::Positioned, Place::PositionedFlow, Place::Flowed] { + let xml = table_form(place, r#"min="1" max="-1""#, 3) + .replace( + r#""#, + r#""#, + ) + .replace("row 2", &format!("{long}")); + let form = layout_xml(&xml).unwrap(); + let all = laid_out(&form); + let qty = named(&all, "qty"); + assert_eq!(qty.len(), 3, "{place:?}"); + let (short, tall) = (qty[0].3, qty[1].3); + assert!(tall.h > short.h + 20.0, "{place:?}: the long row {tall:?} is no taller than a short one {short:?}"); + for pair in qty.windows(2) { + let (a, b) = (pair[0].3, pair[1].3); + assert!((b.y - a.bottom()).abs() < 0.01, "{place:?}: row at {b:?} after {a:?}"); + } + let foot = named(&all, "total")[0].3; + assert!((foot.y - qty[2].3.bottom()).abs() < 0.01, "{place:?}: footer {foot:?} after {:?}", qty[2].3); + if let Some(after) = named(&all, "after").first() { + assert!((after.3.y - foot.bottom()).abs() < 0.01, "{place:?}: after {:?}, footer {foot:?}", after.3); + } + } +} From 0d0162bf81cae43d32a54a49bb54b0128b2361c8 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:06:52 +0800 Subject: [PATCH 6/6] M12: measure each XFA table cell under its own same-name SOM index cells() (behind row_height) measured every cell with size_of(), at SOM index 0, while draw_row() draws cell i at its index among its same-named siblings. With a short qty[0] beside a long qty[1] the row took qty[0]'s height (38.5pt), and the long cell ran into the following rows and content; a non-interactive cell drew its text over them. Each cell is now measured with size_at() at SomIndices::of(..).at(i), the index it is drawn and bound at (XFA 3.3, "Tables", p. 327; "Growable Containers", p. 271). content_width(), group_height() and draw_group() measured their children at index 0 too and now use the same index. size_of() had no callers left and is removed, so every measurement names its index. Tests: a row with short qty[0] and long qty[1] is as tall as the long cell, with the following rows, footer and content after it, in the positioned, positioned-in-flow and flowed placements; a non-interactive long qty[1] keeps its lines inside its own row. Both fail with the index forced back to 0 (38.5pt rows). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK --- crates/xfa/src/layout/draw.rs | 7 ++- crates/xfa/src/layout/measure.rs | 37 +++++++----- crates/xfa/src/table_repeat_tests.rs | 84 ++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 16 deletions(-) diff --git a/crates/xfa/src/layout/draw.rs b/crates/xfa/src/layout/draw.rs index 69309aa0e0..1f31d74f72 100644 --- a/crates/xfa/src/layout/draw.rs +++ b/crates/xfa/src/layout/draw.rs @@ -273,6 +273,8 @@ impl Layouter<'_> { let inner = rect.inset(&g.common.margin); let group = self.unique_name(g.common.name.as_deref().unwrap_or("group")); let nodes: Vec = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect(); + // Each field is measured under the SOM index it is drawn at. + let sibs = SomIndices::of(&nodes); let placed: Vec<(usize, Rect)> = match g.layout { Layout::Tb => { let mut y = 0.0; @@ -281,7 +283,7 @@ impl Layouter<'_> { if !k.common().presence.occupies() { continue; } - let (w, h) = self.size_of(k, inner.w, &ctx, depth + 1); + let (w, h) = self.size_at(k, inner.w, &ctx, depth + 1, sibs.at(i)); out.push((i, Rect::new(0.0, y, w, h))); y += h; } @@ -293,14 +295,13 @@ impl Layouter<'_> { .filter(|(_, k)| k.common().presence.occupies()) .map(|(i, k)| { let c = k.common(); - let (w, h) = self.size_of(k, (inner.w - c.x.unwrap_or(0.0)).max(1.0), &ctx, depth + 1); + let (w, h) = self.size_at(k, (inner.w - c.x.unwrap_or(0.0)).max(1.0), &ctx, depth + 1, sibs.at(i)); let (x, y) = c.top_left(w, h); (i, Rect::new(x, y, w, h)) }) .collect(), _ => self.pack(&nodes, inner.w, &ctx, depth).0, }; - let sibs = SomIndices::of(&nodes); for (i, r) in placed { if let Some(f) = g.fields.get(i) { let on = f.items.first().cloned().filter(|s| !s.trim().is_empty()).unwrap_or_else(|| (i + 1).to_string()); diff --git a/crates/xfa/src/layout/measure.rs b/crates/xfa/src/layout/measure.rs index 78c1ca10ec..551472bae2 100644 --- a/crates/xfa/src/layout/measure.rs +++ b/crates/xfa/src/layout/measure.rs @@ -6,11 +6,6 @@ use super::{ }; impl Layouter<'_> { - /// [`Self::size_at`] for the first instance of `node` (one that doesn't repeat). - pub(super) fn size_of(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> (f64, f64) { - self.size_at(node, avail_w, ctx, depth, 0) - } - /// The width and height instance `index` of `node` under `ctx` takes in `avail_w`: its own /// size, else what its content needs (containers are measured once per width, depth and /// instance, and measuring stops at its caps). Instances differ when the data gives each its @@ -156,20 +151,24 @@ impl Layouter<'_> { Node::Area(a) => (&a.children, Layout::Positioned), _ => return avail_w, }; - let visible = children.iter().filter(|k| k.common().presence.occupies()); + // Each child under the SOM index it is drawn at. + let sibs = SomIndices::of(children); + let visible = children.iter().enumerate().filter(|(_, k)| k.common().presence.occupies()); match layout { Layout::LrTb | Layout::RlTb | Layout::Row => { - let sum: f64 = visible.map(|k| self.size_of(k, avail_w, ctx, depth + 1).0).sum(); + let sum: f64 = visible.map(|(i, k)| self.size_at(k, avail_w, ctx, depth + 1, sibs.at(i)).0).sum(); if sum <= avail_w + 0.05 { sum } else { avail_w } } Layout::Positioned => visible - .map(|k| { - let (w, h) = self.size_of(k, avail_w, ctx, depth + 1); + .map(|(i, k)| { + let (w, h) = self.size_at(k, avail_w, ctx, depth + 1, sibs.at(i)); k.common().top_left(w, h).0 + w }) .fold(0.0, f64::max) .min(avail_w), - Layout::Tb | Layout::Table => visible.map(|k| self.size_of(k, avail_w, ctx, depth + 1).0).fold(0.0, f64::max).min(avail_w), + Layout::Tb | Layout::Table => { + visible.map(|(i, k)| self.size_at(k, avail_w, ctx, depth + 1, sibs.at(i)).0).fold(0.0, f64::max).min(avail_w) + } } } @@ -308,7 +307,15 @@ impl Layouter<'_> { fn group_height(&self, g: &ExclGroup, inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { let nodes: Vec = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect(); match g.layout { - Layout::Tb => nodes.iter().filter(|k| k.common().presence.occupies()).map(|k| self.size_of(k, inner_w, ctx, depth + 1).1).sum(), + Layout::Tb => { + let sibs = SomIndices::of(&nodes); + nodes + .iter() + .enumerate() + .filter(|(_, k)| k.common().presence.occupies()) + .map(|(i, k)| self.size_at(k, inner_w, ctx, depth + 1, sibs.at(i)).1) + .sum() + } Layout::Positioned => self.positioned_height(&nodes, inner_w, ctx, depth), _ => self.packed_height(&nodes, inner_w, ctx, depth), } @@ -364,20 +371,24 @@ impl Layouter<'_> { self.pack(children, inner_w, ctx, depth).1 } - /// Cell rectangles of a row (relative to the row's inner box) and the row height. + /// Cell rectangles of a row (relative to the row's inner box) and the row height. Each cell + /// is measured under its own SOM index among its same-named siblings (`qty[1]`, not + /// `qty[0]`), the index [`Self::draw_row`] draws it at, so a cell that grows with its value + /// takes its own value's height. pub(super) fn cells(&self, row: &Subform, cols: &[f64], inner_w: f64, ctx: &Ctx, depth: usize) -> (Vec<(usize, Rect)>, f64) { let ctx = ctx.child(&row.common); let mut out = Vec::new(); let mut col = 0usize; let mut x = 0.0; let mut h: f64 = 0.0; + let sibs = SomIndices::of(&row.children); for (i, k) in row.children.iter().enumerate() { if !k.common().presence.occupies() { continue; } let span = k.common().col_span.max(1); let w = if col < cols.len() { cols.iter().skip(col).take(span).sum::() } else { k.common().w.unwrap_or((inner_w - x).max(1.0)) }; - let (_, ch) = self.size_of(k, w, &ctx, depth + 1); + let (_, ch) = self.size_at(k, w, &ctx, depth + 1, sibs.at(i)); out.push((i, Rect::new(x, 0.0, w, ch))); h = h.max(ch); x += w; diff --git a/crates/xfa/src/table_repeat_tests.rs b/crates/xfa/src/table_repeat_tests.rs index 68c36023f3..11acfb66c0 100644 --- a/crates/xfa/src/table_repeat_tests.rs +++ b/crates/xfa/src/table_repeat_tests.rs @@ -279,3 +279,87 @@ fn each_row_instance_takes_the_height_of_its_own_growing_text() { } } } + +/// A multi-line `qty` text field 100pt wide that grows with its text, with `access` and +/// template value `value`. +fn qty_cell(access: &str, value: &str) -> String { + format!( + r#"{value}"# + ) +} + +/// [`table_form`] with two same-named cells per `item` row, `qty[0]` and `qty[1]` (`first` and +/// `second`), in two 100pt columns, and the data of row `i` holding `data[i]`. +fn same_name_cells_form(place: Place, first: &str, second: &str, data: [&str; 3]) -> String { + let mut xml = table_form(place, r#"min="1" max="-1""#, 3) + .replace(r#"columnWidths="200pt""#, r#"columnWidths="100pt 100pt""#) + .replace(r#""#, &format!("{first}{second}")); + for (i, d) in data.iter().enumerate() { + xml = xml.replace(&format!("row {}", i + 1), &format!("{d}")); + } + xml +} + +#[test] +fn a_row_is_as_tall_as_its_tallest_same_named_cell() { + // Each cell of a row is measured under its own SOM index among its same-named siblings, + // the index it is drawn and bound at (XFA 3.3, "Tables", p. 327; "Growable Containers", + // p. 271; Chapter 9, p. 336): a short `qty[0]` beside a long `qty[1]` makes the row as tall + // as the long one, and the rows and content after it start below it. + let long = "a long value that wraps over several lines of the cell".repeat(6); + let cell = qty_cell("open", ""); + for place in [Place::Positioned, Place::PositionedFlow, Place::Flowed] { + let data = ["s1t1", &format!("s2{long}"), "s3t3"]; + let form = layout_xml(&same_name_cells_form(place, &cell, &cell, data)).unwrap(); + let all = laid_out(&form); + let first = named(&all, "qty"); + let second: Vec<&Laid> = all.iter().filter(|w| w.1.ends_with(".qty[1]")).collect(); + assert_eq!(first.iter().map(|w| w.2.clone().unwrap_or_default()).collect::>(), ["s1", "s2", "s3"], "{place:?}"); + assert_eq!(second.len(), 3, "{place:?}"); + assert_eq!(second[1].2.as_deref().map(str::trim), Some(long.trim()), "{place:?}"); + let tall = second[1].3; + assert!(tall.h > 60.0, "{place:?}: the long cell {tall:?} did not grow"); + // Both cells of the row take its height, and the next row starts below it. + assert!((first[1].3.h - tall.h).abs() < 0.01, "{place:?}: qty[0] {:?} beside qty[1] {tall:?}", first[1].3); + for cells in [&first, &second] { + for pair in cells.windows(2) { + let (a, b) = (pair[0].3, pair[1].3); + assert!((b.y - a.bottom()).abs() < 0.01, "{place:?}: row at {b:?} after {a:?}"); + } + } + let foot = named(&all, "total")[0].3; + assert!((foot.y - second[2].3.bottom()).abs() < 0.01, "{place:?}: footer {foot:?} after {:?}", second[2].3); + if let Some(after) = named(&all, "after").first() { + assert!((after.3.y - foot.bottom()).abs() < 0.01, "{place:?}: after {:?}, footer {foot:?}", after.3); + } + } +} + +#[test] +fn a_long_non_interactive_same_named_cell_stays_in_its_row() { + // A non-interactive `qty[1]` is drawn as text, not as a widget; measured under its own SOM + // index (where the data holds nothing, so its long template value is what shows), each row + // holds its lines, and none run into the row after it or the footer. + let long = "longword ".repeat(12); + for place in [Place::Positioned, Place::PositionedFlow, Place::Flowed] { + let data = ["s1", "s2", "s3"]; + let form = layout_xml(&same_name_cells_form(place, &qty_cell("open", ""), &qty_cell("nonInteractive", &long), data)).unwrap(); + let all = laid_out(&form); + let rows: Vec = named(&all, "qty").iter().map(|w| w.3).collect(); + assert_eq!(rows.len(), 3, "{place:?}"); + let lines: Vec = form + .pages + .iter() + .flat_map(|p| p.items.iter()) + .filter_map(|it| if let Item::Text(s) = it { Some(s) } else { None }) + .filter(|s| s.text.contains("longword")) + .map(|s| s.baseline) + .collect(); + assert!(lines.len() >= 6, "{place:?}: {lines:?}"); + let per_row: Vec = rows.iter().map(|r| lines.iter().filter(|&&b| b > r.y && b <= r.bottom()).count()).collect(); + assert_eq!(per_row.iter().sum::(), lines.len(), "{place:?}: lines {lines:?} outside rows {rows:?}"); + assert!(per_row.iter().all(|&n| n == per_row[0] && n > 1), "{place:?}: lines per row {per_row:?}"); + let foot = named(&all, "total")[0].3; + assert!((foot.y - rows[2].bottom()).abs() < 0.01, "{place:?}: footer {foot:?} after {:?}", rows[2]); + } +}