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}