Files
pdfcraft/crates/xfa/src/lib.rs
T
Erkan DogruandFredApps f6c59c0b93 fix: integrate persistent AcroForm script workflows with native prompts and appearance writeback (#5699)
* AcroForm: integrate persistent timesheet script workflows

* AcroForm: address review caching, worker stacks and live metadata

* M6: address second review of AcroForm script workflows

* M6: handle top-level ASI boundaries and verify cyclic calculation results

* M6: bound script workers and isolate pending completions across edits

* M6: make form resets visible before subsequent script reads

* M6: include calculated dependents in reset comparison baselines

* M6: bound reset writeback work before native completion

* M6: charge expanded reset selections and field name bytes

* M6: bound string conversion bytes and charge reset widgets

* M6: preserve unread defaults and bound prompt text conversion

* M6: bound reset calls early and preserve calculated field identity

* AcroForm: address grouped calculations, field identity and widget ownership

* AcroForm: retire field instances and address native review edge cases

* AcroForm: route comparison Save As and Print to the source document

* M6: dispatch text widget mouse-up scripts

* M6: honor fit bounds for form button icons

* AcroForm: bound calculation work and preserve reset and save integrity

* Harden AcroForm script requests and worker recovery

* Keep form callbacks and focus consistent across edits

* AcroForm: preserve reset ordering and bound edit callbacks

* Fix reset appearance ordering and XFA focus after row removal

* Keep XFA focus indexes and stopped reset appearances consistent

* Document form script execution and reset helpers

* Keep script workers bounded and reject partial callback edits

* Keep form events available after a document script reaches VM limits

* Keep main's first-value Simple calculation and restore the calculateNow helper test

AFSimple_Calculate (JavaScript) and the native Simple calculation count each field's first
value again, as #5304 landed them; summing every selection of a multiple-selection list is
a separate behaviour change. Restores the document-helper calculateNow test and the
AFSimple_Calculate doc comment, and adds native and JavaScript multiple-selection cases.

* Patch the callback field snapshot in place instead of rebuilding it

Each field callback rebuilt all field states (3,129 on the benchmark form), and the
retained realm held the previous snapshot, so stamping instance tokens copied it again.
The runner now keeps its last snapshot and patches only the values and flags that
changed; ScriptSession::stamp_fields stamps it before dispatch, the worker copies only
when a token changes, and the realm drops the job's snapshot when the job ends (field
identity is checked against the cached keys instead).

* An oversized job script is that script's error, not a refused edit

Realm::run refused a script too long or too deep to run as an execution refusal, which
made the engine reject the whole edit (setting a 300 KiB calculation script failed). As on
main it is now a plain script error that keeps the value; the realm is not touched.

* Read a leading #! line as a comment in the chain scan; follow main's refusal and conditional limits in tests

The nesting guard from #5079 now checks brackets, prefixes and conditionals; the document
worker's preflight still bounds arrow-function and right-associative chains and skips a
leading #! line as the guard does. A refused script is an error, not a refused edit, so
the preflight test now checks the edit goes through with the error reported.

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-11 08:42:04 +01:00

192 lines
8.1 KiB
Rust

//! pdfcraft-xfa — XFA forms (L3). See the README.
//!
//! A dynamic XFA form is a PDF shell around an XML template; viewers without an XFA engine
//! show its one placeholder page ("requires Adobe Reader"). This crate reads the template,
//! lays it out and writes ordinary pages and AcroForm fields into the document, so the rest
//! of PdfCraft (rendering, filling, saving, printing, tools) works on it unchanged.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod data;
pub mod fixtures;
pub mod image;
pub mod layout;
pub mod linked_images;
pub mod model;
pub mod packets;
pub mod parse;
pub mod pdf;
pub mod picture;
mod proto;
pub mod script;
pub mod text;
mod traverse;
use pdfcraft_cos::Document;
/// The deepest element nesting accepted before an XML document reaches the parser.
///
/// roxmltree parses by recursive descent, so its stack use grows with the nesting depth: ten
/// thousand levels overflow the main thread's stack, and a stack overflow is `abort()`, which no
/// `catch_unwind` can intercept. The bound therefore has to be checked before parsing, not while
/// walking the result. Around three thousand levels still parse on the default stack, so this stays
/// clear of the cliff while leaving far more room than the walks below, which cut off at 64 - and
/// more than the five hundred levels `hostile_templates_fail_without_panicking` keeps parsing.
/// `pdfcraft-xfdf` carries the same check for form data files.
pub(crate) const MAX_XML_DEPTH: usize = 1_024;
/// Element nesting depth of `text`, skipping comments, CDATA, processing instructions, the DOCTYPE
/// and quoted attribute values. A tag that closes itself (`<draw/>`) is one level while it is read
/// and does not stay open, so a long run of styling elements cannot accumulate into the bound. It
/// cannot under-count a real element; a stray `<` in text is invalid XML and only makes it
/// over-count, which errs towards refusing the document.
pub(crate) fn xml_nesting_depth(text: &str) -> usize {
/// Where `needle` next starts in `hay` at or after `from`.
fn find(hay: &[u8], from: usize, needle: &[u8]) -> Option<usize> {
(from..hay.len()).find(|&p| hay[p..].starts_with(needle))
}
let b = text.as_bytes();
let (mut i, mut depth, mut max) = (0usize, 0usize, 0usize);
while i < b.len() {
if b[i] != b'<' {
i += 1;
} else if b[i..].starts_with(b"<!--") {
i = find(b, i + 4, b"-->").map_or(b.len(), |p| p + 3);
} else if b[i..].starts_with(b"<![CDATA[") {
i = find(b, i + 9, b"]]>").map_or(b.len(), |p| p + 3);
} else if b[i..].starts_with(b"<?") {
i = find(b, i + 2, b"?>").map_or(b.len(), |p| p + 2);
} else if b[i..].starts_with(b"<!") {
i = find(b, i + 2, b">").map_or(b.len(), |p| p + 1);
} else if b[i..].starts_with(b"</") {
depth = depth.saturating_sub(1);
i = find(b, i + 2, b">").map_or(b.len(), |p| p + 1);
} else {
depth += 1;
max = max.max(depth);
// Skip the rest of the start tag, over any quoted attribute value, and remember its last
// significant byte: a tag that ends in `/` closes itself, so it is one level deep while it
// is read rather than a level left open.
i += 1;
let mut quote = 0u8;
let mut last = 0u8;
while i < b.len() {
let c = b[i];
if quote != 0 {
if c == quote {
quote = 0;
}
} else if c == b'"' || c == b'\'' {
quote = c;
} else if c == b'>' {
i += 1;
break;
} else if !c.is_ascii_whitespace() {
last = c;
}
i += 1;
}
if last == b'/' {
depth -= 1;
}
}
}
max
}
pub use data::{DataImport, ImportNode, export_data, import_data, parse_data_file, write_data_image};
pub use data::{
DataNode, DataOp, DataPath, DatasetsWrite, FieldData, FieldDatum, add_data_instances, build_data, clear_password_values, data_path,
has_password_values, iso_to_pattern, parse_datasets, pattern_to_iso, radio_dataset_value, radio_state_for_item, read_values, read_values_with,
remove_data_instance, som_to_path, write_data_ops, write_data_value, write_datasets, write_datasets_reusing, write_datasets_with,
};
pub use layout::{Action, BorderShape, Form, Item, MAX_PAGES, Page, Widget, WidgetKind, formatted_value, layout};
pub use packets::{Encoding, Packets, decode as decode_packet, encode as encode_packet, read_packets};
pub use parse::{measure, numbers_pages, parse};
pub use pdf::{AREA_KEY, CLICK_KEY, DATE_KEY, LAYOUT_KEY, NODE_KEY, SOM_KEY, Stripped, XfaShape, existing_layout, strip, xfa_shape};
pub use picture::typed_date;
pub use script::{
FormNode, LiveForm, MAX_FORM_NODES, NodeKind, OVERRIDES_KEY, Overrides, ScriptEvent, apply_overrides, data_of, embedded_fields,
field_grows_with_text, field_is_embedded, field_objects_by_som, fields_by_som, form_tree, form_tree_paged, has_focus_scripts, has_scripts,
overrides, page_area_uses, path_is_embedded, refresh_value, rerender, reset_default, set_overrides, template_of,
};
#[derive(Debug, thiserror::Error)]
pub enum XfaError {
#[error("the document is not an XFA form")]
NotXfa,
#[error("the XFA template could not be read: {0}")]
Malformed(String),
#[error("{0}")]
TooLarge(String),
#[error("{0}")]
Cos(#[from] pdfcraft_cos::CosError),
}
/// What laying out a form produced.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Report {
pub pages: usize,
pub fields: usize,
/// Template bytes read.
pub template_bytes: usize,
/// What was approximated or left out, for the user.
pub warnings: Vec<String>,
}
/// Is this a dynamic XFA form still showing its placeholder pages (one PdfCraft has not laid
/// out yet)?
pub fn is_dynamic(doc: &Document) -> bool {
existing_layout(doc).is_none() && matches!(read_packets(doc), Ok(Some(p)) if p.needs_rendering || !p.has_fields)
}
/// Lay a template packet (or whole XDP, whose datasets then fill the fields) out.
pub fn layout_xml(xml: &str) -> Result<Form, XfaError> {
let (tpl, warnings) = parse(xml)?;
let data = parse_datasets(xml);
let mut form = layout(&tpl, data.as_ref())?;
form.warnings.splice(0..0, warnings);
Ok(form)
}
/// Lay a dynamic XFA form out into its own document: its placeholder pages are replaced by the
/// laid-out pages and its fields join the AcroForm, holding the values of the datasets packet.
/// The XFA packets stay, so Adobe's viewers keep rendering it their way. Widget appearances are
/// left for the forms layer to generate.
pub fn render_into(doc: &mut Document) -> Result<Report, XfaError> {
if !is_dynamic(doc) {
return Err(XfaError::NotXfa);
}
let packets = read_packets(doc)?.ok_or(XfaError::NotXfa)?;
if packets.xdp.trim().is_empty() {
return Err(XfaError::Malformed("the XFA packets hold no template".into()));
}
let (mut tpl, mut parse_warnings) = parse(&packets.xdp)?;
parse_warnings.extend(linked_images::resolve(&mut tpl, doc));
let data = parse_datasets(&packets.xdp);
let ov = overrides(doc);
let laid = apply_overrides(&tpl, &ov);
let mut form = layout::layout_with_overrides(&laid, data.as_ref(), &ov)?;
form.warnings.splice(0..0, parse_warnings);
if form.pages.is_empty() {
return Err(XfaError::Malformed("the template laid out to no pages".into()));
}
let written = pdf::write_form(doc, &form)?;
let mut warnings = form.warnings;
warnings.extend(written.warnings);
if written.usage_rights {
warnings.push(crate::pdf::USAGE_RIGHTS_REMOVED.into());
}
warnings.dedup();
Ok(Report { pages: written.pages, fields: written.fields, template_bytes: packets.xdp.len(), warnings })
}
#[cfg(test)]
mod repeat_tests;
#[cfg(test)]
mod table_repeat_tests;
#[cfg(test)]
mod tests;