Files
pdfcraft/crates/xfa/src/lib.rs
T
1ba8b194b2 XFA: choice lists, signature, password, image and barcode fields; PNG and GIF pictures (#414)
* XFA: choice lists, signature, password, image and barcode fields; PNG and GIF pictures

The xfa crate lays out the field kinds that were left blank or shown as
text fields:

- Choice lists become combo boxes (editable with textEntry) or list
  boxes (open="always"; multi-select with open="multiSelect"), with the
  shown items and the saved values of <items save="1"> as /Opt pairs,
  /V, /I and /DV as saved values, several on separate lines in the
  data. Field::saved_value maps shown text to saved values for the
  layout and the script tree alike, so scripts read what is saved.
- Password fields are password text fields; signature fields are /Sig
  fields the app can sign.
- Image fields show the picture the data (base64) or the template
  holds; barcode fields show their value as text, with a warning that
  no bars are drawn.
- PNG and GIF pictures (fields and draws) are decoded to Flate image
  XObjects with a soft mask for transparency; JPEGs stay as they were.
  Sizes come from the headers, so layout never decodes; decoding is
  capped at 40 million pixels and failures are warnings, not errors.

Engine: multi-select list values from scripts and data are one per
line. README and parity notes updated (form.xfa-dynamic).

Hardened after an adversarial review pass: picture sizes refused from
the header past 20 million pixels or 65,535 a side, the GIF decoder
given limits (a frame far larger than its screen is refused before
allocation), template pictures read whole up to 32 MB, pictures
decoded once and remembered in the catalog by content hash so
re-layouts reuse them, data pictures that aren't images reported by
field; choice values matched against saved values first, several
values deduplicated and capped, /I ascending and unique, /DV through
the same mapping, <value> children read, the item cap reported; the
reopen path takes another viewer's choice values (several per line);
CMYK JPEGs inverted only with Adobe APP14.

* D5: README for cos and engine, and an honest cos status line

Thirteen crates have no README, although the session protocol tells every session to read the README of the crate it is touching. These are the two biggest: cos is where every hostile file lands first, and engine is the whole API surface a frontend gets.

Each states its layer and the rules that bind it, sketches the API against the real signatures, and ends with what is deliberately not done. The cos README spells out the clone-is-a-snapshot property and why both writers return bytes rather than taking a sink; the engine README spells out the four-step editing model that makes what you see what Save writes.

Also corrects the module doc on cos/src/lib.rs, which still said encryption and object-stream output for full saves were not done. Both shipped: security.rs handles R2-R6 with crypt filters and /EFF, and SaveOptions::object_streams defaults to true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Preserve alternate appearances and indirect push-button values

Keep rollover, down and custom appearance entries when normal appearances are regenerated, without modifying shared dictionaries. Resolve indirect button caption and layout values. Cover full and incremental saved output, and wait for raster readiness in the existing Linux scroll test.

Co-authored-by: Codex <codex@openai.com>

* M7: select and edit grouped Form XObject figures

Co-Authored-By: Codex <noreply@openai.com>

* M14: let neutral Windows locales fall through to display language

Co-authored-by: Codex <codex@openai.com>

* pdfcraft-cli --help lists `mcp` only when the build has it

Follow-up to #540: a build without the `mcp` feature (`--no-default-features`) still listed
`pdfcraft-cli mcp` in its usage, and `pdfcraft-cli mcp` then said "unknown command".

USAGE is now put together with `concat!`, and its `mcp` lines come from a macro that is empty
without the feature. In such a build `pdfcraft-cli mcp` says the MCP server was left out instead
of calling it an unknown command.

- tests/help.rs: usage_lists_mcp_only_when_built_with_it (runs in both feature sets) and
  mcp_without_the_feature_says_it_was_left_out (without the feature)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* engine README: the execution plan is local-only, so don't link a path that doesn't exist

* Merge land/450 into land/414; XFA pictures are found again by a SHA-256 on their XObject

The image cache was a /PCXfaImages map in the catalog, keyed by DefaultHasher
(not stable across Rust releases) and read back from the file. Each image
XObject now records the SHA-256 of its picture's bytes, and a re-layout finds
the XObjects on the pages it replaces by that hash; nothing goes in the catalog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Tanish Manku <tmanku.dev@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com>
Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Flowershangfromthebranches <152056395+Flowershangfromthebranches@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Streetblock <github@streetblock.de>
Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 14:46:07 -04:00

103 lines
4.0 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 model;
pub mod packets;
pub mod parse;
pub mod pdf;
pub mod script;
pub mod text;
use pdfcraft_cos::Document;
pub use data::{
DataNode, DataOp, DataPath, DatasetsWrite, FieldData, FieldDatum, add_data_instances, build_data, iso_to_pattern, parse_datasets, pattern_to_iso,
read_values, remove_data_instance, som_to_path, write_data_ops, write_data_value, write_datasets, write_datasets_reusing,
};
pub use layout::{Action, BorderShape, Form, Item, MAX_PAGES, Page, Widget, WidgetKind, layout};
pub use packets::{Encoding, Packets, decode as decode_packet, encode as encode_packet, read_packets};
pub use parse::{measure, parse};
pub use pdf::{CLICK_KEY, LAYOUT_KEY, SOM_KEY, existing_layout};
pub use script::{
FormNode, LiveForm, MAX_FORM_NODES, NodeKind, OVERRIDES_KEY, Overrides, ScriptEvent, apply_overrides, data_of, fields_by_som, form_tree,
has_scripts, overrides, rerender, 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 (tpl, parse_warnings) = parse(&packets.xdp)?;
let data = parse_datasets(&packets.xdp);
let laid = apply_overrides(&tpl, &overrides(doc));
let mut form = layout(&laid, data.as_ref())?;
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);
warnings.dedup();
Ok(Report { pages: written.pages, fields: written.fields, template_bytes: packets.xdp.len(), warnings })
}
#[cfg(test)]
mod tests;