Includes #4325#1952#4180#2529#4320#4805, preserving their original histories.
Keep paragraph alignment and hard/soft wrap metadata together; retain CCITT/downsampling/RGB ownership guards with bounded JBIG2 export and exact-copy symbol encoding.
Combined interaction regressions added. Validation pending.
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.
UPDATE_APP (native builds; craft-update is a native-only dependency) carries our 128 px icon and accent (#b70228, its red, from the icon (#5245)). The updater, the self-update
helper and craft-update's startup prompt all get them through crate::UPDATE_APP; the
prompt draws the icon, the accent and "by ArtCraft" (craft-update's default developer).
craft-update is pinned to storytold/craft-libs main at 1065653e7 (#434).
Clippy fixes for Rust 1.99 in untouched code (same logic):
- crates/edit/src/images.rs
- crates/forms/src/appearance.rs
set_props refuses such a notation before writing anything, so the
Calculate tab can't store one either (the tool already refused it). A
refused edit used to close Field Properties and lose everything typed
into it; the dialog now stays open with its changes, as the dialog for
several fields does, and the notice says why (from the review).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
parse_calculate trimmed the notation with str::trim, which also took an
escaped space ending its last name: "Qty * Total\ " read back as
"Qty * Total\", a field the form doesn't have, while the script's
JavaScript still read "Total ". It now trims to its first and last
token, as the field-reading scan does. notation_storable compares the
names and operators read back with those given, so a notation ending in
a lone \ (which escapes the space after it) is refused; a proptest reads
back notations of any names (from the review).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
A notation is kept in the script's /** BVCALC … EVCALC **/ comment and read
back from there. One holding EVCALC was stored cut short ("a EVCALC b"
read back as "a", and its JavaScript translation didn't parse), one
holding */ ended the comment, and an empty one became "event.value = ;".
form_set_props now refuses each (af::notation_storable: it has to read back
as itself). The test also pins a notation beside op alone and beside
fields alone (from the review).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
AFSimple_Calculate("SUM", "a, b", x) gave only a built-name site, so a
rename no longer rewrote a and b (main did) while parse_calculate still
read the sum of a and b: after a rename the sum read missing fields. The
string list now keeps its names when an argument follows, and what
follows is a name built at run time, as for an array (from the review).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
found accepted price.0 (up to the field's last widget), but links,
recalculate and the scripts' getField all take a name as the field or
the fields under it, so a formula reading price.0 got no value and no
order check while the page said nothing was wrong. found now looks
names up the same way; a widget name is flagged as missing until all of
them resolve it (from CodeRabbit's review).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A read like price.12 counted as found whenever price existed. A widget
number now finds something only below the field's widget count; a
field with one widget, or none, still answers to .0 (from CodeRabbit's
review).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- CalculatedField carries the field object: form_calculation_order and
the Calculation order page find each row's formula and value by it,
so two calculated fields sharing a name (a malformed form) each show
their own.
- An AFSimple_Calculate list counts as written out only when it ends
the call: "row" + suffix and [...].concat(more) are names built at
run time, and an array item counts only when it ends at a comma or
the list's end, so ["row" + i] no longer reads, or renames, "row".
- After a reorder, Undo is offered on the missing-field card instead of
bringing back the all-clear beside it.
From CodeRabbit's review.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
calculation_order flags MissingField (a field deleted from under a sum, a
typo in notation, a script's getField; a group's name and a widget's
number count as there) and BuiltNames (a getField or AFSimple_Calculate
list built at run time, found as af::Site::Built in the same pass as the
literal names). form_calculation_order gives each field's formula and the
two new issues; form_fields shows a custom Calculate script; the
Calculation order page explains both in its row tooltips.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
Resolves test-file conflicts where main and this branch both appended tests:
main's file plus this branch's appended tests, unchanged.
fmt, clippy -p pdfcraft-forms -p pdfcraft-automation, and both crates' tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Resolves test-file conflicts where main and this branch both appended tests:
main's file plus this branch's appended tests, unchanged.
fmt, clippy -p pdfcraft-forms -p pdfcraft-automation, and both crates' tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Resolves test-file conflicts where main and this branch both appended tests:
main's file plus this branch's appended tests, unchanged.
fmt, clippy -p pdfcraft-forms -p pdfcraft-automation, and both crates' tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
* 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.
redraw_field after the join redrew every button of the target group, so a
button with its own appearance states lost them to the default marks. Only
the moved buttons are now redrawn under the group's name.
Test: joining_a_group_leaves_its_own_buttons_appearances_as_they_are
(fails before: the group's own button gets new /AP streams).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Sorting a field's own items carries its /I positions through the sort
(ISO 32000-2 12.7.5.4) instead of re-deriving them from /V, which names
only the export value. Values no carried position covers still take their
first position. items_to_sort and write_sorted_opt keep set_props under
the complexity limit.
Test: sorting_keeps_which_of_two_items_with_one_export_value_is_selected
(fails before: /I [0], Alpha, instead of [2], Zeta).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Adopt upstream's split set_props: rename_field returns the group a renamed
radio button joins, and redraw_or_join keeps set_props under the new
cognitive_complexity limit. join.rs imports explicitly (wildcard_imports).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Adopt upstream's split set_props and explicit import lists. The list box
scroll after a property edit moves into scroll_list, keeping set_props under
the new cognitive_complexity limit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Deleting one widget took it out of the field tree through its /Parent,
which a damaged file can leave out or point at another field; the group
kept the widget in its /Kids with no page showing it, and the delete
reported success. The field that owns the widget drops it from its own
/Kids as well (CodeRabbit on #4631).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR
When the source radio field is also its widget, the join removed the
field-only keys but kept its whole /AA, so field triggers (K, F, V, C)
stayed on a dictionary that is now only a widget. Split it as
split_merged does for the target: keep the widget triggers, drop the
field's, and remove /AA when nothing is left.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
set_props chose /TI inside the field-dictionary update, from the field
read before the edit, so a font size or look changed in the same call
was not counted: at a larger size fewer rows fit and the selection
could end up below the visible rows. The field and widget top rows are
now chosen after every layout change, from the field read again,
before the redraw.
Test: a_list_boxs_top_row_follows_a_font_size_changed_with_its_items
(fails with the old ordering: rows 1..2 shown, selection on row 3).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Review follow-ups on #4977:
- ui-egui: after a rename, the Properties dialog re-selects the edited
widget by object, not by its old index. A button that joins a group is
appended after the group's own buttons, so the old index selected the
group's first button and a nudge moved the wrong one.
- split_merged (join and Duplicate): the new widget takes /A and the
widget triggers of /AA (E X D U Fo Bl PO PC PV PI); the field keeps
K F V C.
- join: a clashing export value searches at most taken + 1 ChoiceN
names, so one is always free, and the names are chosen before
anything is written.
Tests: a_joined_merged_groups_button_keeps_its_actions,
a_joining_button_gets_a_free_export_value_when_choice_names_run_long,
a_radio_button_joining_a_group_by_name_stays_selected (ui-egui). Each
fails with its fix reverted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
A widget's own /TI is read before the field's, so each one scrolls by its
own top row and size (ListRows::top_showing). Before, the widgets were only
moved, all to the first widget's new row, when the first widget itself
scrolled: a second widget scrolled past the remaining options, or away from
a new value the first already showed, kept its stale row.
Test each_widget_with_its_own_top_row_scrolls_by_itself covers both
set_props (options shrink) and set_value; restoring the first-widget gate
in either caller makes it fail.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
/V is inheritable like /DV (ISO 32000-2 §12.7.4.1). Clearing a text or
choice field's value removed its own /V, so a parent field's /V showed
through: Reset Form on a kid with no default refilled it with the parent's
value. When an ancestor sets /V, an empty value is now written instead of
removing the key. Check boxes and radio buttons already write /Off.
Test clearing_a_kids_value_does_not_bring_back_its_parents covers a text
kid and a list box kid through reset and through set_value; each half of
the fix was switched off in turn and the test failed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
/DV is inheritable (ISO 32000-2 §12.7.4.1). set_props removed a cleared
/DV, which brought back a parent field's, so Reset Form restored the
parent's default. When an ancestor sets /DV, a cleared default is now
written empty (/Off for buttons, () otherwise); with none it is removed
as before. New tree::ancestor_sets, cycle-safe and capped.
Adapted from pdfcer (MIT, same author) 6af2773ccf599b416fab379673119c6b37c55a2c.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Changing a list box's options could leave /TI (the first visible row)
pointing at a row that moved or no longer exists. A widget's own /TI is
read before the field's, and one past the end draws no options at all.
set_props now runs the same ListRows::top_showing rule set_value uses
(ISO 32000-2 12.7.5.4): /TI stays put while the selected rows are still
visible, otherwise it scrolls to them, clamped to the last full page.
The field's /TI is updated, and so is every widget that carries its own.
The scroll helpers move out of set_value so both paths share them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
set_props rewrote /Opt (an items edit or "Sort items") but left /I, the
selected positions in /Opt (ISO 32000-2 §12.7.5.4), so /I named whatever
now sat at the old positions. It now re-points /I at the selected values'
new positions, by set_value's rule, and removes it when none is offered;
a field /TI past the end of the new list is removed.
Adapted from pdfcer (MIT, same author) 149db0662ecfe5b23a050cdb7513821da918d7cb.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Review follow-up (CodeRabbit): the join read the target's /Kids only when
it was a direct array, so an indirect one was replaced by the moved
buttons alone and the group's own buttons were lost. It now reads
through the reference with tree.rs's list_of, as link() does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Review follow-up. A clash is checked against the group's own values only,
and each clashing value gets one fresh ChoiceN shared by every joining
button that had it, so a button repeated on another page stays in unison.
The group's value selects a moved button only when one of the group's own
buttons has it. A joining field that is its own widget leaves the
calculation order too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Renaming a radio button to an existing radio group's name was refused as
a duplicate, so buttons placed separately could never become one group.
set_props now moves the renamed field's buttons into the named group
(ISO 32000-2 §12.7.5.2.4: a group is one field whose widgets are its
buttons). A clashing export value is renamed to the first free ChoiceN
in /AP /N, /D and /R; a moved button is on only if its value is the
group's value; merged field+widgets are handled on both sides (the
target split via split_merged, now shared with duplicate_field).
Refused when either group has /Opt, has no button, or the target is
locked.
Adapted from pdfcer (MIT, same author) 79870b56 and 116f48d3.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
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