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
The Layers panel had no automation tool: doc_info listed the layers, but
nothing could show or hide one, so layer visibility could not be checked
headlessly. layer_set does what the panel's eye icon does, naming a layer
by name (every group with that name, as CAD exporters repeat names) or by
its 1-based position in doc_info's list.
page_render built its own RenderConfig with only the password and the
reading colours, so it ignored layer visibility and comments_hide as
well: hiding comments through the tool changed nothing it drew. The
automation renderer now takes the document's own RenderConfig
(Session::render_config), and both tools drop the cached renderer when
they change a setting.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
Notes and file attachments are saved with the NoRotate flag (/F bit 5),
but nothing honoured it: on a page with /Rotate the icon was fitted to
/Rect and turned with the page, and sat away from where it was placed.
The vendored renderer now honours NoRotate (hayro-interpret patch 37):
the appearance fitted to /Rect is turned against the page's rotation
about the rectangle's upper-left corner, which stays put (ISO 32000-2
12.5.3). Form fields and links stay in /Rect, where their click targets
come from. A counterrotated appearance, as stamps use, would be turned
twice by viewers that honour the flag.
Annotation::shown_rect gives the box the note is drawn in. The UI uses
it for hit-testing, outlines, moving, the hover pop-up, the Comments
panel flash and the edit composer; a resize maps it back to /Rect.
comment_add anchors an icon's displayed top-left at `at`, and
comment_list, doc_info and comment_edit use the drawn box.
This also covers the sticky-note part of #1869.
Review of #2815: `page_of` built every page to return one, and the Add
Background dialog checked 24 pages a frame that way, so a frame cost
24 page-tree walks (93 ms at 5,000 pages, 385 ms at 20,000).
- `page_backdrops(doc, pages)` walks the tree once for a batch; the
dialog checks one batch a frame and `doc_backdrops` one batch in all.
A page past the end answers None without hiding the others (the
viewer's page count can disagree with the page tree's).
- `scan` and `replace` take the page; `add_background` no longer walks
the tree twice more per page to replace a backdrop.
- Tests: 4,000 pages checked in one batch under a generous bound (17.6 s
with the old lookup), batch order and missing pages, and a background
over several pages sharing streams, replaced and restored.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`pdfcraft-cli info`, `text`, `render` and `audit` rejected the owner
password of RC4-40, RC4-128 and AES-128 files with "the password is
incorrect", while `run doc_open` accepted it (#2670, item 8). The renderer
authenticates only the user password of revisions 2–4. `Session::open`
works around that by handing it the user password that owner
authentication recovers, but the CLI passed the typed password straight to
`inspect` and `PageRenderer`.
`pdfcraft_engine::render_password_for` now gives any render path outside a
session the password the renderer takes, sharing the recovery with
`Session::open`. The CLI uses it for the renderer and keeps the typed
password for the engine session and the cos document, so the owner
password still lifts copy restrictions in `text`.
The render test decodes the PNG and checks its dimensions and that it
has non-white pixels, for every algorithm with either password.
Refs #2670 (item 8 only).
Checks: cargo fmt --check; cargo clippy -p pdfcraft-cli -p pdfcraft-engine
--all-targets -- -D warnings; cargo test -p pdfcraft-cli; cargo test -p
pdfcraft-engine (and --lib); cargo xtask layers, assets, parity.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GoToR file specifications go through craft-docpath: network, device and
object-manager paths (\??\, /??/) are refused without touching the
filesystem, relative targets resolve against the source document's
folder, and a result that is not absolute is refused rather than read
against the working directory. Files named on the command line open
under their absolute path, so their relative links keep resolving.
Preserve the idle repaint and second-edit recovery regression while refreshing already-merged upstream work. Validated exact tree with full local CI and native Linux idle recovery.