## 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 -->
* AcroForm: integrate persistent timesheet script workflows
* AcroForm: address review caching, worker stacks and live metadata
* M6: address second review of AcroForm script workflows
* M6: handle top-level ASI boundaries and verify cyclic calculation results
* M6: bound script workers and isolate pending completions across edits
* M6: make form resets visible before subsequent script reads
* M6: include calculated dependents in reset comparison baselines
* M6: bound reset writeback work before native completion
* M6: charge expanded reset selections and field name bytes
* M6: bound string conversion bytes and charge reset widgets
* M6: preserve unread defaults and bound prompt text conversion
* M6: bound reset calls early and preserve calculated field identity
* AcroForm: address grouped calculations, field identity and widget ownership
* AcroForm: retire field instances and address native review edge cases
* AcroForm: route comparison Save As and Print to the source document
* M6: dispatch text widget mouse-up scripts
* M6: honor fit bounds for form button icons
* AcroForm: bound calculation work and preserve reset and save integrity
* Harden AcroForm script requests and worker recovery
* Keep form callbacks and focus consistent across edits
* AcroForm: preserve reset ordering and bound edit callbacks
* Fix reset appearance ordering and XFA focus after row removal
* Keep XFA focus indexes and stopped reset appearances consistent
* Document form script execution and reset helpers
* Keep script workers bounded and reject partial callback edits
* Keep form events available after a document script reaches VM limits
* Keep main's first-value Simple calculation and restore the calculateNow helper test
AFSimple_Calculate (JavaScript) and the native Simple calculation count each field's first
value again, as #5304 landed them; summing every selection of a multiple-selection list is
a separate behaviour change. Restores the document-helper calculateNow test and the
AFSimple_Calculate doc comment, and adds native and JavaScript multiple-selection cases.
* Patch the callback field snapshot in place instead of rebuilding it
Each field callback rebuilt all field states (3,129 on the benchmark form), and the
retained realm held the previous snapshot, so stamping instance tokens copied it again.
The runner now keeps its last snapshot and patches only the values and flags that
changed; ScriptSession::stamp_fields stamps it before dispatch, the worker copies only
when a token changes, and the realm drops the job's snapshot when the job ends (field
identity is checked against the cached keys instead).
* An oversized job script is that script's error, not a refused edit
Realm::run refused a script too long or too deep to run as an execution refusal, which
made the engine reject the whole edit (setting a 300 KiB calculation script failed). As on
main it is now a plain script error that keeps the value; the realm is not touched.
* Read a leading #! line as a comment in the chain scan; follow main's refusal and conditional limits in tests
The nesting guard from #5079 now checks brackets, prefixes and conditionals; the document
worker's preflight still bounds arrow-function and right-associative chains and skips a
leading #! line as the guard does. A refused script is an error, not a refused edit, so
the preflight test now checks the edit goes through with the error reported.
---------
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
* Fix#5211: make calculateNow synchronous
* Bound and cache calculation execution
* fix(engine): invalidate field calculation cache
* perf(js): build calculation scripts lazily
* Refresh calculation cache after page insertion
* Load calculations for document script helpers
* Assert imported calculation result
* Initialize formatted field-change state
* Update formatted calculation test expectations
* Include the referenced field in calculation test
---------
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Main's Replace Text (#5689) makes the caret the group's primary, as this
branch does. Kept: this branch's member-by-member deletion and release of
orphaned subordinates, with main's seed() (the strikeout also seeds its
caret) and its pair restyle; the writer returns the caret (the group's
comment) and leaves the strikeout without /Contents. Main's tests read the
strikeout from the caret's group instead of its replies, and the automation
test reads the strikeout's own colour from an XFDF export, since
comment_list gives a group its primary's attributes only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Keeps main's recovery renewal after applying redactions alongside the
combined notification, and reads XMP and rich-text streams with their
/DecodeParms given by reference made direct, as main's stream readers do.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
- Appearance export checks a stream's size in the encoding it is written in
(printable data as text, not as hexadecimal).
- Copy no longer writes a redaction mark's appearance, which import never
reads, so it does not use up the budget the other comments share.
- Rich text: a namespace declaration inside another attribute's value is not
a declaration, a '>' in a value does not end the start tag, and a prefixed
root keeps the default namespace its unprefixed descendants inherit.
- import_comments never applies the file's field values, whatever the
placement arguments (a non-finite offset used to fall through to them).
- The repair-log line for skipped appearances escapes control characters and
cuts each description to 200 characters.
- Tests for the shared budget through the export loop, a near-limit appearance
that round-trips, and the six-comment summary.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Refs #5989. Ctrl/Cmd+A selects every object on the page; copied objects paste as page content that draws the same, within a document or into another, with organize's bounded deep copy across documents. Headless: object_copy, object_paste, object_move all.
- import and import_comments work on a copy, kept only on success.
- XFDF export shares the 64 MiB appearance budget import reads over all
comments; import refuses a normal appearance of several states (XFDF has
no /AS to select one, ISO 32000-2 §12.5.5).
- FDF import nulls a page or page tree an appearance reaches (/Type resolved).
- Rich text: an inherited default namespace other than XFDF's is declared,
and a namespace declaration with spaces around `=` is recognised.
- A redaction mark's /Rect grows to hold its quads, so the outline shows.
- The repair log gets one line per import (first five, then a count), and
no longer claims the comment is drawn.
- Tests: the sixty-form chain, and the redaction outline is stroked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
- XFDF export: the finished appearance XML is checked against the import limit;
a page whose /Type is a reference stays out; stream data with a carriage
return goes out as hex (XML 1.0 §2.11 end-of-line handling); where /AS selects
one of several states, the selected stream is written.
- XFDF import: dash arrays of any length come back; consecutive <br/> keep the
blank line in /Contents; rich text written as text on a comment with no
/Contents comes back as /RC; namespace prefixes rich text inherits are declared
on it; appearances share one budget over the import (now tested through it).
- Redaction marks: the file's /AP and a /BE (none in ISO 32000-2 Table 195) are
dropped and the mark is drawn by PdfCraft; one with an area it cannot draw is
refused rather than placed invisible.
- FDF import: /P is not copied (the page is /Page, §12.7.8.3.4 Table 254); a
comment nested past 256 levels refuses the file instead of nulling part of its
appearance (a chain of 60 forms comes back whole).
- Engine: appearances an import left out go to the repair log for Import and
Paste comments.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
The repair reads the page's own fonts. It ran only after a group was expanded, so the same
damaged map on text that was never grouped kept reading wrong. Preparation now repairs it
whenever it finds it, recorded in the repair log as before, and PrepareText (text_prepare)
succeeds when it repaired a map even with nothing to expand. A repaired map no longer matches
the damage's fingerprint, so preparing again changes nothing.
Conflict: crates/automation/tests/it/automation.rs (kept the group test and main's ungroup tests).
Also carries the one-line fix for main's crates/edit/src/rules.rs (content_streams() now returns
ContentStream): page_rules joins its streams with crate::text::joined.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Resolve the conflicts in crates/engine/src/lib.rs (keep both the
redaction-identification and user-regex re-exports) and Cargo.lock
(main's, plus the redact dependencies). Drop the branch's copy of the
test helper pdf_of, which main now defines identically. Fix main's
pdfcraft-edit build: rules.rs joins its content streams through
crate::text::joined now that content_streams returns ContentStream.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Keeps both the comment pop-up test and main's ungroup tests in
automation.rs. Carries the shared fix for main's rules.rs build break
(content_streams now returns ContentStream; join via text::joined).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
Add:
- Report text grouped more than eight levels deep instead of leaving it
silently grouped, and refuse forms whose resource-naming operators carry
surplus operands, which would otherwise lose nested artwork when inlined.
- Return the applied preparation's own counts from Session::prepare_text, so
text_prepare no longer reports a separate preview's.
- Shape a THSarabunPSK program that has its own Thai mark positioning by that
layout, in its own codes. A span the subsets can't show in place is shaped
whole, Latin rows included, by a complete program of the same face and style,
such as a CFF face from Load font.
Change:
- Expand only forms that show text, directly or through nested forms; image-
and vector-only forms stay grouped and uncounted. Match a Do's raw resource
name bytes, and refuse RGB-blended groups on pages naming no blending space.
- Check the recovered THSarabunPSK map and the Windows PUA alternates glyph by
glyph against the subset's advances, leave dropped glyphs unmapped, and
insert the repair after the last endcmap.
- Shape a Thai run split across same-face subsets cluster by cluster and skip
subset glyphs that are empty. Take the legacy path only for programs in the
legacy glyph order, needing only the glyphs the new text uses.
- Require GPOS mark/mkmk in the script and language system HarfRust selects.
- Match same-face candidates by weight and slant from the name, descriptor
weight or a non-zero ItalicAngle; the Italic flag alone is ignored, as
exporters set it on upright faces. Never complete bold or italic text with
the Regular bundled Thai face.
- Name the missing character, or the unavailable builtin:thai choice, in errors.
Chores:
- Document text_prepare's fields, builtin:thai, the app's refusal reporting
limits and the new routing; extend the text-edit parity evidence. Tests that
need craft-fonts now say they skipped instead of returning silently.
Validation: with and without craft-fonts, fonts 105 (+3 build-input), edit
465, engine 482 (+28 integration) and automation 50 + 352 passed; editor flows
248 passed. Formatting, strict Clippy, layers, assets and parity passed; the
wasm check was not run locally. Each new regression failed with its fix
reverted. Real-PDF edits on Royal Gazette 26288 kept their source fonts.