Files
pdfcraft/crates/xfa/src/model.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

636 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The template model: what the layout engine needs from an XFA template (XFA 3.3 part 2,
//! "Template Specification"). Every measurement is in points, every position is top-left
//! based, as in XFA itself.
/// Whether an object is shown and whether it takes space.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Presence {
#[default]
Visible,
/// Takes space, draws nothing.
Invisible,
/// Takes no space.
Hidden,
/// Hidden, and its scripts don't run.
Inactive,
}
impl Presence {
pub fn parse(s: Option<&str>) -> Presence {
match s.map(str::trim) {
Some("invisible") => Presence::Invisible,
Some("hidden") => Presence::Hidden,
Some("inactive") => Presence::Inactive,
_ => Presence::Visible,
}
}
/// Takes space in the layout.
pub fn occupies(self) -> bool {
matches!(self, Presence::Visible | Presence::Invisible)
}
}
/// How a subform places its children.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Layout {
#[default]
Positioned,
/// Top to bottom.
Tb,
/// Left to right, then top to bottom.
LrTb,
/// Right to left, then top to bottom (laid out like `LrTb`, mirrored).
RlTb,
Table,
Row,
}
impl Layout {
pub fn parse(s: Option<&str>) -> Layout {
match s.map(str::trim) {
Some("tb") => Layout::Tb,
Some("lr-tb") => Layout::LrTb,
Some("rl-tb") => Layout::RlTb,
Some("table") => Layout::Table,
Some("row") => Layout::Row,
_ => Layout::Positioned,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Margin {
pub top: f64,
pub right: f64,
pub bottom: f64,
pub left: f64,
}
impl Margin {
pub fn horizontal(&self) -> f64 {
self.left + self.right
}
pub fn vertical(&self) -> f64 {
self.top + self.bottom
}
}
/// An RGB colour, components 0–1.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Color(pub [f64; 3]);
impl Color {
pub const BLACK: Color = Color([0.0, 0.0, 0.0]);
pub const WHITE: Color = Color([1.0, 1.0, 1.0]);
/// `value="r,g,b"` with components 0–255.
pub fn parse(s: &str) -> Option<Color> {
let mut parts = s.split(',').map(|p| p.trim().parse::<f64>().ok());
let (r, g, b) = (parts.next()??, parts.next()??, parts.next()??);
let c = |v: f64| if v.is_finite() { (v / 255.0).clamp(0.0, 1.0) } else { 0.0 };
Some(Color([c(r), c(g), c(b)]))
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Stroke {
#[default]
Solid,
Dashed,
Dotted,
/// Raised, lowered, etched and embossed: drawn as a solid grey line.
Relief,
}
/// One side of a border.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Edge {
pub presence: Presence,
pub thickness: f64,
pub color: Color,
pub stroke: Stroke,
}
impl Default for Edge {
fn default() -> Self {
Edge { presence: Presence::Visible, thickness: 0.5, color: Color::BLACK, stroke: Stroke::Solid }
}
}
impl Edge {
pub fn visible(&self) -> bool {
self.presence == Presence::Visible && self.thickness > 0.0
}
}
/// A box border: four edges (top, right, bottom, left) and a fill.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Border {
pub presence: Presence,
pub edges: [Edge; 4],
pub fill: Option<Color>,
}
impl Border {
pub fn visible_edges(&self) -> [bool; 4] {
if self.presence != Presence::Visible {
return [false; 4];
}
[self.edges[0].visible(), self.edges[1].visible(), self.edges[2].visible(), self.edges[3].visible()]
}
pub fn fill(&self) -> Option<Color> {
if self.presence == Presence::Visible { self.fill } else { None }
}
}
/// Font properties as written on an element; `None` inherits from the ancestors.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct FontSpec {
pub typeface: Option<String>,
pub size: Option<f64>,
pub bold: Option<bool>,
pub italic: Option<bool>,
pub underline: Option<bool>,
pub color: Option<Color>,
}
/// A font with every property resolved.
#[derive(Clone, Debug, PartialEq)]
pub struct Font {
pub typeface: String,
pub size: f64,
pub bold: bool,
pub italic: bool,
pub underline: bool,
pub color: Color,
}
impl Default for Font {
/// XFA's default font: Courier 10 pt.
fn default() -> Self {
Font { typeface: "Courier".into(), size: 10.0, bold: false, italic: false, underline: false, color: Color::BLACK }
}
}
impl Font {
pub fn apply(&self, spec: &FontSpec) -> Font {
Font {
typeface: spec.typeface.clone().unwrap_or_else(|| self.typeface.clone()),
size: spec.size.unwrap_or(self.size),
bold: spec.bold.unwrap_or(self.bold),
italic: spec.italic.unwrap_or(self.italic),
underline: spec.underline.unwrap_or(self.underline),
color: spec.color.unwrap_or(self.color),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum HAlign {
#[default]
Left,
Center,
Right,
Justify,
}
impl HAlign {
pub fn parse(s: Option<&str>) -> Option<HAlign> {
match s.map(str::trim) {
Some("left") => Some(HAlign::Left),
Some("center") => Some(HAlign::Center),
Some("right") => Some(HAlign::Right),
Some("justify" | "justifyAll") => Some(HAlign::Justify),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum VAlign {
#[default]
Top,
Middle,
Bottom,
}
impl VAlign {
pub fn parse(s: Option<&str>) -> Option<VAlign> {
match s.map(str::trim) {
Some("top") => Some(VAlign::Top),
Some("middle") => Some(VAlign::Middle),
Some("bottom") => Some(VAlign::Bottom),
_ => None,
}
}
}
/// Paragraph properties as written on an element; `None` inherits.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ParaSpec {
pub h_align: Option<HAlign>,
pub v_align: Option<VAlign>,
pub line_height: Option<f64>,
pub margin_left: Option<f64>,
pub margin_right: Option<f64>,
pub space_above: Option<f64>,
pub space_below: Option<f64>,
pub text_indent: Option<f64>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Para {
pub h_align: HAlign,
pub v_align: VAlign,
/// `None`: 1.15 × the font size.
pub line_height: Option<f64>,
pub margin_left: f64,
pub margin_right: f64,
pub space_above: f64,
pub space_below: f64,
pub text_indent: f64,
}
impl Para {
pub fn apply(&self, spec: &ParaSpec) -> Para {
Para {
h_align: spec.h_align.unwrap_or(self.h_align),
v_align: spec.v_align.unwrap_or(self.v_align),
line_height: spec.line_height.or(self.line_height),
margin_left: spec.margin_left.unwrap_or(self.margin_left),
margin_right: spec.margin_right.unwrap_or(self.margin_right),
space_above: spec.space_above.unwrap_or(self.space_above),
space_below: spec.space_below.unwrap_or(self.space_below),
text_indent: spec.text_indent.unwrap_or(self.text_indent),
}
}
}
/// What every container and leaf shares: name, box, presence, margins, border, text defaults.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Common {
pub name: Option<String>,
pub id: Option<String>,
pub x: Option<f64>,
pub y: Option<f64>,
pub w: Option<f64>,
pub h: Option<f64>,
pub min_w: Option<f64>,
pub max_w: Option<f64>,
pub min_h: Option<f64>,
pub max_h: Option<f64>,
pub presence: Presence,
pub margin: Margin,
pub border: Option<Border>,
pub font: FontSpec,
pub para: ParaSpec,
/// Table cells: how many columns this cell spans.
pub col_span: usize,
}
#[derive(Clone, Debug, PartialEq)]
pub enum Node {
Subform(Box<Subform>),
Area(Box<Area>),
Field(Box<Field>),
Draw(Box<Draw>),
ExclGroup(Box<ExclGroup>),
}
impl Node {
pub fn common(&self) -> &Common {
match self {
Node::Subform(s) => &s.common,
Node::Area(a) => &a.common,
Node::Field(f) => &f.common,
Node::Draw(d) => &d.common,
Node::ExclGroup(g) => &g.common,
}
}
}
/// How often a subform occurs when no data drives it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Occur {
pub min: usize,
/// `None`: unbounded.
pub max: Option<usize>,
pub initial: usize,
}
impl Default for Occur {
fn default() -> Self {
Occur { min: 1, max: Some(1), initial: 1 }
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Subform {
pub common: Common,
pub layout: Layout,
pub children: Vec<Node>,
/// `<event activity="…"><script>` on the subform (initialize, calculate, …).
pub scripts: Vec<Script>,
pub page_set: Option<PageSet>,
pub occur: Occur,
/// `<breakBefore targetType="pageArea">`: start on a new page.
pub break_before_page: bool,
pub break_after_page: bool,
/// Tables: the width of each column.
pub column_widths: Vec<f64>,
/// `<overflow leader="…">`: the named child (a header row) is repeated after a page break.
pub overflow_leader: Option<String>,
}
/// A positioned group without a box of its own.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Area {
pub common: Common,
pub children: Vec<Node>,
}
/// A radio button group.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ExclGroup {
pub common: Common,
pub layout: Layout,
pub fields: Vec<Field>,
pub tooltip: Option<String>,
pub scripts: Vec<Script>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum Ui {
#[default]
TextEdit,
NumericEdit,
DateTimeEdit,
CheckButton,
ChoiceList,
Button,
Signature,
ImageEdit,
Barcode,
PasswordEdit,
Unknown,
}
/// How a `<choiceList>` presents its items.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ChoiceList {
/// `open="always"` or `"multiSelect"`: a list box that shows its items; else a drop-down.
pub list_box: bool,
/// `open="multiSelect"`: several items may be chosen (newline-separated in the data).
pub multi: bool,
/// `textEntry="1"`: the user may type a value that is not an item.
pub editable: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Script {
pub activity: String,
pub text: String,
/// FormCalc (the default language), not JavaScript.
pub formcalc: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Access {
#[default]
Open,
ReadOnly,
Protected,
NonInteractive,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Field {
pub common: Common,
pub ui: Ui,
/// The border drawn by the widget itself (`<ui><textEdit><border>`), inside the field.
pub ui_border: Option<Border>,
pub ui_margin: Margin,
/// Check buttons: the side of the square (default 10 pt) and whether it is round.
pub check_size: Option<f64>,
pub check_round: bool,
pub multiline: bool,
pub caption: Option<Caption>,
pub value: Value,
/// Check buttons: the on value then the off value; choice lists: the items as shown.
pub items: Vec<String>,
/// Choice lists: the bound values saved to the data (`<items save="1">`), one per shown
/// item; empty when the shown text is the value.
pub item_values: Vec<String>,
/// Choice lists: how the list opens.
pub choice: ChoiceList,
pub max_chars: Option<usize>,
pub tooltip: Option<String>,
pub access: Access,
/// The edit picture clause (`<ui>…<picture>`), e.g. `date{YYYY-MM-DD}`.
pub picture: Option<String>,
/// `<event activity="…"><script>`: click, change, exit, initialize, …
pub scripts: Vec<Script>,
/// `<calculate><script>`: the value is the script's result.
pub calculate: Option<Script>,
/// `<validate><script>`: false (or an exception) shows `validate_message`; the value stays,
/// as Acrobat marks the field invalid rather than reverting it.
pub validate: Option<Script>,
pub validate_message: Option<String>,
/// Numeric fields (numericEdit, or a decimal/integer/float value) give scripts numbers.
pub numeric: bool,
}
impl Field {
/// A choice list's value as saved to the data: an item's shown text becomes its saved
/// value (an editable list keeps typed text); several values stay on separate lines.
/// Other fields keep `v`.
pub fn saved_value(&self, v: &str) -> String {
if self.ui != Ui::ChoiceList {
return v.to_string();
}
// The data holds saved values: one of those is itself, even when another item shows
// the same text (Designer's "specify item values" lists often show 0, 1, 2 and save
// 1, 2, 3).
let saved = |one: &str| {
if self.item_values.is_empty() || self.item_values.iter().any(|s| s == one) {
return one.to_string();
}
self.items.iter().position(|shown| shown == one).and_then(|i| self.item_values.get(i)).cloned().unwrap_or_else(|| one.to_string())
};
if self.choice.multi {
// Each value once, and never more than there are items.
let mut out: Vec<String> = Vec::new();
for l in v.lines().map(str::trim).filter(|l| !l.is_empty()) {
let s = saved(l);
if !out.contains(&s) {
out.push(s);
}
if out.len() >= self.items.len().max(1) {
break;
}
}
out.join("\n")
} else {
// One value: the first line, should the data hold several.
saved(v.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or(""))
}
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Draw {
pub common: Common,
pub value: Value,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub enum Value {
#[default]
Empty,
Text(String),
Rich(RichText),
Image {
content_type: String,
data: Vec<u8>,
},
/// A diagonal line through the box: `\` (top-left to bottom-right) or `/`.
Line {
slope_up: bool,
edge: Edge,
},
Rectangle(Border),
}
impl Value {
pub fn is_empty(&self) -> bool {
match self {
Value::Empty => true,
Value::Text(t) => t.trim().is_empty(),
Value::Rich(r) => r.paragraphs.iter().all(|p| p.runs.iter().all(|r| r.text.trim().is_empty() && r.embed.is_none())),
_ => false,
}
}
/// The plain text of a text or rich value.
pub fn plain(&self) -> Option<String> {
match self {
Value::Text(t) => Some(t.clone()),
Value::Rich(r) => Some(r.plain()),
_ => None,
}
}
}
/// Rich text (`exData` with XHTML): paragraphs of styled runs.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct RichText {
pub paragraphs: Vec<Paragraph>,
}
impl RichText {
pub fn plain(&self) -> String {
self.paragraphs.iter().map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::<String>()).collect::<Vec<_>>().join("\n")
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Paragraph {
pub runs: Vec<Run>,
pub h_align: Option<HAlign>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Run {
pub text: String,
pub bold: Option<bool>,
pub italic: Option<bool>,
pub underline: Option<bool>,
pub size: Option<f64>,
/// A floating field embedded in the text (`xfa:embed="#id"`), shown by its value.
pub embed: Option<String>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Placement {
#[default]
Left,
Right,
Top,
Bottom,
Inline,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Caption {
pub placement: Placement,
/// Space set aside for the caption along its placement axis.
pub reserve: Option<f64>,
pub presence: Presence,
pub value: Value,
pub font: FontSpec,
pub para: ParaSpec,
}
/// A rectangle in points, top-left based.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Rect {
pub x: f64,
pub y: f64,
pub w: f64,
pub h: f64,
}
impl Rect {
pub fn new(x: f64, y: f64, w: f64, h: f64) -> Rect {
Rect { x, y, w, h: h.max(0.0) }
}
pub fn right(&self) -> f64 {
self.x + self.w
}
pub fn bottom(&self) -> f64 {
self.y + self.h
}
pub fn inset(&self, m: &Margin) -> Rect {
Rect::new(self.x + m.left, self.y + m.top, (self.w - m.horizontal()).max(0.0), (self.h - m.vertical()).max(0.0))
}
}
/// The master pages.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PageSet {
pub areas: Vec<PageArea>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PageArea {
pub name: Option<String>,
pub width: f64,
pub height: f64,
/// Where flowed content goes, in order.
pub content: Vec<Rect>,
/// Draws and fields printed on every page that uses this master.
pub items: Vec<Node>,
/// How many pages may use this master (`None`: unbounded).
pub occur_max: Option<usize>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Template {
pub root: Subform,
/// Fields that compute the page number or the page count on layout, by `id`, so text that
/// embeds them can show the numbers.
pub page_roles: Vec<(String, PageRole)>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PageRole {
Number,
Count,
}