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"