diff --git a/crates/engine/tests/form_edit_reads.rs b/crates/engine/tests/form_edit_reads.rs new file mode 100644 index 0000000000..a746fe887f --- /dev/null +++ b/crates/engine/tests/form_edit_reads.rs @@ -0,0 +1,107 @@ +//! A form edit reads the document's fields once, however many the form holds: filling, +//! recalculating, the XFA data and the view each read them, mostly with nothing changed since +//! the step before, and on a form with a thousand fields each reading takes tens of milliseconds. +//! Counted, not timed: `form_reads_on_this_thread` counts the whole-form walks this thread made. + +use std::sync::Arc; + +use pdfcraft_engine::{DocId, Edit, FieldValue, Session}; +use pdfcraft_forms::form_reads_on_this_thread as form_reads; + +/// An AcroForm of `n` text fields `f0`…, fifty to a page. +fn acroform(n: usize) -> Vec { + let pages = n.div_ceil(50).max(1); + let first_page = 5; + let first_field = first_page + pages; + let refs = |from: usize, count: usize| (from..from + count).map(|i| format!("{i} 0 R")).collect::>().join(" "); + let mut objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 3 0 R >>".to_string(), + format!("<< /Type /Pages /Kids [{}] /Count {pages} /MediaBox [0 0 612 792] >>", refs(first_page, pages)), + format!("<< /Fields [{}] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 4 0 R >> >> >>", refs(first_field, n)), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), + ]; + for p in 0..pages { + let count = 50.min(n - p * 50); + objs.push(format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", refs(first_field + p * 50, count))); + } + for i in 0..n { + let (page, row) = (i / 50, i % 50); + let y = 760 - 15 * row; + objs.push(format!( + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (f{i}) /TU (Field {i}) /Rect [40 {} 300 {y}] /P {} 0 R /F 4 >>", + y - 12, + first_page + page + )); + } + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// Fill `name` with `value`, returning how many times the fields were read for it. +fn fill(s: &mut Session, id: DocId, name: &str, value: &str) -> usize { + let before = form_reads(); + s.apply(id, Edit::SetFieldValue { name: name.into(), value: FieldValue::Text(value.into()) }).unwrap(); + form_reads() - before +} + +fn value(s: &Session, id: DocId, name: &str) -> Vec { + s.get(id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone() +} + +#[test] +fn a_form_edit_reads_the_fields_once_however_many_the_form_holds() { + for n in [20, 600] { + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(acroform(n)), None).unwrap(); + for k in 0..3 { + let name = format!("f{k}"); + assert_eq!(fill(&mut s, id, &name, &format!("value {k}")), 1, "{n} fields, edit {k}"); + assert_eq!(value(&s, id, &name), [format!("value {k}")]); + } + let shown = |s: &Session| s.get(id).unwrap().info.fields.iter().find(|f| f.name == "f2").and_then(|f| f.value.clone()); + assert_eq!(shown(&s).as_deref(), Some("value 2")); + // Undo and redo read the restored state once each, and show it. + let before = form_reads(); + s.undo(id).unwrap(); + assert!(value(&s, id, "f2").is_empty()); + assert_eq!(shown(&s), None); + s.redo(id).unwrap(); + assert_eq!(value(&s, id, "f2"), ["value 2"]); + assert!(form_reads() - before <= 2, "{n} fields: undo and redo read {} times", form_reads() - before); + // The same value again changes nothing and still reads the form at most once. + assert!(fill(&mut s, id, "f2", "value 2") <= 1); + } +} + +/// A laid-out XFA form keeps its data in step with the fields as well: the edit reads them +/// once to fill and once after its data is written (its scripts read the fields), however many +/// fields the form holds. +#[test] +fn an_xfa_form_edit_reads_the_fields_a_fixed_number_of_times() { + let mut counts = Vec::new(); + for rows in [2, 60] { + let mut s = Session::new(); + let xdp = pdfcraft_xfa::fixtures::template(rows); + let id = s.open("form.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&xdp)), None).unwrap(); + let name = s.get(id).unwrap().form.iter().find(|f| f.name.contains("familyName")).unwrap().name.clone(); + let fields = s.get(id).unwrap().form.len(); + let reads: Vec = (0..3).map(|k| fill(&mut s, id, &name, &format!("Name {k}"))).collect(); + assert_eq!(value(&s, id, &name), ["Name 2"]); + counts.push((fields, reads)); + } + let [(small, a), (large, b)] = counts.as_slice() else { panic!("{counts:?}") }; + assert!(large > small, "{counts:?}"); + assert_eq!(a, b, "{counts:?}"); + assert!(a.iter().all(|&r| r <= 2), "{counts:?}"); +} diff --git a/crates/forms/README.md b/crates/forms/README.md index 67ca3b3bfb..73a25c503b 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -39,6 +39,12 @@ let cells: Vec = grid.cells_for(names)?; // "Table1_1", "Table ## Behaviour +- `fields` and `fields_with_adopted` keep their last reading on each thread with the state of the + document it read (`pdfcraft_cos::ReadState`, which any change to the document replaces), and + give a copy of it to a document that still reads as that state. A form edit reads the fields in + several steps (filling, recalculating, the XFA data, the view), most with nothing changed in + between, so it walks a form once (twice on a laid-out XFA form, whose data is written in + between); `form_reads_on_this_thread` counts the walks, for tests. - Inheritable attributes (`/FT`, `/Ff`, `/V`, `/DV`, `/DA`, `/Q`, `/MaxLen`) are resolved down the field tree; the form's `/DA` and `/Q` are the last fallback. - Text and choice fields get new appearance streams (`/Tx BMC … EMC`): auto-size (`0 Tf`), comb diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 414132b42b..c9b30c067e 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -43,9 +43,9 @@ mod values; pub use appearances::{FieldAppearanceCache, declares_pdfa, need_appearances, undrawn_fields, with_field_appearances, with_field_appearances_cached}; pub(crate) use read::page_refs; pub use read::selected_options; -pub use read::{Fields, acroform, adopted_page_fields, fields, fields_with_adopted}; +pub use read::{Fields, acroform, adopted_page_fields, fields, fields_with_adopted, form_reads_on_this_thread}; #[cfg(test)] -use read::{MAX_SCRIPT_BYTES, PageWalk, enumerate, form_reads}; +use read::{MAX_SCRIPT_BYTES, PageWalk, enumerate, form_reads_on_this_thread as form_reads}; use read::{first_by_export, script, script_budget}; pub use tab_order::{TabOrder, move_in_tab_order, set_tab_order, tab_orders}; #[cfg(test)] diff --git a/crates/forms/src/read.rs b/crates/forms/src/read.rs index 4a7904f620..f1025e5925 100644 --- a/crates/forms/src/read.rs +++ b/crates/forms/src/read.rs @@ -407,26 +407,53 @@ pub struct Fields { } /// Read all terminal fields and the page-only adoption count in one enumeration. +/// +/// The last reading on this thread is kept with the state of the document it read +/// ([`pdfcraft_cos::ReadState`], which every change to the document replaces), and a document +/// that still reads as that state gets a copy of it instead of a new walk. One form edit reads +/// the fields in several steps (filling, recalculating, the XFA data, the view), most of them +/// with nothing changed since the step before: on a form with a thousand fields each walk takes +/// tens of milliseconds. pub fn fields_with_adopted(doc: &Document) -> Fields { + let kept = LAST_READING + .try_with(|last| match last.try_borrow().as_deref() { + Ok(Some((state, fields))) if state.matches(doc) => Some(fields.clone()), + _ => None, + }) + .ok() + .flatten(); + if let Some(fields) = kept { + return fields; + } let (items, adopted_page_fields) = enumerate(doc); - Fields { items, adopted_page_fields } + let fields = Fields { items, adopted_page_fields }; + // Not kept (the thread is ending, or the slot is in use) costs only a later walk. + let _ = LAST_READING.try_with(|last| { + if let Ok(mut last) = last.try_borrow_mut() { + *last = Some((doc.read_state(), fields.clone())); + } + }); + fields } -#[cfg(test)] thread_local! { - /// Whole-form readings ([`enumerate`]) on this thread. + /// The last [`fields_with_adopted`] on this thread, with the state of the document it read. + /// One entry: one form's fields, and the objects that document had changed when it was read. + static LAST_READING: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; + /// Whole-form walks ([`enumerate`]) on this thread. static FORM_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; } -/// How many times this thread has read the whole form. -#[cfg(test)] -pub(crate) fn form_reads() -> usize { - FORM_READS.with(std::cell::Cell::get) +/// How many times this thread has walked a whole form: [`fields`] and the readings like it, but +/// not one answered from the last reading. Per thread, so tests running in parallel don't count +/// each other's; for tests that check how often an edit reads the form. +#[doc(hidden)] +pub fn form_reads_on_this_thread() -> usize { + FORM_READS.try_with(std::cell::Cell::get).unwrap_or(0) } pub(crate) fn enumerate(doc: &Document) -> (Vec, usize) { - #[cfg(test)] - FORM_READS.with(|n| n.set(n.get() + 1)); + let _ = FORM_READS.try_with(|n| n.set(n.get().saturating_add(1))); let af = acroform(doc); let mut page_of = std::collections::HashMap::new(); let mut annot_index = std::collections::HashMap::new(); diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index a2a87f3a73..f5b925816a 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -6029,7 +6029,8 @@ fn set_values_reads_the_form_once_not_per_value() { let before = form_reads(); assert!(set_values(&mut doc, &again).unwrap().iter().all(Result::is_ok)); let reads = form_reads() - before; - assert_eq!(reads, 3, "one to fill, one when f0 comes again, one to recalculate"); + // Filling starts from the reading the check above made, nothing having changed since. + assert_eq!(reads, 2, "one when f0 comes again, one to recalculate"); } /// Redrawing every field of a large form reads the form once, not once per field: a laid out XFA @@ -6060,6 +6061,45 @@ fn redraw_fields_reads_the_form_once_however_many_fields_it_holds() { } } +/// The fields of a document that has not changed since they were last read are not read again, +/// and any change, a clone's state or another document reads afresh: a form edit reads them in +/// several steps, most with nothing changed in between. +#[test] +fn fields_are_read_again_only_after_the_document_changes() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(), + "<< /Fields [5 0 R] /DA (/Helv 10 Tf 0 g) >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (old) /Rect [50 10 250 30] /P 3 0 R >>".into(), + ]; + let value = |doc: &Document| field(&fields(doc), "name").value.clone(); + let mut doc = document(&objs); + let before = form_reads(); + assert_eq!(value(&doc), ["old"]); + assert_eq!(value(&doc), ["old"]); + assert_eq!(fields_with_adopted(&doc).items, fields(&doc)); + assert_eq!(form_reads() - before, 1, "nothing changed: read once"); + let unchanged = doc.clone(); + doc.update_dict(ObjRef::new(5, 0), |d| d.set(b"V".to_vec(), Object::String(PdfString::text("new")))).unwrap(); + let before = form_reads(); + assert_eq!(value(&doc), ["new"], "an edit is read"); + assert_eq!(value(&unchanged), ["old"], "a copy kept from before it is read as it is"); + assert_eq!(value(&doc), ["new"]); + assert_eq!(form_reads() - before, 3); + // An object another field's value refers to counts as a change too. + let r = doc.add(Object::String(PdfString::text("indirect"))); + doc.update_dict(ObjRef::new(5, 0), |d| d.set(b"V".to_vec(), Object::Ref(r))).unwrap(); + assert_eq!(value(&doc), ["indirect"]); + doc.set(r, Object::String(PdfString::text("changed in place"))); + assert_eq!(value(&doc), ["changed in place"]); + // Another document with the same bytes is read, not given this one's fields. + let other = document(&objs); + let before = form_reads(); + assert_eq!(value(&other), ["old"]); + assert_eq!(form_reads() - before, 1); +} + /// A radio group `size` with the buttons Small (on), Medium and Large, a per-widget `/Opt`, and /// the group's buttons in the calculation order; and a text field `name` with two widgets. fn radio_group_form() -> Document { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0cdbc3e856..000b4bdda0 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4638,7 +4638,7 @@ milestone = "M6" status = "shipped" commands = ["form.fields"] tools = ["form_fill", "form_fields"] -evidence = ["crates/ui-egui/tests/it/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/it/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/it/automation.rs::orphan_form_fields_through_tools", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value", "crates/ui-egui/tests/it/forms.rs::hovering_a_field_shows_its_tooltip", "crates/forms/src/tests.rs::zero_max_len_is_unlimited_and_overrides_inherited_limit", "crates/engine/src/tests.rs::password_field_values_are_kept_in_memory_but_written_to_no_file", "crates/forms/src/tests.rs::a_cleared_password_field_returns_to_its_default_value", "crates/forms/src/tests.rs::a_text_field_says_what_fits_it_as_its_appearance_draws_it", "crates/forms/src/tests.rs::an_edit_to_a_full_field_keeps_what_fits", "crates/ui-egui/tests/it/forms.rs::a_field_that_doesnt_scroll_stops_taking_text_when_full", "crates/ui-egui/tests/it/forms.rs::a_multi_line_auto_sized_field_that_doesnt_scroll_stops_when_full", "crates/engine/src/tests.rs::an_xfa_field_that_doesnt_scroll_opens_with_do_not_scroll_and_a_text_box", "crates/xfa/src/tests.rs::fields_that_dont_scroll_are_written_with_do_not_scroll", "crates/forms/src/tests.rs::comb_fields_show_their_cells", "crates/forms/src/tests.rs::thai_a_field_font_cannot_hold_is_drawn_from_the_thai_face", "crates/automation/tests/it/automation.rs::thai_in_fill_sign_text_comments_fields_and_page_marks_is_drawn_and_found"] +evidence = ["crates/ui-egui/tests/it/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/it/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/it/automation.rs::orphan_form_fields_through_tools", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value", "crates/ui-egui/tests/it/forms.rs::hovering_a_field_shows_its_tooltip", "crates/forms/src/tests.rs::zero_max_len_is_unlimited_and_overrides_inherited_limit", "crates/engine/src/tests.rs::password_field_values_are_kept_in_memory_but_written_to_no_file", "crates/forms/src/tests.rs::a_cleared_password_field_returns_to_its_default_value", "crates/forms/src/tests.rs::a_text_field_says_what_fits_it_as_its_appearance_draws_it", "crates/forms/src/tests.rs::an_edit_to_a_full_field_keeps_what_fits", "crates/ui-egui/tests/it/forms.rs::a_field_that_doesnt_scroll_stops_taking_text_when_full", "crates/ui-egui/tests/it/forms.rs::a_multi_line_auto_sized_field_that_doesnt_scroll_stops_when_full", "crates/engine/src/tests.rs::an_xfa_field_that_doesnt_scroll_opens_with_do_not_scroll_and_a_text_box", "crates/xfa/src/tests.rs::fields_that_dont_scroll_are_written_with_do_not_scroll", "crates/forms/src/tests.rs::comb_fields_show_their_cells", "crates/forms/src/tests.rs::thai_a_field_font_cannot_hold_is_drawn_from_the_thai_face", "crates/automation/tests/it/automation.rs::thai_in_fill_sign_text_comments_fields_and_page_marks_is_drawn_and_found", "crates/engine/tests/form_edit_reads.rs::a_form_edit_reads_the_fields_once_however_many_the_form_holds"] notes = "in-place editor; Thai the field's /DA font can't hold is drawn from the craft-fonts Thai face as Add text draws it (Type 3 glyphs with a ToUnicode map beside the field's font, /DA kept, no /NeedAppearances asked for; the ink of stacked marks and descenders kept inside the border, an automatic size shrinking for it), and refused, saying what to do, in a build without a Thai face (#2354); comb and list fields still draw Thai as `?`, marked; Enter or clicking away commits, Escape cancels; auto-size (0 Tf) fields shrink, and multiline ones wrap, the text being typed to fit the box, on screen as on the page (the screen measures in the interface font, so its size can be a little smaller than the page's Helvetica fit); fixed sizes are kept and clip, as in Acrobat; a field that doesn't scroll (DoNotScroll, ISO 32000-2 Table 228, and XFA's hScrollPolicy/vScrollPolicy=\"off\", written as it) takes no more text than its appearance shows, measured in the field's own font, padding and size (the smallest automatic size, 4 pt, for 0 Tf): typing into a full field adds nothing, and a paste or a replaced selection keeps what fits (form_fill isn't interactive and still takes any value); no format/keystroke JavaScript yet (M6.4); resting the pointer on any field shows its tooltip (/TU), as Acrobat does (#3854); imported MaxLen 0 permits text input and overrides inherited limits; positive limits remain enforced. A text field with the Password flag is saved as Acrobat saves it (#3926, black-box test): the typed value is written to no file (Save, Save As, Reduce File Size, recovery, Extract/Split/Combine, signing); the field gets back its default value (/DV) or is left empty, and its asterisks are redrawn from that value. /DV and the field's scripts stay. Known limits, which Acrobat shares: a script that copies the password into an ordinary field saves it there (Acrobat recalculates a Calculate copy on save, PdfCraft does not), and an incremental save leaves a password stored by an earlier revision in that revision. A comb field with a border colour shows its MaxLen cells as lines in that colour, in the appearances PdfCraft saves and in display-only ones (up to 500 cells)." [[feature]]