Files
pdfcraft/crates/forms
FredAppsandDWAA1660 059dbd6e00 feat(forms): open, Will Save and Will Print actions, widget pointer/focus scripts, setItems/setAction/toSource (port of #344) (#6969)
* feat(js, forms): field setItems, setAction and a bounded toSource (#269)

Ported from #344 by @DWAA1660 onto the per-document script session.

- `field.setItems(items)` replaces a combo or list box's items (text, or
  [text, export] pairs). The value stays when an item still has it, and
  otherwise becomes the first item's. Written as the field's /Opt (pairs
  only where the export value differs), /I dropped, field redrawn.
- `field.setAction(trigger, script)` installs a JavaScript action for one
  trigger (MouseUp/Down/Enter/Exit, OnFocus/OnBlur, Keystroke, Validate,
  Calculate, Format), replacing only that trigger's action.
- `Object.prototype.toSource()` writes plain data (strings, numbers,
  booleans, null, undefined, arrays, objects) that eval reads back. It
  refuses functions, cycles, more than 64 levels, 10,000 values or 1 MiB
  with an error the script can catch.

Co-authored-by: DWAA1660 <dwatnip123@gmail.com>

* feat(engine): run a form's open, Will Save and Will Print actions and its widgets' pointer and focus scripts (#269)

Ported from #344 by @DWAA1660 onto the per-document script session: each
runs in the document's retained realm, so state the document scripts and
earlier events left is kept.

- The catalog /OpenAction's JavaScript (and its /Next chain, bounded and
  cycle-safe; other action types skipped) runs once on opening, after the
  document scripts, for background opens and reloads too, and again on
  Revert in a fresh realm. Event Doc/Open.
- `Session::run_document_actions` runs /AA /WS (Will Save) or /AA /WP
  (Will Print). A failing action is reported and never stops the save or
  print. doc_save and doc_print run them first and report what they said
  (`will_save`, `will_print`).
- Scripts that run unasked (open, Will Save, Will Print, pointer over,
  focus) may only ask for highlight, zoom, focus or a page; a click's
  Mouse Down / Mouse Up keep their requests.
- `Session::field_trigger` runs a widget's Mouse Enter, Mouse Exit, Mouse
  Down, Mouse Up, Focus or Blur JavaScript (one undoable edit), reading only
  that widget's dictionary. `field_focus` now runs an AcroForm widget's On
  Focus / On Blur as well as XFA enter/exit.
- New tool `js_event`; form_focus's description says AcroForm focus
  scripts run too.

Co-authored-by: DWAA1660 <dwatnip123@gmail.com>

* feat(ui): Will Save before saving, Will Print before printing, and widget pointer scripts in the app (#269, #247)

Ported from #344 by @DWAA1660.

- Save (both paths) runs the document's Will Save actions first, and Print
  its Will Print actions; what they change is saved or printed, and what
  they say is shown.
- The pointer moving onto or off a form widget queues its Mouse Enter /
  Mouse Exit script, a press its Mouse Down. A click on a check box or radio
  button queues its Mouse Up after the toggle, so the script reads the value
  the click stored (#247). The queue runs after the frame's edit.

Co-authored-by: DWAA1660 <dwatnip123@gmail.com>

* docs(parity): document actions and widget triggers are partly supported (#269)

Co-authored-by: DWAA1660 <dwatnip123@gmail.com>

* test: check toSource's text (scripts can't eval it) and the trigger event names (#269)

Co-authored-by: DWAA1660 <dwatnip123@gmail.com>

* test: a check box's Mouse Up reads its new value; keep the fixtures on screen and the realm across an in-place save (#247, #269)

Co-authored-by: DWAA1660 <dwatnip123@gmail.com>

* fix(engine, forms, js, ui): review fixes for document actions and field triggers

- widget trigger actions follow /Next, each action its own edit; errors don't stop the chain
- setAction installs on the field the object stands for when names repeat
- toSource refuses non-plain objects and a __proto__ key
- doc_print checks the destination before Will Print; js_event returns only its own output
- combo/list clicks run Mouse Up; a refused click's Mouse Up doesn't run
- label crops are re-read when Will Print changed the document

Co-authored-by: DWAA1660 <dwatnip123@gmail.com>

* forms: run a pointer trigger on the field it hit and refresh the edited field's draft

A trigger now carries the field's object, so of fields sharing a name the one under the
pointer runs its own script (Session::field_trigger_target); js_event passes the field it
resolved. A trigger script changing the field being edited shows in its editor unless the
user already typed there, as focus scripts do.

Co-authored-by: DWAA1660 <dwatnip123@gmail.com>

* forms: carry the field's identity through focus scripts and js_event

Focus transitions now track the field object, so moving between fields sharing a name is a
move and the field that gained or lost focus runs its own script
(Session::field_focus_target). js_event takes an optional field_id from form_fields to pick
one of fields sharing a name.

Co-authored-by: DWAA1660 <dwatnip123@gmail.com>

---------

Co-authored-by: DWAA1660 <dwatnip123@gmail.com>
2026-11-11 13:09:37 +01:00
..

pdfcraft-forms

Interactive forms (AcroForm, ISO 32000-2 §12.7). Layer L3; depends on pdfcraft-cos and pdfcraft-fonts.

API

let all: Vec<Field> = fields(&doc);                         // terminal fields with widgets, options, flags, /DA
let snapshot = fields_with_adopted(&doc);                     // fields and page-only adoption count in one pass
let all = snapshot.items;
let adopted = snapshot.adopted_page_fields;
set_value(&mut doc, "name", &FieldValue::Text("Ada".into()))?;
set_value(&mut doc, "agree", &FieldValue::Check(true))?;
set_value(&mut doc, "size", &FieldValue::Radio(Some("L".into())))?;
set_value(&mut doc, "toppings", &FieldValue::Choice(vec!["Ham".into()]))?;
// Many values: as set_value one by one, but reading the form once and recalculating once.
let results: Vec<Result<(), FormError>> = set_values(&mut doc, &[("name".into(), FieldValue::Text("Ada".into()))])?;
reset(&mut doc, None)?;                                      // Clear form (back to /DV)

// Prepare a form
let name = add_field(&mut doc, 0, [72.0, 700.0, 216.0, 722.0], &NewField::Text { multiline: false }, None)?; // "Text1"
set_props(&mut doc, &name, &FieldProps { name: Some("email".into()), required: Some(true), ..Default::default() })?;
// A partial appearance edit keeps each widget's other styles and custom /DA font.
set_props(&mut doc, "email", &FieldProps {
    appearance: Some(LookPatch { width: Some(3.0), fill: Some(None), ..Default::default() }),
    ..Default::default()
})?;
delete_field(&mut doc, "email")?;

// A table of text fields (view space, y down), laid out and named before any of it is added:
// refused whole for a cell under MIN_FIELD_SIDE or a name with a period, repeated or taken.
let names = all.iter().map(|f| f.name.as_str());
let base = table::free_base(names.clone());                    // "Table1", or the next one free
let mut grid = table::FieldTable::new([40.0, 240.0, 240.0, 360.0], &base);
grid.set_columns(2, &base);
let cells: Vec<table::Cell> = grid.cells_for(names)?;      // "Table1_1", "Table1_c2_1", …

Behaviour

  • Inheritable attributes (/FT, /Ff, /V, /DV, /DA, /Q, /MaxLen) are resolved down the field tree; the form's /DA and /Q are the last fallback.
  • Text and choice fields get new appearance streams (/Tx BMC … EMC): auto-size (0 Tf), comb (cells parted in the border colour), multiline wrapping, password masking, quadding, /MK background and border, list-box selection. The /DA font is used when /DR has it as a simple font or a composite font with a predefined Unicode CMap; otherwise Helvetica (WinAnsi). Thai in a text field or combo box that the font can't hold is drawn from the craft-fonts Thai face with Add text's writer (pdfcraft_fonts::shaped_text), beside the field's font, keeping /DA; a build without a Thai face refuses it. Any other value or button caption the font can't hold (Chinese in a Helvetica field, a character beyond U+FFFF in a UCS-2 CMap) is kept, as Acrobat keeps it: /V is stored as typed, the appearance draws ? for those characters, the widget is marked with the first one (pdfcraft_fonts::UNDRAWN_MARK, cleared once all of it can be drawn) and the form sets /NeedAppearances, so other viewers draw the field from its value with fonts that have them. Not in a document that declares PDF/A, which forbids that flag (declares_pdfa: the bytes pdfaid:part in XMP metadata each filter stage of which decodes within 8 MiB, the cap preflight's Standards panel reads it with; preflight reads the identifier by its namespace, so a packet that gives that namespace another prefix declares PDF/A there and not here). undrawn_fields lists the marked fields; the engine reports each edit that draws one, and flattening refuses to make that ? permanent.
  • Displaying a form that leaves its appearances to the viewer (/NeedAppearances, or a text or choice widget with no /AP /N): with_field_appearances returns a copy with those widgets drawn from their values, as typing them would draw them; the engine shows and prints that copy and saves the file as it was (#3470). with_field_appearances_cached keeps each widget's drawing while the objects it was drawn from are the same (FieldAppearanceCache), so typing in one field redraws that field, not the form. Check boxes and radio buttons with no appearance for their state are drawn from /MK (border, background, and when on the /MK /CA ZapfDingbats symbol in the /DA colour), and with /NeedAppearances so is every one that has an /MK, over the appearance the file carries; push buttons with none are drawn from /MK, /BS and their caption.
  • Check boxes and radio buttons switch /V and each widget's /AS. Widgets with no appearances get PdfCraft's own (a drawn check mark or dot; no symbol font needed).
  • Imported /MaxLen 0 is treated as unlimited (also clearing an inherited limit), while the original entry is preserved. Positive limits still apply.
  • Values are validated (options, MaxLen, single vs multi-select, NoToggleToOff, read-only) and a failed call changes nothing.
  • Native AF recognition stops at the first genuine matching function call. If its arguments are unsupported, the complete action remains a custom script for the caller's Scripts interpreter; a later literal call must not replace the original conditional validation. Async for-of loop headers and bodies do not expose AF-looking text inside regex literals.
  • Moving choice fields preserves their own options rather than copying an ancestor's /Opt; inherited check-box and radio-button export mappings remain intact.
  • FieldProps::appearance applies a LookPatch independently to each widget. None keeps a property; Some(None) removes a border/fill colour. Unchanged /DA, custom font resources, widget-specific /DA overrides, indirect /MK and /BS contents, dash arrays and unknown dictionary keys are preserved. It is mutually exclusive with the complete look replacement. Use the engine's Edit::Batch to change several fields atomically in one undo step.
  • /RV (rich text) is removed when a value is set so it can't contradict /V.
  • New fields follow Acrobat: names Text1, Check Box1, Group1 (radio groups), Dropdown1, List Box1, Button1, Date1, Signature1; /Helv is added to /DR; appearances are drawn at once. Radio buttons joining a group become kid widgets with their own export state. Renaming changes only the last part of a dotted name.
  • Renaming a field renames it in every Calculate script that reads it (af::rename_in_calculation): AFSimple_Calculate field lists, simplified field notation (the BVCALC comment and its translation), and getField calls whose argument is the name as one string literal. The rest of each script is kept as written. Names built at run time, a script stream that isn't UTF-8, other events' scripts and a name another field still has are left alone. calculations_changed compares two readings of a form to say which formulas an edit rewrote.
  • The calculation order (/CO, §12.7.3): calculation_order lists the calculated fields in the order recalculate runs them, each with its field object (two can share a name in a malformed form), with the fields each formula reads (Calculate::reads, af::calculation_reads; names built at run time aren't known) and what is wrong with the order: a field reading one that calculates later, a loop, a field /CO leaves out, and /CO entries that calculate nothing. Two issues are about the formula itself: it reads a field the form doesn't have (MissingField; the field of that name and a field under it count as there, as recalculation and getField find them, but not an Acrobat widget name such as price.0, which neither resolves), or its script builds names at run time (BuiltNames, af::reads_built_names), so its reads are incomplete. CalculationOrder::fixed is Fix order: a stable topological sort that keeps the current order wherever it can and leaves loops as they are. set_calculation_order writes /CO afresh. A field that gets a calculation joins the end of the order; one whose calculation changes keeps its place.
  • Widget::caption reads a push button's normal label (/MK /CA, including indirect values). FieldProps { caption: Some((widget_index, text)), ..Default::default() } changes or clears that label, independently of the field name. The index is 0-based. A label-only edit redraws just that widget, copying shared/indirect /MK and appearance dictionaries before editing. Other widgets, actions, icons, rotation and unknown entries are preserved. Existing icon-only layouts keep hiding the caption; caption rendering retains the existing Helvetica/WinAnsi limits and reports unavailable characters as above.

Not yet

Rich-text values and exact appearance metrics/layout remain incomplete. JavaScript execution and FDF/XFDF exchange are integrated through the engine; this crate provides field actions and native AF rules. /MK /R rotation (0, 90, 180, 270) is drawn into the appearance.

Button export values

FieldProps::export_value edits a zero-based widget's /Opt value while preserving appearance names, /AP, /AS, /V and /DV. Widgets sharing an on-state remain consistent. Export-only changes do not regenerate appearances. Invalid indices, empty/Off values and ambiguous/conflicting mappings are rejected. Field::export_values resolves selected states for data export.