Skip to main content

pdf_xfa/
session.rs

1//! `XfaSession` — parse-once interactive fill session over an XFA PDF.
2//!
3//! **Phase 1** ([`open`](XfaSession::open) / [`set_value`](XfaSession::set_value)
4//! / [`save_to_bytes`](XfaSession::save_to_bytes)): enumerate the currently
5//! visible/layouted fields, set values (with write-through to the bound data
6//! nodes), and save a PDF whose datasets packet carries the filled values so
7//! Adobe Acrobat/Reader reopens the form with them. No scripts run and the
8//! layout is not recomputed.
9//!
10//! **Phase 2** ([`open_interactive`](XfaSession::open_interactive) /
11//! [`commit_value`](XfaSession::commit_value)): the interactive commit loop —
12//! a value commit fires the field's `change`/`click` scripts and a `calculate`
13//! pass, then re-layouts, so conditional sections the value reveals or hides
14//! are reflected in the refreshed [`fields`](XfaSession::fields) /
15//! [`page_count`](XfaSession::page_count). Requires the `xfa-js-sandboxed`
16//! feature; without it `commit_value` degrades to a value write plus re-layout.
17//! Still out of scope: instanceManager add/remove and form-packet *structural*
18//! writes.
19//!
20//! ```no_run
21//! use pdf_xfa::session::{XfaSession, XfaWriteValue};
22//!
23//! let bytes = std::fs::read("form.pdf").unwrap();
24//! let mut session = XfaSession::open(&bytes).unwrap();
25//! println!("pages: {}", session.page_count());
26//! for f in session.fields() {
27//!     println!("{} = {:?}", f.name, f.value);
28//! }
29//! session
30//!     .set_value("form1.applicant.name", XfaWriteValue::Text("Alice"))
31//!     .unwrap();
32//! let filled = session.save_to_bytes().unwrap();
33//! std::fs::write("filled.pdf", filled).unwrap();
34//! ```
35
36// Copyright (c) 2026 Innovation Trigger B.V.
37//
38// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
39// the PDFluent Commercial Licence. See the LICENSE file in this repository --
40// that file travels with the copy you received, which a URL does not.
41
42use std::collections::HashMap;
43
44use lopdf::Document;
45use xfa_dom_resolver::data_dom::{DataDom, DataNode, DataNodeId};
46use xfa_layout_engine::form::{
47    Access, FieldKind, FormNodeId, FormNodeType, FormTree, GroupKind, Presence,
48};
49use xfa_layout_engine::layout::{LayoutContent, LayoutDom, LayoutEngine, LayoutNode};
50
51use crate::datasets_writeback::{self, DatasetsEdit, FormPacketEdit};
52#[cfg(feature = "xfa-js-sandboxed")]
53use crate::dynamic::InteractiveTrigger;
54use crate::dynamic::{apply_dynamic_scripts, PresenceChange};
55pub use crate::dynamic::{XfaScriptError, XfaScriptErrorKind, XfaScriptReport, XfaScriptsSkipped};
56use crate::error::{Result, XfaError};
57use crate::extract::{extract_xfa_from_bytes, XfaPackets};
58use crate::flatten::{
59    apply_form_dom_presence, extract_embedded_images, inject_resolved_metrics,
60    resolve_template_fonts, XfaRenderingPolicy,
61};
62use crate::merger::FormMerger;
63
64/// A value to write into an XFA field.
65#[derive(Debug, Clone, Copy)]
66pub enum XfaWriteValue<'a> {
67    /// Text-like fields: text, multiline text, numeric, date/time, password,
68    /// and choice lists (pass the save value or the display value).
69    Text(&'a str),
70    /// Checkbox: `true` selects the on-value, `false` the off-value.
71    Checkbox(bool),
72    /// Radio group (exclGroup): the on-value of the member to select.
73    Radio(&'a str),
74}
75
76/// Public field type, mirroring [`FieldKind`] without leaking the
77/// layout-engine type into the SDK surface.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum XfaFieldType {
80    /// Single-line or multiline text edit.
81    Text,
82    /// Standalone checkbox.
83    Checkbox,
84    /// Exclusive radio group (exclGroup with checkButton members).
85    RadioGroup,
86    /// Push button (not fillable).
87    Button,
88    /// Dropdown / choice list.
89    Dropdown,
90    /// Signature field (not fillable in Phase 1).
91    Signature,
92    /// Date/time picker (accepts text in Phase 1).
93    DateTime,
94    /// Numeric edit (accepts text in Phase 1).
95    Numeric,
96    /// Password edit.
97    Password,
98    /// Image edit (not fillable in Phase 1).
99    Image,
100    /// Barcode (not fillable).
101    Barcode,
102}
103
104impl XfaFieldType {
105    fn from_kind(kind: FieldKind) -> Self {
106        match kind {
107            FieldKind::Text => XfaFieldType::Text,
108            FieldKind::Checkbox => XfaFieldType::Checkbox,
109            FieldKind::Radio => XfaFieldType::RadioGroup,
110            FieldKind::Button => XfaFieldType::Button,
111            FieldKind::Dropdown => XfaFieldType::Dropdown,
112            FieldKind::Signature => XfaFieldType::Signature,
113            FieldKind::DateTimePicker => XfaFieldType::DateTime,
114            FieldKind::NumericEdit => XfaFieldType::Numeric,
115            FieldKind::PasswordEdit => XfaFieldType::Password,
116            FieldKind::ImageEdit => XfaFieldType::Image,
117            FieldKind::Barcode => XfaFieldType::Barcode,
118        }
119    }
120
121    /// Whether [`XfaSession::set_value`] accepts writes for this type.
122    pub fn is_fillable(self) -> bool {
123        !matches!(
124            self,
125            XfaFieldType::Button
126                | XfaFieldType::Signature
127                | XfaFieldType::Image
128                | XfaFieldType::Barcode
129        )
130    }
131}
132
133/// Axis-aligned rectangle in page space, points, top-left origin (XFA
134/// coordinate convention: y grows downward from the top of the page).
135#[derive(Debug, Clone, Copy, PartialEq)]
136pub struct XfaRect {
137    /// Left edge.
138    pub x: f64,
139    /// Top edge.
140    pub y: f64,
141    /// Width.
142    pub width: f64,
143    /// Height.
144    pub height: f64,
145}
146
147/// One layouted widget occurrence of a field.
148#[derive(Debug, Clone)]
149pub struct XfaWidget {
150    /// 0-based page index in the layout result.
151    pub page: usize,
152    /// Widget rectangle (page space, top-left origin, points).
153    pub rect: XfaRect,
154    /// For radio groups: the on-value asserted by this member widget.
155    pub on_value: Option<String>,
156}
157
158/// A selectable option of a dropdown / list field.
159#[derive(Debug, Clone)]
160pub struct XfaFieldOption {
161    /// User-visible text.
162    pub display: String,
163    /// Persisted save value (equals `display` when the template defines no
164    /// separate save items).
165    pub save: String,
166}
167
168/// SDK-level model of one fillable XFA form field, derived from the merged
169/// form tree and the current layout.
170#[derive(Debug, Clone)]
171pub struct XfaFieldModel {
172    /// Fully-qualified dotted name with `[n]` indices omitted when 0
173    /// (Adobe display-SOM style), e.g. `form1.applicant.name` or
174    /// `form1.rows.row[2].amount`.
175    pub name: String,
176    /// Fully-qualified SOM path with explicit `[n]` on every segment,
177    /// e.g. `form1[0].rows[0].row[2].amount[0]`.
178    pub som_path: String,
179    /// Field type.
180    pub field_type: XfaFieldType,
181    /// Current value (the form-tree raw value; radio groups report the
182    /// selected member's on-value, empty when none selected).
183    pub value: String,
184    /// Effective read-only state: the field or an ancestor container sets
185    /// `access="readOnly" | "protected" | "nonInteractive"`.
186    pub read_only: bool,
187    /// Mandatory field (`<validate nullTest="error">`).
188    pub required: bool,
189    /// Multiline text edit (`<ui><textEdit multiLine="1">`).
190    pub multiline: bool,
191    /// Hidden/invisible/inactive via XFA presence (template, saved form
192    /// state, or script-derived).
193    pub hidden: bool,
194    /// Dropdown/list options. For radio groups: one option per member
195    /// (display = member on-value).
196    pub options: Vec<XfaFieldOption>,
197    /// Checkbox on-value (`<items>` / checkButton binding), when known.
198    pub on_value: Option<String>,
199    /// Checkbox off-value, when known.
200    pub off_value: Option<String>,
201    /// First page this field appears on (0-based), `None` when the field is
202    /// not part of the current layout (e.g. hidden).
203    pub page: Option<usize>,
204    /// Rectangle of the first widget occurrence.
205    pub rect: Option<XfaRect>,
206    /// All layouted widget occurrences (radio groups: one per member).
207    pub widgets: Vec<XfaWidget>,
208    /// Whether the field has a bound data node in the datasets DOM. Values
209    /// written to unbound fields are persisted by creating the data node on
210    /// demand (default binding), except for `bind match="none"` fields.
211    pub bound_to_data: bool,
212    /// `<bind match="none">` — the field deliberately does not participate
213    /// in data binding; Adobe persists such values only in the form packet.
214    pub bind_none: bool,
215}
216
217/// Outcome of a successful [`XfaSession::set_value`] call.
218#[derive(Debug, Clone)]
219pub struct XfaSetOutcome {
220    /// The normalized value written to the form tree (e.g. the resolved
221    /// save value of a dropdown, or a checkbox on/off value).
222    pub raw_value: String,
223    /// The value was written through to a datasets node (existing or
224    /// created on demand) and will be persisted by [`XfaSession::save_to_bytes`].
225    pub persisted_to_datasets: bool,
226}
227
228/// Outcome of an interactive [`XfaSession::commit_value`] (Phase 2).
229///
230/// Extends the plain value write with the script-driven dynamic effects: the
231/// commit fired the field's `change`/`click` scripts and a `calculate` pass,
232/// then re-layouted. `presence_changes` and the page-count delta describe what
233/// the commit revealed or hid.
234#[derive(Debug, Clone)]
235pub struct XfaCommitOutcome {
236    /// The underlying value write (same as [`XfaSession::set_value`]).
237    pub set: XfaSetOutcome,
238    /// Whether interactive scripts ran (false when the `xfa-js-sandboxed`
239    /// feature is not compiled in — the commit then only re-layouts).
240    pub interactive: bool,
241    /// Number of event scripts the runtime executed during this commit.
242    pub scripts_executed: usize,
243    /// Page count before the commit.
244    pub page_count_before: usize,
245    /// Page count after re-layout.
246    pub page_count_after: usize,
247    /// Fields/subforms whose presence changed (revealed or hidden) as a result
248    /// of the committed value.
249    pub presence_changes: Vec<PresenceChange>,
250}
251
252/// Report from a save/writeback.
253#[derive(Debug, Clone, Default)]
254pub struct XfaWritebackReport {
255    /// Datasets packet rewritten by surgical splice (original XML preserved
256    /// except for the changed values). `false` = lossless splice failed and
257    /// the data section was regenerated from the data DOM instead.
258    pub datasets_spliced: bool,
259    /// Number of `<value>` updates applied to the saved form packet (kept in
260    /// sync so Adobe's `restoreState` does not resurrect stale values).
261    pub form_packet_values_updated: usize,
262}
263
264struct FieldEntry {
265    /// The field node (for radio groups: the exclGroup node).
266    node: FormNodeId,
267    /// Member fields of a radio group, with their on-values.
268    members: Vec<(FormNodeId, String)>,
269    /// Effective access at enumeration time.
270    read_only: bool,
271    /// Path of named segments from the root, with same-name sibling indices.
272    segments: Vec<(String, usize)>,
273}
274
275/// Parse-once session over a single XFA PDF.
276///
277/// Holds the extracted packets, the parsed datasets DOM, the merged form
278/// tree, and the layout result. Field enumeration and value writes operate
279/// on the cached state; no re-parse or re-layout happens per call (Phase 1:
280/// value writes do not reflow the layout).
281pub struct XfaSession {
282    pdf_bytes: Vec<u8>,
283    packets: XfaPackets,
284    data_dom: DataDom,
285    tree: FormTree,
286    layout: LayoutDom,
287    /// Root form node id — retained for re-layout in the Phase 2 commit loop.
288    root_id: FormNodeId,
289    fields: Vec<FieldEntry>,
290    models: Vec<XfaFieldModel>,
291    by_name: HashMap<String, usize>,
292    /// Data nodes whose values changed (existing nodes).
293    dirty_data: Vec<DataNodeId>,
294    /// Data nodes created on demand: (id, parent path at creation time).
295    created_data: Vec<DataNodeId>,
296    /// Form-packet value syncs to apply at save.
297    form_packet_sync: Vec<FormPacketEdit>,
298    /// Field values as parsed, before any script ran: the baseline a commit
299    /// compares against to find the values scripts wrote.
300    baseline_values: HashMap<String, String>,
301    /// Scripts not run, accumulated over the session (see
302    /// [`XfaSession::script_report`]).
303    script_report: XfaScriptReport,
304    /// Phase 2: interactive mode is active — the open lifecycle ran with
305    /// presence writes landing, and [`XfaSession::commit_value`] dispatches
306    /// user-edit scripts + re-layouts. Set by `open_interactive`/lazy upgrade.
307    ///
308    /// The sandboxed runtime is **not** held on the session — it is constructed
309    /// per episode (open lifecycle / each commit) and dropped — so `XfaSession`
310    /// stays `Send + Sync` (the rquickjs context is `!Send`). The interactive
311    /// *state* lives in `tree` (presence values), which persists across commits.
312    interactive: bool,
313}
314
315impl XfaSession {
316    /// Open a session over the given PDF bytes.
317    ///
318    /// Mirrors the flatten pipeline's Extract → Bind → (static scripts) →
319    /// saved-form-state → Layout stages, then caches everything.
320    ///
321    /// Returns [`XfaError::PacketNotFound`] when the PDF has no XFA template
322    /// packet (i.e. it is not an XFA form).
323    pub fn open(pdf_bytes: &[u8]) -> Result<XfaSession> {
324        let packets = extract_xfa_from_bytes(pdf_bytes.to_vec())?;
325        let template_xml = packets
326            .template()
327            .ok_or_else(|| XfaError::PacketNotFound("template".to_string()))?
328            .to_string();
329
330        // A malformed or empty datasets packet must not block the session —
331        // the form is still fillable; the writeback regenerates a canonical
332        // packet from scratch (Adobe tolerates both).
333        let data_dom = match packets.datasets() {
334            Some(ds) => DataDom::from_xml(ds).unwrap_or_else(|e| {
335                log::warn!("XfaSession: datasets packet unparseable ({e}); starting empty");
336                DataDom::new()
337            }),
338            None => DataDom::new(),
339        };
340
341        let image_files = match Document::load_mem(pdf_bytes) {
342            Ok(doc) => extract_embedded_images(&doc),
343            Err(_) => HashMap::new(),
344        };
345
346        let merger = FormMerger::new(&data_dom).with_image_files(image_files);
347        let (mut tree, root_id) = merger
348            .merge(&template_xml)
349            .map_err(|e| XfaError::ParseFailed(format!("template merge: {e}")))?;
350
351        // Static-analysis script pass only (BestEffortStatic): presence
352        // toggles and FormCalc the engine can resolve without a JS runtime.
353        // Phase 1 deliberately runs no change/click events.
354        let _ = apply_dynamic_scripts(&mut tree, root_id)?;
355
356        // Saved form state (Adobe-Reader-saved PDFs): presence overrides and
357        // saved field values, exactly as the flatten pipeline applies them.
358        if let Some(form_xml) = packets.get_packet("form") {
359            let _ = apply_form_dom_presence(
360                &mut tree,
361                root_id,
362                form_xml,
363                XfaRenderingPolicy::SavedStateFaithful,
364                true,
365            );
366            apply_form_dom_access(&mut tree, root_id, form_xml);
367        }
368
369        // Resolved font metrics make the layout geometry match the flatten
370        // output (widths drive wrapping and therefore field positions).
371        let resolved = resolve_template_fonts(&template_xml, pdf_bytes);
372        inject_resolved_metrics(&mut tree, &resolved);
373
374        let layout = LayoutEngine::new(&tree)
375            .layout(root_id)
376            .map_err(|e| XfaError::LayoutFailed(e.to_string()))?;
377
378        let mut session = XfaSession {
379            pdf_bytes: pdf_bytes.to_vec(),
380            packets,
381            data_dom,
382            tree,
383            layout,
384            root_id,
385            fields: Vec::new(),
386            models: Vec::new(),
387            by_name: HashMap::new(),
388            dirty_data: Vec::new(),
389            created_data: Vec::new(),
390            form_packet_sync: Vec::new(),
391            baseline_values: HashMap::new(),
392            script_report: XfaScriptReport::default(),
393            interactive: false,
394        };
395        session.enumerate_fields(root_id);
396        session.baseline_values = session
397            .models
398            .iter()
399            .map(|m| (m.name.clone(), m.value.clone()))
400            .collect();
401        Ok(session)
402    }
403
404    /// Open a session in **interactive mode** (Phase 2).
405    ///
406    /// Identical to [`open`](Self::open), then — when the `xfa-js-sandboxed`
407    /// feature is compiled in — runs the interactive open lifecycle
408    /// (`initialize` → `docReady` → `calculate`) with `field.presence` writes
409    /// landing in the tree, re-layouts, and re-enumerates. The result reflects
410    /// a dynamic form's real initial visible state (the script-driven
411    /// show/hide of conditional sections), not the static one-shot render.
412    ///
413    /// Without the feature this is equivalent to [`open`](Self::open) and
414    /// [`commit_value`](Self::commit_value) degrades to a value write plus
415    /// re-layout (no script-driven toggles).
416    pub fn open_interactive(pdf_bytes: &[u8]) -> Result<XfaSession> {
417        let mut session = XfaSession::open(pdf_bytes)?;
418        session.ensure_interactive();
419        Ok(session)
420    }
421
422    /// Number of pages in the cached layout result.
423    pub fn page_count(&self) -> usize {
424        self.layout.pages.len()
425    }
426
427    /// Page dimensions `(width, height)` in points for a 0-based page index.
428    pub fn page_size(&self, page: usize) -> Option<(f64, f64)> {
429        self.layout.pages.get(page).map(|p| (p.width, p.height))
430    }
431
432    /// The enumerated field models, in document order.
433    pub fn fields(&self) -> &[XfaFieldModel] {
434        &self.models
435    }
436
437    /// Look up one field model by fully-qualified name. Accepts both the
438    /// display name (`a.b[2].c`) and the explicit SOM path (`a[0].b[2].c[0]`).
439    pub fn field(&self, name: &str) -> Option<&XfaFieldModel> {
440        self.by_name.get(name).map(|&i| &self.models[i])
441    }
442
443    /// Set a field's value.
444    ///
445    /// Updates the form-tree raw value, writes through to the bound
446    /// datasets node (creating it on demand for default-bound fields), and
447    /// records the change for [`save_to_bytes`](Self::save_to_bytes).
448    ///
449    /// Rejects writes to read-only fields ([`XfaError::FieldReadOnly`]) and
450    /// to non-fillable kinds (buttons, signatures, …). No change/click
451    /// scripts run and the layout is not recomputed (Phase 1).
452    pub fn set_value(&mut self, name: &str, value: XfaWriteValue<'_>) -> Result<XfaSetOutcome> {
453        let idx = *self
454            .by_name
455            .get(name)
456            .ok_or_else(|| XfaError::FieldNotFound(name.to_string()))?;
457
458        if self.fields[idx].read_only {
459            return Err(XfaError::FieldReadOnly(name.to_string()));
460        }
461        let field_type = self.models[idx].field_type;
462        if !field_type.is_fillable() {
463            return Err(XfaError::InvalidFieldValue {
464                name: name.to_string(),
465                reason: format!("{field_type:?} fields are not fillable"),
466            });
467        }
468
469        let raw = self.normalize_value(idx, value, name)?;
470
471        // 1. Form-tree raw value.
472        self.write_tree_value(idx, &raw);
473
474        // 2-3. Datasets write-through and saved-form-packet sync.
475        let persisted = self.persist_value(idx, &raw)?;
476
477        // 4. Refresh the public model.
478        self.models[idx].value = raw.clone();
479
480        Ok(XfaSetOutcome {
481            raw_value: raw,
482            persisted_to_datasets: persisted,
483        })
484    }
485
486    /// Whether interactive mode is active (the open lifecycle ran with presence
487    /// writes and commits dispatch user-edit scripts). Only ever true with the
488    /// `xfa-js-sandboxed` feature compiled in.
489    pub fn is_interactive(&self) -> bool {
490        self.interactive
491    }
492
493    /// **Phase 2 interactive commit.** Set a field's value, then run the
494    /// dynamic effects: fire the field's `change`/`click` scripts, a global
495    /// `calculate` pass, re-layout, and re-enumerate the field/page model.
496    ///
497    /// This is the interactive analogue of [`set_value`](Self::set_value): the
498    /// data writeback is identical (so [`save_to_bytes`](Self::save_to_bytes)
499    /// still produces an Acrobat-reopenable PDF), but conditional sections that
500    /// the committed value reveals or hides are reflected in the new
501    /// [`fields`](Self::fields) / [`page_count`](Self::page_count).
502    ///
503    /// Without the `xfa-js-sandboxed` feature this degrades to a value write
504    /// plus re-layout: `interactive == false`, `scripts_executed == 0`, and no
505    /// script-driven presence changes (a static value edit only).
506    pub fn commit_value(
507        &mut self,
508        name: &str,
509        value: XfaWriteValue<'_>,
510    ) -> Result<XfaCommitOutcome> {
511        // Validate the field exists before any work.
512        if !self.by_name.contains_key(name) {
513            return Err(XfaError::FieldNotFound(name.to_string()));
514        }
515
516        // 0. Establish the interactive initial state BEFORE writing the value,
517        //    so the open lifecycle (docReady/calculate) runs with the *pre-edit*
518        //    value. Otherwise a lazily-upgraded session would reveal the section
519        //    during open (seeing the new value) and the change episode below
520        //    would observe no diff. Re-enumeration here can move field indices,
521        //    so resolve `idx`/`changed_node` afterwards.
522        let interactive = self.ensure_interactive();
523
524        let idx = *self
525            .by_name
526            .get(name)
527            .ok_or_else(|| XfaError::FieldNotFound(name.to_string()))?;
528        let changed_node = self.fields[idx].node;
529        let field_type = self.models[idx].field_type;
530        let page_count_before = self.layout.pages.len();
531
532        // 1. Phase-1 value write (tree + datasets + form-packet sync).
533        let set = self.set_value(name, value)?;
534
535        // 2. Interactive script dispatch (change/click + calculate). The
536        //    runtime is constructed for this commit and dropped — the resulting
537        //    presence/value state lives in `self.tree`.
538        let (scripts_executed, presence_changes) = if interactive {
539            self.dispatch_commit(changed_node, field_type)?
540        } else {
541            (0, Vec::new())
542        };
543
544        // 3. Re-layout + re-enumerate against the mutated tree.
545        self.relayout_and_reenumerate()?;
546
547        // 4. Values the scripts wrote go to the datasets too, so a save keeps
548        //    them and a static open shows them.
549        if interactive {
550            self.persist_script_values(name)?;
551        }
552
553        Ok(XfaCommitOutcome {
554            set,
555            interactive: self.interactive,
556            scripts_executed,
557            page_count_before,
558            page_count_after: self.layout.pages.len(),
559            presence_changes,
560        })
561    }
562
563    /// Scripts that did not run, over the whole session so far: FormCalc and
564    /// other non-JavaScript scripts that were due, and JavaScript scripts that
565    /// threw, timed out or were refused. Each script is counted once. Empty
566    /// until the session is interactive.
567    pub fn script_report(&self) -> &XfaScriptReport {
568        &self.script_report
569    }
570
571    /// Write every field whose value differs from the parsed document, other
572    /// than `committed` (which `set_value` already wrote), to the datasets.
573    fn persist_script_values(&mut self, committed: &str) -> Result<()> {
574        let changed: Vec<(usize, String)> = self
575            .models
576            .iter()
577            .enumerate()
578            .filter(|(_, m)| m.name != committed)
579            .filter(|(_, m)| self.baseline_values.get(&m.name) != Some(&m.value))
580            .map(|(i, m)| (i, m.value.clone()))
581            .collect();
582        for (idx, value) in changed {
583            self.persist_value(idx, &value)?;
584        }
585        Ok(())
586    }
587
588    /// Lazily upgrade the session to interactive mode: construct a sandboxed
589    /// runtime, run the interactive open lifecycle (presence writes land),
590    /// drop the runtime, and re-layout/re-enumerate. Idempotent. Returns
591    /// whether interactive mode is active afterwards (false when the feature is
592    /// not compiled in). The runtime is not retained (keeps `XfaSession: Send`);
593    /// the resulting initial state lives in `self.tree`.
594    fn ensure_interactive(&mut self) -> bool {
595        if self.interactive {
596            return true;
597        }
598        #[cfg(feature = "xfa-js-sandboxed")]
599        {
600            let Ok(mut runtime) = crate::js_runtime::SandboxedRuntime::new() else {
601                return false;
602            };
603            if let Ok((_, report)) = crate::dynamic::apply_interactive_open_reported(
604                &mut self.tree,
605                self.root_id,
606                &mut runtime,
607            ) {
608                self.script_report.merge(report);
609            }
610            drop(runtime);
611            self.interactive = true;
612            // The open lifecycle may have toggled presence — realise it.
613            let _ = self.relayout_and_reenumerate();
614            true
615        }
616        #[cfg(not(feature = "xfa-js-sandboxed"))]
617        {
618            false
619        }
620    }
621
622    /// Dispatch the interactive commit for one edited field: construct a
623    /// runtime, fire its `change`/`click` scripts + a global `calculate` pass,
624    /// drop the runtime. Returns `(scripts_executed, presence_changes)`.
625    /// Only reached when interactive mode is active (feature compiled in).
626    fn dispatch_commit(
627        &mut self,
628        changed_node: FormNodeId,
629        field_type: XfaFieldType,
630    ) -> Result<(usize, Vec<PresenceChange>)> {
631        #[cfg(feature = "xfa-js-sandboxed")]
632        {
633            // Buttons/checkboxes/radios fire `click`; everything fires `change`.
634            let triggers: &[InteractiveTrigger] = match field_type {
635                XfaFieldType::Checkbox | XfaFieldType::RadioGroup | XfaFieldType::Button => {
636                    &[InteractiveTrigger::Click, InteractiveTrigger::Change]
637                }
638                _ => &[InteractiveTrigger::Change],
639            };
640            let Ok(mut runtime) = crate::js_runtime::SandboxedRuntime::new() else {
641                return Ok((0, Vec::new()));
642            };
643            let (outcome, report) = crate::dynamic::apply_interactive_commit_reported(
644                &mut self.tree,
645                self.root_id,
646                changed_node,
647                triggers,
648                &mut runtime,
649            )?;
650            self.script_report.merge(report);
651            Ok((outcome.scripts_executed, outcome.presence_changes))
652        }
653        #[cfg(not(feature = "xfa-js-sandboxed"))]
654        {
655            let _ = (changed_node, field_type);
656            Ok((0, Vec::new()))
657        }
658    }
659
660    /// Re-run layout against the current (script-mutated) tree and rebuild the
661    /// field/page model. `LayoutEngine::layout` is a pure function of the tree
662    /// and `enumerate_fields` clears-then-rebuilds, so this is safe to repeat.
663    fn relayout_and_reenumerate(&mut self) -> Result<()> {
664        let layout = LayoutEngine::new(&self.tree)
665            .layout(self.root_id)
666            .map_err(|e| XfaError::LayoutFailed(e.to_string()))?;
667        self.layout = layout;
668        // enumerate_fields rebuilds `fields`; clear first so it does not append.
669        self.fields.clear();
670        self.enumerate_fields(self.root_id);
671        Ok(())
672    }
673
674    /// Serialize the updated datasets (and form-packet value syncs) into a
675    /// copy of the original PDF and return the new bytes.
676    pub fn save_to_bytes(&self) -> Result<Vec<u8>> {
677        let mut doc = Document::load_mem(&self.pdf_bytes)
678            .map_err(|e| XfaError::LoadFailed(format!("reload for writeback: {e}")))?;
679        self.write_into_document(&mut doc)?;
680        let mut out = Vec::new();
681        doc.save_to(&mut out)
682            .map_err(|e| XfaError::WritebackFailed(format!("PDF save: {e}")))?;
683        Ok(out)
684    }
685
686    /// Apply the datasets/form-packet writeback to an already-loaded
687    /// `lopdf::Document` (the SDK facade path: the caller owns the document
688    /// and serializes it itself).
689    pub fn write_into_document(&self, doc: &mut Document) -> Result<XfaWritebackReport> {
690        let mut report = XfaWritebackReport::default();
691
692        // Build the new datasets packet: surgical splice of the original
693        // packet text, falling back to data-section regeneration.
694        let edits = self.collect_datasets_edits();
695        let original_datasets = self.packets.datasets();
696        let new_datasets = match original_datasets {
697            Some(orig) => match datasets_writeback::splice_datasets(orig, &edits) {
698                Some(spliced) => {
699                    report.datasets_spliced = true;
700                    spliced
701                }
702                None => datasets_writeback::regenerate_datasets(orig, &self.data_dom),
703            },
704            None => datasets_writeback::regenerate_datasets("", &self.data_dom),
705        };
706
707        // Saved form packet: keep recorded field values in sync.
708        let new_form = self.packets.get_packet("form").and_then(|orig| {
709            if self.form_packet_sync.is_empty() {
710                return None;
711            }
712            let (xml, updated) =
713                datasets_writeback::splice_form_packet(orig, &self.form_packet_sync);
714            report.form_packet_values_updated = updated;
715            if updated > 0 {
716                Some(xml)
717            } else {
718                None
719            }
720        });
721
722        datasets_writeback::write_packets_into_pdf(doc, &new_datasets, new_form.as_deref())?;
723        Ok(report)
724    }
725
726    /// True when any value was changed since the session opened.
727    pub fn is_dirty(&self) -> bool {
728        !self.dirty_data.is_empty()
729            || !self.created_data.is_empty()
730            || !self.form_packet_sync.is_empty()
731    }
732
733    // ── internals ──────────────────────────────────────────────────────
734
735    fn normalize_value(&self, idx: usize, value: XfaWriteValue<'_>, name: &str) -> Result<String> {
736        let model = &self.models[idx];
737        match (model.field_type, value) {
738            (XfaFieldType::Checkbox, XfaWriteValue::Checkbox(on)) => Ok(if on {
739                model.on_value.clone().unwrap_or_else(|| "1".to_string())
740            } else {
741                model.off_value.clone().unwrap_or_else(|| "0".to_string())
742            }),
743            (XfaFieldType::Checkbox, XfaWriteValue::Text(s)) => {
744                let on_v = model.on_value.clone().unwrap_or_else(|| "1".to_string());
745                let off_v = model.off_value.clone().unwrap_or_else(|| "0".to_string());
746                if s == on_v || s.eq_ignore_ascii_case("true") || s == "1" {
747                    Ok(on_v)
748                } else if s == off_v || s.eq_ignore_ascii_case("false") || s == "0" || s.is_empty()
749                {
750                    Ok(off_v)
751                } else {
752                    Err(XfaError::InvalidFieldValue {
753                        name: name.to_string(),
754                        reason: format!("checkbox accepts {on_v:?} or {off_v:?}, got {s:?}"),
755                    })
756                }
757            }
758            (XfaFieldType::RadioGroup, XfaWriteValue::Radio(s))
759            | (XfaFieldType::RadioGroup, XfaWriteValue::Text(s)) => {
760                let known = self.fields[idx].members.iter().any(|(_, on)| on == s);
761                if known {
762                    Ok(s.to_string())
763                } else {
764                    let opts: Vec<&str> = self.fields[idx]
765                        .members
766                        .iter()
767                        .map(|(_, on)| on.as_str())
768                        .collect();
769                    Err(XfaError::InvalidFieldValue {
770                        name: name.to_string(),
771                        reason: format!("radio accepts one of {opts:?}, got {s:?}"),
772                    })
773                }
774            }
775            (XfaFieldType::Dropdown, XfaWriteValue::Text(s)) => {
776                if model.options.is_empty() {
777                    return Ok(s.to_string());
778                }
779                if let Some(opt) = model.options.iter().find(|o| o.save == s) {
780                    return Ok(opt.save.clone());
781                }
782                if let Some(opt) = model.options.iter().find(|o| o.display == s) {
783                    return Ok(opt.save.clone());
784                }
785                // XFA choice lists may allow free entry; the template parser
786                // does not currently surface `open="..."`, so accept unknown
787                // values rather than over-rejecting.
788                Ok(s.to_string())
789            }
790            (_, XfaWriteValue::Text(s)) => Ok(s.to_string()),
791            (ft, v) => Err(XfaError::InvalidFieldValue {
792                name: name.to_string(),
793                reason: format!("{v:?} is not assignable to a {ft:?} field"),
794            }),
795        }
796    }
797
798    fn write_tree_value(&mut self, idx: usize, raw: &str) {
799        let entry = &self.fields[idx];
800        if entry.members.is_empty() {
801            let id = entry.node;
802            if let FormNodeType::Field { .. } = self.tree.get(id).node_type {
803                self.tree.get_mut(id).node_type = FormNodeType::Field {
804                    value: raw.to_string(),
805                };
806            }
807        } else {
808            // exclGroup: the group node itself carries the selected on-value
809            // (so `this.rawValue` in a change script reads the new selection),
810            // the matching member gets the value, and every other member is
811            // cleared (mirrors the merger's apply_exclusive_choice_value).
812            let group = entry.node;
813            if let FormNodeType::Field { .. } = self.tree.get(group).node_type {
814                self.tree.get_mut(group).node_type = FormNodeType::Field {
815                    value: raw.to_string(),
816                };
817            }
818            let members = entry.members.clone();
819            for (member, on_value) in members {
820                let new_value = if on_value == raw {
821                    raw.to_string()
822                } else {
823                    String::new()
824                };
825                if let FormNodeType::Field { .. } = self.tree.get(member).node_type {
826                    self.tree.get_mut(member).node_type = FormNodeType::Field { value: new_value };
827                }
828            }
829        }
830    }
831
832    /// Write through to the datasets DOM. Returns `true` when the value is
833    /// now held by a data node (existing or created).
834    /// Write a field's value through to the datasets DOM and record the
835    /// saved-form-packet sync. Shared by [`set_value`](Self::set_value) and the
836    /// script-written values a commit persists. Returns whether the value
837    /// reached a datasets node.
838    fn persist_value(&mut self, idx: usize, raw: &str) -> Result<bool> {
839        let persisted = self.write_data_value(idx, raw)?;
840
841        // Saved-form-packet value sync (recorded; spliced at save time).
842        if self.packets.get_packet("form").is_some() {
843            let segments = self.fields[idx].segments.clone();
844            // Drop earlier edits for this field (group prefix covers the
845            // radio-member edits too).
846            self.form_packet_sync
847                .retain(|e| !e.segments.starts_with(&segments));
848            if self.fields[idx].members.is_empty() {
849                self.form_packet_sync.push(FormPacketEdit {
850                    segments,
851                    value: raw.to_string(),
852                    insert_if_missing: true,
853                });
854            } else {
855                // Radio group: the saved form DOM stores the selection on
856                // the member fields — the selected member carries the
857                // on-value, deselected members are cleared. Also refresh a
858                // group-level <value> when the producer wrote one.
859                for (member, on) in self.fields[idx].members.clone() {
860                    let mut member_segments = segments.clone();
861                    member_segments.push((self.tree.get(member).name.clone(), 0));
862                    let selected = on == raw;
863                    self.form_packet_sync.push(FormPacketEdit {
864                        segments: member_segments,
865                        value: if selected {
866                            raw.to_string()
867                        } else {
868                            String::new()
869                        },
870                        insert_if_missing: selected,
871                    });
872                }
873                self.form_packet_sync.push(FormPacketEdit {
874                    segments,
875                    value: raw.to_string(),
876                    insert_if_missing: false,
877                });
878            }
879        }
880        Ok(persisted)
881    }
882
883    fn write_data_value(&mut self, idx: usize, raw: &str) -> Result<bool> {
884        let entry_node = self.fields[idx].node;
885        if self.tree.meta(entry_node).data_bind_none {
886            // `bind match="none"` — by design not persisted in datasets.
887            return Ok(false);
888        }
889
890        if let Some(raw_id) = self.tree.meta(entry_node).bound_data_node {
891            let id = DataNodeId::from_raw(raw_id);
892            match self.data_dom.get(id) {
893                Some(DataNode::DataValue { .. }) => {
894                    self.data_dom
895                        .set_value(id, raw.to_string())
896                        .map_err(|e| XfaError::WritebackFailed(e.to_string()))?;
897                    if !self.dirty_data.contains(&id) {
898                        self.dirty_data.push(id);
899                    }
900                    return Ok(true);
901                }
902                Some(DataNode::DataGroup { .. }) => {
903                    // Container-bound (e.g. an exclGroup bound to a group):
904                    // fall through to create/find a value child below.
905                }
906                None => return Ok(false),
907            }
908        }
909
910        // No directly bound value node: create one on demand under the
911        // nearest bound ancestor (XFA default binding by name).
912        let created = self.create_data_node_for(idx, raw)?;
913        Ok(created)
914    }
915
916    fn create_data_node_for(&mut self, idx: usize, raw: &str) -> Result<bool> {
917        let field_node = self.fields[idx].node;
918        let data_name = self.tree.get(field_node).name.clone();
919        if data_name.is_empty() {
920            return Ok(false);
921        }
922
923        // Find the nearest ancestor (or the node itself when group-bound)
924        // with a bound data *group*.
925        let mut parent_group: Option<DataNodeId> = None;
926        // The node itself may be bound to a group (exclGroup case).
927        if let Some(raw_id) = self.tree.meta(field_node).bound_data_node {
928            let id = DataNodeId::from_raw(raw_id);
929            if matches!(self.data_dom.get(id), Some(DataNode::DataGroup { .. })) {
930                // The group IS the field's data home: a value child named
931                // like the field is the XFA §4.4.5 group-value convention —
932                // but Designer datasets normally store the exclGroup value
933                // directly on the group-named *value* node. Prefer an
934                // existing value child named like the field, else write a
935                // value child with the field's name.
936                parent_group = Some(id);
937            }
938        }
939        if parent_group.is_none() {
940            let mut cursor = self.parent_of(field_node);
941            while let Some(p) = cursor {
942                if let Some(raw_id) = self.tree.meta(p).bound_data_node {
943                    let id = DataNodeId::from_raw(raw_id);
944                    if matches!(self.data_dom.get(id), Some(DataNode::DataGroup { .. })) {
945                        parent_group = Some(id);
946                        break;
947                    }
948                }
949                cursor = self.parent_of(p);
950            }
951        }
952        if parent_group.is_none() {
953            // No bound ancestor at all (empty or absent datasets): synthesize
954            // the group chain from the data root along the field's named
955            // path — the same tree Adobe Reader creates on first save.
956            // Explicit `<bind ref>` fields are skipped: their data home is
957            // not the name chain.
958            if self.tree.meta(field_node).data_bind_ref.is_none() {
959                parent_group = self.synthesize_group_chain(idx)?;
960            }
961        }
962        let Some(group) = parent_group else {
963            return Ok(false);
964        };
965
966        // Reuse an existing same-name child value node when present.
967        let existing = self
968            .data_dom
969            .children_by_name(group, &data_name)
970            .into_iter()
971            .find(|&c| matches!(self.data_dom.get(c), Some(DataNode::DataValue { .. })));
972        let id = match existing {
973            Some(c) => {
974                self.data_dom
975                    .set_value(c, raw.to_string())
976                    .map_err(|e| XfaError::WritebackFailed(e.to_string()))?;
977                if !self.dirty_data.contains(&c) {
978                    self.dirty_data.push(c);
979                }
980                self.tree.meta_mut(self.fields[idx].node).bound_data_node = Some(c.as_raw());
981                return Ok(true);
982            }
983            None => self
984                .data_dom
985                .create_value(group, &data_name, raw)
986                .map_err(|e| XfaError::WritebackFailed(e.to_string()))?,
987        };
988        self.created_data.push(id);
989        self.tree.meta_mut(self.fields[idx].node).bound_data_node = Some(id.as_raw());
990        Ok(true)
991    }
992
993    /// Create (or find) the data-group chain `root → seg[0] → … → seg[n-2]`
994    /// for the field at `idx`, creating a `<data>` root when the DataDom is
995    /// empty. Returns the deepest group, or `None` when an indexed segment
996    /// (`name[i>0]`) is missing — repeated-instance synthesis is out of
997    /// Phase-1 scope.
998    fn synthesize_group_chain(&mut self, idx: usize) -> Result<Option<DataNodeId>> {
999        let segments = self.fields[idx].segments.clone();
1000        if segments.len() < 2 {
1001            // A root-level field still needs a root group to live under.
1002            if segments.is_empty() {
1003                return Ok(None);
1004            }
1005        }
1006
1007        let root = match self.data_dom.root() {
1008            Some(r)
1009                if self
1010                    .data_dom
1011                    .get(r)
1012                    .map(DataNode::is_group)
1013                    .unwrap_or(false) =>
1014            {
1015                r
1016            }
1017            _ => {
1018                let r = self.data_dom.alloc(DataNode::DataGroup {
1019                    name: "data".to_string(),
1020                    namespace: None,
1021                    children: Vec::new(),
1022                    is_record: false,
1023                    parent: None,
1024                });
1025                self.data_dom.set_root(r);
1026                r
1027            }
1028        };
1029
1030        let mut cursor = root;
1031        for (name, index) in &segments[..segments.len() - 1] {
1032            let groups: Vec<DataNodeId> = self
1033                .data_dom
1034                .children_by_name(cursor, name)
1035                .into_iter()
1036                .filter(|&c| {
1037                    self.data_dom
1038                        .get(c)
1039                        .map(DataNode::is_group)
1040                        .unwrap_or(false)
1041                })
1042                .collect();
1043            cursor = match groups.get(*index) {
1044                Some(&g) => g,
1045                None if *index == 0 => self
1046                    .data_dom
1047                    .create_group(cursor, name)
1048                    .map_err(|e| XfaError::WritebackFailed(e.to_string()))?,
1049                None => return Ok(None),
1050            };
1051        }
1052        Ok(Some(cursor))
1053    }
1054
1055    fn parent_of(&self, node: FormNodeId) -> Option<FormNodeId> {
1056        // FormTree stores no parent links; derive lazily from the arena.
1057        for (i, n) in self.tree.nodes.iter().enumerate() {
1058            if n.children.contains(&node) {
1059                return Some(FormNodeId(i));
1060            }
1061        }
1062        None
1063    }
1064
1065    fn collect_datasets_edits(&self) -> Vec<DatasetsEdit> {
1066        let mut edits = Vec::new();
1067        for &id in &self.dirty_data {
1068            if let Some(path) = self.data_path_of(id) {
1069                if let Ok(value) = self.data_dom.value(id) {
1070                    edits.push(DatasetsEdit::SetValue {
1071                        path,
1072                        value: value.to_string(),
1073                    });
1074                }
1075            }
1076        }
1077        for &id in &self.created_data {
1078            let Some(node) = self.data_dom.get(id) else {
1079                continue;
1080            };
1081            let name = node.name().to_string();
1082            let Some(parent) = node.parent() else {
1083                continue;
1084            };
1085            let Some(parent_path) = self.data_path_of(parent) else {
1086                continue;
1087            };
1088            if let Ok(value) = self.data_dom.value(id) {
1089                edits.push(DatasetsEdit::CreateValue {
1090                    parent_path,
1091                    name,
1092                    value: value.to_string(),
1093                });
1094            }
1095        }
1096        edits
1097    }
1098
1099    /// Path of `(name, index-among-same-name-element-siblings)` segments
1100    /// from the data root down to `id`. The root itself is segment 0; the
1101    /// upward walk stops there (the parse may keep wrapper parents like
1102    /// `<xfa:datasets>` linked above the effective root).
1103    fn data_path_of(&self, id: DataNodeId) -> Option<Vec<(String, usize)>> {
1104        let root = self.data_dom.root()?;
1105        let mut rev = Vec::new();
1106        let mut cursor = Some(id);
1107        while let Some(c) = cursor {
1108            let node = self.data_dom.get(c)?;
1109            let name = node.name().to_string();
1110            let at_root = c == root;
1111            let parent = if at_root { None } else { node.parent() };
1112            let index = match parent {
1113                Some(p) => {
1114                    // Index among same-name siblings, skipping nodes the
1115                    // splicer cannot see as XML elements (attribute-derived
1116                    // metadata values).
1117                    let mut k = 0usize;
1118                    let mut found = None;
1119                    for &sib in self.data_dom.children(p) {
1120                        let Some(sn) = self.data_dom.get(sib) else {
1121                            continue;
1122                        };
1123                        if sn.name() != name {
1124                            continue;
1125                        }
1126                        if is_metadata_value(sn) {
1127                            continue;
1128                        }
1129                        if sib == c {
1130                            found = Some(k);
1131                            break;
1132                        }
1133                        k += 1;
1134                    }
1135                    found?
1136                }
1137                None => 0,
1138            };
1139            rev.push((name, index));
1140            if at_root {
1141                cursor = None;
1142            } else {
1143                // A node that never reaches the effective root (e.g. it
1144                // lives under a wrapper sibling) cannot be spliced by a
1145                // root-relative path.
1146                cursor = Some(node.parent()?);
1147            }
1148        }
1149        rev.reverse();
1150        Some(rev)
1151    }
1152
1153    // ── enumeration ─────────────────────────────────────────────────────
1154
1155    fn enumerate_fields(&mut self, root: FormNodeId) {
1156        // Geometry first: form node → widget occurrences.
1157        let mut geometry: HashMap<usize, Vec<(usize, XfaRect)>> = HashMap::new();
1158        for (page_idx, page) in self.layout.pages.iter().enumerate() {
1159            for node in &page.nodes {
1160                collect_geometry(node, 0.0, 0.0, page_idx, &mut geometry);
1161            }
1162        }
1163
1164        let mut entries = Vec::new();
1165        let mut segments: Vec<(String, usize)> = Vec::new();
1166        let mut sibling_counters: Vec<HashMap<String, usize>> = vec![HashMap::new()];
1167        // The merged tree's root is a synthetic container (named "root");
1168        // Adobe SOM names start at the template's root subform, so walk the
1169        // root's children instead of the root itself.
1170        let root_access = self.tree.meta(root).access.unwrap_or(Access::Open);
1171        for &child in &self.tree.get(root).children.clone() {
1172            self.walk_enumerate(
1173                child,
1174                root_access,
1175                &mut segments,
1176                &mut sibling_counters,
1177                &mut entries,
1178            );
1179        }
1180
1181        let mut models = Vec::new();
1182        let mut by_name: HashMap<String, usize> = HashMap::new();
1183        for entry in entries {
1184            let model = self.build_model(&entry, &geometry);
1185            let idx = models.len();
1186            // First registration wins; later duplicates stay addressable via
1187            // their explicit SOM path.
1188            by_name.entry(model.name.clone()).or_insert(idx);
1189            by_name.entry(model.som_path.clone()).or_insert(idx);
1190            models.push(model);
1191            self.fields.push(entry);
1192        }
1193        self.models = models;
1194        self.by_name = by_name;
1195    }
1196
1197    #[allow(clippy::too_many_arguments)]
1198    fn walk_enumerate(
1199        &self,
1200        node_id: FormNodeId,
1201        inherited_access: Access,
1202        segments: &mut Vec<(String, usize)>,
1203        sibling_counters: &mut Vec<HashMap<String, usize>>,
1204        out: &mut Vec<FieldEntry>,
1205    ) {
1206        let node = self.tree.get(node_id);
1207        let meta = self.tree.meta(node_id);
1208        let access = meta.access.unwrap_or(inherited_access);
1209
1210        let named = !node.name.is_empty();
1211        if named {
1212            let counter = sibling_counters
1213                .last_mut()
1214                .expect("sibling counter scope present");
1215            let idx = *counter
1216                .entry(node.name.clone())
1217                .and_modify(|c| *c += 1)
1218                .or_insert(0);
1219            segments.push((node.name.clone(), idx));
1220        }
1221
1222        let is_radio_group = matches!(node.node_type, FormNodeType::ExclGroup)
1223            || meta.group_kind == GroupKind::ExclusiveChoice;
1224        let is_field = matches!(node.node_type, FormNodeType::Field { .. });
1225
1226        if is_radio_group {
1227            let mut members = Vec::new();
1228            for &child in &node.children {
1229                if let FormNodeType::Field { .. } = self.tree.get(child).node_type {
1230                    let cmeta = self.tree.meta(child);
1231                    let on = cmeta
1232                        .item_value
1233                        .clone()
1234                        .or_else(|| cmeta.style.check_button_on_value.clone())
1235                        .unwrap_or_else(|| self.tree.get(child).name.clone());
1236                    members.push((child, on));
1237                }
1238            }
1239            if !members.is_empty() {
1240                out.push(FieldEntry {
1241                    node: node_id,
1242                    members,
1243                    read_only: access.denies_writes(),
1244                    segments: segments.clone(),
1245                });
1246            }
1247        } else if is_field {
1248            out.push(FieldEntry {
1249                node: node_id,
1250                members: Vec::new(),
1251                read_only: access.denies_writes(),
1252                segments: segments.clone(),
1253            });
1254        }
1255
1256        // Recurse into containers (not into exclGroup members or field
1257        // internals — members are folded into the group entry above).
1258        if !is_field && !is_radio_group {
1259            sibling_counters.push(HashMap::new());
1260            for &child in &node.children {
1261                self.walk_enumerate(child, access, segments, sibling_counters, out);
1262            }
1263            sibling_counters.pop();
1264        }
1265
1266        if named {
1267            segments.pop();
1268        }
1269    }
1270
1271    fn build_model(
1272        &self,
1273        entry: &FieldEntry,
1274        geometry: &HashMap<usize, Vec<(usize, XfaRect)>>,
1275    ) -> XfaFieldModel {
1276        let node = self.tree.get(entry.node);
1277        let meta = self.tree.meta(entry.node);
1278
1279        let name = segments_to_display_name(&entry.segments);
1280        let som_path = segments_to_som_path(&entry.segments);
1281
1282        let field_type = if entry.members.is_empty() {
1283            XfaFieldType::from_kind(meta.field_kind)
1284        } else {
1285            XfaFieldType::RadioGroup
1286        };
1287
1288        // Value: field raw value, or selected member's on-value for groups.
1289        let value = if entry.members.is_empty() {
1290            match &node.node_type {
1291                FormNodeType::Field { value } => value.clone(),
1292                _ => String::new(),
1293            }
1294        } else {
1295            entry
1296                .members
1297                .iter()
1298                .find_map(|(m, on)| match &self.tree.get(*m).node_type {
1299                    FormNodeType::Field { value } if !value.is_empty() && value == on => {
1300                        Some(on.clone())
1301                    }
1302                    _ => None,
1303                })
1304                .unwrap_or_default()
1305        };
1306
1307        // Options.
1308        let options = if !entry.members.is_empty() {
1309            entry
1310                .members
1311                .iter()
1312                .map(|(_, on)| XfaFieldOption {
1313                    display: on.clone(),
1314                    save: on.clone(),
1315                })
1316                .collect()
1317        } else {
1318            let displays = &meta.display_items;
1319            let saves = &meta.save_items;
1320            displays
1321                .iter()
1322                .enumerate()
1323                .map(|(i, d)| XfaFieldOption {
1324                    display: d.clone(),
1325                    save: saves.get(i).cloned().unwrap_or_else(|| d.clone()),
1326                })
1327                .collect()
1328        };
1329
1330        let on_value = meta
1331            .style
1332            .check_button_on_value
1333            .clone()
1334            .or_else(|| meta.item_value.clone());
1335        let off_value = meta.style.check_button_off_value.clone();
1336
1337        // Geometry: the group node's own rect plus member rects.
1338        let mut widgets: Vec<XfaWidget> = Vec::new();
1339        if let Some(occ) = geometry.get(&entry.node.0) {
1340            for (page, rect) in occ {
1341                widgets.push(XfaWidget {
1342                    page: *page,
1343                    rect: *rect,
1344                    on_value: None,
1345                });
1346            }
1347        }
1348        for (member, on) in &entry.members {
1349            if let Some(occ) = geometry.get(&member.0) {
1350                for (page, rect) in occ {
1351                    widgets.push(XfaWidget {
1352                        page: *page,
1353                        rect: *rect,
1354                        on_value: Some(on.clone()),
1355                    });
1356                }
1357            }
1358        }
1359        widgets.sort_by(|a, b| {
1360            a.page.cmp(&b.page).then(
1361                a.rect
1362                    .y
1363                    .partial_cmp(&b.rect.y)
1364                    .unwrap_or(std::cmp::Ordering::Equal),
1365            )
1366        });
1367        let page = widgets.first().map(|w| w.page);
1368        let rect = widgets.first().map(|w| w.rect);
1369
1370        let bound = meta
1371            .bound_data_node
1372            .map(|raw| self.data_dom.get(DataNodeId::from_raw(raw)).is_some())
1373            .unwrap_or(false);
1374
1375        XfaFieldModel {
1376            name,
1377            som_path,
1378            field_type,
1379            value,
1380            read_only: entry.read_only,
1381            required: meta.required,
1382            multiline: meta.multiline,
1383            hidden: meta.presence != Presence::Visible,
1384            options,
1385            on_value,
1386            off_value,
1387            page,
1388            rect,
1389            widgets,
1390            bound_to_data: bound,
1391            bind_none: meta.data_bind_none,
1392        }
1393    }
1394}
1395
1396fn is_metadata_value(node: &DataNode) -> bool {
1397    match node {
1398        DataNode::DataValue { contains, .. } => {
1399            *contains == xfa_dom_resolver::data_dom::DataContains::MetaData
1400        }
1401        DataNode::DataGroup { .. } => false,
1402    }
1403}
1404
1405fn segments_to_display_name(segments: &[(String, usize)]) -> String {
1406    segments
1407        .iter()
1408        .map(|(n, i)| {
1409            if *i == 0 {
1410                n.clone()
1411            } else {
1412                format!("{n}[{i}]")
1413            }
1414        })
1415        .collect::<Vec<_>>()
1416        .join(".")
1417}
1418
1419fn segments_to_som_path(segments: &[(String, usize)]) -> String {
1420    segments
1421        .iter()
1422        .map(|(n, i)| format!("{n}[{i}]"))
1423        .collect::<Vec<_>>()
1424        .join(".")
1425}
1426
1427/// Accumulate absolute widget rectangles per form node.
1428///
1429/// Layout coordinates are parent-relative (the render bridge accumulates
1430/// them the same way); rectangles are reported in XFA page space (top-left
1431/// origin, points).
1432fn collect_geometry(
1433    node: &LayoutNode,
1434    parent_x: f64,
1435    parent_y: f64,
1436    page_idx: usize,
1437    out: &mut HashMap<usize, Vec<(usize, XfaRect)>>,
1438) {
1439    let abs_x = node.rect.x + parent_x;
1440    let abs_y = node.rect.y + parent_y;
1441
1442    let record = matches!(node.content, LayoutContent::Field { .. })
1443        || matches!(
1444            node.content,
1445            LayoutContent::WrappedText {
1446                from_field: true,
1447                ..
1448            }
1449        );
1450    if record {
1451        out.entry(node.form_node.0).or_default().push((
1452            page_idx,
1453            XfaRect {
1454                x: abs_x,
1455                y: abs_y,
1456                width: node.rect.width,
1457                height: node.rect.height,
1458            },
1459        ));
1460    }
1461
1462    for child in &node.children {
1463        collect_geometry(child, abs_x, abs_y, page_idx, out);
1464    }
1465}
1466
1467/// Apply `access="…"` attributes from the saved form packet onto matching
1468/// form-tree nodes (session-only pass; the flatten pipeline ignores access).
1469///
1470/// Adobe Reader records interactive locks (e.g. fields disabled after
1471/// submission) as `access="readOnly"` in the form packet. Without this pass
1472/// the session would let callers write fields Acrobat refuses to edit.
1473fn apply_form_dom_access(tree: &mut FormTree, root_id: FormNodeId, form_xml: &str) {
1474    let Ok(doc) = roxmltree::Document::parse(form_xml) else {
1475        return;
1476    };
1477
1478    fn matches_node(tree: &FormTree, fid: FormNodeId, tag: &str, name: &str) -> bool {
1479        let node = tree.get(fid);
1480        match (tag, &node.node_type) {
1481            ("subform", FormNodeType::Subform | FormNodeType::Area) => node.name == name,
1482            ("exclGroup", FormNodeType::ExclGroup) => node.name == name,
1483            // Some producers serialize exclGroups as <field> in the form DOM.
1484            ("field", FormNodeType::Field { .. } | FormNodeType::ExclGroup) => node.name == name,
1485            _ => false,
1486        }
1487    }
1488
1489    fn walk(tree: &mut FormTree, fid: FormNodeId, xml: roxmltree::Node<'_, '_>) {
1490        if let Some(access) = xml.attribute("access") {
1491            tree.meta_mut(fid).access = Some(Access::parse(access));
1492        }
1493
1494        let xml_children: Vec<roxmltree::Node<'_, '_>> = xml
1495            .children()
1496            .filter(|c| {
1497                c.is_element() && matches!(c.tag_name().name(), "subform" | "field" | "exclGroup")
1498            })
1499            .collect();
1500        if xml_children.is_empty() {
1501            return;
1502        }
1503
1504        let tree_children = tree.get(fid).children.clone();
1505        let mut used = vec![false; tree_children.len()];
1506        for xc in xml_children {
1507            let tag = xc.tag_name().name();
1508            let name = xc.attribute("name").unwrap_or("");
1509            if name.is_empty() {
1510                continue;
1511            }
1512            for (i, &cid) in tree_children.iter().enumerate() {
1513                if used[i] || !matches_node(tree, cid, tag, name) {
1514                    continue;
1515                }
1516                used[i] = true;
1517                walk(tree, cid, xc);
1518                break;
1519            }
1520        }
1521    }
1522
1523    // The form packet root is <form> (sometimes namespaced); its element
1524    // children mirror the template root subform's children. The template
1525    // FormTree root is the <template> wrapper whose first child is the root
1526    // subform — align by descending one level when the names don't match.
1527    let Some(form_root) = doc.root().children().find(|c| c.is_element()) else {
1528        return;
1529    };
1530    // Try matching the root subform: the form packet's first subform child
1531    // corresponds to the tree root's subform child of the same name.
1532    let root_children = tree.get(root_id).children.clone();
1533    let packet_subforms: Vec<roxmltree::Node<'_, '_>> = form_root
1534        .children()
1535        .filter(|c| c.is_element() && c.tag_name().name() == "subform")
1536        .collect();
1537    for xc in packet_subforms {
1538        let name = xc.attribute("name").unwrap_or("");
1539        for &cid in &root_children {
1540            if matches_node(tree, cid, "subform", name) {
1541                walk(tree, cid, xc);
1542                break;
1543            }
1544        }
1545    }
1546}