crates/xfa/src/script.rs: template_field and named_in moved to crate::data in #3397; field_grows_with_text calls crate::data::template_field.
parity/acrobat-features.toml: form.xfa evidence lists unioned, notes take both sides' insertions.
ROADMAP.md: progress-log line added at the top
docs/roadmap.md: status line, M10 row note and revision row applied onto main's
parity/acrobat-features.toml: export-docx evidence and notes insertions applied onto main's
crates/edit/src/tests.rs, crates/engine/src/tests.rs: new tests appended after main's
crates/engine/src/tests.rs: union of #3180's new test and #3397's doc comment on the next test.
crates/xfa/src/layout.rs: kept #3782's formatted_value (it calls date_pattern, now built on date_letters) and button_action doc.
crates/xfa/src/parse.rs: parse_field doc comment from #3397 (names the data picture).
crates/xfa/src/script.rs: field() doc from #3582 with #3397's "as scripts read it".
crates/xfa/src/tests.rs: union of fixture imports and of the new tests; one doc comment each for the date-conversion and data-fill tests.
* 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
* M6: Prepare a form ▸ Calculation order: a page to reorder, check and fix the order fields calculate in
The calculated fields in the order they calculate (/CO), each with its
formula and the value it shows, moved with Up/Down arrows (as Acrobat's Set
Field Calculation Order), Alt+Up/Down or by dragging. A card says which
fields read one calculated later, which read each other in a loop and which
/CO leaves out, and holds Fix order; a loop's card opens one of its
formulas. On the page, arrows show what the field in focus reads and what
reads it, with each linked field's step. The order is a page of the Prepare
a form panel, opened from its Fields tools, with a back arrow to Prepare a
form; opening it ends Preview and closes the page grid.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
* M6: form.calculation_order runs form_calculation_order headlessly
The command had no tool linked, so command_list showed none for it and
command_run refused it. form_calculation_order is now its tool: run without
a change, it reads the order.
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
* 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>
A 0×0, 0×n or n×0 raster is the right length (no bytes) and already
failed in all three encoders, but through each one's own error (PNG's
"Zero width not allowed", the JPEG encoder's size check, tiff's
dimension check). check_raster now refuses it first, with one message
for every format, so the rule doesn't depend on the encoders' versions.
rasters_of_no_pixels_are_an_error asserts that message for PNG, JPEG and
TIFF; without the check it fails on PNG's own error. From CodeRabbit's
review.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
encode_jpeg and encode_tiff composited the raster over white with
as_chunks::<4>(), which drops a partial trailing pixel, and handed the
result to encoders that trust its length. Run before this change:
- JPEG: a partial pixel at the end was dropped and the rest written as a
valid image; a buffer a pixel short, a pixel too many, empty, or with a
size whose byte count overflows panicked (image's JpegEncoder checks
the length with assert_eq!).
- TIFF: a partial pixel or a pixel too many was written as a valid
image; a buffer a pixel short panicked (tiff's write_data compares the
buffer with the pixel count, not the sample count, then slices).
The length check encode_png gained in #2425 is now check_raster, and all
three encoders call it first. Rendered pages are always the right size,
so what Export > Image writes is unchanged.
export::raster_tests::rasters_of_the_wrong_length_are_rejected now runs
its seven cases through PNG, JPEG and TIFF; it fails without the fix at
"JPEG, opaque, a partial pixel after". rasters_of_no_pixels_are_an_error
covers 0x0, 0x3 and 3x0, which are the right length and still errors.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
* fix(engine): PNG export writes opaque pages as RGB at deflate level 3
`encode_png` wrote every raster as RGBA at the png crate's default level
(zlib level 6). A rendered page is always opaque, so the alpha channel only
made the file bigger and slower to write. An opaque raster is now written as
RGB, at deflate level 3 with adaptive filtering; a raster with translucent
pixels is still written as RGBA with straight alpha.
The files come out the same size, 3-5x faster (release build, Linux):
300-dpi scan drawn at 220 dpi 805 -> 167 ms 4.46 -> 4.47 MB
text page at 220 dpi 70 -> 40 ms 0.85 -> 0.89 MB
annual-report page at 150 dpi 179 -> 64 ms 1.41 -> 1.38 MB
This is the encoder behind Export > Image (PNG), `pdfcraft-cli render` and
Print as Image; the automation tools (`page_render` and the other page
previews, MCP included) had a copy of it and now call it instead, so png is
only a dev-dependency of pdfcraft-automation. Two automation tests that read
`page_render` output as RGBA now accept the RGB a page renders as.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoEx9Nj8KC4WKDcf5YaokQ
* fix(engine): PNG export rejects a raster of the wrong length
encode_png now checks that the buffer is exactly width × height × 4 bytes
(checked arithmetic) before choosing RGB or RGBA. The opaque path's
as_chunks::<4>() dropped a partial trailing pixel, so encode_png(1, 1,
&[10, 20, 30, 255, 0]) wrote a valid 1×1 PNG where the old RGBA encoder
returned an error.
Regression test export::png_tests::rasters_of_the_wrong_length_are_rejected
covers opaque and translucent buffers with a partial pixel, a pixel short,
a pixel too many, an empty buffer and a size whose byte count overflows; it
fails on the previous head at the reviewer's case.
Review: https://github.com/storytold/pdfcraft/pull/2425#discussion_r4281603405
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoEx9Nj8KC4WKDcf5YaokQ
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
CodeRabbit: the engine test skipped when the viewer's render was blank, so a
viewer pool that lost the installed-font fallback would have skipped instead of
failing. Whether this machine has a face that can draw the page is now asked of
a renderer with RenderConfig::system_fonts switched on by hand, and the viewer's
render must then draw it. Without the fallback on the viewer's pool the test
fails.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
FredApps: AGENTS.md §1.4 allows reading an installed font at runtime only as
the interface's last fallback and says nothing from it may enter a PDF or
package, while the renderer fallback also served exports and printing.
RenderConfig gains system_fonts, off by default. The engine sets it only on the
pool that draws the viewer (display_config); exports, printing, OCR and the
automation tools render with the document's own configuration, so nothing drawn
with an installed font is saved or printed. Tests: the render of a non-embedded
CNS1, GB1 and Korea1 font is blank without the flag and drawn with it; the
viewer's pool draws such a page and an export of it does not (it fails if the
flag is on everywhere). No Adobe-named font can be a candidate.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A bookmark made in the app always went to the top of the page. It now goes to
the top-left of the selected text, or with nothing selected to the top of the
window, as /XYZ left top null: the reader's zoom is kept, and with only the top
pinned so is the horizontal position.
- render: PageInfo::xyz_for_view, the inverse of dest_fraction. It pins only
the coordinates given; on a page turned a quarter the displayed y decides the
user left and the displayed x the user top.
- organize: add_bookmark_at and OutlineError::BadPosition (a coordinate that is
not finite is refused before anything changes).
- engine: Edit::AddBookmark takes left and top (None, None is the page top, as
before).
- automation: bookmark_add takes top and left in points from the top-left of the
displayed page, as bookmark_list reports them, bounded and refused when not a
usable number.
- ui: DocView::bookmark_position. It follows the window's vertical when the view
is turned a quarter (View > Rotate View), picks the selection's corner that
shows at the top-left, and in a spread of unequal pages uses the page that
crosses the window's top edge.
- parity: organize.bookmark-add evidence and what is still missing.
Adds a Finnish (Suomi, `fi`) catalog with 2,592 entries: every key of
the other bundled catalogs, written in sentence case from the English
labels. Finnish uses the one/other plural rule with the partitive
singular after any number but 1 (1 sivu, 2 sivua, 0 sivua).
Finnish is also a date language: calendar headings use the nominative
month (Lokakuu 2026) and dates the partitive (14. lokakuuta 2026).
Any fi-* locale selects it.
Tests cover registration, locale tags, persistence, plurals, calendar
wording, commands/tools, scanned UI literals, every bundled catalog key
and the Latin UI fonts. The catalog has its ATTRIBUTION.toml entry
(ATTRIBUTION.md regenerated with `cargo xtask assets --write`).
Clean-room, AI-assisted (Claude Code) translation; not yet proofread
line by line by a native speaker.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A checkmark, cross, dot or line placed on a page with /Rotate was drawn
in the page's own axes, so on a page turned 180 degrees the checkmark
came out upside down, and on a quarter turn it lay on its side.
Marks now record the page rotation (PCStampRotate, as rubber stamps do)
and their appearance is drawn upright in the displayed box and mapped
into /Rect, sharing the stamps' code for it. The mark's box is sized in
displayed axes (FillMark::rect_at), used by both the Fill & Sign tool and
fill_sign_add, so a line runs across the page as displayed.
A date the field's data picture writes as 20261018 reaches scripts as
2026-10-18, the rawValue another viewer gives them, so a script that
checks String(rawValue).length == 10 works (DA Form 31's 10b TO).
Doc comments on the functions this branch touches.
Fields keep their <format><picture>. A date display picture turns the
ISO value into its pattern for scripts' formattedValue (JavaScript and
FormCalc), so date arithmetic on formattedValue works as forms expect.
Once a script sets the value, formattedValue gives that value. Number
and text display pictures still give the raw value.
Once drawn, a line or arrow could be moved but not changed: selected, it showed a frame and
no handles, so a wrong length or angle meant drawing it again. A selected line, connected
lines, polygon or cloud now has a handle on each end or corner instead of the frame; dragging
one moves that point (Shift holds it to 45° from the point before), the new shape is drawn
while dragging, and the release is one undo step. The drawing tools keep drawing from an end
(a new line starts there); the Select and Fill & Sign tools pick the handles up.
pdfcraft_annot::set_points sets /L or /Vertices, lets /Rect follow with room for the width
and arrowheads (the rule a new comment is drawn with) and draws the appearance anew;
measurements keep the points they measured, locked comments their shape. Edit::ReshapeAnnotation
("Reshape comment"), comment_edit `points`; the comment list (Summary, DocInfo, comment_list)
carries a line's ends and a polygon's corners.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A page coloured with Edit a PDF > Background exported to Word as a
white page: export pages carried text and images only, and the Word
section has no page colour.
- pdfcraft-edit `page_background`: the colour of the first thing a
page paints, when that is an opaque fill (no soft mask, Normal blend)
in a plain gray, RGB or CMYK colour, of a path with a rectangle
covering the visible page, not clipped smaller; `None` otherwise.
- `export::Page::background` carries it; the Word writer sets it as
the document background (`w:background`, plus the
`w:displayBackgroundShape` setting Word needs to show it) when every
page shares one non-white colour. Word has one background per
document, so differing colours are left out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz