Preserve caption editing and current form/Prepare module boundaries. Carry export-only appearance preservation, validation and data round-trip tests to their current paths, and retain current attribution metadata. Hosted CI must validate this refreshed head.
* M6: Organize pages ▸ Page properties sets the selected pages' tab order
Page properties in the Organize pages panel was listed as planned. In
the app, a page's tab order could only be set for every page at once,
with the form.tab_order commands, which offer no Unspecified. (The
form_tab_order tool already set chosen pages, Unspecified included.)
- Page properties (page.properties) opens a Tab Order dialog for the
selected pages, with Use Row Order, Use Column Order, Use Document
Structure and Unspecified. OK writes Edit::SetTabOrder for those pages
as one undo step. The dialog opens on the pages' order, or with no
choice made when they differ, and then OK is off until one is chosen.
OK on the order the pages already have adds no undo step.
- Rows and columns run right to left in a document bound on the right,
as Use Row Order and Use Column Order are described. PdfCraft reads a
right-to-left binding from /ViewerPreferences /Direction /R2L
(ISO 32000-1, 12.2, Table 150). Before, rank_tabs ranked them left to
right.
- A page whose /Tabs is an indirect object now takes its row, column or
structure order. Before, rank_tabs read only a direct name and ranked
the page in annotation order.
- pdfcraft_forms::tab_orders and Document::tab_orders read each page's
/Tabs. form_tab_order returns them as page_tabs, and reads without
changing anything when it is given no order or field.
- set_option("dialog", "page-properties") opens the dialog.
- Parity: organize.page-tab-order is shipped, and its notes say the
Actions tab of Page Properties (organize.page-actions) is still
planned; the Organize pages grid notes list Page properties.
* Page properties: OK applies only to the document the dialog was opened on
The draft keeps the document's id. When another document is active by
OK, the dialog closes without changing either document.
* Menus: list Page properties… in the Pages menu
The menu bar now lists its commands explicitly, and every command with a menu group must appear in it.
* Page properties: apply the tab order only to an unchanged document
An edit, undo or redo made while the dialog is open can move the pages
at the stored indices. OK now closes the dialog without changing a page
when the document's edit generation differs from when it opened.
* Page properties: say when a document change cancels the tab order
OK shows a notice when the document changed or another document became
active while the dialog was open, so the user knows to open it again.
main deleted crates/engine/src/reexports.rs and moved its re-exports
into crates/engine/src/lib.rs; the comb_auto_text_size re-export this
branch added now sits in lib.rs. forms_ui.rs moved to
crates/ui-egui/src/tools/; the branch's hunks carried over unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Review on #4862 found two mismatches between the on-page field editor and
the field's appearance:
- Comb fields: the editor sized them as single-line text (20.87 pt for a
500 x 30 comb) while the appearance uses 4-12 pt from the cell height.
The comb test is now `Field::is_comb` and the comb size
`appearance::comb_auto_text_size`, both shared by the appearance and
`forms_ui::field_text_size`. The editor's test was looser (it ignored
Multiline and Password); it now matches the page's.
- Tall fields: `field_text_size` capped at 300 pt and the three call sites
clamped the screen font to 64. Both are gone; `field_screen_font` scales
by zoom and bounds the result at MAX_SCREEN_FONT (256 screen points) so a
glyph stays well inside the font atlas.
A non-finite comb height now gives 4 pt, as single-line auto-size does.
Test: it::forms::the_field_editor_sizes_auto_size_text_as_the_page_does.
With the comb change reverted the comb assertion fails (20.87 vs 12); with
the 300/64 caps restored the 100 pt assertion fails.
Also fixes it::forms::dragging_over_another_fields_text_focuses_it_and_selects_from_the_press,
which the previous commit broke: the name field now auto-sizes to 13.9 pt
instead of 12, so the press point 30 px in fell on "A|da"; it is now 38 px
in, inside "Ad|a" as the test intends.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
* M6: with NeedAppearances, check boxes and radio buttons draw their /MK caption (ISO 32000-2 Table 224)
A form that sets /NeedAppearances asks the viewer to construct the
appearances of its widgets (ISO 32000-2 Table 224; deprecated in PDF 2.0).
The display copy already redraws text and choice fields for it, but check
boxes and radio buttons kept the file's own appearance streams, so a box
whose /MK /CA is ZapfDingbats 4 (a check mark) showed the filled square
its /AP carried.
With /NeedAppearances, every check box and radio button that has an /MK
is now drawn in its current state through button_state_shown (merged in
#4368): /MK /BG and /BC, and when on the /MK /CA ZapfDingbats character
(Table 192) in the /DA colour, sized to the box. A widget without /MK
keeps its own appearance, and without /NeedAppearances nothing changes.
A radio button with no /AS now takes its state from /V when /V names its
own on state. Display and print only: the file is saved as it was.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
* chore(forms): wrap button_state_shown's doc comment
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Opening a static XFA form gives its fields the values in its datasets
(a form filled in by another application). Each value went through
forms::set_value, which reads the whole field tree to find the field,
writes it, and recalculates, reading the tree again. With n values that
is about 2n readings of n fields: quadratic. A public 136-page form with
about 6,000 fields took 261 s to open, a 95-page one with about 4,000
took 112 s; 8,000 synthetic fields took over ten minutes.
forms::set_values fills a batch as set_value fills each in turn, reading
the form once and recalculating once after the last value (the
calculation order, ISO 32000-2 Table 224 /CO, runs once over the final
values). Fields with the same fully qualified name (12.7.4.2) still share
the value. A name the batch already wrote, or a twin of it, makes the
form be read again first, so each write starts from what the earlier one
left, as one by one. Each value reports its own result; a failed
recalculation fails the batch, as it fails set_value. The engine collects
the changed values and fills them with set_values, finding fields by name
through a map rather than a scan.
The two forms now open in 4 s and 3 s, with the same fields, values,
warnings and document information as before; 8,000 synthetic fields open
in 1.5 s.
Tests: set_values matches set_value one by one (values, twins, the
calculated total, every appearance, saved; refusals for an unknown name,
a read-only field, an unknown option and clearing a no-toggle-off radio
group the batch selected); it reads the form a fixed number of times; the
engine takes 59 data values on a 60-field static form exactly as filling
them one by one does (the read-only one refused); and opening 800 fields
takes under 16 times as long as 100 (about 4x now, about 48x before).
Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Prepare Form deletes the selected widget: one radio button of a group or
one copy of a duplicated field, as Acrobat does; a field's last widget
takes the field with it. The widget leaves its page, the field's /Kids
and /CO; a per-widget /Opt loses its entry, and a group that was on only
through it turns Off. form_delete_field takes an optional widget.
crates/forms/src/tests.rs: append conflict, kept both tests.
parity/acrobat-features.toml: form.fill-text-field evidence is the union; notes keep HEAD's text plus the comb-cells sentence.
The on-screen drop-down button for combo boxes drawn display-only, the
narrowed text area beside it, and the Print as Image copy without it
(with_printed_field_appearances, printed_display_bytes) are removed.
Open PR #3567 draws the arrow for every combo box in the page view.
Check boxes, radio buttons, push buttons and comb lines are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
A form that leaves its appearances to the viewer had its text and choice
fields drawn for display (#3475), but not its buttons. Now, also for
display only, without changing the file:
- Check boxes and radio buttons with no normal appearance for their
state show their /MK background and border (Table 192), and when on,
the /MK /CA caption as a standard-14 ZapfDingbats character
(12.7.5.2.3), 4 or l by default, in the /DA colour, auto-sized for
0 Tf.
- Push buttons with none show their /MK background, their /BS border
(beveled per Table 168) and their centred caption.
- Comb fields with a border colour draw the MaxLen - 1 lines between
their positions (Table 228), up to 500 cells. This also applies to the
appearances PdfCraft saves for comb fields.
- A combo box drawn for display shows a drop-down button on screen, with
its text kept clear of it. Saved appearances and Print as Image leave
it out (with_printed_field_appearances, printed_display_bytes).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
The actions chained after a trigger's first one (/Next) are kept
whatever replaces it, not only another script, and the row reads
"…, then N more actions". Edit is disabled on every row while a
script is open, so it can't be overwritten.
Redact: refuse Redact marks written in place; remove replies and pop-ups listed on
other pages; delete form fields by object, not name; resolve Do by its last operand
like the renderer; sanitize drops PCAdded and flattens widgets before hidden-content
cleanup. Sign: freed signed objects and edits to an already-signed widget are
modifications. Cos: only reading trusts /Type /XRef to skip encryption.
Review on #3943, point 1. has_password_values ran a full fields() walk
on every save and recovery snapshot, and clear_password_values walked
every field's ancestors again for each ancestor of each target.
has_password_flag now answers first, reading only /Ff, /Kids and /Parent:
the field tree from /Fields (seen set, 32 levels) and, as fields() does,
the widgets on the pages with the fields above them. A form without such
a field returns before any Field is built. clear_password_values finds
each field's ancestors once.
The test puts a page-only widget, a flag inherited from an unlisted
parent and a nested field whose widget is on no page each alone in a
document. Mutants that drop the page scan, the parent chain, the tree
walk or the descent into kids each fail it, as does treating every
ancestor as exclusive. Dropping the seen set or the early-out changes
only speed (equivalent).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review on #3943. A black-box test of Acrobat (synthetic form, Save As and
Ctrl+S, every decoded object searched) showed how it saves a Password
field: the typed value is nowhere in the file; a field with /DV is saved
holding /DV, one without is left empty; the asterisks are drawn from the
saved value (5 for a 5-letter default, not 13 for the typed password);
/DV and /AA are kept.
clear_password_values now does the same: it restores the (inherited)
/DV as the value, or leaves the field empty, and redraws the widgets, so
the appearance no longer shows how long the typed password was. A field
already at its default no longer counts as holding a password, so such
a form needs no cleaned copy. The parity note for form.fill-text says so
and lists the two limits Acrobat shares (a script copying the password
into an ordinary field, and an earlier revision kept by an incremental
save).
Mutants, each failing a test: no /DV restore, no redraw, the default
ignored in password_fields, emptiness ignored (an empty string in an
array). Dropping the empty-default filter was equivalent, so the filter
is gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-up: the helpers checked only the field dictionary's own /V. A value
inherited through /Parent or carried by a separate widget was missed, so the
document could be written as it was. Detection now uses the resolved value and
the widgets; clearing removes /V from the field, its widgets and every ancestor
only password fields inherit from, and gives the field an empty value of its own
where a shared ancestor value has to stay.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A text field with the Password flag kept its typed value in /V, so Save, a full
rewrite, Reduce File Size and the recovery snapshots all wrote it in plain text.
ISO 32000-2 §12.7.5.3, Table 231 (note to the Password flag): PDF processors
should never store the value in the PDF file. Each of those paths now writes a
copy without the values; the open document keeps them in memory, and the masked
appearance is kept.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-up (CodeRabbit on #3488): a form an earlier PdfCraft made
should work in Acrobat once it has been filled here too, not only once a
field is added or edited. set_value now runs the same conversion (shared
with ensure_form through inline_fonts_as_objects and store_form_fonts);
it adds nothing, only changes where the existing fonts are stored.
Not in a form a signature covers (a signature field with /V): there the
file stays as the signer saw it apart from the value. PdfCraft's own
change check counts a /DR change after a form-fill certification as form
fill, but another validator may not.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A /Kids array listing one widget many times, or shared by several fields,
built a Widget for every listing, so a small file expanded into fields times
listings widgets. A widget now belongs to the first field that lists it, and a
page widget already owned is not adopted again.
Each field action and document-script entry decoded its /JS stream
with a fresh 1 GiB allowance and kept its own copy, so many fields or
name-tree pairs naming one compressed stream retained gigabytes. A
reading of the fields, one field's actions, and the document scripts
each share a 64 MiB DecodeBudget for their script streams.
* M6: Prepare a form ▸ Text field table: rows and columns of text fields as one edit
Drag an area with the Text field table tool and set it up in a movable
window: rows, columns, row and column spacing, the first row number,
column widths (draggable dividers, arrow keys once focused) and a name
pattern per column, with the table previewed on the page. Add fields adds
every cell as one undoable edit ("Add text field table").
- forms: `table` lays the table out in view space and names its cells. It
refuses the whole table for a cell under MIN_FIELD_SIDE (now one
constant) or a name with a period, used twice or already taken, and
free_base picks the first free TableK for default names.
- engine: `field_table::edit` maps the cells onto the page, /Rotate and
/UserUnit included, and checks their size again there.
- automation: `form_add_field_table`, with an end-to-end test.
- ui-egui: the window belongs to one page of one document and closes when
that stops being the one prepared; Enter commits a number instead of
adding the table; dividers between many columns no longer panic in
f64::clamp. Strings in all 22 catalogs, and the Spanish engine-wording
tests cover the table's mistakes.
- parity: form.multiple-copies is partial; copying an existing field into
a grid is still missing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M6: text field table: refuse corners past the page's number range, run it by its command
Review follow-up on #3479.
- engine: a corner finite as an f64 but past f32's range came back from
the page's f32 mapping infinite. With one such corner a side was
infinite rather than NaN, so the cell passed and add_field refused it
only once the edit was applied ("the field is too small"). The edit now
refuses any corner that isn't finite on the page with the table's own
reason, before anything is added.
- automation: form.add.text_table runs form_add_field_table through
command_run, as comment.stamp runs stamp_custom.
- ui-egui: doc comments for the window's three remaining helpers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M6: text field table: close on document edits, lay out once per change, keep Escape for fields
Review follow-up on #3479 (benandez).
- The window belongs to the document as it was when it opened: it closes
when the edit generation moves (a page rotated, moved or deleted, an edit
undone), so a table set up for one page never lands on another or off it.
- The table is laid out and checked once per change, not several times a
frame: the window and the page preview share one `Laid` (cells and the
edit), and the preview draws only the cells in view, naming only those
with room for a name.
- Escape in a field of the window leaves the field and keeps the setup; a
second Escape closes the window.
- A table placed with a click is moved in from the page's edges, so all of
it is on the page.
- `free_base` takes one pass over the names; `{seq}` is documented as
counting from the first row number; `form_add_field_table` takes its
names from the batch instead of laying the table out again; the
unreachable `Dialog::FieldTable` arm says why it is there.
Each new test fails with its fix reverted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
* M6: text field table: drawn with its panel closed, the window opens Prepare a form instead of closing
With the tool picked and the Prepare a form panel closed, the form is
still prepared and a table can be drawn. Drawing sends the tool back to
Select, though, so nothing was prepared on the next frame, and the table
window, which lasts only while the form is prepared, closed itself at
once: the table couldn't be set up. Opening the window now opens the
Prepare a form panel it belongs to. Closing the panel while the window
is open still closes the window.
Found by CodeRabbit, which saw the preview stop drawing; the window
closing first was the larger effect. prepare.rs
a_table_drawn_with_the_panel_closed_opens_it_again fails without the fix
("the window stays open": (None, false)).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
* Drop the second generation binding the main merge left in document_area
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: aiongg <aiongtaigi@gmail.com>
* M6: renaming a field renames it in the calculations that read it
A rename silently broke every formula that used the field: a sum dropped
it, notation read it as 0 and a script kept a stale value. set_props now
rewrites the name in each field's Calculate script, in the same edit:
AFSimple_Calculate field lists, simplified field notation (the BVCALC
comment and its translation) and getField calls naming it in one string
literal. The rest of each script is kept as written; names built at run
time, non-UTF-8 script streams and a name another field still has are
left alone, and a self-check keeps a script whose meaning would change.
Notation is read by one lexer shared by notation_js, eval_notation and
the rename. form_set_props returns formulas_updated; Field Properties
says how many calculations now use the new name.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
* M6: form_set_props leaves the renamed field out of formulas_updated by object
The renamed field was matched by the name given, but set_props stores it trimmed and under
its parent's prefix. When the same call changed the field's own formula, formulas_updated
listed the field as one the rename had rewritten.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
* M6: calculation order: read, check, fix and set the order fields calculate in
A form's calculations run in its calculation order (/CO), which a field
joins when it gets a calculation, so a chain defined in the wrong order
shows stale values. calculation_order reads the order against the fields,
with what each formula reads (field lists, notation names, literal getField
names) and what is wrong: a field reading one that calculates later, a loop
(strongly connected components, found without recursion), a field /CO
leaves out, and /CO entries that calculate nothing. Fix order is a stable
topological sort that moves only what must move and leaves loops alone.
set_calculation_order writes /CO afresh; a name two fields share (a
malformed form) stands for the next of them in the current order.
Edit::SetCalculationOrder sets it in one undo step and recalculates, and
form_calculation_order reads, sets, moves one field or fixes the order
headlessly. An edited formula now keeps its field's place in /CO, a field
/CO lists twice calculates once, and recalculation runs each field by
object, so two fields sharing a name both calculate. The /CO code of the
Calculate tab, field scripts and recalculation goes through one place.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: aiongg <aiongtaigi@gmail.com>
* M6: renaming a field renames it in the calculations that read it
A rename silently broke every formula that used the field: a sum dropped
it, notation read it as 0 and a script kept a stale value. set_props now
rewrites the name in each field's Calculate script, in the same edit:
AFSimple_Calculate field lists, simplified field notation (the BVCALC
comment and its translation) and getField calls naming it in one string
literal. The rest of each script is kept as written; names built at run
time, non-UTF-8 script streams and a name another field still has are
left alone, and a self-check keeps a script whose meaning would change.
Notation is read by one lexer shared by notation_js, eval_notation and
the rename. form_set_props returns formulas_updated; Field Properties
says how many calculations now use the new name.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
* M6: form_set_props leaves the renamed field out of formulas_updated by object
The renamed field was matched by the name given, but set_props stores it trimmed and under
its parent's prefix. When the same call changed the field's own formula, formulas_updated
listed the field as one the rename had rewritten.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: aiongg <aiongtaigi@gmail.com>
annot::page_refs, the forms page_refs and organize's walk queued every
/Kids entry before the seen check, and organize cloned the inherited
attributes, Resources included, for each queued entry. A page tree whose
/Kids lists the same child many times, or whose nodes share one /Kids
array, held all of those entries at once before they were dropped as
already seen.
Each walk is now a small iterator with one cursor per open /Kids array,
the way pdfcraft_model::pages has walked since #1926. organize carries
inherited attributes as the nodes that set them and copies them only for
the pages it returns. Page order, the pages found, the inherited values
and the million-node cap are unchanged.