From ea2e13370c24fb7ecf620f8c8ef087b267b3c621 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Tue, 10 Nov 2026 02:22:02 -0500 Subject: [PATCH] M12: dynamic XFA forms tab in their template's traversal order (#4149) --- crates/engine/src/tests.rs | 14 ++ crates/xfa/src/fixtures.rs | 93 ++++++++ crates/xfa/src/layout.rs | 12 + crates/xfa/src/layout/flow.rs | 8 +- crates/xfa/src/lib.rs | 1 + crates/xfa/src/model.rs | 13 + crates/xfa/src/parse.rs | 22 +- crates/xfa/src/tests.rs | 161 ++++++++++++- crates/xfa/src/traverse.rs | 435 ++++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 10 files changed, 753 insertions(+), 10 deletions(-) create mode 100644 crates/xfa/src/traverse.rs diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 741730453c..51018d6b37 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -8550,6 +8550,20 @@ fn an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error() { s.apply(id, Edit::SetFieldValue { name: "leave".into(), value: FieldValue::Check(true) }).expect("can be checked now"); } +/// A laid-out XFA form tabs the way its template's traversal says: the probe form, which +/// another viewer tabs `start A2 A3 A4 A1 B1 B3 B2 B4 C3 D1 D4 D6 finish C1 C2`. +#[test] +fn xfa_forms_tab_in_their_templates_traversal_order() { + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::traversal_template())); + let mut s = Session::new(); + let id = s.open("tab.pdf", None, bytes, None).unwrap(); + let doc = s.get(id).unwrap(); + let mut tabs: Vec<(usize, &str)> = doc.form.iter().flat_map(|f| f.widgets.iter().map(move |w| (w.tab, f.name.as_str()))).collect(); + tabs.sort_unstable(); + let names: Vec<&str> = tabs.iter().map(|t| t.1).filter(|n| !["log", "D5"].contains(n)).collect(); + assert_eq!(names, ["start", "A2", "A3", "A4", "A1", "B1", "B3", "B2", "B4", "C3", "D1", "D4", "D6", "finish", "C1", "C2"]); +} + /// A scripted form end to end: initialize and calculate on open, recalculation after a change, /// a failing validation's message, buttons that add and remove rows and show messages, a check /// box that shows a subform (undoable), FormCalc, and a save and reopen that keep it all. diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index 0f6cc3a696..05f2aec654 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -286,6 +286,99 @@ pub fn unnamed_hidden_template() -> String { .to_string() } +/// The Tab-order probe: fields placed out of document order (A), `next` links (B), a subform +/// whose `first` link goes to a draw that links on (C), hidden, invisible, read-only and +/// protected fields (D), and a protected `log` at the top. Another viewer tabs it `start A2 A3 +/// A4 A1 B1 B3 B2 B4 C3 D1 D4 D6 finish C1 C2`, then back to `start`. +pub fn traversal_template() -> String { + r##" + + + +"## + .to_string() +} + +/// The second Tab-order probe: in each row, the first field links to an object between the +/// second and third that links on to the fourth: a hidden subform (E), a hidden field (F), an +/// invisible field (G) and a protected field (H). Another viewer tabs it `start E1 E2 E3 E4 F1 +/// F4 G1 G4 H1 H4 finish F2 F3 G2 G3 H2 H3`, then back to `start`. +pub fn traversal_targets_template() -> String { + r##" + + + +"## + .to_string() +} + /// A scripted form (JavaScript events): `qty` (initialised to 2, validated ≤ 100) × `price` /// (5) = `total` (calculated); a check box `more` that shows the hidden `details` subform; /// a table whose `row` (item, amount) repeats, with `addRow`, `removeRow` and `hello` buttons; diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 6eac6757e0..24682caf9f 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -215,6 +215,16 @@ pub fn instance_count(occur: &Occur, from_data: usize) -> usize { n.min(occur.max.unwrap_or(usize::MAX)).clamp(1, MAX_INSTANCES) } +/// How many instances of `sf` the layout makes under the container at `parent_som`: its `occur` +/// and the instances `data` holds ([`instance_count`]). The traversal counts them the same way. +pub(crate) fn subform_instances(tpl: &Template, data: Option<&DataNode>, sf: &Subform, parent_som: &str) -> usize { + let from_data = match (&sf.common.name, data) { + (Some(name), Some(d)) if sf.occur.max != Some(1) => d.count(&data_path(tpl, parent_som), name), + _ => 0, + }; + instance_count(&sf.occur, from_data) +} + /// Lay a template out, with the values and repeat counts of `data` (the `xfa:data` element). pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result { layout_inner(tpl, data, None) @@ -284,6 +294,8 @@ fn layout_inner(tpl: &Template, data: Option<&DataNode>, overrides: Option<&crat for p in &mut l.pages { fill_page_count(&mut p.items, &count); } + // Tab goes the way the template's traversal says. + crate::traverse::order_widgets(&root_node, tpl, data, &mut l.pages); #[cfg(test)] LAID_OUT_ITEMS.with(|c| c.set(l.items)); Ok(Form { pages: l.pages, fields: l.fields, warnings: l.warnings }) diff --git a/crates/xfa/src/layout/flow.rs b/crates/xfa/src/layout/flow.rs index 89134f611f..9d979d97e1 100644 --- a/crates/xfa/src/layout/flow.rs +++ b/crates/xfa/src/layout/flow.rs @@ -2,7 +2,7 @@ use super::common::{WidgetRest, clamp_opt, divide, is_row, moved, solid_extent, use super::items::Item; use super::{ Ctx, Frame, Layout, Layouter, MAX_DEPTH, MAX_INSTANCES, MAX_MEASURES, Node, Rect, SomIndices, Splitting, Subform, XfaError, data_path, - instance_count, + subform_instances, }; impl Layouter<'_> { @@ -466,11 +466,7 @@ impl Layouter<'_> { /// repeating subform's `occur` and data instances ([`instance_count`]); one for anything else. pub(super) fn instances(&self, node: &Node, parent_som: &str) -> usize { let Node::Subform(sf) = node else { return 1 }; - 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, - }; - instance_count(&sf.occur, from_data) + subform_instances(self.tpl, self.data, sf, parent_som) } /// [`Self::instances`] for laying `node` out: the count every layout that repeats a subform diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs index 91b468cea7..4e99093bc8 100644 --- a/crates/xfa/src/lib.rs +++ b/crates/xfa/src/lib.rs @@ -20,6 +20,7 @@ pub mod picture; mod proto; pub mod script; pub mod text; +mod traverse; use pdfcraft_cos::Document; diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs index 9b00ee6f3e..6f264500f1 100644 --- a/crates/xfa/src/model.rs +++ b/crates/xfa/src/model.rs @@ -387,12 +387,25 @@ pub struct Common { pub para: ParaSpec, /// Table cells: how many columns this cell spans. pub col_span: usize, + /// ``: where Tab goes from here. + pub traversal: Traversal, /// The object's own attributes and its property elements as the template writes them, for /// scripts to read (see [`Props`]). Shared: the live form's instances of the object and /// copies of the template point at the same tree. pub props: std::sync::Arc, } +/// A container's or object's `` links, as SOM references relative to it. Only the +/// two Tab follows are kept; the arrow-key ones (`up`, `down`, `left`, `right`, `back`) aren't. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Traversal { + /// `operation="first"`: where Tab goes on entering a container. + pub first: Option, + /// `operation="next"` (the default): where Tab goes after this object, or after the last + /// stop inside a container. + pub next: Option, +} + /// What scripts read of an object's properties (XFA 3.3 ch. 3, "Properties vs. Children", /// p. 78): the object's own attributes and the property elements the template gives it (border, /// font, para, margin, ui, validate, occur, break, assist, format, value, bind, desc, extras, keep), diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index 5665cfa77b..cc330b3d3b 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -8,7 +8,7 @@ use crate::XfaError; use crate::model::{ Access, Anchor, Area, Border, Caption, ChoiceList, Color, Common, Corner, Draw, Edge, ExclGroup, Field, FontSpec, HAlign, Layout, Margin, Node, Occur, PageArea, PageRole, PageSet, ParaSpec, Paragraph, Placement, Presence, Prop, Props, Rect, RichText, Run, Script, Stroke, Subform, - Template, Ui, VAlign, Value, + Template, Traversal, Ui, VAlign, Value, }; /// Deepest element nesting followed (templates are shallow; hostile ones are not). @@ -265,9 +265,29 @@ fn parse_common(n: XmlNode, warnings: &mut Vec) -> Common { font: parse_font(child(n, "font")), para: parse_para(child(n, "para")), col_span: n.attribute("colSpan").and_then(|c| c.trim().parse::().ok()).unwrap_or(1).clamp(1, 64), + traversal: parse_traversal(child(n, "traversal")), } } +/// Longest traversal reference kept (a SOM path); longer ones are ignored. +const MAX_TRAVERSE_REF: usize = 1024; + +/// ``: the first of each kept. A +/// `` that computes its target with a script, or names none, is ignored. +fn parse_traversal(n: Option) -> Traversal { + let mut out = Traversal::default(); + for t in n.into_iter().flat_map(|n| children(n, "traverse")) { + let Some(r) = t.attribute("ref").map(str::trim).filter(|r| !r.is_empty() && r.len() <= MAX_TRAVERSE_REF) else { continue }; + let slot = match t.attribute("operation").map(str::trim).unwrap_or("next") { + "first" => &mut out.first, + "next" => &mut out.next, + _ => continue, + }; + slot.get_or_insert_with(|| r.to_string()); + } + out +} + /// The property elements of an object that scripts read through the object model (XFA 3.3 /// ch. 17: border p. 629, font p. 741, para p. 800, margin p. 776, ui p. 877, validate p. 880, /// occur p. 786, break p. 632, breakBefore p. 637, breakAfter p. 635, assist p. 609, format diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 02e8a205e5..682887e673 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -9,7 +9,7 @@ use crate::data::{ use crate::data::{add_data_instances, remove_data_instance, write_data_value}; use crate::fixtures::{ TINY_GIF, bound_date_template, fields_template, scripted_template, shell, static_shell, template, template_with_data, tiny_bmp, tiny_png, - tiny_tiff, unnamed_hidden_template, + tiny_tiff, traversal_targets_template, traversal_template, unnamed_hidden_template, }; use crate::layout::Item; use crate::layout::WidgetKind; @@ -4968,6 +4968,149 @@ fn a_typed_date_is_rewritten_only_by_date_clauses_into_a_first_pattern_that_keep assert_eq!(typed("form[0].pick[0]", "20011203").as_deref(), Some("011203"), "the next pattern that the first keeps"); } +/// A one-page positioned form around `body`, for Tab-order tests. +fn positioned_form(body: &str) -> String { + format!( + r#""# + ) +} + +/// The SOM paths of the widgets on the first page, in the order they are written. +fn widget_soms(xml: &str) -> Vec { + let form = layout_xml(xml).unwrap(); + widgets(&form.pages[0]).iter().map(|w| w.som.trim_start_matches("form1[0].page[0].").to_string()).collect() +} + +/// The widgets are written in the order another viewer tabs the probe form: rows without +/// links, `next` links, a subform's `first` link through a draw, the stops the chain never +/// reached at the end, and hidden, invisible and protected fields passed over (the protected +/// ones keep their places, as do widgets the order doesn't cover). +#[test] +fn widgets_are_written_in_the_templates_tab_order() { + let soms = widget_soms(&traversal_template()); + let stops: Vec<&str> = soms.iter().map(String::as_str).filter(|s| *s != "log[0]" && *s != "D5[0]").collect(); + assert_eq!( + stops, + [ + "start[0]", + "A2[0]", + "A3[0]", + "A4[0]", + "A1[0]", + "B1[0]", + "B3[0]", + "B2[0]", + "B4[0]", + "C[0].C3[0]", + "D1[0]", + "D4[0]", + "D6[0]", + "finish[0]", + "C[0].C1[0]", + "C[0].C2[0]" + ] + ); + // The protected widgets keep their places among the widgets: `log` first, as written, and + // `D5` 16th, between `D4` and `D6` in the order written, so after `finish` here. + assert_eq!(soms.iter().position(|s| s == "log[0]"), Some(0), "{soms:?}"); + assert_eq!(soms.iter().position(|s| s == "D5[0]"), Some(15), "{soms:?}"); + assert_eq!(soms.len(), 18, "{soms:?}"); +} + +/// Links to objects that aren't stops, as another viewer follows them: a hidden subform is +/// passed over as if it weren't there (the link is ignored, and so is the subform's own), +/// while a hidden, invisible or protected field passes Tab on by its own link. +#[test] +fn links_to_hidden_subforms_are_ignored_and_other_non_stops_pass_tab_on() { + let soms = widget_soms(&traversal_targets_template()); + let stops: Vec<&str> = soms.iter().map(String::as_str).filter(|s| *s != "HT[0]").collect(); + assert_eq!( + stops, + [ + "start[0]", + "E1[0]", + "E2[0]", + "E3[0]", + "E4[0]", + "F1[0]", + "F4[0]", + "G1[0]", + "G4[0]", + "H1[0]", + "H4[0]", + "finish[0]", + "F2[0]", + "F3[0]", + "G2[0]", + "G3[0]", + "H2[0]", + "H3[0]" + ] + ); +} + +/// `#draw[1]` counts every draw, named or not, and a dotted reference goes down from the +/// first part found: `a` links into the subform's second draw, which links to `z`. +#[test] +fn class_and_dotted_traversal_references_resolve_as_som_paths() { + let field = |n: &str, x: &str, y: &str, link: &str| { + let t = if link.is_empty() { String::new() } else { format!(r#""#) }; + format!(r#"{t}"#) + }; + let body = format!( + r#"{} +Head +Note +{}{}{}{}"#, + field("a", "0in", "0in", "S[0].#draw[1]"), + field("x", "0in", "1in", ""), + field("y", "0in", "1.5in", ""), + field("z", "0in", "2in", ""), + field("b", "0in", "5in", ""), + ); + assert_eq!(widget_soms(&positioned_form(&body)), ["a[0]", "S[0].z[0]", "b[0]", "S[0].x[0]", "S[0].y[0]"]); +} + +/// Links that loop or name nothing end the chain, and the rest follow in rows; every field is +/// still written once (they are written in the template out of order). +#[test] +fn looping_and_dangling_traversal_links_keep_every_field_once() { + let field = |n: &str, y: &str, link: &str| { + format!( + r#""# + ) + }; + let body = [field("q", "1in", "p[0]"), field("s", "3in", "#field[99]"), field("r", "2in", "nowhere[0]"), field("p", "0in", "q[0]")].concat(); + assert_eq!(widget_soms(&positioned_form(&body)), ["p[0]", "q[0]", "r[0]", "s[0]"]); +} + +/// A repeating subform is tabbed one instance after another, each in its own rows. +#[test] +fn repeated_subform_instances_are_tabbed_one_after_another() { + let body = r#" + + + +"#; + let soms = widget_soms(&positioned_form(body)); + let short: Vec = soms.iter().map(|s| s.trim_start_matches("rows[0].").to_string()).collect(); + assert_eq!(short, ["row[0].a[0]", "row[0].b[0]", "row[1].a[0]", "row[1].b[0]", "row[2].a[0]", "row[2].b[0]"]); +} + +/// `` keeps the first `first` and `next` links and ignores the arrow-key ones. +#[test] +fn traversal_links_are_parsed() { + let (tpl, _) = parse(&positioned_form( + r##""##, + )) + .unwrap(); + let Some(Node::Subform(page)) = tpl.root.children.first() else { panic!("no page") }; + let Some(Node::Field(f)) = page.children.first() else { panic!("no field") }; + assert_eq!(f.common.traversal, Traversal { first: Some("#draw[2]".into()), next: Some("n[0]".into()) }); +} + /// A word longer than its line is broken by character in work linear in its length. #[test] fn a_long_word_breaks_in_linear_work() { @@ -5208,6 +5351,22 @@ fn faces_measure_text_at_their_standard_14_widths() { assert!((condensed.width(sample) - 0.8 * Std14::TimesBold.text_width(sample, 10.0)).abs() < 1e-9); } +/// Every instance of a repeating subform the layout makes is there to link to, even one that +/// holds no field: `a` links to the third instance of a row of labels, whose label links to `z`. +#[test] +fn links_reach_every_instance_of_a_repeating_subform() { + let field = |n: &str, y: &str, link: &str| { + let t = if link.is_empty() { String::new() } else { format!(r#""#) }; + format!(r#"{t}"#) + }; + let rows = r#" + +Row +"#; + let body = [field("a", "0in", "rows[0].row[2]"), field("b", "1in", ""), rows.to_string(), field("z", "5in", "")].concat(); + assert_eq!(widget_soms(&positioned_form(&body)), ["a[0]", "z[0]", "b[0]"]); +} + /// #3944: clearing XFA password values removes values typed into `` fields from the /// datasets packet (including across repeating subforms), restores a field's template default /// `…` when it has one, and replaces the datasets stream in place so diff --git a/crates/xfa/src/traverse.rs b/crates/xfa/src/traverse.rs new file mode 100644 index 0000000000..cb8d58975c --- /dev/null +++ b/crates/xfa/src/traverse.rs @@ -0,0 +1,435 @@ +//! Tab order of a laid-out form, from the template's `` links. +//! +//! What Tab does, as measured black-box in another viewer with a synthetic form: +//! - Without links, a positioned container's children go in rows, top to bottom, then left to +//! right; a flowed container's go in document order. +//! - A `next` link is followed. Entering a container goes to its `first` link, or else to its +//! first child. An object that isn't a stop (a draw, or a hidden, invisible or protected +//! field) passes Tab on by its own link or the default order. +//! - After a container's last child, Tab goes to the container's `next` link, or else on in +//! the default order after the container. +//! - Read-only fields are stops; hidden, invisible and protected ones are not. +//! - A container that isn't shown is passed over whole, as if it weren't there: a link to it is +//! ignored (Tab goes on in the default order from the object that had the link), and its own +//! links are never followed. +//! - Once the chain ends, or comes back to an object it has passed, the stops it never reached +//! follow in the default order, each going on by its own links. +//! +//! The order is applied by writing each page's widgets in it: the pages have no `/Tabs`, so +//! viewers tab in annotation order. + +use std::collections::HashMap; + +use crate::data::DataNode; +use crate::layout::{Item, Page, subform_instances}; +use crate::model::{Access, Common, Field, Layout, Node, Presence, Subform, Template}; + +/// Most objects the order is worked out for; a larger form keeps the layout's order. +const MAX_NODES: usize = 200_000; +/// Deepest nesting followed, as in the layout. +const MAX_DEPTH: usize = 64; +/// Most `.`-separated parts a traversal reference may have. +const MAX_REF_PARTS: usize = 64; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum Class { + Subform, + Area, + ExclGroup, + Field, + Draw, +} + +impl Class { + /// The class a `#class` reference names. + fn named(name: &str) -> Option { + Some(match name { + "subform" | "subformSet" => Class::Subform, + "area" => Class::Area, + "exclGroup" => Class::ExclGroup, + "field" => Class::Field, + "draw" => Class::Draw, + _ => return None, + }) + } + + /// Subforms, areas and radio groups hold other objects. + fn is_container(self) -> bool { + matches!(self, Class::Subform | Class::Area | Class::ExclGroup) + } +} + +/// One object of the form (a repeating subform once per instance). +struct TNode<'t> { + class: Class, + name: Option<&'t str>, + parent: Option, + /// Children in document order (what SOM indices count). + kids: Vec, + /// Children in the default Tab order. + tab: Vec, + /// This object's place in its parent's `tab`. + place: usize, + /// Top, then left, in its parent's coordinates (positioned containers sort by it). + at: (f64, f64), + first: Option<&'t str>, + next: Option<&'t str>, + /// Shown, with every container above it: a container that isn't is passed over whole. + shown: bool, + /// The laid-out widget (page, item) a field stops at. + stop: Option<(usize, usize)>, +} + +/// A container's children as SOM paths see them: unnamed containers are see-through. +#[derive(Default)] +struct SomIndex<'t> { + by_name: HashMap<&'t str, Vec>, + by_class: HashMap>, +} + +struct Tree<'t> { + nodes: Vec>, + /// The laid-out widgets by SOM path, in layout order; each is claimed once. + widgets: HashMap>, + /// The template and data the layout counted repeating subforms' instances with. + tpl: &'t Template, + data: Option<&'t DataNode>, + /// SOM indices of the containers references were resolved in. + som: HashMap>, + too_big: bool, +} + +/// `parent.name[index]`, or `parent` for an unnamed object (which SOM paths skip), as the +/// layout writes them. +fn with_som(parent: &str, name: Option<&str>, index: usize) -> String { + match name { + Some(n) if parent.is_empty() => format!("{n}[{index}]"), + Some(n) => format!("{parent}.{n}[{index}]"), + None => parent.to_string(), + } +} + +/// The SOM index of each of `names` among the same-named ones before it (0 when unnamed). +fn som_indices<'a>(names: impl Iterator>) -> Vec { + let mut seen: HashMap<&str, usize> = HashMap::new(); + names + .map(|n| { + n.map_or(0, |n| { + let c = seen.entry(n).or_insert(0); + *c += 1; + *c - 1 + }) + }) + .collect() +} + +impl<'t> Tree<'t> { + /// An empty tree knowing the widgets laid out on `pages`, from `tpl` with `data`. + fn new(tpl: &'t Template, data: Option<&'t DataNode>, pages: &[Page]) -> Tree<'t> { + let mut widgets: HashMap> = HashMap::new(); + for (pi, page) in pages.iter().enumerate() { + for (ii, item) in page.items.iter().enumerate() { + if let Item::Widget(w) = item { + widgets.entry(w.som.clone()).or_default().push((pi, ii)); + } + } + } + // Claimed from the front in layout order. + for list in widgets.values_mut() { + list.reverse(); + } + Tree { nodes: Vec::new(), widgets, tpl, data, som: HashMap::new(), too_big: false } + } + + /// Add one object under `parent`; `None` once the tree is full. + fn push(&mut self, class: Class, c: &'t Common, parent: Option, shown: bool) -> Option { + if self.nodes.len() >= MAX_NODES { + self.too_big = true; + return None; + } + let (x, y) = c.top_left(c.w.unwrap_or(0.0), c.h.unwrap_or(0.0)); + let id = self.nodes.len(); + self.nodes.push(TNode { + class, + name: c.name.as_deref(), + parent, + kids: Vec::new(), + tab: Vec::new(), + place: 0, + at: (y, x), + first: c.traversal.first.as_deref(), + next: c.traversal.next.as_deref(), + shown, + stop: None, + }); + if let Some(p) = parent.and_then(|p| self.nodes.get_mut(p)) { + p.kids.push(id); + } + Some(id) + } + + /// How many instances of `sf` under the container at `parent_som` the layout made, counted + /// as it counts them (one for a subform that isn't shown, which it doesn't lay out). + fn instances(&self, sf: &Subform, parent_som: &str, shown: bool) -> usize { + if shown { subform_instances(self.tpl, self.data, sf, parent_som) } else { 1 } + } + + /// Add `node` (each instance of a repeating subform) and everything in it, under `parent` + /// whose SOM path is `parent_som`; `sib` is the node's SOM index there, as the layout counts. + fn add(&mut self, node: &'t Node, parent: Option, parent_som: &str, sib: usize, shown: bool, depth: usize) { + if depth > MAX_DEPTH || self.too_big { + return; + } + let c = node.common(); + let shown = shown && c.presence == Presence::Visible; + match node { + Node::Subform(sf) => { + for k in 0..self.instances(sf, parent_som, shown) { + let som = with_som(parent_som, c.name.as_deref(), sib + k); + let Some(id) = self.push(Class::Subform, c, parent, shown) else { return }; + let sibs = som_indices(sf.children.iter().map(|k| k.common().name.as_deref())); + for (i, kid) in sf.children.iter().enumerate() { + self.add(kid, Some(id), &som, sibs.get(i).copied().unwrap_or(0), shown, depth + 1); + } + self.order(id, sf.layout == Layout::Positioned); + } + } + Node::Area(a) => { + let som = with_som(parent_som, c.name.as_deref(), sib); + let Some(id) = self.push(Class::Area, c, parent, shown) else { return }; + let sibs = som_indices(a.children.iter().map(|k| k.common().name.as_deref())); + for (i, kid) in a.children.iter().enumerate() { + self.add(kid, Some(id), &som, sibs.get(i).copied().unwrap_or(0), shown, depth + 1); + } + self.order(id, true); + } + Node::ExclGroup(g) => { + let som = with_som(parent_som, c.name.as_deref(), sib); + let Some(id) = self.push(Class::ExclGroup, c, parent, shown) else { return }; + let sibs = som_indices(g.fields.iter().map(|f| f.common.name.as_deref())); + for (i, f) in g.fields.iter().enumerate() { + self.add_field(f, id, &som, sibs.get(i).copied().unwrap_or(0), shown); + } + self.order(id, g.layout == Layout::Positioned); + } + Node::Field(f) => { + if let Some(p) = parent { + self.add_field(f, p, parent_som, sib, shown); + } + } + Node::Draw(_) => { + self.push(Class::Draw, c, parent, shown); + } + } + } + + /// Add field `f` and claim the widget the layout made for it, a stop unless it is protected. + fn add_field(&mut self, f: &'t Field, parent: usize, parent_som: &str, sib: usize, shown: bool) { + let shown = shown && f.common.presence == Presence::Visible; + let Some(id) = self.push(Class::Field, &f.common, Some(parent), shown) else { return }; + if !shown { + return; + } + // The layout names an unnamed field `field`. + let som = with_som(parent_som, Some(f.common.name.as_deref().unwrap_or("field")), sib); + let widget = self.widgets.get_mut(&som).and_then(Vec::pop); + if f.access != Access::Protected + && let Some(n) = self.nodes.get_mut(id) + { + n.stop = widget; + } + } + + /// Put `id`'s children in the default Tab order: rows then columns in a positioned + /// container, document order in a flowed one. + fn order(&mut self, id: usize, positioned: bool) { + let Some(kids) = self.nodes.get(id).map(|n| n.kids.clone()) else { return }; + let mut tab = kids; + if positioned { + let at = |k: &usize| self.nodes.get(*k).map_or((0.0, 0.0), |n| n.at); + tab.sort_by(|a, b| { + let (a, b) = (at(a), at(b)); + a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1)) + }); + } + for (place, k) in tab.iter().enumerate() { + if let Some(n) = self.nodes.get_mut(*k) { + n.place = place; + } + } + if let Some(n) = self.nodes.get_mut(id) { + n.tab = tab; + } + } + + /// `id`'s children as SOM paths see them, built on first use. + fn som_index(&mut self, id: usize) -> &SomIndex<'t> { + if !self.som.contains_key(&id) { + let mut index = SomIndex::default(); + let mut stack: Vec = self.nodes.get(id).map(|n| n.kids.iter().rev().copied().collect()).unwrap_or_default(); + while let Some(k) = stack.pop() { + let Some(n) = self.nodes.get(k) else { continue }; + if n.name.is_none() && n.class.is_container() { + stack.extend(n.kids.iter().rev().copied()); + continue; + } + if let Some(name) = n.name { + index.by_name.entry(name).or_default().push(k); + } + index.by_class.entry(n.class).or_default().push(k); + } + self.som.insert(id, index); + } + // Inserted just above when missing. + self.som.entry(id).or_default() + } + + /// The child of `id` that one part of a SOM reference (`name[2]`, `#draw[0]`, `name`) names. + fn pick(&mut self, id: usize, part: &str) -> Option { + let (head, index) = match part.find('[') { + Some(i) => (part.get(..i)?, part.get(i + 1..)?.strip_suffix(']')?.trim().parse::().ok()?), + None => (part, 0), + }; + let index_of = self.som_index(id); + let list = match head.strip_prefix('#') { + Some(class) => index_of.by_class.get(&Class::named(class)?), + None => index_of.by_name.get(head), + }; + list.and_then(|l| l.get(index)).copied() + } + + /// The object `reference` names, from `id`: its first part is looked for among `id`'s + /// children, then each container's above it; the rest go down from there. + fn resolve(&mut self, id: usize, reference: &str) -> Option { + let parts: Vec<&str> = reference.split('.').map(str::trim).collect(); + if parts.len() > MAX_REF_PARTS { + return None; + } + let (first, rest) = parts.split_first()?; + let mut scope = Some(id); + while let Some(s) = scope { + if let Some(hit) = self.pick(s, first) { + return rest.iter().try_fold(hit, |at, part| self.pick(at, part)); + } + scope = self.nodes.get(s)?.parent; + } + None + } + + /// Whether Tab passes over `id` whole, as if it weren't there: a container not shown. + fn skipped(&self, id: usize) -> bool { + self.nodes.get(id).is_some_and(|n| n.class.is_container() && !n.shown) + } + + /// The first of `id`'s children from place `from` on, in the default order, that Tab + /// doesn't pass over. + fn kid_from(&self, id: usize, from: usize) -> Option { + self.nodes.get(id)?.tab.get(from..)?.iter().copied().find(|k| !self.skipped(*k)) + } + + /// Where a link from `id` goes: nowhere when it names nothing or something Tab passes over. + fn target(&mut self, id: usize, reference: &str) -> Option { + self.resolve(id, reference).filter(|t| !self.skipped(*t)) + } + + /// Where Tab goes after `id` (after everything inside it, for a container): its `next` + /// link, else its next sibling in the default order, else on after its parent. + fn after(&mut self, mut id: usize) -> Option { + for _ in 0..=MAX_DEPTH + 1 { + let node = self.nodes.get(id)?; + let (next, parent, place) = (node.next, node.parent, node.place); + if let Some(t) = next.and_then(|r| self.target(id, r)) { + return Some(t); + } + let p = parent?; + if let Some(s) = self.kid_from(p, place.saturating_add(1)) { + return Some(s); + } + id = p; + } + None + } + + /// Follow Tab from `start` until the chain ends or comes back, adding the stops it reaches. + fn chain(&mut self, start: usize, passed: &mut [bool], out: &mut Vec<(usize, usize)>) { + let mut at = Some(start); + let mut steps = 0usize; + let limit = self.nodes.len().saturating_mul(MAX_DEPTH + 2).saturating_add(16); + while let Some(id) = at { + steps += 1; + match passed.get_mut(id) { + Some(p) if !*p && steps <= limit => *p = true, + _ => return, + } + let Some(node) = self.nodes.get(id) else { return }; + let (class, shown, first, stop) = (node.class, node.shown, node.first, node.stop); + let entry = self.kid_from(id, 0); + at = if class.is_container() && shown { + match first.and_then(|r| self.target(id, r)) { + Some(t) => Some(t), + None => entry.or_else(|| self.after(id)), + } + } else { + if let Some(w) = stop { + out.push(w); + } + self.after(id) + }; + } + } + + /// The stops in the default order: shown containers' children in their Tab order. + fn default_stops(&self, root: usize) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![root]; + while let Some(id) = stack.pop() { + let Some(n) = self.nodes.get(id) else { continue }; + if n.stop.is_some() { + out.push(id); + } + if n.class.is_container() && n.shown { + stack.extend(n.tab.iter().rev().copied()); + } + } + out + } +} + +/// Write each page's widgets in the template's Tab order. Widgets the order doesn't place +/// (protected fields, master pages, a header row repeated after a page break) keep their +/// places; so does everything when the form is too large to work out. +pub(crate) fn order_widgets(root: &Node, tpl: &Template, data: Option<&DataNode>, pages: &mut [Page]) { + let mut tree = Tree::new(tpl, data, pages); + tree.add(root, None, "", 0, true, 0); + if tree.too_big || tree.nodes.is_empty() { + return; + } + let mut passed = vec![false; tree.nodes.len()]; + let mut order: Vec<(usize, usize)> = Vec::new(); + tree.chain(0, &mut passed, &mut order); + for s in tree.default_stops(0) { + if !passed.get(s).copied().unwrap_or(true) { + tree.chain(s, &mut passed, &mut order); + } + } + let rank: HashMap<(usize, usize), usize> = order.iter().enumerate().map(|(r, w)| (*w, r)).collect(); + for (pi, page) in pages.iter_mut().enumerate() { + let slots: Vec = (0..page.items.len()).filter(|i| rank.contains_key(&(pi, *i))).collect(); + let mut ranked: Vec<(usize, usize)> = slots.iter().map(|i| (rank.get(&(pi, *i)).copied().unwrap_or(usize::MAX), *i)).collect(); + ranked.sort_unstable(); + // Slot j takes the widget ranked j-th on the page; everything else stays put. + let mut perm: Vec = (0..page.items.len()).collect(); + for (slot, (_, from)) in slots.iter().zip(&ranked) { + if let Some(p) = perm.get_mut(*slot) { + *p = *from; + } + } + let mut check = perm.clone(); + check.sort_unstable(); + if !check.iter().enumerate().all(|(i, v)| i == *v) { + continue; + } + let mut items: Vec> = std::mem::take(&mut page.items).into_iter().map(Some).collect(); + page.items = perm.iter().filter_map(|i| items.get_mut(*i).and_then(Option::take)).collect(); + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ae3261876d..a62f0611d8 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5279,8 +5279,8 @@ milestone = "M6" status = "shipped" commands = ["form.tab_order.row", "form.tab_order.column", "form.tab_order.structure"] tools = ["form_tab_order"] -evidence = ["crates/forms/src/tests.rs::tab_order_follows_the_page_setting", "crates/automation/tests/it/automation.rs::tab_order_and_field_appearance_through_tools", "crates/forms/src/tests.rs::ordering_tabs_manually", "crates/ui-egui/tests/it/prepare.rs::the_fields_panel_orders_tabs_manually"] -notes = "rows, columns, structure/annotation order (page /Tabs), and manual order from the Fields panel while preparing a form; honoured by Tab" +evidence = ["crates/forms/src/tests.rs::tab_order_follows_the_page_setting", "crates/automation/tests/it/automation.rs::tab_order_and_field_appearance_through_tools", "crates/forms/src/tests.rs::ordering_tabs_manually", "crates/ui-egui/tests/it/prepare.rs::the_fields_panel_orders_tabs_manually", "crates/xfa/src/tests.rs::widgets_are_written_in_the_templates_tab_order", "crates/xfa/src/tests.rs::links_to_hidden_subforms_are_ignored_and_other_non_stops_pass_tab_on", "crates/xfa/src/tests.rs::class_and_dotted_traversal_references_resolve_as_som_paths", "crates/xfa/src/tests.rs::looping_and_dangling_traversal_links_keep_every_field_once", "crates/xfa/src/tests.rs::repeated_subform_instances_are_tabbed_one_after_another", "crates/xfa/src/tests.rs::traversal_links_are_parsed", "crates/engine/src/tests.rs::xfa_forms_tab_in_their_templates_traversal_order"] +notes = "rows, columns, structure/annotation order (page /Tabs), and manual order from the Fields panel while preparing a form; honoured by Tab. A laid-out dynamic XFA form tabs as its template's says, as measured on two synthetic forms in another viewer: next links, a container's first link, rows then columns without links, hidden, invisible and protected fields passed over (a link to one goes on by its own link), a hidden subform passed over as if absent (a link to it is ignored), and the stops a chain never reached afterwards. Missing for XFA: read-only fields are stops there but Tab skips them here; the arrow-key traversal links; traversal scripts; the order across pages follows the pages" [[feature]] id = "form.show-tab-numbers"