mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
M12: dynamic XFA forms tab in their template's traversal order (#4149)
This commit is contained in:
1 parent
67cd69050e
commit
ea2e13370c
10 files changed
+753
-10
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
|
||||
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
|
||||
<subform name="form1" layout="tb">
|
||||
<pageSet><pageArea name="front"><contentArea x="0.25in" y="0.25in" w="8in" h="10.5in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
|
||||
<subform name="page" layout="position" w="8in" h="10in">
|
||||
<field name="log" x="0in" y="0in" w="8in" h="1.1in" access="protected"><ui><textEdit multiLine="1"/></ui></field>
|
||||
<field name="start" x="0in" y="1.3in" w="1.5in" h="0.35in"><ui><textEdit/></ui></field>
|
||||
<draw x="0in" y="1.85in" w="7.5in" h="0.3in"><value><text>Section A: no links (written A1 A2 A3 A4, placed A2 A3 / A4 A1)</text></value></draw>
|
||||
<field name="A1" x="3in" y="2.65in" w="1.5in" h="0.35in"><ui><textEdit/></ui></field>
|
||||
<field name="A2" x="0in" y="2.2in" w="1.5in" h="0.35in"><ui><textEdit/></ui></field>
|
||||
<field name="A3" x="3in" y="2.2in" w="1.5in" h="0.35in"><ui><textEdit/></ui></field>
|
||||
<field name="A4" x="0in" y="2.65in" w="1.5in" h="0.35in"><ui><textEdit/></ui></field>
|
||||
<draw x="0in" y="3.2in" w="7.5in" h="0.3in"><value><text>Section B: next links B1 to B3, B3 to B2, B2 to B4</text></value></draw>
|
||||
<field name="B1" x="0in" y="3.55in" w="1.5in" h="0.35in"><ui><textEdit/></ui><traversal><traverse ref="B3[0]"/></traversal></field>
|
||||
<field name="B2" x="2in" y="3.55in" w="1.5in" h="0.35in"><ui><textEdit/></ui><traversal><traverse ref="B4[0]"/></traversal></field>
|
||||
<field name="B3" x="4in" y="3.55in" w="1.5in" h="0.35in"><ui><textEdit/></ui><traversal><traverse ref="B2[0]"/></traversal></field>
|
||||
<field name="B4" x="6in" y="3.55in" w="1.5in" h="0.35in"><ui><textEdit/></ui></field>
|
||||
<draw x="0in" y="4.1in" w="7.5in" h="0.3in"><value><text>Section C: the subform's first link goes to a label, which links to C3</text></value></draw>
|
||||
<subform name="C" x="0in" y="4.45in" w="8in" h="0.4in" layout="position"><traversal><traverse operation="first" ref="Clabel[0]"/></traversal>
|
||||
<draw name="Clabel" x="0in" y="0in" w="0.6in" h="0.35in"><value><text>C:</text></value><traversal><traverse ref="C3[0]"/></traversal></draw>
|
||||
<field name="C1" x="0.7in" y="0in" w="1.5in" h="0.35in"><ui><textEdit/></ui></field>
|
||||
<field name="C2" x="2.9in" y="0in" w="1.5in" h="0.35in"><ui><textEdit/></ui></field>
|
||||
<field name="C3" x="5.1in" y="0in" w="1.5in" h="0.35in"><ui><textEdit/></ui></field>
|
||||
</subform>
|
||||
<draw x="0in" y="5.0in" w="7.5in" h="0.3in"><value><text>Section D: D2 hidden, D3 invisible, D4 read-only, D5 protected</text></value></draw>
|
||||
<field name="D1" x="0in" y="5.35in" w="1.2in" h="0.35in"><ui><textEdit/></ui></field>
|
||||
<field name="D2" x="1.35in" y="5.35in" w="1.2in" h="0.35in" presence="hidden"><ui><textEdit/></ui></field>
|
||||
<field name="D3" x="2.7in" y="5.35in" w="1.2in" h="0.35in" presence="invisible"><ui><textEdit/></ui></field>
|
||||
<field name="D4" x="4.05in" y="5.35in" w="1.2in" h="0.35in" access="readOnly"><ui><textEdit/></ui></field>
|
||||
<field name="D5" x="5.4in" y="5.35in" w="1.2in" h="0.35in" access="protected"><ui><textEdit/></ui></field>
|
||||
<field name="D6" x="6.75in" y="5.35in" w="1.2in" h="0.35in"><ui><textEdit/></ui></field>
|
||||
<field name="finish" x="0in" y="6.0in" w="1.5in" h="0.35in"><ui><textEdit/></ui></field>
|
||||
</subform>
|
||||
</subform>
|
||||
</template>
|
||||
</xdp:xdp>
|
||||
"##
|
||||
.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##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
|
||||
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
|
||||
<subform name="form1" layout="tb">
|
||||
<pageSet><pageArea name="front"><contentArea x="0.25in" y="0.25in" w="8in" h="10.5in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
|
||||
<subform name="page" layout="position" w="8in" h="10in">
|
||||
<field name="start" x="0in" y="1.3in" w="1.5in" h="0.35in"><ui><textEdit/></ui></field>
|
||||
<draw x="0in" y="1.85in" w="7.5in" h="0.3in"><value><text>Section E: E1 links to ET, a hidden subform between E2 and E3, which links to E4</text></value></draw>
|
||||
<field name="E1" x="0in" y="2.2in" w="1.3in" h="0.35in"><ui><textEdit/></ui><traversal><traverse ref="ET[0]"/></traversal></field>
|
||||
<field name="E2" x="1.6in" y="2.2in" w="1.3in" h="0.35in"><ui><textEdit/></ui></field>
|
||||
<subform name="ET" x="3.0in" y="2.2in" w="1.3in" h="0.35in" layout="position" presence="hidden"><traversal><traverse ref="E4[0]"/></traversal>
|
||||
<field name="ETin" x="0in" y="0in" w="1.3in" h="0.35in"><ui><textEdit/></ui></field>
|
||||
</subform>
|
||||
<field name="E3" x="4.4in" y="2.2in" w="1.3in" h="0.35in"><ui><textEdit/></ui></field>
|
||||
<field name="E4" x="6.0in" y="2.2in" w="1.3in" h="0.35in"><ui><textEdit/></ui></field>
|
||||
<draw x="0in" y="2.75in" w="7.5in" h="0.3in"><value><text>Section F: F1 links to FT, a hidden field between F2 and F3, which links to F4</text></value></draw>
|
||||
<field name="F1" x="0in" y="3.1in" w="1.3in" h="0.35in"><ui><textEdit/></ui><traversal><traverse ref="FT[0]"/></traversal></field>
|
||||
<field name="F2" x="1.6in" y="3.1in" w="1.3in" h="0.35in"><ui><textEdit/></ui></field>
|
||||
<field name="FT" x="3.0in" y="3.1in" w="1.3in" h="0.35in" presence="hidden"><ui><textEdit/></ui><traversal><traverse ref="F4[0]"/></traversal></field>
|
||||
<field name="F3" x="4.4in" y="3.1in" w="1.3in" h="0.35in"><ui><textEdit/></ui></field>
|
||||
<field name="F4" x="6.0in" y="3.1in" w="1.3in" h="0.35in"><ui><textEdit/></ui></field>
|
||||
<draw x="0in" y="3.65in" w="7.5in" h="0.3in"><value><text>Section G: G1 links to GT, an invisible field between G2 and G3, which links to G4</text></value></draw>
|
||||
<field name="G1" x="0in" y="4.0in" w="1.3in" h="0.35in"><ui><textEdit/></ui><traversal><traverse ref="GT[0]"/></traversal></field>
|
||||
<field name="G2" x="1.6in" y="4.0in" w="1.3in" h="0.35in"><ui><textEdit/></ui></field>
|
||||
<field name="GT" x="3.0in" y="4.0in" w="1.3in" h="0.35in" presence="invisible"><ui><textEdit/></ui><traversal><traverse ref="G4[0]"/></traversal></field>
|
||||
<field name="G3" x="4.4in" y="4.0in" w="1.3in" h="0.35in"><ui><textEdit/></ui></field>
|
||||
<field name="G4" x="6.0in" y="4.0in" w="1.3in" h="0.35in"><ui><textEdit/></ui></field>
|
||||
<draw x="0in" y="4.55in" w="7.5in" h="0.3in"><value><text>Section H: H1 links to HT, a protected field between H2 and H3, which links to H4</text></value></draw>
|
||||
<field name="H1" x="0in" y="4.9in" w="1.3in" h="0.35in"><ui><textEdit/></ui><traversal><traverse ref="HT[0]"/></traversal></field>
|
||||
<field name="H2" x="1.6in" y="4.9in" w="1.3in" h="0.35in"><ui><textEdit/></ui></field>
|
||||
<field name="HT" x="3.0in" y="4.9in" w="1.3in" h="0.35in" access="protected"><ui><textEdit/></ui><traversal><traverse ref="H4[0]"/></traversal></field>
|
||||
<field name="H3" x="4.4in" y="4.9in" w="1.3in" h="0.35in"><ui><textEdit/></ui></field>
|
||||
<field name="H4" x="6.0in" y="4.9in" w="1.3in" h="0.35in"><ui><textEdit/></ui></field>
|
||||
<field name="finish" x="0in" y="5.5in" w="1.5in" h="0.35in"><ui><textEdit/></ui></field>
|
||||
</subform>
|
||||
</subform>
|
||||
</template>
|
||||
</xdp:xdp>
|
||||
"##
|
||||
.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;
|
||||
|
||||
@@ -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<Form, XfaError> {
|
||||
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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod picture;
|
||||
mod proto;
|
||||
pub mod script;
|
||||
pub mod text;
|
||||
mod traverse;
|
||||
|
||||
use pdfcraft_cos::Document;
|
||||
|
||||
|
||||
@@ -387,12 +387,25 @@ pub struct Common {
|
||||
pub para: ParaSpec,
|
||||
/// Table cells: how many columns this cell spans.
|
||||
pub col_span: usize,
|
||||
/// `<traversal>`: 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<Props>,
|
||||
}
|
||||
|
||||
/// A container's or object's `<traversal>` 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<String>,
|
||||
/// `operation="next"` (the default): where Tab goes after this object, or after the last
|
||||
/// stop inside a container.
|
||||
pub next: Option<String>,
|
||||
}
|
||||
|
||||
/// 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),
|
||||
|
||||
+21
-1
@@ -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<String>) -> Common {
|
||||
font: parse_font(child(n, "font")),
|
||||
para: parse_para(child(n, "para")),
|
||||
col_span: n.attribute("colSpan").and_then(|c| c.trim().parse::<usize>().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;
|
||||
|
||||
/// `<traversal><traverse operation="first|next" ref="…"/>`: the first of each kept. A
|
||||
/// `<traverse>` that computes its target with a script, or names none, is ignored.
|
||||
fn parse_traversal(n: Option<XmlNode>) -> 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
|
||||
|
||||
+160
-1
@@ -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#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
|
||||
<subform name="form1" layout="tb"><pageSet><pageArea><contentArea x="0.25in" y="0.25in" w="8in" h="10.5in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
|
||||
<subform name="page" layout="position" w="8in" h="10in">{body}</subform></subform></template></xdp:xdp>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// The SOM paths of the widgets on the first page, in the order they are written.
|
||||
fn widget_soms(xml: &str) -> Vec<String> {
|
||||
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#"<traversal><traverse ref="{link}"/></traversal>"#) };
|
||||
format!(r#"<field name="{n}" x="{x}" y="{y}" w="1.5in" h="0.3in"><ui><textEdit/></ui>{t}</field>"#)
|
||||
};
|
||||
let body = format!(
|
||||
r#"{}<subform name="S" x="0in" y="1in" w="8in" h="3in" layout="position">
|
||||
<draw name="head" x="0in" y="0in" w="2in" h="0.3in"><value><text>Head</text></value></draw>
|
||||
<draw x="0in" y="0.4in" w="2in" h="0.3in"><value><text>Note</text></value><traversal><traverse ref="z[0]"/></traversal></draw>
|
||||
{}{}{}</subform>{}"#,
|
||||
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#"<field name="{n}" x="0in" y="{y}" w="1.5in" h="0.3in"><ui><textEdit/></ui><traversal><traverse ref="{link}"/></traversal></field>"#
|
||||
)
|
||||
};
|
||||
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#"<subform name="rows" x="0in" y="0in" w="8in" h="3in" layout="tb">
|
||||
<subform name="row" layout="position" w="8in" h="0.5in"><occur min="3" max="5" initial="3"/>
|
||||
<field name="b" x="3in" y="0in" w="1.5in" h="0.3in"><ui><textEdit/></ui></field>
|
||||
<field name="a" x="0in" y="0in" w="1.5in" h="0.3in"><ui><textEdit/></ui></field>
|
||||
</subform></subform>"#;
|
||||
let soms = widget_soms(&positioned_form(body));
|
||||
let short: Vec<String> = 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]"]);
|
||||
}
|
||||
|
||||
/// `<traversal>` 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##"<field name="f" x="0in" y="0in" w="1in" h="0.3in"><ui><textEdit/></ui><traversal><traverse operation="down" ref="d[0]"/><traverse ref=" n[0] "/><traverse operation="next" ref="later[0]"/><traverse operation="first" ref="#draw[2]"/><traverse operation="next"/></traversal></field>"##,
|
||||
))
|
||||
.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#"<traversal><traverse ref="{link}"/></traversal>"#) };
|
||||
format!(r#"<field name="{n}" x="0in" y="{y}" w="1.5in" h="0.3in"><ui><textEdit/></ui>{t}</field>"#)
|
||||
};
|
||||
let rows = r#"<subform name="rows" x="0in" y="2in" w="8in" h="2in" layout="tb">
|
||||
<subform name="row" layout="position" w="8in" h="0.4in"><occur min="1" max="5" initial="3"/>
|
||||
<draw x="0in" y="0in" w="2in" h="0.3in"><value><text>Row</text></value><traversal><traverse ref="z[0]"/></traversal></draw>
|
||||
</subform></subform>"#;
|
||||
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 `<passwordEdit/>` fields from the
|
||||
/// datasets packet (including across repeating subforms), restores a field's template default
|
||||
/// `<value><text>…</text></value>` when it has one, and replaces the datasets stream in place so
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
//! Tab order of a laid-out form, from the template's `<traversal>` 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<Class> {
|
||||
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<usize>,
|
||||
/// Children in document order (what SOM indices count).
|
||||
kids: Vec<usize>,
|
||||
/// Children in the default Tab order.
|
||||
tab: Vec<usize>,
|
||||
/// 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<usize>>,
|
||||
by_class: HashMap<Class, Vec<usize>>,
|
||||
}
|
||||
|
||||
struct Tree<'t> {
|
||||
nodes: Vec<TNode<'t>>,
|
||||
/// The laid-out widgets by SOM path, in layout order; each is claimed once.
|
||||
widgets: HashMap<String, Vec<(usize, usize)>>,
|
||||
/// 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<usize, SomIndex<'t>>,
|
||||
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<Item = Option<&'a str>>) -> Vec<usize> {
|
||||
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<String, Vec<(usize, usize)>> = 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<usize>, shown: bool) -> Option<usize> {
|
||||
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<usize>, 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<usize> = 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<usize> {
|
||||
let (head, index) = match part.find('[') {
|
||||
Some(i) => (part.get(..i)?, part.get(i + 1..)?.strip_suffix(']')?.trim().parse::<usize>().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<usize> {
|
||||
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<usize> {
|
||||
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<usize> {
|
||||
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<usize> {
|
||||
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<usize> {
|
||||
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<usize> = (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<usize> = (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<Option<Item>> = 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();
|
||||
}
|
||||
}
|
||||
@@ -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 <traversal> 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"
|
||||
|
||||
Reference in new issue
Block a user