Files
pdfcraft/crates/engine
Michael Higgins 4efcfa6ea3 fix(crypt, engine): refuse page extraction when Protect allows any change except extracting pages (#5081)
## What this changes

Adds `Permissions::extract_pages()`: `assemble()` **and** copying (bit 5), as #5081 asks, since extracted pages carry their text and graphics into a file without the source's protection. It is used for Extract, Split, Split by size and every Combine/Insert/Replace/paste source check (`sources::source_document`). Changes within the document (rotate, delete, move, insert) keep `assemble()`.

Since this PR was opened, #6309 made `assemble()` revision-aware on `main`: bit 11 alone at revision 3 and later, bit 4 at revision 2. That already refuses "Any except extracting pages" at revision 3+. This PR builds on it:

- At revision 2, `assemble()` reads bit 4, which "Any except extracting pages" sets, so such a file could still be extracted and split into unprotected copies. Requiring copying closes that.
- New `Needs::PageExtraction` greys out Extract and Split up front. It reuses the existing security-refusal string, so no catalogue changes.
- `Document::allows_page_extraction()`, and an `extract_pages` key in the automation security JSON.
- `page_extract` with `separate: true` extracts and writes one page at a time (`extract_separate`), so it no longer buffers every page. The first page is extracted before `out_dir` is created, so a refused extraction leaves no empty directory. If a later page fails, the files this call wrote are removed, and `out_dir` too if this call created it (review on this PR).
- Insert Pages checks a PDF through the same path as the insert itself: new `Session::insert_source(dest, name, bytes)`, which the UI's insert now calls (review on this PR).
- The `organize.combine` parity notes now say "page extraction".

Fixes #5081

## How you verified it

`cargo fmt --all` is clean. Regression tests (adopted from Harrison-Booth's review patch, plus CodeRabbit's follow-ups):

- `crates/crypt/src/tests.rs::page_extraction_needs_assembly_and_copying`: unit test for `extract_pages()`.
- `crates/engine/src/tests.rs::extraction_permissions_cover_revision_three_to_six_and_leave_no_partial_output`: AnyExceptExtract on RC4-128, AES-128 and AES-256 refuses Extract, Split, Split by size and Combine/Insert/Replace sources without the permissions password, leaves source and destination untouched, and the owner can do everything. The authorized combine output has 2 pages, and a differently encrypted or unprotected destination is refused.
- `crates/engine/src/tests.rs::page_extraction_needs_the_copy_permission_on_every_revision`: a file that withholds only copying (R2 RC4-40 and AES-256) refuses extraction; the owner password lifts it.
- `crates/engine/src/tests.rs::pages_under_the_same_protection_are_inserted_without_page_extraction`: a separately built file under matching protection is inserted, and the result stays restricted.
- `crates/automation/tests/it/automation.rs::refused_extraction_writes_no_split_or_page_output`, and the `extract_pages` checks in `protecting_through_tools`, which now cover three presets, including a user (not the owner) for whom extraction is allowed.

**Only the crypt unit test has been run locally, and it passes.** The engine and automation tests timed out while compiling on this machine and have not been run, nor seen failing without the fix. The one `pdfcraft-ui-egui` call-site change has not been compiled. CI is the first place these build.

- [x] `cargo fmt --check`
- [ ] clippy with `-D warnings` on the crates I changed (not re-run after the rebase)
- [ ] tests pass on the crates I changed (crypt only)

## Anything a reviewer should look at first

- **Rebased onto #6309.** The tests no longer expect "Any except extracting pages" to allow assembly at revision 3+. The same-protection paste tests now use the "Inserting, deleting and rotating pages" preset without copying.
- **Copy (bit 5) is now required**, as the issue suggests. `crates/ui-egui/tests/it/editing.rs::protected_files_that_can_be_combined_are_flagged` used a copy-withheld file as "protected but combinable"; it now uses a print-withheld file, since a no-copy file is no longer a Combine source without the permissions password.
- **Same-document paste**: Paste re-opens the document's own bytes as an Insert source, which the new check would refuse. `sources::open_source_into` accepts a source that withholds extraction but allows assembly only when the destination will be saved under exactly the same protection (file key and `/Encrypt` values), so cut/copy and paste within one protected document still works. Pasting into an unprotected or differently protected document is refused. One limit: after changing security in the same session without saving, a cut can't be pasted back until you save.
- **Not in this PR:** CodeRabbit's `disabled_reason` suggestion for read-only documents predates this change and covers other commands too, so it belongs in a separate UI change.


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

- **New Features**
  - Page extraction and splitting now follow a dedicated permission check, distinct from page assembly.
  - Pages can be inserted from certain protected PDFs when their protection settings match the destination document.

- **Bug Fixes**
  - Separate-page extraction now cleans up files created during a failed operation and avoids creating an output directory when the first page cannot be extracted.
  - Security details now report whether page extraction is permitted.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-11 09:06:48 +01:00
..

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, eframe and rfd are forbidden below L7, and xtask layers enforces it.
  • The crate denies unwrap/expect/panic!/todo!. Entry points that run untrusted-document code keep a last-resort catch_unwind guard 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.

  1. 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.
  2. After every edit the working file is produced by an incremental write: the original bytes plus one appended revision.
  3. The view is refreshed from those bytes, so what you see is exactly what Save will write.
  4. 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
open_parameters A path's PDF open parameters (manual.pdf#nameddest=Intro, #page=12): the file to open and where to show it. The app and doc_open both read them
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

AcroForm Keystroke, Validate, Calculate and Format scripts run automatically. Their Print, ShowPanel and SaveAs requests are ignored with a JavaScript console diagnostic; field values, alerts and other requests are retained. Explicit button and console scripts can still request printing (#4088).

Not done here

  • Rendering is still borrowed. pdfcraft-render wraps hayro and inspects through lopdf (ADR-0004). The engine only sees inspect() -> DocInfo, RenderPool and text::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 automation tool; cargo xtask parity lists 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.