* M11: Optimize runs when a category it discards holds nothing PDF Optimizer's Discard Objects ticks pass their categories to remove_hidden, which refuses a run that removes nothing. Ticking "Discard bookmarks" on a file without bookmarks, or doc_optimize with such a discard, ended the whole optimization with "there are no redaction marks to apply". An empty category is now nothing discarded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M8: doc_remove_hidden says when the listed categories hold nothing Its error came from remove_hidden's refusal: "there are no redaction marks to apply". It now checks the scan first, as redact_apply checks for marks, and says there is nothing to remove. The scan counts what removal takes (an AcroForm with no fields as 1), so the check refuses only what the removal would. The tool description says it fails then. The tool test also runs doc_optimize discarding categories that hold nothing, end to end. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M11: Optimize passes remove_hidden only the ticked categories that hold something Catching remove_hidden's refusal kept a copy it may have changed: with bookmarks ticked it drops an empty outline root before counting nothing, and its own documentation says to drop such a copy. The optimizer now scans first and passes only the ticked categories the scan finds something in, and with none doesn't call it, so an empty outline root stays, as Remove Hidden Information leaves it. Corrects 278a588bf's message: the ticks are under Discard Objects and Discard User Data, and hidden text is one of them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M8: doc_remove_hidden reads one scan, and says why it can't edit A document that can't be edited has no scan, so the check read every category as empty and said the listed ones hold nothing; the reason it can't be edited comes through again. The check and the count of what was removed now share the one scan the tool took already. Corrects ada2a1ccb's message: the removal also takes what its scan counts as 0 (an empty outline root); what the check matches is that the removal refuses when its scan counts every listed category as 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M11: the optimizer test discards a category that holds something Mutants of the category filter survived: no test ticked a category that holds something, or an empty one beside an unticked one that does. A page with a comment and no bookmarks, discarding both, discards the comment and only it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M11: Optimize passes every ticked category, and keeps the copy from before a refusalc7c018ac6passed remove_hidden only the ticked categories its scan finds something in. But with form fields ticked, discarding hidden text also takes the hidden text the flattened appearances bring onto the page, which no scan before flattening counts, so with Flatten form fields and Discard hidden text ticked, the hidden text was dropped from the list and stayed in the file, where main removes it. Every ticked category goes to remove_hidden again, as on main. When it refuses (every category counts 0), it may have changed the copy (an empty outline root goes with bookmarks, and then it refuses), so the copy from before the call is kept and nothing is discarded. That also drops the extra scanc7c018ac6added. Corrects c7c018ac6's message: remove_hidden counts first, then drops an empty outline root, then refuses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
pdfcraft-engine
The façade every frontend talks to. It holds the open documents, their edit history and the tool catalogue; frontends never reach past it into the parsing, rendering or editing crates.
word_count::count provides cancellable, page-at-a-time Unicode word and character statistics
with optional semantic exclusions; see word-count behavior and limits.
- Layer: L6. It depends on most of L1–L4 and on nothing above itself. No UI toolkit —
egui,winit,eframeandrfdare forbidden below L7, andxtask layersenforces it. - The crate denies
unwrap/expect/panic!/todo!. Entry points that run untrusted-document code keep a last-resortcatch_unwindguard so an escaped panic becomes an error and the user keeps their document.
Two frontends exist on top of it today: pdfcraft-ui-egui (desktop and web) and
pdfcraft-automation (headless tools, the CLI and MCP). Anything one can do, the other must be
able to do — see AGENTS.md §3.
When dynamic XFA layout is active, JsRunner does not initialize Acrobat JavaScript with
scripts from the PDF container's /Names /JavaScript tree. Those scripts belong to the
fallback PDF view and can otherwise show a misleading viewer-compatibility warning.
They remain in the document and in Document::document_scripts. Requested console scripts,
field actions and XFA events can still show messages; ordinary PDFs, static XFA and shells
without an active layout keep their document scripts. Converting XFA to a static form and
undoing that conversion switches the context with the document state.
The editing model
Each open document holds a pdfcraft_cos::Document: the object graph plus a copy-on-write overlay.
- An edit runs on a clone. On success the previous state is pushed onto the undo stack. Clones share all unchanged data, so a snapshot is cheap — this is what makes 100 levels of undo affordable on a 200 MB file.
- After every edit the working file is produced by an incremental write: the original bytes plus one appended revision.
- The view is refreshed from those bytes, so what you see is exactly what Save will write.
- Saving rebases onto the written bytes, so the next save appends only new edits.
Consequences worth preserving: the original bytes of an opened file are never rewritten in place; a full rewrite happens only where it must (redaction, PDF/A conversion, print imposition, a changed password); and unknown data survives a round trip because nothing is reconstructed that wasn't understood.
API
let mut s = Session::new();
let id = s.open("report.pdf", Some(path), bytes, None)?; // -> DocId
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 })?;
let label = s.undo(id)?; // "Rotate pages"
s.redo(id)?;
let doc = s.get(id).ok_or("gone")?; // &Document: queries, never edits
doc.can_undo(); doc.editable(); doc.allows_printing();
doc.text_blocks(page); doc.page_boxes(page); doc.security_summary();
let bytes = s.save_bytes(id)?; // incremental: keeps signatures over earlier revisions valid
let bytes = s.save_full_bytes(id)?; // full rewrite, garbage-collected
s.mark_saved(id, bytes.clone(), path)?;
Edit is one enum of 75 variants — every undoable change in the product, from
RotatePages to AddAnnotation, SetFieldValue, ApplyRedactions and AddWatermark. Adding a
feature means adding a variant and handling it in apply, which is what keeps undo, the dirty
flag, autosave and the automation tools consistent for free.
Session also owns the operations that produce a new document rather than editing one:
create_blank, create_from_images, create_from_text, combine, extract, split,
split_by_size, reduced_bytes, optimized_bytes, print_pdf, export_data, and the signing
family (sign, sign_with_timestamp, timestamp_document, embed_ltv).
Modules
| Module | What it holds |
|---|---|
commands |
The command registry: id, label, menu, shortcut, and a Needs precondition encoding document security. Menus, the ⌘K palette, shortcuts and the control channel all read it, so every surface agrees on what exists and when it is enabled |
catalog |
Acrobat's "All tools" information architecture as data — groups, sections, icons, hues, and an Availability of Ready, Planned(milestone) or Provider. The UI renders what is not built yet, honestly |
actions |
PDF action dispatch (go-to, URI, named, set-layer-state, JavaScript) |
links |
Link creation, properties, and finding URLs in page text |
js |
The bridge to pdfcraft-js. forms never depends on js directly; the engine injects it, so a build without JavaScript is possible |
xfa, ocr, compare, export |
Façades over the L3/L4 crates of the same names |
Not done here
- Rendering is still borrowed.
pdfcraft-renderwrapshayroand inspects throughlopdf(ADR-0004). The engine only seesinspect() -> DocInfo,RenderPoolandtext::PageText, and that API stays frozen while M2 replaces what is behind it. - Performance budgets (
misc.performance-budgets) are not set or enforced. - Some shipped features are still reachable only from the UI, not through an
automationtool;cargo xtask paritylists them; closing that gap is tracked in the execution plan (D4).
doc.text_edit_preview(page, block) prepares a read-only page background with the selected
paragraph’s glyphs removed, preserving text positioning, images and colours. It shares the
working document’s render configuration and never enters the undo history.