The template field behind a SOM path is now found by its number, as the
form numbers same-named siblings and repeated instances; the live form's
value refresh uses the same lookup. A data picture without a year, month
and day (date{YYYYMM}) keeps ISO dates in the data, since it can't hold
the date the field shows.
The layout revision replaces the placeholder pages and adds the fields,
so usage rights (/Perms /UR3) signed over the form as published no
longer match it, and a viewer may then open the form with every field
locked. write_form now takes them off with the helper strip already
needed, and the layout report says so.
A field's data picture (<bind><picture>, such as date{YYYYMMDD}) is how
its value is written in the XFA data, but PdfCraft always wrote dates
there as ISO (2026-10-18) and read them back as ISO, so a form that keeps
its dates as 20261018 got a different format from PdfCraft than from
other viewers, and data those viewers wrote was shown as stored.
The parser keeps <bind><picture> as Field::data_picture. A typed date is
still read in the field's own pattern and turned into ISO; it is then
written in the data picture when the field has one. Reading goes the
other way, on layout and when values are taken from the data of a form
laid out before. A value that doesn't match its data picture is read as
ISO, so data written by earlier versions still reads. The date-letters
rule for XFA picture clauses moves to one helper (date_letters) that the
layout's date_pattern now uses too.
M3: preserve reading position while joining page edges, with persisted defaults and opt-in UI control. Translate both labels in all 22 catalogs, update attribution, and cover catalog completeness with a regression test. Rebase PR #3101 onto current main while retaining navigation and rendering fixes.
On top of the merge with 932bf9c6; no conflicts. ATTRIBUTION.toml is upstream's with every sha256 recomputed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M10: exported TIFF and JPEG pages record the resolution they were drawn at
A page exported as TIFF said 1 x 1 with no unit, whatever the dpi, and
a JPEG export said nothing, so other programs showed and printed them at
a default (a 300-dpi page shown at 72 dpi is four times too big).
encode_image now takes the dpi: TIFF X/YResolution in inches, the JFIF
density in dots per inch.
PNG stays without pHYs. It counts pixels per metre, so 72 dpi would read
back as 72.009, and a page made from an exported PNG (doc_create from
images, as enhance_camera_image_through_tools does) would come out 0.01%
small.
Exporter records the resolution a page was actually drawn at (dpi_used),
so a page too large for the renderer says the lower one. render records
--dpi, since it refuses oversized pages. The TIFF is written with the
tiff crate, already in the graph, because image's TiffEncoder can't set
a resolution.
* M10: TIFF page exports are LZW-compressed, as documented
encode_tiff's comment said "LZW-compressed by the encoder's default",
but the TIFF was written uncompressed: a Letter page at 300 dpi came to
25 MB. It is now LZW without a predictor, the compression TIFF readers
support most widely after none.
* Contributors: add my name for the About window
Conflicts: ATTRIBUTION.toml, crates/organize/src/tests.rs (code resolved by hand: crates/organize/src/tests.rs). Catalogs keep upstream's rows, then this branch's rows upstream lacks; ATTRIBUTION.toml is upstream's with every sha256 recomputed; progress lines keep both sides, upstream's first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pictures in templates and data that are BMP or TIFF were left out with a
warning. They are now recognised by their signature, sized from their
headers, and decoded through the same bounded reader as GIF (first page
of a TIFF). A BMP needs a valid info-header size after "BM", so text
starting with those two letters is still not taken for one.
A BMP or TIFF chosen for an XFA image field is kept in the data as
image/bmp or image/tiff, since the layout can now show it.
* M4: Ctrl-drag copies pages in the Organize grid (#2997)
Dragging thumbnails in the Organize page grid with Ctrl held (Option on
macOS) drops copies at the gap instead of moving the pages: a "+" badge
by the pointer, the copies land at the gap selected, and one undo step
takes them out. The insertion point joins the engine edit
(Edit::DuplicatePages { at }) so the copy is one undo step rather than a
duplicate plus a move.
page_duplicate takes a 1-based "at" (the first copy becomes page "at"),
so agents can drive the same thing, and the UI control channel's
ui.drag modifiers now reach the input state -- which gestures that read
it (this copy-drag, and Alt-drag column select) need.
Closes#2997.
* fix(ui): take the grid's copy modifier from the runtime OS (#3092 review)
Option-drag in the Organize grid moved the pages instead of copying them in a Mac
browser: `cfg!(target_os = "macos")` is false in the wasm build, and eframe sets the
context's OS from the browser. `copy_modifier` now takes the context and asks
`commands::mac_shortcuts`, as the shortcut labels do.
The drag helpers in `tests/editing.rs` and `tests/control.rs` picked the modifier the
same compile-time way, so they would have sent Ctrl on a Mac host; both read the
context's OS now. The new regression test
`option_copies_and_ctrl_moves_when_the_context_reports_macos` drives a Mac context —
Ctrl moves, Option copies — and fails without this change.
Layer options in the Layers panel header resets every layer to the state the
file's default configuration (/OCProperties /D) gives it, as Acrobat does.
Visibility is view state, so the reset doesn't mark the document changed and
doesn't enter Undo. doc_info now reports each layer's initial state.
Merges #2813 (dynamic-XFA layout in the CLI) into the owner-password branch. In
info, text and render the engine Session and the cos document keep the typed
password, while anything handed to `inspect` or `PageRenderer` gets the render
password (the user password recovered from an R2-R4 owner password), including
the dynamic-XFA detection in render and the original-bytes check in text.
Checked: cargo fmt --check; clippy -p pdfcraft-cli -p pdfcraft-engine --all-targets
-D warnings; cargo test -p pdfcraft-cli (75 passed) and -p pdfcraft-engine (283 lib
+ integration passed); cargo xtask layers, assets, parity; a throwaway RC4-40,
RC4-128 and AES-128 dynamic-XFA check with the owner and user passwords through
info, text and render (not committed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
crates/cos/src/document.rs: both new accessors, header_offset (#2525) and reconstructed (#2029).
parity/acrobat-features.toml: the malformed-xref entry has both sides' evidence and notes (#2029 full save of a repaired file, #2717 table numbered from 1).
* M4: custom document properties through the engine and the tools (#organize.custom-properties)
/Info entries beyond the ones the spec names are custom properties:
organize lists them (custom_info) and set_info accepts any such name
after Acrobat's checks (no standard name, no empty name, no spaces,
delimiters, / or #, at most 127 bytes); inspection lists them too
(DocInfo::custom); doc_info reports them under custom and doc_set_info
sets or clears them. The Custom tab in Document Properties and the XMP
mirror stay planned.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
* M4: custom document properties list the same before and after an edit
Review follow-up. The open-time reader (render/inspect.rs) now uses the
engine's rule (pdfcraft_organize::custom_info, trimmed, empty values
dropped): text strings only, NUL/whitespace trimmed, empty values left out,
and the 256 cap counting listed entries. Before, it also listed name
values (`/Dept /RnD`) and capped before filtering, so the list could change
after the first unrelated edit. An automation test opens a file with a name
value, blank, NUL-padded, empty and numeric entries and checks doc_info's
custom list is the same before and after setting the Title.
Drops the ROADMAP.md log line, which conflicted with main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
* M4: custom properties drop NUL/whitespace padding in one pass, before the cap
Review follow-up. custom_info now trims NULs and whitespace in any mix
(" \0Lab\0 " is "Lab"), leaves out values that are only padding, and does
both before taking 256, so blank entries can't crowd real ones out. It is
the one rule: the engine uses it as it is, and the open-time reader trims
the same way. Tests: 300 blank entries before a real one, and mixed
padding, in organize and through doc_info.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* fix(engine): Save writes a repaired file in full even without edits (#1937)
A damaged file is repaired on open (its cross-reference index rebuilt from
object headers, as Document Properties reports), but Save wrote it back as it
was: `Session::save_bytes` returned the bytes as opened whenever nothing was
edited, and a repair is not an edit. The "repaired" file on disk was still the
damaged one.
`save_bytes` now writes a reconstructed document in full even with no edits.
`Document::reconstructed()` in cos names the condition (no revision chain:
rebuilt, or built from scratch), and `write_incremental` uses it too, so the
writer and the engine share one definition.
Tests: `engine::tests::a_repaired_file_is_rewritten_by_save_even_without_edits`
(table offsets shifted; no table at all) and
`automation::saving_a_repaired_file_puts_the_repair_on_disk` (doc_open,
doc_info, doc_save over the same path, reopen). Both fail with the condition
removed: the saved bytes equal the damaged file. parity: core.repair-broken-files
cites them and notes that a repair still does not count as an unsaved change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
* fix(engine): a signed repaired file keeps its bytes on Save without edits (#1937)
Review follow-up: writing a reconstructed file in full on Save puts the
repair on disk, but for a signed file it would also invalidate its
signatures without warning. Save now skips the early return only when the
document is reconstructed and not signed. A regression test signs a
document, appends a `startxref` that points nowhere (the signed bytes stay
intact, the cross-reference data must be rebuilt) and checks that Save
without edits returns those bytes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
ROADMAP.md, docs/roadmap.md: union of both sides' lines, each once.
parity/acrobat-features.toml: the XFA entry has both sides' evidence and notes (#2604 page areas, #2813 CLI layout).
Add a clean-room Romanian translation of the UI catalog (2,670 entries,
every bundled key), the CLDR plural rule for Romanian (one / few /
other, "20 de pagini"), the ATTRIBUTION.toml entry, coverage and plural
tests, and the localization docs and roadmap entries.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JadgGo4G2GHaAMNb3qyygk
ROADMAP.md, docs/roadmap.md: union of both sides' log lines and milestone rows, each line once.
parity/acrobat-features.toml, crates/xfdf/README.md: next's text, a superset of main's (#1917, #2686 plus #2688).
crates/edit/src/tests.rs: both sides' new tests (#2558 and #3084).
crates/ui-egui/src/editing.rs: the picked picture is cleared on every edit, and a new drawing commits its raster (#3149).
* M6: detect a row of comb boxes as one comb text field (fixes#1887)
* M6: keep empty tables of wide cells out of comb detection (#1887)
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Organize pages ▸ More ▸ Reverse pages (and the command palette) reverses
the selected pages among their own positions, or the whole document when
fewer than two pages are selected. It is one undoable step, needs the
assemble permission like the other page edits, and is available headless
as the page_reverse automation tool (pages optional, default all).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(engine): a new or recovered document stays unsaved through undo
#1435 made undo back to the last saved bytes clean. For a document
created by Combine, Extract or Create, or restored from recovery, those
bytes were never written where the document belongs, so edit then Undo
made it clean: Close no longer asked to save and autosave stopped keeping
it. Editor::unsaved_file, set by open_new and mark_recovered and cleared
by mark_saved, keeps such a document unsaved until it is saved; Revert
follows the same rule.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
* fix(engine): test that doc_close refuses a recovered or new document after undo
Review follow-up on #2346: a recovered document and a new one, rotated
and undone, are refused by doc_close without discard_changes, and close
with it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
The engine caches a page's text blocks for opening a paragraph and
applying its edit, but only while the document is unmodified: it was
keyed by the source identity, which edits keep. After the first edit the
cache never hit again, and the page was grouped again on every call (the
inline editor and the panel each call it after an apply).
pdfcraft_cos::Document::read_state() returns what the document reads as:
its source identity, its trailer and every changed object, compared as
allocations that the state keeps alive. A clone or a snapshot restored by
undo has the same state; any set, add, free or trailer change makes a
different one. The cache is keyed by it, so it hits after edits and never
returns another state's blocks.
On a page of 120 rows, text_blocks after an edit took 0.215 ms per call
and now 0.006 ms (release build, best of three; a throwaway timing test).
Tests: a read state matches a clone and not a document changed by set,
free or a trailer change, nor another document; after an edit the engine
groups the page once and undo and redo get their own blocks. The engine
test fails without this change.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Review follow-up. The config packet is decoded as UTF-16 (byte order mark,
or `<` written as two bytes) or UTF-8 before the scan, as crates/xfa reads
packets. The scan walks tags, skipping comments, CDATA sections, processing
instructions and declarations, and takes the value only at
config/acrobat/acrobat7/dynamicRender, so the same name in form data, in a
comment or elsewhere in the config no longer counts.
The engine test now adds the element to the fixture's own config packet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
The print-ready PDF copied the document's /OCProperties as they are, so it
printed the layers in their default state: a layer hidden in the Layers
panel disappeared on screen and came back on paper. Print as image already
used the view's layer choices (it renders with the document's config).
Once the layers have been changed, the copy imposed for printing now has
its default configuration (/D) set to them: /BaseState /ON, /ON the
visible layers, /OFF the hidden ones, its other entries kept. With the
layers untouched, the document's own configuration prints as before.
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
An XFA form with no AcroForm fields was always classified as dynamic, so
opening it laid the template out into new pages and appended them as a
revision: the pages the user saw, and any later save, were PdfCraft's
layout rather than the file's own pages, with nothing saying so. Adobe
Designer writes <dynamicRender>forbidden</dynamicRender> in the config
packet of a static PDF form; such a form is now static and its pages are
kept. /NeedsRendering still makes a form dynamic, and with no word from
the config a form without fields stays dynamic as before.
The config packet is found in an /XFA array or a whole-XDP stream, read
with a 16 MiB decode cap, and scanned for the one element.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz