diff --git a/.github/workflows/nightly-macos.yml b/.github/workflows/nightly-macos.yml new file mode 100644 index 0000000000..062e964687 --- /dev/null +++ b/.github/workflows/nightly-macos.yml @@ -0,0 +1,32 @@ +name: Nightly macOS + +# The workspace's tests on macOS, each night and on demand. Pull requests and main run Linux and +# Windows only (ci.yml): the org's 5 macOS runners are kept for release builds, and release.yml +# builds and signs the macOS app without running the tests. This one leg a night finds a +# macOS-only failure within a day, not at release time or from a user. +on: + schedule: + - cron: "41 2 * * *" + workflow_dispatch: + +# The token is only used to check out the code: no job writes to the repository. +permissions: + contents: read + +# A newer run replaces one still queued or running. +concurrency: + group: nightly-macos-${{ github.ref }} + cancel-in-progress: true + +jobs: + gate: + # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. + uses: storytold/craft-common-actions/.github/workflows/gate.yml@main + with: + os: '["macos-latest"]' + # Same craft-fonts commit as ci.yml, release.yml and freebsd.yml. + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 + # Every test, and all of their failures in one run. The whole workspace took 37 minutes on + # macos-latest on 10 Nov 2026. + command: cargo test --workspace --locked --no-fail-fast + timeout-minutes: 90 diff --git a/AGENTS.md b/AGENTS.md index 6126d4887f..297105c4c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -175,6 +175,7 @@ PdfCraft opens files from strangers, and people trust it with their work. A malf - **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state. `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it. - **Page cursor.** Page modules claim the cursor for what is under the pointer (`view.cursor.claim`, `crates/ui-egui/src/canvas/page_cursor.rs`) and never set it themselves. `page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it` enforces it. - **Page presses.** A page module decides what is under a point in one hit test (`Hit` in `crates/ui-egui/src/canvas/page_router.rs`) and acts on a press only through that router. Modules never read the router; they are told. `page_pointer::tests::page_modules_act_on_a_press_only_through_the_router` enforces it. +- **Page keys.** Page code (everything under `canvas/` and `tools/`, `app/keys.rs`, `app/clipboard.rs` and the Pages panel's `panels/pages.rs`) reads keys and clipboard events through `PageKeys` (`crates/ui-egui/src/canvas/page_keys.rs`), which has none while something is over the pages (a dialog, the palette, a popup, a menu: `PdfCraftApp::over_pages`), never egui's raw key state. The functions `NOT_PAGE_KEYS` lists, each with why, read raw: a window's or a field's own keys, which `PageKeys` would hide, and the few page reads it can't make (chords that wait for the menu bar instead, Escape ending what the pages hold, any key stopping a glide, the clipboard's hand-off to text fields). `page_pointer::tests::page_modules_read_keys_only_through_page_keys` enforces it. - **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it. - **Fonts** are the optional build input `CRAFT_FONTS_DIR`. Code using `pdfcraft_fonts::CRAFT_FONTS` must work when it is empty. - **Quality gates** before every commit: `cargo fmt --check`, clippy with `-D warnings`, the tests, `cargo xtask layers`, `cargo xtask assets`, and the wasm check. New workspace crates include `workspace = true` under `[lints]`. Scope `cargo clippy -p --all-targets -- -D warnings` and `cargo test -p ` to the crates you touched; the workspace sweep is CI's job. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index cfc16f78f4..edba4995df 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -275,7 +275,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `epaint_default_fonts` 0.37.0 | `fonts/Hack-Regular.ttf` | Hack Regular | Source Foundry Authors (derived from Bitstream Vera / DejaVu) | MIT AND Bitstream-Vera | https://github.com/source-foundry/Hack | egui fallback fonts (eframe feature default_fonts) | | `epaint_default_fonts` 0.37.0 | `fonts/NotoEmoji-Regular.ttf` | Noto Emoji (monochrome) | Google Inc. | OFL-1.1 | https://github.com/googlefonts/noto-emoji | egui monochrome fallback font (egui feature monochrome_emoji_fonts) | | `epaint_default_fonts` 0.37.0 | `fonts/emoji-icon-font.ttf` | emoji-icon-font | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | egui monochrome fallback font (egui feature monochrome_emoji_fonts) | -| `epaint_default_fonts` 0.37.0 | `fonts/egui-icons.ttf` | egui icons (monochrome emoji-icon-font subset) | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE. | +| `epaint_default_fonts` 0.37.0 | `fonts/egui-icons.ttf` | egui icons (monochrome emoji-icon-font subset) | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/725a39fca940f77149ffbb9c58eef405a16997bc/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE. | | `hayro-interpret` 0.7.0 | `src/font/generated/metrics.rs` | Standard-14 font metrics and encodings (as Rust tables) (non-visual data, AGENTS.md §1.1) | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | MIT OR Apache-2.0 | ISO 32000-2 Annex D; Adobe Core14 AFM files (via vendored hayro-interpret) | Glyph widths and encodings for non-embedded standard fonts. Non-visual data, allowed by AGENTS.md §1.1 | | `icu_segmenter_data` 2.3.0 | `data/segmenter_dictionary_extended_v1.rs.data` | Southeast Asian word dictionaries for line breaking (Thai, Lao, Khmer, Burmese) | The ICU4X Project Developers; word lists from the ICU project (Unicode, Inc.) | Unicode-3.0 | https://github.com/unicode-org/icu4x (via icu_segmenter_data) | Word boundaries for wrapping Thai text in Edit text and Add text (crates/edit/src/linebreak.rs). Non-visual data; the line segmenter loads only these dictionaries, not the Chinese and Japanese one | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index be935422ce..4cdaa70575 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -2235,7 +2235,7 @@ author = "John Slegers" source = "https://github.com/jslegers/emoji-icon-font" licence = "MIT" kind = "font" -usage = "Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE." +usage = "Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/725a39fca940f77149ffbb9c58eef405a16997bc/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE." sha256 = "122806555c73df7cef55aa1d945289ae27fbc4dbbd31abbb4204ee0cda829cc8" [[asset]] diff --git a/Cargo.lock b/Cargo.lock index b32faba523..c4e08c1086 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -238,7 +238,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.61.2", + "windows-sys 0.52.0", "wl-clipboard-rs", "x11rb", ] @@ -321,7 +321,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.5", + "rustix 1.1.6", "slab", "windows-sys 0.61.2", ] @@ -352,7 +352,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.5", + "rustix 1.1.6", ] [[package]] @@ -378,7 +378,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.5", + "rustix 1.1.6", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -878,7 +878,7 @@ checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" dependencies = [ "bitflags 2.13.2", "polling", - "rustix 1.1.5", + "rustix 1.1.6", "slab", "tracing", ] @@ -902,7 +902,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" dependencies = [ "calloop 0.14.4", - "rustix 1.1.5", + "rustix 1.1.6", "wayland-backend", "wayland-client", ] @@ -1243,14 +1243,14 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=7ee35a9fbd47af572d55fd63dbf0333d1f00da17#7ee35a9fbd47af572d55fd63dbf0333d1f00da17" +source = "git+https://github.com/storytold/craft-libs?rev=60ede99985ef1e5df87a2aca66ed538eb23dec99#60ede99985ef1e5df87a2aca66ed538eb23dec99" dependencies = [ "craft-winprint-gdi", "log", "miniz_oxide 0.9.1", "roxmltree 0.21.2", "tempfile", - "winprint", + "winprint 0.2.1 (git+https://github.com/storytold/winprint.rs?rev=bc06a7c48b768eb27a828c7986f6d2868b245ee0)", "winsafe", ] @@ -1696,7 +1696,7 @@ dependencies = [ [[package]] name = "ecolor" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc" dependencies = [ "bytemuck", "emath", @@ -1725,10 +1725,14 @@ dependencies = [ "zeroize", ] +[[package]] +name = "edit-session" +version = "0.1.0-alpha.2" + [[package]] name = "eframe" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc" dependencies = [ "ahash", "bytemuck", @@ -1766,7 +1770,7 @@ dependencies = [ [[package]] name = "egui" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc" dependencies = [ "accesskit", "ahash", @@ -1787,7 +1791,7 @@ dependencies = [ [[package]] name = "egui-wgpu" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc" dependencies = [ "ahash", "bytemuck", @@ -1806,7 +1810,7 @@ dependencies = [ [[package]] name = "egui-winit" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc" dependencies = [ "accesskit_winit", "arboard", @@ -1833,7 +1837,7 @@ dependencies = [ [[package]] name = "egui_extras" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc" dependencies = [ "ahash", "egui", @@ -1847,7 +1851,7 @@ dependencies = [ [[package]] name = "egui_glow" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc" dependencies = [ "bytemuck", "egui", @@ -1860,7 +1864,7 @@ dependencies = [ [[package]] name = "egui_kittest" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc" dependencies = [ "eframe", "egui", @@ -1903,7 +1907,7 @@ dependencies = [ [[package]] name = "emath" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc" dependencies = [ "bytemuck", "serde", @@ -1984,7 +1988,7 @@ dependencies = [ [[package]] name = "epaint" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc" dependencies = [ "ahash", "bytemuck", @@ -2011,7 +2015,7 @@ dependencies = [ [[package]] name = "epaint_default_fonts" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc" [[package]] name = "equator" @@ -2046,7 +2050,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2423,7 +2427,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.5", + "rustix 1.1.6", "windows-link", ] @@ -4168,7 +4172,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4259,7 +4263,7 @@ dependencies = [ [[package]] name = "pdfcraft" -version = "0.20.0" +version = "0.21.0" dependencies = [ "chrono", "craft-update", @@ -4284,7 +4288,7 @@ dependencies = [ [[package]] name = "pdfcraft-a11y" -version = "0.20.0" +version = "0.21.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -4295,7 +4299,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.20.0" +version = "0.21.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -4307,7 +4311,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.20.0" +version = "0.21.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -4318,7 +4322,7 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.20.0" +version = "0.21.0" dependencies = [ "base64 0.23.1", "flate2", @@ -4334,7 +4338,7 @@ dependencies = [ "pdfcraft-render", "pdfcraft-xfa", "png 0.18.1", - "rustix 1.1.5", + "rustix 1.1.6", "serde_json", "sha2 0.11.1", "tempfile", @@ -4342,7 +4346,7 @@ dependencies = [ [[package]] name = "pdfcraft-cli" -version = "0.20.0" +version = "0.21.0" dependencies = [ "base64 0.23.1", "pdfcraft-audit", @@ -4353,18 +4357,18 @@ dependencies = [ "pdfcraft-preflight", "pdfcraft-render", "png 0.18.1", - "rustix 1.1.5", + "rustix 1.1.6", "serde_json", "winresource", ] [[package]] name = "pdfcraft-compare" -version = "0.20.0" +version = "0.21.0" [[package]] name = "pdfcraft-content" -version = "0.20.0" +version = "0.21.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -4372,7 +4376,7 @@ dependencies = [ [[package]] name = "pdfcraft-cos" -version = "0.20.0" +version = "0.21.0" dependencies = [ "chrono", "hayro-syntax", @@ -4389,7 +4393,7 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.20.0" +version = "0.21.0" dependencies = [ "encoding_rs", "hayro-ccitt", @@ -4401,6 +4405,7 @@ dependencies = [ "pdfcraft-model", "pdfcraft-xfa", "png 0.18.1", + "rustix 1.1.6", "thiserror 2.0.21", "tiff", "zune-jpeg", @@ -4408,7 +4413,7 @@ dependencies = [ [[package]] name = "pdfcraft-crypt" -version = "0.20.0" +version = "0.21.0" dependencies = [ "aes", "md-5", @@ -4416,11 +4421,12 @@ dependencies = [ "sha2 0.11.1", "stringprep", "thiserror 2.0.21", + "zeroize", ] [[package]] name = "pdfcraft-edit" -version = "0.20.0" +version = "0.21.0" dependencies = [ "hayro-syntax", "icu_segmenter", @@ -4439,7 +4445,7 @@ dependencies = [ [[package]] name = "pdfcraft-engine" -version = "0.20.0" +version = "0.21.0" dependencies = [ "chrono", "craft-docpath", @@ -4483,14 +4489,14 @@ dependencies = [ [[package]] name = "pdfcraft-export" -version = "0.20.0" +version = "0.21.0" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-filters" -version = "0.20.0" +version = "0.21.0" dependencies = [ "fax", "flate2", @@ -4503,7 +4509,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.20.0" +version = "0.21.0" dependencies = [ "harfrust", "pdfcraft-cos", @@ -4513,7 +4519,7 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.20.0" +version = "0.21.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4526,11 +4532,11 @@ dependencies = [ [[package]] name = "pdfcraft-geom" -version = "0.20.0" +version = "0.21.0" [[package]] name = "pdfcraft-js" -version = "0.20.0" +version = "0.21.0" dependencies = [ "boa_engine", "chrono", @@ -4538,7 +4544,7 @@ dependencies = [ [[package]] name = "pdfcraft-measure" -version = "0.20.0" +version = "0.21.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4551,19 +4557,20 @@ dependencies = [ [[package]] name = "pdfcraft-model" -version = "0.20.0" +version = "0.21.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.20.0" +version = "0.21.0" dependencies = [ "ocrs", "pdfcraft-fonts", "png 0.18.1", "rten", + "rten-imageproc", "serde_json", "sha2 0.11.1", "tempfile", @@ -4572,7 +4579,7 @@ dependencies = [ [[package]] name = "pdfcraft-ocr-worker" -version = "0.20.0" +version = "0.21.0" dependencies = [ "js-sys", "pdfcraft-ocr", @@ -4582,7 +4589,7 @@ dependencies = [ [[package]] name = "pdfcraft-optimize" -version = "0.20.0" +version = "0.21.0" dependencies = [ "image", "pdfcraft-annot", @@ -4599,11 +4606,11 @@ dependencies = [ [[package]] name = "pdfcraft-oracle" -version = "0.20.0" +version = "0.21.0" [[package]] name = "pdfcraft-organize" -version = "0.20.0" +version = "0.21.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4614,17 +4621,18 @@ dependencies = [ [[package]] name = "pdfcraft-platform" -version = "0.20.0" +version = "0.21.0" dependencies = [ "async-channel", "futures-lite", - "rustix 1.1.5", + "log", + "rustix 1.1.6", "zbus", ] [[package]] name = "pdfcraft-prc" -version = "0.20.0" +version = "0.21.0" dependencies = [ "flate2", "proptest", @@ -4633,7 +4641,7 @@ dependencies = [ [[package]] name = "pdfcraft-prc-render" -version = "0.20.0" +version = "0.21.0" dependencies = [ "flate2", "pdfcraft-prc", @@ -4642,7 +4650,7 @@ dependencies = [ [[package]] name = "pdfcraft-preflight" -version = "0.20.0" +version = "0.21.0" dependencies = [ "flate2", "pdfcraft-content", @@ -4653,19 +4661,22 @@ dependencies = [ [[package]] name = "pdfcraft-print" -version = "0.20.0" +version = "0.21.0" dependencies = [ "craft-print", + "log", "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", "pdfcraft-render", + "skrifa 0.44.0", "thiserror 2.0.21", + "winprint 0.2.1 (git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49)", ] [[package]] name = "pdfcraft-redact" -version = "0.20.0" +version = "0.21.0" dependencies = [ "aho-corasick", "pdfcraft-annot", @@ -4681,13 +4692,15 @@ dependencies = [ "pdfcraft-testkit", "pdfcraft-xfa", "regex", + "roxmltree 0.21.2", "sha2 0.11.1", "thiserror 2.0.21", + "unicode-normalization", ] [[package]] name = "pdfcraft-render" -version = "0.20.0" +version = "0.21.0" dependencies = [ "bytemuck", "flate2", @@ -4706,11 +4719,12 @@ dependencies = [ "skrifa 0.44.0", "thiserror 2.0.21", "unicode-bidi", + "url", ] [[package]] name = "pdfcraft-sign" -version = "0.20.0" +version = "0.21.0" dependencies = [ "aes", "aws-lc-rs", @@ -4744,15 +4758,16 @@ dependencies = [ "sha2 0.11.1", "sha3", "thiserror 2.0.21", + "zeroize", ] [[package]] name = "pdfcraft-testkit" -version = "0.20.0" +version = "0.21.0" [[package]] name = "pdfcraft-ui-egui" -version = "0.20.0" +version = "0.21.0" dependencies = [ "arboard", "base64 0.23.1", @@ -4765,6 +4780,7 @@ dependencies = [ "craft-quit", "craft-ui", "craft-update", + "edit-session", "eframe", "egui", "egui_extras", @@ -4792,7 +4808,7 @@ dependencies = [ "pollster 1.0.1", "proptest", "rfd", - "rustix 1.1.5", + "rustix 1.1.6", "serde", "serde_json", "skrifa 0.44.0", @@ -4805,11 +4821,12 @@ dependencies = [ "web-time", "winsafe", "xattr", + "zeroize", ] [[package]] name = "pdfcraft-web" -version = "0.20.0" +version = "0.21.0" dependencies = [ "craft-web-api", "craft-web-host", @@ -4824,7 +4841,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.20.0" +version = "0.21.0" dependencies = [ "base64 0.23.1", "image", @@ -4838,7 +4855,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfdf" -version = "0.20.0" +version = "0.21.0" dependencies = [ "base64 0.23.1", "pdfcraft-annot", @@ -5127,7 +5144,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.5", + "rustix 1.1.6", "windows-sys 0.61.2", ] @@ -5385,7 +5402,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5937,15 +5954,15 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.5" +version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" dependencies = [ "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6015,7 +6032,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6639,7 +6656,7 @@ dependencies = [ "libc", "log", "memmap2", - "rustix 1.1.5", + "rustix 1.1.6", "thiserror 2.0.21", "wayland-backend", "wayland-client", @@ -6904,8 +6921,8 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix 1.1.5", - "windows-sys 0.61.2", + "rustix 1.1.6", + "windows-sys 0.52.0", ] [[package]] @@ -7657,7 +7674,7 @@ checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", - "rustix 1.1.5", + "rustix 1.1.6", "scoped-tls", "smallvec", "wayland-sys", @@ -7670,7 +7687,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ "bitflags 2.13.2", - "rustix 1.1.5", + "rustix 1.1.6", "wayland-backend", "wayland-scanner", ] @@ -7692,7 +7709,7 @@ version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" dependencies = [ - "rustix 1.1.5", + "rustix 1.1.6", "wayland-client", "xcursor", ] @@ -8030,7 +8047,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8307,7 +8324,7 @@ dependencies = [ [[package]] name = "winprint" version = "0.2.1" -source = "git+https://github.com/storytold/winprint.rs?rev=51934db382f225454cf152a8657eb03789be63c4#51934db382f225454cf152a8657eb03789be63c4" +source = "git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49#5f80b9baf6b58937c401656634e42d33f00ddf49" dependencies = [ "flate2", "fmt-derive", @@ -8325,6 +8342,28 @@ dependencies = [ "xml", ] +[[package]] +name = "winprint" +version = "0.2.1" +source = "git+https://github.com/storytold/winprint.rs?rev=bc06a7c48b768eb27a828c7986f6d2868b245ee0#bc06a7c48b768eb27a828c7986f6d2868b245ee0" +dependencies = [ + "flate2", + "fmt-derive", + "reqwest", + "scopeguard", + "sigstore-trust-root", + "sigstore-types", + "sigstore-verify", + "strum", + "tar", + "thiserror 2.0.21", + "windows", + "windows-core", + "windows-implement", + "windows-numerics", + "xml", +] + [[package]] name = "winreg" version = "0.55.0" @@ -8366,7 +8405,7 @@ dependencies = [ "libc", "log", "os_pipe", - "rustix 1.1.5", + "rustix 1.1.6", "thiserror 2.0.21", "tree_magic_mini", "wayland-backend", @@ -8424,7 +8463,7 @@ dependencies = [ "libc", "libloading", "once_cell", - "rustix 1.1.5", + "rustix 1.1.6", "x11rb-protocol", ] @@ -8466,7 +8505,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix 1.1.5", + "rustix 1.1.6", ] [[package]] @@ -8573,7 +8612,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix 1.1.5", + "rustix 1.1.6", "serde", "serde_repr", "tracing", @@ -8842,14 +8881,14 @@ dependencies = [ [[patch.unused]] name = "egui_demo_lib" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc" [[patch.unused]] name = "egui_inspection" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc" [[patch.unused]] name = "egui_system_font_provider" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc" diff --git a/Cargo.toml b/Cargo.toml index 3f667f8c6d..eaf28264e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,13 +5,15 @@ exclude = ["vendor"] default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"] [workspace.package] -version = "0.20.0" +version = "0.21.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" repository = "https://github.com/storytold/pdfcraft" [workspace.dependencies] +# Small, revision-bound UI drafts; the existing engine remains the owner of PDF edits/history. +edit-session = { path = "vendor/edit-session", version = "=0.1.0-alpha.2" } # Shared frontend components; engines remain independent of egui. craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", features = ["serde"] } # Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`. @@ -24,47 +26,47 @@ craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1 # the commit in `craft-i18n.rev`). Already in the tree through craft-ui. craft-i18n = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # Shared system spooler; document composition remains in pdfcraft-print. -craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "7ee35a9fbd47af572d55fd63dbf0333d1f00da17" } +craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "60ede99985ef1e5df87a2aca66ed538eb23dec99" } # Whether a path stored in a document may be opened (GoToR link targets). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # craft-update: app-specific pretend versions apply to startup and manual checks. craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] } # internal -pdfcraft-geom = { path = "crates/geom", version = "0.20.0" } -pdfcraft-filters = { path = "crates/filters", version = "0.20.0" } -pdfcraft-cos = { path = "crates/cos", version = "0.20.0" } -pdfcraft-crypt = { path = "crates/crypt", version = "0.20.0" } -pdfcraft-organize = { path = "crates/organize", version = "0.20.0" } -pdfcraft-measure = { path = "crates/measure", version = "0.20.0" } -pdfcraft-annot = { path = "crates/annot", version = "0.20.0" } -pdfcraft-audit = { path = "crates/audit", version = "0.20.0" } -pdfcraft-fonts = { path = "crates/fonts", version = "0.20.0" } -pdfcraft-model = { path = "crates/model", version = "0.20.0" } -pdfcraft-content = { path = "crates/content", version = "0.20.0" } -pdfcraft-redact = { path = "crates/redact", version = "0.20.0" } -pdfcraft-print = { path = "crates/print", version = "0.20.0" } -pdfcraft-xfdf = { path = "crates/xfdf", version = "0.20.0" } -pdfcraft-sign = { path = "crates/sign", version = "0.20.0" } -pdfcraft-optimize = { path = "crates/optimize", version = "0.20.0" } -pdfcraft-a11y = { path = "crates/a11y", version = "0.20.0" } -pdfcraft-oracle = { path = "crates/oracle", version = "0.20.0" } -pdfcraft-testkit = { path = "crates/testkit", version = "0.20.0" } -pdfcraft-ocr = { path = "crates/ocr", version = "0.20.0" } -pdfcraft-js = { path = "crates/js", version = "0.20.0" } -pdfcraft-compare = { path = "crates/compare", version = "0.20.0" } -pdfcraft-preflight = { path = "crates/preflight", version = "0.20.0" } -pdfcraft-export = { path = "crates/export", version = "0.20.0" } -pdfcraft-edit = { path = "crates/edit", version = "0.20.0" } -pdfcraft-create = { path = "crates/create", version = "0.20.0" } -pdfcraft-forms = { path = "crates/forms", version = "0.20.0" } -pdfcraft-xfa = { path = "crates/xfa", version = "0.20.0" } -pdfcraft-render = { path = "crates/render", version = "0.20.0" } -pdfcraft-engine = { path = "crates/engine", version = "0.20.0" } -pdfcraft-platform = { path = "crates/platform", version = "0.20.0" } -pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.20.0" } -pdfcraft-automation = { path = "crates/automation", version = "0.20.0", default-features = false } -pdfcraft-prc = { path = "crates/prc", version = "0.20.0" } -pdfcraft-prc-render = { path = "crates/prc-render", version = "0.20.0" } +pdfcraft-geom = { path = "crates/geom", version = "0.21.0" } +pdfcraft-filters = { path = "crates/filters", version = "0.21.0" } +pdfcraft-cos = { path = "crates/cos", version = "0.21.0" } +pdfcraft-crypt = { path = "crates/crypt", version = "0.21.0" } +pdfcraft-organize = { path = "crates/organize", version = "0.21.0" } +pdfcraft-measure = { path = "crates/measure", version = "0.21.0" } +pdfcraft-annot = { path = "crates/annot", version = "0.21.0" } +pdfcraft-audit = { path = "crates/audit", version = "0.21.0" } +pdfcraft-fonts = { path = "crates/fonts", version = "0.21.0" } +pdfcraft-model = { path = "crates/model", version = "0.21.0" } +pdfcraft-content = { path = "crates/content", version = "0.21.0" } +pdfcraft-redact = { path = "crates/redact", version = "0.21.0" } +pdfcraft-print = { path = "crates/print", version = "0.21.0" } +pdfcraft-xfdf = { path = "crates/xfdf", version = "0.21.0" } +pdfcraft-sign = { path = "crates/sign", version = "0.21.0" } +pdfcraft-optimize = { path = "crates/optimize", version = "0.21.0" } +pdfcraft-a11y = { path = "crates/a11y", version = "0.21.0" } +pdfcraft-oracle = { path = "crates/oracle", version = "0.21.0" } +pdfcraft-testkit = { path = "crates/testkit", version = "0.21.0" } +pdfcraft-ocr = { path = "crates/ocr", version = "0.21.0" } +pdfcraft-js = { path = "crates/js", version = "0.21.0" } +pdfcraft-compare = { path = "crates/compare", version = "0.21.0" } +pdfcraft-preflight = { path = "crates/preflight", version = "0.21.0" } +pdfcraft-export = { path = "crates/export", version = "0.21.0" } +pdfcraft-edit = { path = "crates/edit", version = "0.21.0" } +pdfcraft-create = { path = "crates/create", version = "0.21.0" } +pdfcraft-forms = { path = "crates/forms", version = "0.21.0" } +pdfcraft-xfa = { path = "crates/xfa", version = "0.21.0" } +pdfcraft-render = { path = "crates/render", version = "0.21.0" } +pdfcraft-engine = { path = "crates/engine", version = "0.21.0" } +pdfcraft-platform = { path = "crates/platform", version = "0.21.0" } +pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.21.0" } +pdfcraft-automation = { path = "crates/automation", version = "0.21.0", default-features = false } +pdfcraft-prc = { path = "crates/prc", version = "0.21.0" } +pdfcraft-prc-render = { path = "crates/prc-render", version = "0.21.0" } # pdf hayro = "0.7.1" @@ -84,6 +86,7 @@ hayro-jbig2 = "0.3" # foundation thiserror = "2" +zeroize = "1" # Linear-time matching (no backtracking) for caller-supplied redaction patterns. `perf-dfa` gives # the lazy-DFA cache that `RegexBuilder::dfa_size_limit` bounds (the limit is inert without it). regex = { version = "1", default-features = false, features = ["std", "unicode", "perf-dfa"] } @@ -149,22 +152,22 @@ lopdf = { path = "vendor/lopdf" } vello_common = { path = "vendor/vello_common" } vello_cpu = { path = "vendor/vello_cpu" } # The egui and winit crates below are pinned to immutable storytold git revisions (egui -# 6d518fc799681571963842f95b55a6b68431b15a, winit a517b3846ee8457d84ee4975110bfda6797c44dc). +# 725a39fca940f77149ffbb9c58eef405a16997bc, winit a517b3846ee8457d84ee4975110bfda6797c44dc). # Only the retained `vendor/winit` and `vendor/egui-wgpu` copies are provenance for the app's # earlier patches and their source guards, not path patches; the other `vendor/` entries above # remain real path patches. See vendor/README.md and the migration plan. -ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } -emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } -epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } -epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } -egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } -egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } -egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } -egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } -egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } -egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } -egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } -eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } -egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } -egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +ecolor = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" } +emath = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" } +egui = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" } winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } diff --git a/LICENSES.toml b/LICENSES.toml index ac54d9f702..e52a55751e 100644 --- a/LICENSES.toml +++ b/LICENSES.toml @@ -16,7 +16,19 @@ sha256 = "cd7032ea33b71427f188e34ffb4aee9d6868a57fd196037c5c8b2be03a2a0352" [[document]] source = "NOTICE" destination = "NOTICE" -sha256 = "d9ba162ad2bc95d22ab9885065e253bb823bd6795068828e7b39858344b660eb" +sha256 = "a7620386409bc691c7e223917db186ec447362f655fd81a1196d9853dcac1330" + +[[document]] +source = "vendor/edit-session/LICENSE-MIT" +licences = ["MIT"] +destination = "vendor/edit-session/LICENSE-MIT" +sha256 = "665a942f9317a358930b470ad522a22a58ff6c346db0395463ec84dd3e19d775" + +[[document]] +source = "vendor/edit-session/LICENSE-APACHE" +licences = ["Apache-2.0"] +destination = "vendor/edit-session/LICENSE-APACHE" +sha256 = "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30" [[document]] source = "assets/app-icon/LICENSE-document.txt" diff --git a/NOTICE b/NOTICE index eed2d654b0..0906eb0f9a 100644 --- a/NOTICE +++ b/NOTICE @@ -9,6 +9,8 @@ docs/brand/LICENSE-brand.txt. Third-party material +- edit-session 0.1.0-alpha.2, Copyright (c) 2026 PerchAnton. See vendor/edit-session/LICENSE-MIT and vendor/edit-session/LICENSE-APACHE. + This product bundles third-party material under its own licenses, listed below. The per-file list (author, source, license) is in ATTRIBUTION.md. diff --git a/ROADMAP.md b/ROADMAP.md index 1fb7459f49..86ee190fa6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,17 +1,28 @@ +- 2026-11-04 (M6, PR #5699 group/cache/MK/button review): Expand calculation groups/duplicates with streaming bounded lookup; retain one canonical Field object per exposed identity and prune stale roots. Protect outside shared-MK owners and carry exact button IDs through native clicks and js_run, with regression and persistence coverage. +- 2026-11-04 (M6, PR #5699 early reset/duplicate identities): Enforce the shared 64-call reset budget before argument conversion/scanning, with sticky atomic rejection and Open/event budget sharing. Preserve PDF object identity in Calculate order, live targets and value writeback; regress duplicate names, reversed order, native completion and persistence. +- 2026-11-04 (M6, PR #5699 independent review): Preserve unknown defaults through the JS reset shadow; bound menu/response text before copies. Cover native/console reset persistence, inherited defaults, decode-budget exhaustion and exact Unicode dialog boundaries. +- 2026-11-04 (M6, PR #5699 memory/widget bounds): Count reset widgets and bound escaped UTF-8 text conversion before copies; add negative and Unicode boundary regressions. +- 2026-11-04 (M6, PR #5699 reset estimate): Charge expanded-name scans and field-name copy/comparison bytes; add three negative regressions and byte-exact refusal checks. # PdfCraft roadmap +> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M6, PR #4166/#4088: integrate main f137a289 at 0.16.1; retain automatic-field Print suppression, explicit button/console printing and document-error provenance; 761 scoped tests and verification gates passed; 13-step CLI exercise and render inspection passed; existing warnings retained; estimates unchanged) +> **Last reviewed:** 2026-11-09 | **Last updated:** 2026-11-09 | **Change:** patch (M10 #5130: preserve manual duplex defaults; validate DocumentDuplex review fix and CI formatting; estimates unchanged) +> > **Last reviewed:** 2026-11-08 | **Last updated:** 2026-11-08 | **Change:** patch (M10 #6514: refresh craft-print to 7ee35a9f, PDF loader to 51934db3 and public GDI kept at 64e7afd4 after #605 merged; preserve crash fix and stream-test repairs; new-graph CI pending; estimates unchanged) > **Last reviewed:** 2026-11-07 | **Last updated:** 2026-11-07 | **Change:** patch (M10: file-backed Windows PDF loading without whole-job buffering or per-chunk async waits; approved dependency chain retains #5006; physical output still unqualified; estimates unchanged) - > **Last reviewed:** 2026-11-05 | **Last updated:** 2026-11-05 | **Change:** patch (M7 #2964: directly edit Thai source text inside supported Form XObjects; estimates unchanged) - > **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged) - > **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged) > **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** minor (M3/M4, #5330: Crop pages is directly available in the document quick toolbar; estimates unchanged) - - +> **2026-11-04 (M6, PR #5699 reset budget):** Reject reset amplification before request copies, preview and PDF mutation; cap reset/selector counts and aggregate estimated work. Add native completion, boundary, flood and large-form regressions; preserve ordered under-budget resets. +> **2026-11-04 (M6, PR #5699 dependent reset):** Reuse form reset on an isolated comparison document so unnamed built-in calculated text/combo fields are included; retain normal post-write calculation. Extend persisted-default and typed-response regressions. +> **2026-11-04 (M6, PR #5699 reset ordering):** Reset live field values synchronously, commit resets before final script deltas using a reset baseline, and cover Doc Open reinitialization, save/reopen, Undo/Redo and replay-sensitive UI counters. +> **2026-11-04 (M6, PR #5699 review):** Bound worker capacity and synchronous deadlines, isolate expired/pending realms before edits, keep script prompts above other dialogs, and cover block ASI, control-position regex literals, event routing and complete undo state. Validation is reported in the PR; parity remains partial. +> **2026-11-03 (M6, PR #5699 follow-up):** Accept independent top-level statements separated by automatic semicolon insertion while retaining continuation-chain limits; assert cyclic calculation results and preserve upstream multiline Fill & Sign coverage. +> **2026-11-02 (M6, PR #5699 second review):** Count active expression chains instead of whole-source tokens; align native button events and automation schemas, block document input under script prompts, and validate prompt dismissal and idle repaint scheduling. +> **2026-11-02 (M6, PR #5699 review):** Cache per-edit script geometry/calculation plans, expose live document metadata, and bound persistent worker stacks to 64 MiB; add operation-count, lifecycle and isolated hostile-input/address-space regressions. +> **2026-11-02 (M6, #5211/#269):** Integrate persistent native AcroForm realms, synchronous calculation, page boxes, appearance/geometry/icon writeback and native button prompt continuation. Synthetic workflow and cancellation tests added; validation details accompany the PR. Remaining JavaScript parity stays partial. > **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 further review: inherited drawing/text state, affine image gestures, paste/selection ownership, no-op scripts and menu permissions; estimates unchanged) > **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 latest comments: explicit image-paste events, mixed-size field refresh, and documented mixed-script normalization with Undo; estimates unchanged) @@ -28,6 +39,7 @@ > **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: reconcile current main while retaining display-only CJK fallback and upstream Japanese font selection; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M2, PR #4172: extend on-screen CJK fallback to Combine thumbnails and figure previews; recognise FangSong and subset-prefixed Founder names; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (typed small Edit font sizes and Fill & Sign text/date colour, #2444/#2496; estimates unchanged) +> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (M7, #2750: Single-style Keep source fonts edits retain source glyphs and batch only missing glyphs in fallback fonts. Per-rewrite character eligibility and widths are cached; source and fallback edge spaces stay extractable. Estimates unchanged.) > **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** patch (M1, #1143: repaired aliases survive preflight, snapshots and normalized saves; retained materialized revisions report generation disagreements; estimates unchanged) @@ -36,10 +48,19 @@ > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M1: refresh object-generation reference identity against current upstream; estimates unchanged) **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: vector printing preserves annotation optional-content membership; estimates unchanged) +> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M6, PR #4166/#4088: preserve automatic-field Print suppression, explicit script printing and document-error provenance on upstream 0.15; retain the integrated craft-libs revision; estimates unchanged) +> +> +> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged) +> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: vector-print appearance transforms retain small coefficients; estimates unchanged) > **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M2: scans keep their strokes at screen size and are smoothed when zoomed; M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged) > **Last reviewed:** 2026-10-28 | **Last updated:** 2026-10-28 | **Change:** minor (M10, #4119 partial: Enhance scanned file cleans scanned pages in place: background removal, edge shadow removal, despeckle; estimates unchanged) > **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (web: same-document host mode on the canvas, `data-host="page"`; estimates unchanged) +> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M10: vector printing resolves annotation rectangle coordinates; estimates unchanged) +> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M10: vector printing resolves annotation rectangles and appearance-state names; estimates unchanged) +> **Last reviewed:** 2026-10-26 | **Last updated:** 2026-10-26 | **Change:** minor (M10: vector printing follows bounded annotation-value reference chains; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged) @@ -66,6 +87,7 @@ > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged) > **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M4 #2997: Ctrl-drag (Option on macOS) in the Organize grid copies the pages into the drop gap; `page_duplicate` takes `at`; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged) +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M10, gap 8: Office documents open through an installed LibreOffice; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M2/gap 12: the Annex D.2 alias codes read as the ink they draw in both tables; the text oracle scores reading order; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M4: indirect page rotations; M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -238,6 +260,26 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log +- **2026-11-09 (M10, #5130):** Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. Both Windows submission paths preserve DLL guards and current spooler fixes. Review follow-up validates both duplex scopes through craft-print 9fe50c94 and corrects CI formatting. Estimates unchanged. + +- **2026-10-27 (M10, #5005):** Vector printing retains each annotation's optional-content membership when drawing its appearance. + Shared appearances and existing layer state remain independent; estimates unchanged. + +- **2026-10-29 (M10, #5029):** Vector printing also honours the implicit NoRotate behavior of existing Text appearances when the flag is clear. + Print eligibility, source dictionaries and shared appearance streams remain unchanged. + +- **2026-10-27 (M10, #5029):** Vector printing retains the orientation and upper-left anchor of existing NoRotate annotation appearances on rotated source pages. + Crop origins, UserUnit and later sheet transforms remain effective; estimates unchanged. + +- **2026-10-27 (M10, annotation print precision):** Vector printing preserves small placement coefficients for existing form, comment and stamp appearances with large bounding boxes or matrices. + Synthetic render and save/reopen regressions cover the change; estimates unchanged. + +- **2026-10-31 (M6, PR #4166/#4088 integration):** Integrated main f137a289 (0.16.1), preserving upstream progress entries and automatic-field Print suppression with its console diagnostic. ShowPanel/SaveAs filtering, explicit button/console printing, field effects and document-error provenance remain intact, together with all three engine regressions and the headless automation regression. Manifests, lockfile and CI match incoming main. Windows x64 / Rust 1.95 validation: 761 engine/automation tests passed, zero failed and four existing ignored; formatting, scoped all-target Clippy with -D warnings, layers/assets/parity and the engine/automation WebAssembly check passed. A fresh CLI build and the 13-step synthetic form exercise passed; the inspected render shows Qty=4 and Total=8, and explicit button/console Print requests are retained. Main's 100 headless parity notices, five renderer WebAssembly warnings and three optional egui patch notices remain. Estimates unchanged. + +- **2026-10-29 (M6, PR #4166/#4088 integration):** Retained automatic-field Print suppression, ShowPanel/SaveAs filtering, explicit button/console printing and document-error provenance while integrating upstream 0.15. All three engine regressions and the headless regression remain in their current test modules, with matching parity evidence. Dependency manifests retain main's integrated craft-libs revision, which includes #366. Estimates unchanged. +- **2026-10-27 (M10, annotation print precision):** Vector printing preserves small placement coefficients for existing form, comment and stamp appearances with large bounding boxes or matrices. + Synthetic render and save/reopen regressions cover the change; estimates unchanged. + - **2026-10-13 (M8, redact by regex):** `redact_mark` takes `regex` (caller expression, linear-time engine, length/size/nesting caps, empty-matching refused) with optional `validate` (luhn, mod97, iban). Headless only; issue #1886. - **2026-11-08 (M5, comment exchange):** Comments keep their formatting and appearance through XFDF and FDF. XFDF export and import carry rich text, default style, border width, style and dash pattern, rotation, justification, fringe, line leaders and captions, the caret symbol and redaction overlay text as XFDF 3.0 (ISO 19444-1) names them, and a stamp's appearance as `` (the DICT/STREAM/DATA encoding, bounded); copy and paste carries every comment's appearance. FDF export keeps a stamp's /AP with its forms and images, as XFDF does, and FDF import keeps any /AP the file carries (ISO 32000-2 §12.7.8.3.4). The intent goes out as both `intent` and `IT`. An unreadable XFDF appearance is left out, said in the import report and the repair log, and the comment is drawn by PdfCraft; an FDF past the import budget is refused whole. A redaction mark is always drawn by PdfCraft. @@ -280,6 +322,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - 2026-11-01: PR #2155 maintainer edits remove redundant image/text helpers and add regressions for crop ownership, arrange scope/state fidelity, clipboard precedence, crop-drag bounds, and font scanning. Transform and Unicode-script tools complete the headless paths. - **2026-10-31 (M3/M4, #5330):** The document quick toolbar offers Crop pages directly through the existing page.crop command, with the command’s assembly-permission check and an active-tool highlight. The existing crop, undo and headless page_set_box paths are unchanged. No toolbar customization or new shortcut. Estimates unchanged. +- **2026-10-30 (M6):** Button scripts can read and change the normal push-button caption with `buttonGetCaption` and `buttonSetCaption`. The caption is drawn and retained through undo/redo and save/reopen; unsupported face indices are refused. Estimates unchanged. - **2026-10-18 (M3, #1354, local candidate):** Integrated --two-paged with current main, retaining upstream modal/picker ownership and single-tab reuse. Native launch/opening/password regressions, negative checks against the old wrapper, visual inspection, formatting, strict scoped Clippy, layers, assets, parity and scoped WASM compilation pass. Three existing socket tests are unavailable in this workspace; one existing network test remains ignored. No push or GitHub merge; estimates unchanged. - **2026-10-31 (M2, PR #4172 review):** Added same-machine pixel oracles for GBK Song, Hei, Kai and FangSong, including all four subset-prefixed Founder aliases. Reference PDFs use explicit Unicode mappings and independently selected installed faces; font data and rasters remain in memory. All eight cases ran; wrong-glyph and wrong-family production mutations each failed the new assertion and were restored. Validation: 422 renderer unit tests, renderer all-target Clippy with -D warnings, formatting, layers/assets/parity and renderer WASM checks passed; existing optional-patch/WASM warnings and parity notes remain. Estimates unchanged. @@ -290,6 +333,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-27 (M2, PR #4172 module-split conflicts):** Integrated main 1dd2737b, retaining viewer-only CJK fallback in split session modules and Combine thumbnail workers, and moving renderer regressions to the new test module. Validation: 798 scoped tests passed (371 engine, 412 renderer, 15 Combine; two existing engine tests ignored); scoped all-target Clippy with -D warnings, formatting, layers, assets, parity and renderer/engine WASM checks passed. Estimates unchanged. - **2026-10-27 (M10, `ocr.ocr-web`, rebase fix):** The web build loads OCR models only when their SHA-256 matches `ATTRIBUTION.toml`. With ONNX loading now on, rten's parser spins forever on some bytes that aren't a model (a corrupt or wrong download), so a bad file now fails at once instead of hanging a worker. +- **2026-10-27 (M3, #3425):** A file named with open parameters, as help systems start a PDF reader (`manual.pdf#nameddest=Intro`, `#page=12` or `#Intro`), opens at that destination instead of failing to read `manual.pdf#…`: at launch, when another launch hands it over, and in its tab when it is already open. Other parameters are ignored. The app and `link_open` now also apply a GoToR link's named destination, looked up in the target's /Names /Dests or catalog /Dests, instead of warning that it wasn't applied; the app looks names up off the UI thread. `doc_open` reports the destination of a path with open parameters. Estimates unchanged. - **2026-10-27 (M10, OCR models):** Model staging downloads and verifies the ONNX pair after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, retains complete RTEN fallback and preserves directory precedence. - **2026-10-26 (M2, PR #4172):** Reconciled current main, preserving its Japanese serif/weight selection and both branches' progress records. Installed CJK viewer and preview fallback remains display-only; estimates unchanged. @@ -298,7 +342,14 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-29 (M2):** Scanned pages look like the paper again. An image that doesn't ask for /Interpolate was shrunk by nearest-neighbour (since #635), which kept one sample in N per axis, so the text of a 300 dpi scan at 100% lost most of its strokes; it is now area-averaged to whole device pixels (on a 13-page 300 dpi scan every sample at 96–192 dpi is within 1 of the exact area average; it was up to 255 off). Enlarged, an image of page-scan size is smoothed bilinearly, so a zoomed scan has no staircases; icons, QR codes and pixel art keep hard pixels. Estimates unchanged. - **2026-10-26 (M11, JBIG2 symbols):** Lossless JBIG2 now stores each distinct mark of a black-and-white image once, as a symbol, and places its exact copies, when that is smaller than the generic region: on six pages of rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's, and the same bits back through two independent decoders (hayro-jbig2 and Poppler). Scans, whose letters are seldom identical, gain little until refinement coding; a dictionary shared between pages (`/JBIG2Globals`) is next. Estimates unchanged. - **2026-10-25 (M5, comment replies):** One reply can be deleted from the Comments panel without its thread. On the selected card each reply has its own "…" menu (Edit, Copy text, Delete); Delete takes that reply with its pop-up and the replies to it, as `comment_delete` already did headlessly, and the comment and its other replies stay. The card stays selected, and the panel's editor stays open, on what they were on though the delete moved it in `/Annots`. Undo and Redo close the editor, since either can move what it was open on. A locked reply can't be deleted. Estimates unchanged. +- **2026-10-25 (M7, #2750):** Single-style Keep source fonts edits retain source glyphs and batch only missing glyphs in fallback fonts. Per-rewrite character eligibility and widths are cached; source and fallback edge spaces stay extractable. Estimates unchanged. - **2026-10-25 (web, same-document host mode):** A page that loads the PdfCraft web module into its own document (no iframe) can opt into the host bridge with `data-host="page"` on the app's canvas — the exact value `page`; any other value keeps the normal app with downloads, unless a same-origin framed `?host=parent` selects the parent bridge (unframed or cross-origin, `?host=parent` also keeps the normal app). It is the `?host=parent` bridge with the transport swapped: the same five messages travel as non-bubbling `CustomEvent`s on the canvas (`detail` holds the parent messages' fields without `type`; the host answers with a `pdfcraft:open` event), so saves reach the host instead of downloading and dirty and start-up-failure reporting are unchanged. Unit tests cover the opt-in rule; no parity or estimate changes. +- **2026-10-25 (M10):** Vector printing keeps field and comment artwork whose rectangle coordinates are indirect, requiring exactly four finite resolved values. +- **2026-10-26 (M10):** Vector printing follows up to 64 indirect references for annotation rectangles, coordinates and appearance-state names, rejecting cycles and longer chains without changing source objects. +- **2026-10-25 (M10):** Vector printing keeps field and comment artwork whose rectangle coordinates or appearance-state names are indirect, requiring exactly four finite resolved rectangle values and a usable state name for state dictionaries. + Synthetic CLI and source regressions cover the behavior. + Estimates unchanged. + - **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged. - **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. - **2026-10-26 (M10, `ocr.ocr-web`, review fix):** On the web, Edit on a scanned page starts the OCR model download, and the page is read once the models load; it was taken for no scan and left unread until the document changed. @@ -329,7 +380,10 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md The regression checks two distinct appearances, inherited resources, print/reopen pixels and unchanged source data. Estimates unchanged. - **2026-10-21 (M3, window resize and move on Wayland):** With PdfCraft's own title bar on GNOME/Wayland, a second resize by a window edge, or a second move by the empty tab strip, works straight after the first; it waited for a click inside the window, because the compositor keeps the button's release from egui. A unit test covers the release given back; checked by hand in a test build. Estimates unchanged. +- **2026-10-22 (M6, #4088):** Automatic AcroForm Keystroke, Validate, Calculate and Format scripts cannot open the print dialog; ignored requests are reported in the JavaScript console. Explicit button and console printing remain available, including when they trigger automatic calculations. Engine and headless automation regressions cover the boundary. Estimates unchanged. + - **2026-10-24 (M5, move preview):** A comment being moved is drawn where it goes while it is dragged (a rectangle or oval showed only its blue frame): the comment is rendered alone once as the drag starts and laid over the page, which shows it where it was until the move is applied. Engine `Document::comment_layer`; synthetic engine and UI regressions. Estimates unchanged. +- **2026-10-22 (M10, #3132):** Windows printing no longer swaps the letters of embedded TrueType subsets. A subset renumbers its glyphs (ReportLab's Arial space is glyph 11, the installed Arial's is 3), and the Windows print path drew those numbers with the installed font of the same name: `Revisión` printed as `Wj{nxn`, the bold `z` as `Ä`, on Brother queues and the Adobe PDF printer alike. The copy sent to the Windows spooler now names every embedded TrueType/OpenType program `PdfCraftPrint`, in its `name` table and in the PDF; glyphs, encodings and `/ToUnicode` are untouched and a synthetic test renders the page identically before and after. Not yet checked on a physical Windows printer. Estimates unchanged. - **2026-10-24 (M5, border shape):** Text boxes and Fill & Sign's typed text take an oval or a circle for their border (Comment Properties ▸ Border shape, Fill & Sign's toolbar): the text box keeps its place and the shape goes around it, the text centred, a circle snug around the text (its box no wider than the text needs); stored as /RD and /PCBorderShape and drawn in the appearance. comment_add / comment_edit / fill_sign_add `border_shape`. One new label in every catalog. Synthetic annot, UI and automation regressions. Estimates unchanged. - **2026-10-24 (M5, Escape in Fill & Sign):** Escape leaves a Fill & Sign tool as it leaves a comment tool: the first press closes the open type box, the next goes back to Select, so the Text tool no longer opens a box at every click with no way out by the keyboard. A synthetic UI regression covers the Text and Checkmark tools. Estimates unchanged. - **2026-10-23 (M5, ortho):** Shift while drawing holds a line or arrow level or plumb, whichever is nearer (a CAD drawing's ortho mode), and each new side of connected lines, a polygon or a cloud from the corner before; dragging an end or corner with Shift is held the same way (it turned to 45° steps). `measure::snap::ortho`; `comment_add ortho`. Estimates unchanged. @@ -441,11 +495,14 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-16 (M14, localization):** Czech goes from menus-and-dialogs to the whole surface: 2,674 entries after adding the ~220 keys the other catalogs had -- the sentences the engine words (signature details, accessibility findings and report, PDF/A problems, pages to print, history labels), the date picker's weekday heads, the certificate viewer's fields and shared buttons. "Tag" is *tag* throughout (the Tags panel said *značky*, which is also a redaction mark), and "{n} tags" no longer reads "1 značek". Two new catalog tests. Estimates unchanged. - **2026-10-16 (M5, #2583, #1869):** A sticky note on a page with `/Rotate` is drawn upright, with its top-left where it was placed. Notes are saved with the NoRotate flag, and the renderer now honours it: the appearance is turned against the page's rotation about the upper-left corner of `/Rect`, which stays put (ISO 32000-2 §12.5.3; vendored hayro-interpret patch 37). Attachment icons follow the same rule. Selection, hover, the edit composer, the Comments panel's flash and the `comment_list` and `doc_info` rectangles use the drawn box, and `comment_add` anchors an icon's displayed top-left at `at`. Estimates unchanged. - **2026-10-16 (M11, #1201):** Optimize decodes a JPEG with its colour transform: an Adobe APP14 marker's flag, else the filter's `/ColorTransform` (ISO 32000-2 §7.4.8). An RGB JPEG stored untransformed (`/ColorTransform 0`, no marker) used to be converted from YCbCr anyway and came out in other colours, recompressed or merged from strips; it now keeps them. A JPEG whose marker and `/ColorTransform` disagree, or whose components are named R, G and B without `/ColorTransform 0`, is left as it is, since readers differ on it. Estimates unchanged. +- **2026-10-17 (M10, gap 8):** File ▸ Open opens Word, Excel, PowerPoint, OpenDocument and RTF files as new, unsaved PDFs, converted on the open worker by an installed LibreOffice (headless, a private temporary folder and fresh profile with macros off, stopped after 120 s); without LibreOffice the user is told it is needed. The Open dialog lists PDF (the default), Images, Office documents, Text and All files. Insert, Combine and Create ▸ Multiple files refuse Office documents for now, since they convert on the frame thread. `create.from-office` planned → partial. On 2026-10-26 the profile also stopped Writer and Calc from updating linked content (Impress has no such setting), and tests that skip without LibreOffice convert generated DOCX, XLSX, PPTX and RTF files through `doc_create_multiple` and `doc_combine` and check their text. Estimates unchanged. - **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged. - **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged. - **2026-10-15 (M14, gap 16, #1683):** Split view. View ▸ Split (⌘\\ / Ctrl+\\) and a tab's Split right / Split down show two panes, each with its own tab strip, scroll and zoom; a tab dropped on the right half of the page area splits right, and the divider drags. One document in both panes is one document with one Undo; closing one of its views keeps it open. The pane last clicked is the one menus, keys and panels act on. Nothing about the panes is kept across restarts. `view.split-window` planned → shipped. Multiple windows stay planned. Estimates unchanged. - **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of a form, with its button, comes last). Acrobat Pro also puts that page last, and when printing keeps it but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet; both re-observed on a synthetic fixture, see the 2026-10-19 line. Synthetic regression tests. Estimates unchanged. - **2026-10-19 (M12, XFA, #2574):** The Acrobat observations behind the line above were first made on a public real-world form, which the Privacy rule does not allow; they are replaced by ones made on a synthetic form (three `breakBefore pageArea` sections, then a page area with `occur min="1" max="1"`, with and without `relevant="-print"`). Acrobat Pro shows the closing page last in both and prints it in both, blank only with `relevant="-print"`: confirmed. The synthetic form also shows a difference not fixed yet: Acrobat opens it as 5 pages, with an empty first body page before the first section, where PdfCraft lays out 4. Two more synthetic forms narrow it down: with `breakBefore targetType="pageArea"` on the first section, with or without `startNew`, Acrobat opens an empty body page first; without a break on the first section it lays out 4, as PdfCraft does (so `startNew` is not the cause). Not fixed yet: `layout.rs` skips a break while the page is still empty. Estimates unchanged. +- **2026-10-15 (M10, print a selected area, #2051):** The area Take a Snapshot copies stays outlined with a small menu under it, Print area… and Save as image… (PNG). Print area… opens Print with Selected area (page N, size) under Pages to Print: the area prints alone, as a page of that size, with Fit, Actual size, Shrink or a custom scale, clipped to the area; Multiple, Booklet, Poster and Label stay for pages. `doc_print` takes `area`. New parity entry `print.selected-area` (shipped). Estimates unchanged. +- **2026-10-15 (M7, find and replace in Edit text):** In Edit text mode the floating Find box gains a Replace with row (Edit ▸ Replace…, ⇧⌘H / Ctrl+Shift+H): Replace changes the highlighted match, Replace all every match as one undo step. Each match is rewritten inside its own paragraph through the paragraph rewrite, so fonts, sizes and colours stay and the paragraph rewraps; matches that span paragraphs, sit in OCR text, need another font or lie outside editable paragraphs are left alone and counted. `text_replace` drives the same plan. `edit.find-replace` planned → partial. Estimates unchanged. - **2026-10-15 (M8, #2447):** Removing links or comments (Remove Hidden Information, Sanitize) also drops the tagged-PDF structure tree's object references to them, so the full rewrite no longer keeps a removed link's URL or a comment's text. Regression seen to fail without the fix. Estimates unchanged. - **2026-10-15 (M6, CSV tab-containing headers):** CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Synthetic export/import and incremental/full save regressions cover header tabs, quotes and line breaks, plus ordinary TSV controls. Formula-cell policy is unchanged. Estimates unchanged. - **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged. @@ -456,6 +513,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (M3, closable community card):** The ArtCraft community card on Home has a close button that hides it for the rest of the run; it returns on the next launch, and its links stay in the top bar's Discord button and the Help menu. A UI regression covers closing and the fresh-launch default. Estimates unchanged. - **2026-10-15 (M14, Combine files from File Explorer, #585):** The Windows MSI adds "Combine files in PdfCraft" to PDF files' right-click menu (with the Edit verb, same switch); each selected file starts `pdfcraft --combine`, and the single-instance hand-off (`"combine": true`, `OsEvent::Combine`) collects them into the running PdfCraft's Combine files tab. Tests: the request round trip and hand-off in `single_instance.rs`, the OS event in `tests/ui.rs`, the verb rows in `test-msi.ps1`. Estimates unchanged. - **2026-10-15 (M14, MSI install folder, #1139):** The Windows installer's welcome dialog has an "Install to" row with a native folder browser, starting on the folder chosen for the per-user or per-machine scope (the recorded one on an update). `test-msi.ps1` checks the dialog wiring: both Install buttons set the folder before the dialog closes, and Browse's OK and Cancel. Estimates unchanged. +- **2026-10-15 (M8, #2448):** Applying redactions also removes the document information entries and the document's and pages' XMP metadata that repeat the removed text, so a redacted word can't survive in `/Title`, `/Keywords` or the XMP that mirrors them. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. - **2026-10-18 (M1, PR #1143 repair policy):** A live in-file reference resolves when its generation matches the xref row or a same-number header at that indexed offset. Neither-match and free slots remain null, including when old object bytes remain. Opening records bounded xref/header disagreements without parsing object bodies or expanding compact xref rows. Synthetic controls cover read order, clones, overlays, dictionary edits, full classic/packed saves and an independent reader of incremental output. **Correction to the 2026-10-15 note below:** its async-opening-test sentence was not evidenced by this COS change; no fresh async-opening or GUI validation is claimed. The full-reference stream-length recursion key is defensive hardening, not an independently isolated regression fix. Estimates unchanged. - **2026-10-15 (M1, PR #1143 refresh):** COS reference lookups and full saves distinguish complete object references, so stale generations cannot expose replacement content. Snapshot restoration also keeps edits to objects with nonzero generations before incremental saves. Synthetic regressions cover caches, overlays, snapshots, stream lengths and classic/packed saves. Current upstream async-opening test corrections are included. Estimates unchanged. @@ -526,7 +584,10 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (M3, #1248):** A fresh install follows the desktop's appearance: the theme preference defaults to System (dark on a dark desktop, Light where the desktop says nothing); saved settings and `--theme` still win. Dark page rendering is Reading colours, and the page reading themes are #1732. - **2026-10-15 (M3, #1767):** Restore last view settings when reopening documents, as Acrobat's preference does: a file opened again shows the page, zoom and page display it was last shown with (remembered for the last 100 files when a tab closes or settings are saved), over the file's own initial view; on by default, Preferences ▸ Documents and view turns it off. +- **2026-10-14 (M10, #2001):** The Print dialog remembers two-sided, grayscale, collate, page sizing (with its custom percent) and Comments & forms across restarts, under the names `doc_print` uses; copies, pages, layout and orientation still start fresh for each job. The option names now live in one place (`pdfcraft_print::names`) for the tool and the settings file. - **2026-10-14 (M2, #623):** A page whose content was cut at the renderer's limits no longer looks complete: a notice names it (and how many), `ui.state` lists the affected pages under `active.cut_pages`, and `text_extract` marks its text `truncated`, since the cut part's text can't be found or copied. The renderer already reported the cut; nothing showed it. +- **2026-10-14 (M5, comment pop-ups; reworked 2026-11-01):** A double-click on a comment opens its pop-up window on the page: a title bar with the comment's kind, author and date; its text and its replies' typed in place, the replies as panels each inside the one it answers, a Reply button under each; the window keeps its `/Rect` size on screen as the page zooms, scrolls its thread, drags by its title bar, resizes by its corner and closes with ×, each an undo step saved in the file's `/Popup` so other viewers agree. A faint wedge in the comment's colour joins the comment to its open window on roll-over or selection. `comment_popup` does the same headlessly and `comment_list` reports the pop-up. +- **2026-10-15 (M6, #1741):** Custom stamps can be dynamic: Create Custom Stamp adds an optional line under the picture, a template with {name}, {date} and {time} filled in when the stamp is placed (the built-in dynamic stamps' format), kept in the stamp library; `stamp_custom` takes the same `line`. - **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged. - **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged. - **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged. @@ -586,6 +647,9 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md Newest first: the date, what moved, and the new overall estimate. +- **2026-11-10 (M4, #4594):** Reconcile current main and keep the original draft-safety entry in this progress log instead of duplicating it in the shared roadmap header. Preserve inactive password cleanup and the Properties guard; behavior and estimates unchanged. +- **2026-11-09 (M4, #4594):** Reconcile upstream changes in the license ledger, roadmap and UI test registry; retain the vendored draft guard, local interaction replay and upstream split-view tests. Behavior and estimates unchanged; maintainer review remains pending. + - **2026-10-28 (M10, #1595):** Export to Markdown: `doc_export_office` with a `.md` or `.markdown` path writes CommonMark with GitHub pipe tables (headings, paragraphs with bold and italic, tables with their first row as the header, a rule between pages); text that Markdown would read as markup is escaped. Images, lists and links are not in the export's layout model and are left out. No menu item yet. Estimates unchanged. - **2026-10-12 (M10, `ocr.ocr-web`):** OCR in the browser, partly. The web zip carries the text recognition models in `models/` beside `index.html`; the first time Recognize Text is needed the web build downloads them (about 12 MB, with a spinner, an error and a retry). Pages are rendered on the page and read in Web Workers (up to two at once, so the page keeps responding), built with WebAssembly SIMD: 8.5 s a page instead of 20.5 s in wasmtime, still about 17× native. Where workers can't start, the page reads the pages itself. No automated browser test yet. Estimates unchanged. - **2026-10-12 (M10, `ocr.ocr-web`):** Web OCR shipped: reading scanned pages in Web Workers was checked by hand in a browser (4-page synthetic scan). Still no automated browser test, and a page reads about 17× slower than native. @@ -816,14 +880,28 @@ Newest first: the date, what moved, and the new overall estimate. - M13: common command, batch, inspection and bounded preview tools share the existing confined automation path. MCP tools carry complete hints and reject unknown top-level keys as invalid parameters. - **2026-10-10 (UI docking):** Tools and Inspector can be regrouped, split beside the PDF, or floated inside the app window. Existing toggles reopen panels at their saved locations; UI commands arrange/reset panels, and preferences retain their layout. Read/full-screen modes preserve the focused document view. No milestone percentage change. +- **2026-10-25 (M4, #4594 review):** Documented the session bridge and replay adapter; the stale notice now participates in translation-gap scanning, with a regression test. The original proposal depended on a shared release exception. The revised proposal vendors the Rust source and keeps the Python replay local. Maintainer review is pending. + +- **2026-10-25 (M4):** Document Properties drafts now reject changed document identities/revisions, including reload and Undo/Redo, through pinned edit-session; real UI JSONL scenarios replay with interaction-check. Broader dialog invalidation remains separate (#3917); estimates unchanged. + ## Revision history +**2026-10-27 · minor:** M10 #5005: vector printing preserves annotation optional-content membership. +Source resources and shared appearances remain unchanged. +**2026-10-31 · patch:** M6/PR #4166 integrated main f137a289 (0.16.1), preserving both roadmap histories. Automatic Keystroke, Validate, Calculate and Format scripts still suppress Print alongside ShowPanel/SaveAs; explicit button and console Print requests, field effects and document-error provenance are retained. The three engine regressions and headless automation regression remain in their current modules. Manifests, lockfile and CI match incoming main. Windows x64 / Rust 1.95 validation: 761 engine/automation tests passed, zero failed and four existing ignored; formatting, scoped all-target Clippy with -D warnings, layers/assets/parity and the engine/automation WebAssembly check passed. A fresh CLI build and the 13-step synthetic form exercise passed; the inspected render shows Qty=4 and Total=8, and explicit button/console Print requests are retained. Main's 100 headless parity notices, five renderer WebAssembly warnings and three optional egui patch notices remain. Estimates unchanged. + +**2026-10-29 · minor:** M6/PR #4166 refreshed against upstream 0.15. Automatic field scripts still suppress Print with a console diagnostic alongside ShowPanel/SaveAs filtering, and explicit button/console scripts retain their requests and document-error provenance. The three engine regressions, headless regression and parity evidence remain in the current modules. Dependency manifests retain main's integrated craft-libs revision, including #366. Estimates unchanged. +**2026-10-27 · minor:** M10: vector printing retains small appearance placement coefficients. +Synthetic rendering and save/reopen regressions cover the change. +Estimates unchanged. + **2026-10-25 · minor:** M14 localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Estimates unchanged. **2026-10-15 · minor:** M14/#2383 candidate: GPU device requests clamp unsupported limits while retaining the adapter texture extent. Synthetic V3D/downlevel regressions and Windows GPU-required tests pass; Raspberry Pi hardware remains unverified. Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-11-09 | patch | M10 #5130: Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. Estimates unchanged. | | 2026-11-08 | patch | M10 #6514/#636: retain main's public GDI Git revision (64e7afd4) to preserve downstream Rust type identity, update the internal PDF loader independently, and refresh the application dependency/lockfile. New-graph CI pending; estimates unchanged. | | 2026-11-08 | patch | M10 #6514: refresh the craft-print and winprint/GDI pins after #605 merged; retain the Direct2D crash fix and stream-test cleanup repairs, record single-page HUAWEI output with its diagnostic limits, and rerun CI for the new dependency graph. Estimates unchanged. | | 2026-11-07 | patch | M10 #6514: load Windows PDF print jobs on demand through a read-only file stream, avoiding StorageFile access denial, whole-file buffering and per-chunk async waits; approved dependencies retain the #5006 crash fix. Regression tests pass; physical output remains unqualified; estimates unchanged. | @@ -831,8 +909,12 @@ Newest first: the date, what moved, and the new overall estimate. | 2026-11-03 | patch | M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged. | | 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. | | 2026-10-28 | patch | M2, PR #4172: integrate main 5af10276 and retain both roadmap histories; viewer-only CJK and print/output font policies and estimates unchanged. | +| 2026-10-28 | minor | M10: Recognize text widens each found line by fractions of its word height before reading it, so the first and last letters are no longer clipped at 300 dpi (word errors on synthetic 150–400 dpi pages 3.8 % → 0.3 %); word boxes keep their size. Estimates unchanged. | | 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. | | 2026-10-20 | minor | M9 #79 review, M2, M8: /ActualText that marks no glyph extracts where it paints, as far as it is visible; redaction strips /ActualText around paint it clips; named-layer recognition needs the layer form to draw n1 and n3. Estimates unchanged. | +| 2026-10-25 | minor | M10: vector printing resolves annotation rectangle coordinates; synthetic regression evidence, estimates unchanged. | +| 2026-10-26 | minor | M10: annotation rectangles, coordinates and appearance-state names follow up to 64 indirect references with cycle detection; standalone-object save/reopen and malformed-chain regressions, estimates unchanged. | +| 2026-10-25 | minor | M10: vector printing resolves annotation rectangles and appearance-state names; synthetic save/reopen and print-content-mode evidence, estimates unchanged. | | 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. | | 2026-10-23 | minor | M11: image downsampling measures physical resolution after page-local UserUnit scaling, including shared nested forms; synthetic measurement and optimization regressions. Estimates unchanged. | | 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. | @@ -898,6 +980,7 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-21 (M4, #3865):** A new bookmark records where the reader is: the top-left of the selected text, or the top of the window, as `/XYZ left top null` (zoom kept), instead of the page top (`Edit::AddBookmark` takes `left` and `top`; `bookmark_add` takes `top` and `left`; `PageInfo::xyz_for_view`). - **2026-10-18 (renderer, hayro patches 19/35):** A mesh shading (types 4–7) is sampled over its own triangles instead of the whole path: a BricsCAD plan with 310 one-triangle `sh` fills took 2 s per 1024 px tile at every zoom (and hit the 20 s watchdog on a busy machine); now about 70 ms, so zooming into dense topology drawings stays fluid. Estimates unchanged. +- **2026-10-22 (M3, #3530, builds on PR #3547):** A find step keeps its match in the middle of the window after zooming: a zoom pending in the same frame no longer puts the page-top jump off to the next frame, and the rest of an eased ⌘/Ctrl-wheel zoom anchors on the match instead of the pointer. Synthetic UI regressions step through matches on pages larger and smaller than the window at 10–3200% and in every page display. Estimates unchanged. - **2026-10-22 (PR #3227 integration):** M11: integrate upstream `80b7d215`; retain partial target-version compatibility and upstream content-boundary and image-size regression coverage. Scope and estimates unchanged. @@ -916,3 +999,5 @@ PR #2133 retains document-local Pages sidebar focus for Ctrl/Cmd+A and Escape af PR #1952 retains per-widget checkbox/radio export-value editing in the split form-authoring and Prepare modules. Caption editing remains supported. Tests cover preserved appearances and selection, invalid/locked refusals, data-format round trips, and Apply/Cancel/Undo. Current-head hosted validation is pending; feature estimates are unchanged. - **2026-10-31 (#1577):** Reconciled the page-shadow preference with split viewer, organizer, settings and control modules, preserving upstream page-gap shadow suppression. CI validation pending. + +- 2026-11-04: AcroForm review follow-up covers field-instance retirement across history, rejected-writeback realm invalidation, mixed widget icons, shared calculation budgets and multi-select/checkbox consistency, frozen-global events, exact image imports, comparison-half scripts, focus reveal and explicit busy/timeout feedback. Synthetic regressions and elapsed UI deadlines accompany the fixes. diff --git a/apps/pdfcraft-cli/build.rs b/apps/pdfcraft-cli/build.rs index ee77f13f6a..9a6500b7f3 100644 --- a/apps/pdfcraft-cli/build.rs +++ b/apps/pdfcraft-cli/build.rs @@ -6,12 +6,25 @@ //! cross-compile from macOS or Linux still links, unless `PDFCRAFT_REQUIRE_WINRES=1` turns it //! into an error (for release builds). +/// The in-box DLLs only printing uses (Windows.Data.Pdf draws the job, prntvpt reads the +/// driver's print capabilities). Trimmed Windows installations (NTLite) can lack them, and a +/// load-time import of a missing DLL stops the program before `main` (#2630). Delay-loaded, they +/// load on the first print; `pdfcraft_print::spool` checks they exist before calling in. +/// Same list in `apps/pdfcraft{,-cli}/build.rs`, `crates/print/build.rs` and `crates/print/src/spool.rs`. +const DELAY_LOADED: [&str; 2] = ["windows.data.pdf.dll", "prntvpt.dll"]; + fn main() { println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-env-changed=PDFCRAFT_REQUIRE_WINRES"); if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { return; } + if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") { + for dll in DELAY_LOADED { + println!("cargo:rustc-link-arg-bins=/DELAYLOAD:{dll}"); + } + println!("cargo:rustc-link-arg-bins=delayimp.lib"); + } let mut res = winresource::WindowsResource::new(); res.set("ProductName", "PdfCraft") .set("FileDescription", "PdfCraft command-line tool") diff --git a/apps/pdfcraft-cli/src/commands.rs b/apps/pdfcraft-cli/src/commands.rs index 5a74f19f56..b5effbc118 100644 --- a/apps/pdfcraft-cli/src/commands.rs +++ b/apps/pdfcraft-cli/src/commands.rs @@ -434,12 +434,21 @@ pub(crate) fn render(args: &[String]) -> Result<(), CliError> { for warning in &p.warnings { match warning { pdfcraft_render::RenderWarning::ContentTruncated => { - let _ = writeln!(std::io::stderr().lock(), "warning: page {page}: content past the page's safety budget was skipped"); + let _ = writeln!( + std::io::stderr().lock(), + "warning: page {page}: content past the page's safety budget, or a picture whose JBIG2 mask is past a decoder limit, was skipped" + ); } pdfcraft_render::RenderWarning::ColourFallback => { let _ = writeln!(std::io::stderr().lock(), "warning: page {page}: a colour that could not be computed was painted in a fallback colour"); } + pdfcraft_render::RenderWarning::MalformedIndexedImage => { + let _ = writeln!( + std::io::stderr().lock(), + "warning: page {page}: an image with a malformed Indexed colour table may be drawn wrongly or not at all" + ); + } pdfcraft_render::RenderWarning::Type3WidthsMissing => { let _ = writeln!( std::io::stderr().lock(), diff --git a/apps/pdfcraft/build.rs b/apps/pdfcraft/build.rs index 35d519d99e..b6892e7b9d 100644 --- a/apps/pdfcraft/build.rs +++ b/apps/pdfcraft/build.rs @@ -9,6 +9,13 @@ #[path = "src/windows_manifest.rs"] mod windows_manifest; +/// The in-box DLLs only printing uses (Windows.Data.Pdf draws the job, prntvpt reads the +/// driver's print capabilities). Trimmed Windows installations (NTLite) can lack them, and a +/// load-time import of a missing DLL stops the program before `main` (#2630). Delay-loaded, they +/// load on the first print; `pdfcraft_print::spool` checks they exist before calling in. +/// Same list in `apps/pdfcraft{,-cli}/build.rs`, `crates/print/build.rs` and `crates/print/src/spool.rs`. +const DELAY_LOADED: [&str; 2] = ["windows.data.pdf.dll", "prntvpt.dll"]; + fn main() { println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-changed=../../assets/app-icon/pdfcraft.ico"); @@ -18,6 +25,12 @@ fn main() { if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { return; } + if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") { + for dll in DELAY_LOADED { + println!("cargo:rustc-link-arg-bins=/DELAYLOAD:{dll}"); + } + println!("cargo:rustc-link-arg-bins=delayimp.lib"); + } let mut res = winresource::WindowsResource::new(); res.set_icon("../../assets/app-icon/pdfcraft.ico") .set_icon_with_id("../../assets/app-icon/pdfcraft-document.ico", "2") diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 837a803e22..797e14e52f 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -475,6 +475,7 @@ fn app_creator<'a>( } app.set_startup_update_check(true); app.printer_source = Some(pdfcraft_ui_egui::system_printers()); + app.two_sided_source = Some(pdfcraft_ui_egui::system_two_sided()); app.os_key_store_ids = cfg!(any(target_os = "macos", target_os = "windows")); #[cfg(target_os = "macos")] { diff --git a/apps/pdfcraft/src/single_instance.rs b/apps/pdfcraft/src/single_instance.rs index 60f5d1d493..f60e74dd2f 100644 --- a/apps/pdfcraft/src/single_instance.rs +++ b/apps/pdfcraft/src/single_instance.rs @@ -152,9 +152,20 @@ pub fn parse_request(line: &str) -> Option<(Vec, bool)> { } /// File arguments made absolute against this launch's working folder, which the running app -/// doesn't share. A path that can't be made absolute is sent as it is. +/// doesn't share. A path that can't be made absolute is sent as it is. Of a file named with open +/// parameters (`manual.pdf#nameddest=Intro`, #3425), only the file is made absolute and the +/// parameters are sent as given: as part of the path, `//` and `/./` in a destination name were +/// folded, and on Windows `/` became `\`, `..` took the file name with it and a trailing dot went. pub fn absolute(paths: &[String]) -> Vec { - paths.iter().map(|p| std::path::absolute(p).map_or_else(|_| p.clone(), |a| a.to_string_lossy().into_owned())).collect() + let made = |p: &str| std::path::absolute(p).map_or_else(|_| p.to_string(), |a| a.to_string_lossy().into_owned()); + paths + .iter() + .map(|p| match pdfcraft_engine::open_parameters::split(p, |f| std::path::Path::new(f).is_file()) { + // `file` is what comes before one of `p`'s `#`, so the rest starts at that `#`. + Some((file, _)) => format!("{}{}", made(&file), p.get(file.len()..).unwrap_or_default()), + None => made(p), + }) + .collect() } /// Decide what this launch does. `files` are absolute ([`absolute`]); `may_hand_off` is false @@ -416,6 +427,26 @@ mod tests { assert_eq!(absolute(std::slice::from_ref(&already)), vec![already]); } + /// #3425: the open parameters after a file reach the running app as they were given. Made + /// absolute with the file, `a//b/./c` became `a/b/c` (`a\b\c` on Windows), and on Windows + /// `x/../y` lost the file name and `Intro.` its dot. + #[test] + fn only_the_file_before_open_parameters_is_made_absolute() { + let dir = std::env::temp_dir().join(format!("pdfcraft-instance-{}-open-parameters", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (manual, notes) = (dir.join("manual.pdf"), dir.join("notes#2.pdf")); + std::fs::write(&manual, b"%PDF-1.7").unwrap(); + std::fs::write(¬es, b"%PDF-1.7").unwrap(); + let made = |p: &std::path::Path| std::path::absolute(p).unwrap().to_string_lossy().into_owned(); + for parameters in ["#nameddest=a//b/./c", "#nameddest=x/../y", "#Intro.", "#page=2"] { + let given = format!("{}{parameters}", manual.to_string_lossy()); + assert_eq!(absolute(&[given]), vec![format!("{}{parameters}", made(&manual))], "{parameters}"); + } + // A file with `#` in its name is made absolute whole, as before. + assert_eq!(absolute(&[notes.to_string_lossy().into_owned()]), vec![made(¬es)]); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn each_windows_session_gets_its_own_socket_name() { // The session id decides: #3338, a launch from Task Scheduler (no SESSIONNAME) in the diff --git a/apps/pdfcraft/tests/it/vendor_graph.rs b/apps/pdfcraft/tests/it/vendor_graph.rs index a6a4d4b292..aa870c92cc 100644 --- a/apps/pdfcraft/tests/it/vendor_graph.rs +++ b/apps/pdfcraft/tests/it/vendor_graph.rs @@ -49,7 +49,7 @@ fn assert_resolved(name: &str, version: &str, url: &str, rev: &str) { #[test] fn vendor_provenance_window_stack_graph_is_resolved() { assert_resolved("winit", "0.30.13", "https://github.com/storytold/winit.git", "a517b3846ee8457d84ee4975110bfda6797c44dc"); - assert_resolved("egui-wgpu", "0.37.0", "https://github.com/storytold/egui.git", "6d518fc799681571963842f95b55a6b68431b15a"); + assert_resolved("egui-wgpu", "0.37.0", "https://github.com/storytold/egui.git", "725a39fca940f77149ffbb9c58eef405a16997bc"); // Retained provenance: the audited copies and their recorded W1–W13 / E2+E3 rows. let readme = std::fs::read_to_string(root().join("vendor/README.md")).expect("vendor/README.md"); for marker in ["W1–W13", "surface_fit", "catch_errors", "e9809ef54b18499bb4f2cac945719ecc2a61061b"] { diff --git a/contributors/people.toml b/contributors/people.toml index a22c884ccb..06c98f6464 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -191,3 +191,6 @@ real_name = "Helen Kwok" real_name = "Valentin Klein" display_name = "vangel76" sync_github_name = false + +[people.enriquemelzer-bit] +sync_github_name = true diff --git a/crates/a11y/src/autotag.rs b/crates/a11y/src/autotag.rs index 1b898fdfda..59d30930ff 100644 --- a/crates/a11y/src/autotag.rs +++ b/crates/a11y/src/autotag.rs @@ -1,9 +1,12 @@ -//! Create a valid, reviewable structure tree for PDFs whose painted content is text only. +//! Create a valid, reviewable structure tree for untagged PDFs. //! -//! The writer refuses mixed content and existing tags. Each text object becomes a paragraph, -//! which can then be retagged with the existing tag-property tool after human review. +//! Each text object becomes a paragraph, which can then be retagged with the existing +//! tag-property tool after human review. Painted paths, shadings and images outside text objects +//! are wrapped in `/Artifact BMC … EMC` so the resulting page passes the tagged-content check. -use pdfcraft_content::{Op, parse, serialize_ops}; +use std::collections::{HashMap, HashSet}; + +use pdfcraft_content::{Op, Pieces, serialize_ops}; use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; const MAX_PAGES: usize = 1_000; @@ -41,25 +44,27 @@ struct Plan { page: ObjRef, ops: Vec, spans: Vec<(usize, usize)>, + artifacts: HashMap, } fn unsupported(page: usize, reason: impl Into) -> AutoTagError { AutoTagError::Unsupported { page: page + 1, reason: reason.into() } } -fn content_bytes(doc: &Document, page: &pdfcraft_model::Page, index: usize) -> Result, AutoTagError> { - let Some(contents) = page.dict.get(b"Contents") else { return Ok(Vec::new()) }; +fn content_pieces(doc: &Document, page: &pdfcraft_model::Page, index: usize) -> Result { + let Some(contents) = page.dict.get(b"Contents") else { return Ok(Pieces::join::<&[u8]>(&[])) }; let resolved = doc.resolve(contents); - let pieces: Vec = match &*resolved { + let items: Vec = match &*resolved { Object::Array(items) => items.clone(), Object::Stream(_) => vec![contents.clone()], _ => return Err(unsupported(index, "contents are not a stream or array of streams")), }; - let mut all = Vec::new(); - for item in pieces { + let mut pieces = Vec::with_capacity(items.len()); + let mut total = 0usize; + for item in items { let value = doc.resolve(&item); let Object::Stream(stream) = &*value else { return Err(unsupported(index, "contents include a non-stream object")) }; - let remaining = MAX_PAGE_BYTES.saturating_sub(all.len().saturating_add(1)); + let remaining = MAX_PAGE_BYTES.saturating_sub(total.saturating_add(1)); if remaining == 0 { return Err(unsupported(index, "decoding the content takes more than 16 MiB")); } @@ -79,10 +84,18 @@ fn content_bytes(doc: &Document, page: &pdfcraft_model::Page, index: usize) -> R unsupported(index, format!("content cannot be decoded strictly: {why}")) } })?; - all.extend_from_slice(&decoded); - all.push(b' '); + total = total.saturating_add(decoded.len()).saturating_add(1); + pieces.push(decoded); } - Ok(all) + Ok(Pieces::join(&pieces)) +} + +fn page_xobjects(doc: &Document, page: &Dict) -> Dict { + page.get(b"Resources") + .map(|r| doc.resolve(r)) + .and_then(|r| r.as_dict().and_then(|r| r.get(b"XObject").map(|x| doc.resolve(x)))) + .and_then(|x| x.as_dict().cloned()) + .unwrap_or_default() } fn harmless_outside(op: &[u8]) -> bool { @@ -110,9 +123,35 @@ fn harmless_outside(op: &[u8]) -> bool { | b"sc" | b"SCN" | b"scn" + | b"m" + | b"l" + | b"c" + | b"v" + | b"y" + | b"h" + | b"re" + | b"W" + | b"W*" + | b"n" ) } +fn painted_outside(doc: &Document, xobjects: &Dict, op: &Op) -> bool { + match op.op.as_slice() { + b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"S" | b"s" | b"sh" | b"BI" => true, + b"Do" => op + .name(0) + .and_then(|n| xobjects.get(n)) + .map(|o| doc.resolve(o)) + .and_then(|o| match &*o { + Object::Stream(s) => s.dict.name(b"Subtype").map(<[u8]>::to_vec), + _ => None, + }) + .is_some_and(|st| st == b"Image"), + _ => false, + } +} + fn harmless_inside(op: &[u8]) -> bool { matches!( op, @@ -142,17 +181,38 @@ fn harmless_inside(op: &[u8]) -> bool { ) } -/// The `(BT, ET)` operator index pairs of each text object that shows text, or why the page cannot be tagged. -fn text_spans(ops: &[Op], index: usize) -> Result, AutoTagError> { - let mut start = None; - let mut has_text = false; - let mut spans = Vec::new(); - for (at, op) in ops.iter().enumerate() { +/// `totals` is the decoded bytes and operators of the pages planned so far, bounded by +/// `MAX_TOTAL_BYTES` and `MAX_TOTAL_OPERATORS`. +fn plan_page(doc: &Document, page: &pdfcraft_model::Page, index: usize, totals: &mut (usize, usize)) -> Result { + if page.dict.contains(b"StructParents") { + return Err(unsupported(index, "page already has a structure parent key")); + } + if page.dict.get(b"Annots").is_some_and(|a| match &*doc.resolve(a) { + Object::Array(items) => !items.is_empty(), + _ => true, + }) { + return Err(unsupported(index, "annotations need separate structure tags")); + } + let pieces = content_pieces(doc, page, index)?; + totals.0 = totals.0.saturating_add(pieces.data().len()); + let parsed = pieces.parse(); + if parsed.skipped > 0 || parsed.ambiguous_images > 0 || parsed.ops.len() > MAX_OPERATORS { + return Err(unsupported(index, "content has malformed or too many operators")); + } + totals.1 = totals.1.saturating_add(parsed.ops.len()); + if totals.0 > MAX_TOTAL_BYTES || totals.1 > MAX_TOTAL_OPERATORS { + return Err(unsupported(index, "the document has too much content to auto-tag in one edit")); + } + let xobjects = page_xobjects(doc, &page.dict); + let (mut start, mut has_text, mut spans, mut artifacts) = (None, false, Vec::new(), HashMap::new()); + // Marked-content boundaries must enclose the whole path, not just its paint operator. + let mut path = None; + for (at, op) in parsed.ops.iter().enumerate() { + if path.is_some() && matches!(op.op.as_slice(), b"BT" | b"Do" | b"BI" | b"sh") { + return Err(unsupported(index, "painted content interrupts an unfinished path")); + } match op.op.as_slice() { - b"BT" if start.is_none() => { - start = Some(at); - has_text = false; - } + b"BT" if start.is_none() => (start, has_text) = (Some(at), false), b"ET" => { let Some(first) = start.take() else { return Err(unsupported(index, "unmatched ET operator")) }; if has_text { @@ -164,14 +224,31 @@ fn text_spans(ops: &[Op], index: usize) -> Result, AutoTagEr } b"Tj" | b"TJ" | b"'" | b"\"" if start.is_some() => has_text = true, name if start.is_some() && harmless_inside(name) => {} - name if start.is_none() && harmless_outside(name) => {} + name if start.is_none() && harmless_outside(name) => match name { + b"m" | b"re" => { + path.get_or_insert(at); + } + b"n" => path = None, + _ => {} + }, + _ if start.is_none() && painted_outside(doc, &xobjects, op) => { + let first = if matches!(op.op.as_slice(), b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"S" | b"s") { + path.take().unwrap_or(at) + } else { + at + }; + artifacts.insert(first, at); + } _ => return Err(unsupported(index, format!("unsupported or painted operator {}", String::from_utf8_lossy(&op.op)))), } } if start.is_some() { return Err(unsupported(index, "unterminated BT text object")); } - Ok(spans) + if path.is_some() { + return Err(unsupported(index, "unterminated path object")); + } + Ok(Plan { page: page.obj, ops: parsed.ops, spans, artifacts }) } fn plan(doc: &Document) -> Result, AutoTagError> { @@ -190,30 +267,11 @@ fn plan(doc: &Document) -> Result, AutoTagError> { } let mut out = Vec::with_capacity(pages.len()); let mut total = 0usize; - let (mut total_bytes, mut total_ops) = (0usize, 0usize); + let mut totals = (0usize, 0usize); for (index, page) in pages.iter().enumerate() { - if page.dict.contains(b"StructParents") { - return Err(unsupported(index, "page already has a structure parent key")); - } - if page.dict.get(b"Annots").is_some_and(|a| match &*doc.resolve(a) { - Object::Array(items) => !items.is_empty(), - _ => true, - }) { - return Err(unsupported(index, "annotations need separate structure tags")); - } - let bytes = content_bytes(doc, page, index)?; - total_bytes = total_bytes.saturating_add(bytes.len()); - let parsed = parse(&bytes); - if parsed.skipped > 0 || parsed.ops.len() > MAX_OPERATORS { - return Err(unsupported(index, "content has malformed or too many operators")); - } - total_ops = total_ops.saturating_add(parsed.ops.len()); - if total_bytes > MAX_TOTAL_BYTES || total_ops > MAX_TOTAL_OPERATORS { - return Err(unsupported(index, "the document has too much content to auto-tag in one edit")); - } - let spans = text_spans(&parsed.ops, index)?; - total = total.saturating_add(spans.len()); - out.push(Plan { page: page.obj, ops: parsed.ops, spans }); + let planned = plan_page(doc, page, index, &mut totals)?; + total = total.saturating_add(planned.spans.len()); + out.push(planned); } if total == 0 { return Err(AutoTagError::NoText); @@ -221,62 +279,36 @@ fn plan(doc: &Document) -> Result, AutoTagError> { Ok(out) } -/// Tag every text object in an untagged, text-only PDF as a paragraph. The full structure tree, -/// parent tree and marked-content IDs are written together. Mixed content is refused before any -/// mutation. On success the caller can use tag-property edits to assign reviewed heading roles. -pub fn autotag_text(doc: &mut Document) -> Result { - let plans = plan(doc)?; - let mut next = doc.clone(); - let catalog = next.root().ok_or(AutoTagError::NoCatalog)?; - let root = next.add(Object::Null); - let document = next.add(Object::Null); - let mut children = Vec::new(); - let mut numbers = Vec::new(); - let mut tagged_pages = 0usize; - let mut paragraphs = 0usize; - for page in plans { - if page.spans.is_empty() { - continue; +fn mark_ops(ops: Vec, starts: &HashMap, ends: &HashSet, artifacts: &HashMap) -> Result, AutoTagError> { + let artifact_ends: HashSet = artifacts.values().copied().collect(); + let extra = starts.len().saturating_add(artifacts.len()).saturating_mul(2); + let mut marked = Vec::with_capacity(ops.len().saturating_add(extra)); + for (at, op) in ops.into_iter().enumerate() { + let is_artifact = artifacts.contains_key(&at); + if let Some(mcid) = starts.get(&at) { + let mut properties = Dict::new(); + properties.set(b"MCID".to_vec(), Object::Int(i64::try_from(*mcid).map_err(|_| AutoTagError::TooManyPages)?)); + marked.push(Op::new("BDC", vec![Object::name("P"), Object::Dict(properties)])); + } else if is_artifact { + marked.push(Op::new("BMC", vec![Object::name("Artifact")])); } - let key = i64::try_from(tagged_pages).map_err(|_| AutoTagError::TooManyPages)?; - let mut parents = Vec::with_capacity(page.spans.len()); - let mut starts = std::collections::HashMap::new(); - let mut ends = std::collections::HashSet::new(); - for (mcid, (first, last)) in page.spans.iter().enumerate() { - let mut element = Dict::new(); - element.set(b"Type".to_vec(), Object::name("StructElem")); - element.set(b"S".to_vec(), Object::name("P")); - element.set(b"P".to_vec(), Object::Ref(document)); - element.set(b"Pg".to_vec(), Object::Ref(page.page)); - element.set(b"K".to_vec(), Object::Int(i64::try_from(mcid).map_err(|_| AutoTagError::TooManyPages)?)); - let reference = next.add(element); - parents.push(Object::Ref(reference)); - children.push(Object::Ref(reference)); - starts.insert(*first, mcid); - ends.insert(*last); - paragraphs = paragraphs.saturating_add(1); + marked.push(op); + if ends.contains(&at) || artifact_ends.contains(&at) { + marked.push(Op::new("EMC", Vec::new())); } - let mut marked = Vec::with_capacity(page.ops.len().saturating_add(page.spans.len().saturating_mul(2))); - for (at, op) in page.ops.into_iter().enumerate() { - if let Some(mcid) = starts.get(&at) { - let mut properties = Dict::new(); - properties.set(b"MCID".to_vec(), Object::Int(i64::try_from(*mcid).map_err(|_| AutoTagError::TooManyPages)?)); - marked.push(Op::new("BDC", vec![Object::name("P"), Object::Dict(properties)])); - } - marked.push(op); - if ends.contains(&at) { - marked.push(Op::new("EMC", Vec::new())); - } - } - let stream = next.add(Stream::flate(Dict::new(), &serialize_ops(&marked))); - next.update_dict(page.page, |d| { - d.set(b"Contents".to_vec(), Object::Ref(stream)); - d.set(b"StructParents".to_vec(), Object::Int(key)); - })?; - numbers.push(Object::Int(key)); - numbers.push(Object::Array(parents)); - tagged_pages += 1; } + Ok(marked) +} + +fn write_structure( + next: &mut Document, + catalog: ObjRef, + root: ObjRef, + document: ObjRef, + children: Vec, + numbers: Vec, + tagged_pages: usize, +) -> Result<(), AutoTagError> { let mut tree = Dict::new(); tree.set(b"Nums".to_vec(), Object::Array(numbers)); let parent_tree = next.add(tree); @@ -299,6 +331,50 @@ pub fn autotag_text(doc: &mut Document) -> Result { d.set(b"StructTreeRoot".to_vec(), Object::Ref(root)); d.set(b"MarkInfo".to_vec(), Object::Dict(mark_info)); })?; + Ok(()) +} + +/// Tag every text object in an untagged PDF as a paragraph and mark painted paths, shadings and +/// images outside text objects as artifacts. The full structure tree, parent tree and +/// marked-content IDs are written together. Unsupported operators are refused before any +/// mutation. On success the caller can use tag-property edits to assign reviewed heading roles. +pub fn autotag_text(doc: &mut Document) -> Result { + let plans = plan(doc)?; + let mut next = doc.clone(); + let catalog = next.root().ok_or(AutoTagError::NoCatalog)?; + let (root, document) = (next.add(Object::Null), next.add(Object::Null)); + let (mut children, mut numbers, mut tagged_pages, mut paragraphs) = (Vec::new(), Vec::new(), 0usize, 0usize); + for page in plans { + if page.spans.is_empty() && page.artifacts.is_empty() { + continue; + } + let (mut starts, mut ends) = (HashMap::new(), HashSet::new()); + if !page.spans.is_empty() { + let key = i64::try_from(tagged_pages).map_err(|_| AutoTagError::TooManyPages)?; + let mut parents = Vec::with_capacity(page.spans.len()); + for (mcid, (first, last)) in page.spans.iter().enumerate() { + let mut element = Dict::new(); + element.set(b"Type".to_vec(), Object::name("StructElem")); + element.set(b"S".to_vec(), Object::name("P")); + element.set(b"P".to_vec(), Object::Ref(document)); + element.set(b"Pg".to_vec(), Object::Ref(page.page)); + element.set(b"K".to_vec(), Object::Int(i64::try_from(mcid).map_err(|_| AutoTagError::TooManyPages)?)); + let reference = next.add(element); + parents.push(Object::Ref(reference)); + children.push(Object::Ref(reference)); + starts.insert(*first, mcid); + ends.insert(*last); + paragraphs = paragraphs.saturating_add(1); + } + next.update_dict(page.page, |d| d.set(b"StructParents".to_vec(), Object::Int(key)))?; + numbers.extend([Object::Int(key), Object::Array(parents)]); + tagged_pages += 1; + } + let marked = mark_ops(page.ops, &starts, &ends, &page.artifacts)?; + let stream = next.add(Stream::flate(Dict::new(), &serialize_ops(&marked))); + next.update_dict(page.page, |d| d.set(b"Contents".to_vec(), Object::Ref(stream)))?; + } + write_structure(&mut next, catalog, root, document, children, numbers, tagged_pages)?; *doc = next; Ok(AutoTagResult { pages: tagged_pages, paragraphs }) } diff --git a/crates/a11y/src/autotag_fields.rs b/crates/a11y/src/autotag_fields.rs index df15b3420d..c93591c1d0 100644 --- a/crates/a11y/src/autotag_fields.rs +++ b/crates/a11y/src/autotag_fields.rs @@ -57,7 +57,15 @@ pub fn tag_form_fields(doc: &mut Document) -> Result { let mut widgets = Vec::new(); // A widget listed in more than one page's /Annots is tagged once (one key, one /Form). let mut seen = std::collections::HashSet::new(); + // A shared indirect /Annots array is scanned for the first page that lists it: its widgets + // are tagged once anyway, so scanning it again for every page only repeats the work. + let mut arrays = std::collections::HashSet::new(); for page in &pages { + if let Some(array) = page.dict.get(b"Annots").and_then(Object::as_ref) + && !arrays.insert(array) + { + continue; + } let annots = page.dict.get(b"Annots").map(|a| doc.resolve(a)).as_deref().and_then(Object::as_array).cloned().unwrap_or_default(); for annot in annots { let Some(reference) = annot.as_ref() else { continue }; diff --git a/crates/a11y/src/content.rs b/crates/a11y/src/content.rs index 118b7af4e5..faf1a87bad 100644 --- a/crates/a11y/src/content.rs +++ b/crates/a11y/src/content.rs @@ -262,11 +262,13 @@ fn annotation_rect(doc: &Document, annot: &Dict) -> Option<[f64; 4]> { pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usize], scan: &Scan, tagged: bool) -> Vec { let mut out = Vec::new(); - let annots = |p: &Page| -> Vec<(ObjRef, Dict)> { + // The annotation objects are shared, never deep-copied: a reference repeated across a page's + // Annots array (or its pages) must not copy the dictionary each time. + let annots = |p: &Page| -> Vec<(ObjRef, std::sync::Arc)> { let a = p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); a.iter() - .filter_map(|x| x.as_ref().and_then(|r| doc.get(r).as_dict().cloned().map(|d| (r, d)))) - .filter(|(_, d)| d.name(b"Subtype") != Some(b"Popup")) + .filter_map(|x| x.as_ref().map(|r| (r, doc.get(r)))) + .filter(|(_, o)| o.as_dict().is_some_and(|d| d.name(b"Subtype") != Some(b"Popup"))) .collect() }; match rule { @@ -284,7 +286,8 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz Rule::TaggedAnnotations | Rule::TaggedMultimedia | Rule::TaggedFormFields => { for &p in list { let Some(page) = pages.get(p) else { continue }; - for (_, a) in annots(page) { + for (_, obj) in annots(page) { + let Some(a) = obj.as_dict() else { continue }; let st = a.name(b"Subtype").unwrap_or(b""); let applies = match rule { Rule::TaggedFormFields => st == b"Widget", @@ -299,11 +302,11 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz let pattern = if st == b"Widget" { "Form field on page {p} isn't tagged" } else { - values.push(("{}", subtype_name(&a))); + values.push(("{}", subtype_name(a))); "{} annotation on page {p} isn't tagged" }; let mut finding = Finding::worded(Some(p), pattern, values); - finding.bbox = annotation_rect(doc, &a); + finding.bbox = annotation_rect(doc, a); out.push(finding); } } @@ -345,7 +348,8 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz let mut seen = HashSet::new(); for &p in list { let Some(page) = pages.get(p) else { continue }; - for (r, a) in annots(page) { + for (r, obj) in annots(page) { + let Some(a) = obj.as_dict() else { continue }; if a.name(b"Subtype") != Some(b"Widget") { continue; } @@ -362,7 +366,7 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz "Field \"{}\" on page {p} has no description (tooltip)", vec![("{}", full_name(doc, field)), ("{p}", (p + 1).to_string())], ); - finding.bbox = annotation_rect(doc, &a); + finding.bbox = annotation_rect(doc, a); out.push(finding); } } diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index e5bc3e425a..890eb33e9f 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -142,17 +142,120 @@ fn autotag_text_says_why_it_cannot_read_a_page() { } #[test] -fn autotag_text_refuses_paint_and_does_not_modify_the_document() { +fn autotag_text_marks_vector_graphics_and_images_as_artifacts() { + let mut doc = pdf( + &[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R 7 0 R] /Count 2 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im1 6 0 R >> >> >>".into(), + stream("0 0 200 200 m 200 0 l 200 200 l 0 200 l h f 10 10 50 50 re B BT /F1 12 Tf 20 100 Td (Hello) Tj ET q 20 0 0 20 80 80 cm /Im1 Do Q"), + FONT.into(), + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{0}\nendstream".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 8 0 R >>".into(), + stream("0 0 100 100 re S"), + ], + "", + ); + let result = autotag_text(&mut doc).unwrap(); + assert_eq!(result, AutoTagResult { pages: 1, paragraphs: 1 }); + let report = check(&doc, &Options { rules: [Rule::TaggedPdf, Rule::TaggedContent].into(), pages: None }); + assert_eq!(status(&report, Rule::TaggedPdf), Status::Passed); + assert_eq!(status(&report, Rule::TaggedContent), Status::Passed); + let bytes = pdfcraft_cos::write_incremental(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(bytes)).unwrap(); + let reopened_report = check(&reopened, &Options { rules: [Rule::TaggedPdf, Rule::TaggedContent].into(), pages: None }); + assert_eq!(status(&reopened_report, Rule::TaggedPdf), Status::Passed); + assert_eq!(status(&reopened_report, Rule::TaggedContent), Status::Passed); +} + +#[test] +fn autotag_artifacts_wrap_complete_paths_and_leave_unpainted_clips_outside() { + let content = "0 0 m 10 0 l 10 10 l h f 0 0 20 20 re W B 0 0 50 50 re W* n /Shade sh BT /F1 12 Tf (Text) Tj ET 3 3 5 5 re S"; let mut doc = pdf( &[ "<< /Type /Catalog /Pages 2 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(), - stream("BT /F1 12 Tf 20 100 Td (Hello) Tj ET 0 0 50 50 re f"), + stream(content), FONT.into(), ], "", ); + autotag_text(&mut doc).unwrap(); + let bytes = pdfcraft_cos::write_incremental(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap(); + let doc = Document::open(Arc::new(bytes)).unwrap(); + let page = pdfcraft_model::pages(&doc).remove(0); + let content = doc.resolve(page.dict.get(b"Contents").unwrap()); + let Object::Stream(content) = &*content else { panic!("page stream") }; + let ops = pdfcraft_content::parse(&content.decoded_strict().unwrap()).ops; + let names: Vec<&[u8]> = ops.iter().map(|op| op.op.as_slice()).collect(); + assert_eq!( + names, + vec![ + b"BMC".as_slice(), + b"m", + b"l", + b"l", + b"h", + b"f", + b"EMC", + b"BMC", + b"re", + b"W", + b"B", + b"EMC", + b"re", + b"W*", + b"n", + b"BMC", + b"sh", + b"EMC", + b"BDC", + b"BT", + b"Tf", + b"Tj", + b"ET", + b"EMC", + b"BMC", + b"re", + b"S", + b"EMC", + ] + ); +} + +#[test] +fn autotag_rejects_text_interleaved_with_an_unfinished_path_atomically() { + for tail in ["10 10 l S", "n"] { + let mut doc = pdf( + &[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(), + stream(&format!("0 0 m BT /F1 12 Tf (Text) Tj ET {tail}")), + FONT.into(), + ], + "", + ); + let before = pdfcraft_cos::write_incremental(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap(); + assert!(matches!(autotag_text(&mut doc), Err(AutoTagError::Unsupported { .. }))); + assert_eq!(pdfcraft_cos::write_incremental(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap(), before); + } +} + +#[test] +fn autotag_text_refuses_form_xobjects_and_does_not_modify_the_document() { + let mut doc = pdf( + &[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Fm1 6 0 R >> >> >>".into(), + stream("BT /F1 12 Tf 20 100 Td (Hello) Tj ET /Fm1 Do"), + FONT.into(), + "<< /Type /XObject /Subtype /Form /BBox [0 0 50 50] /Length 12 >>\nstream\n0 0 10 10 re f\nendstream".into(), + ], + "", + ); assert!(matches!(autotag_text(&mut doc), Err(AutoTagError::Unsupported { .. }))); assert!(!doc.get(doc.root().unwrap()).as_dict().unwrap().contains(b"StructTreeRoot")); assert_eq!(autotag_text(&mut pdf(&good(), "")), Err(AutoTagError::AlreadyTagged)); @@ -1780,3 +1883,37 @@ fn autotag_text_refuses_pages_that_share_content_past_the_total_operator_allowan let Err(AutoTagError::Unsupported { reason, .. }) = autotag_text(&mut doc) else { panic!("expected a refusal") }; assert!(reason.contains("too much content"), "{reason}"); } + +#[test] +fn autotag_form_fields_tags_widgets_of_a_shared_annots_array_once() { + let mut doc = pdf( + &[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >>".into(), + "<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >>".into(), + "<< /Length 0 >>\nstream\n\nendstream".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 60 120 80] >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >>".into(), + "[5 0 R 5 0 R]".into(), + ], + "", + ); + assert_eq!(tag_form_fields(&mut doc).unwrap(), 1); +} + +#[test] +fn checker_reports_each_untagged_annotation_without_copying_shared_dictionaries() { + let doc = pdf( + &[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R 4 0 R 5 0 R] >>".into(), + "<< /Type /Annot /Subtype /Text /Rect [20 60 120 80] >>".into(), + "<< /Type /Annot /Subtype /Popup /Rect [20 60 120 80] >>".into(), + ], + "", + ); + let pages = pdfcraft_model::pages(&doc); + let found = content::page_rule(&doc, Rule::TaggedAnnotations, &pages, &[0], &Default::default(), false); + assert_eq!(found.len(), 2, "the popup is skipped, each listed text annotation reported"); +} diff --git a/crates/annot/README.md b/crates/annot/README.md index 6ab3736ed2..3c258a2ab7 100644 --- a/crates/annot/README.md +++ b/crates/annot/README.md @@ -27,7 +27,9 @@ Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends on `pdfcraft-cos`, comment another program drew with a font that has those characters, whose appearance is kept rather than redrawn with `?` (new text for it is taken). - **Edits:** reply (`/IRT`), set review status (a `/State` reply, as Acrobat's "Set status" - does), change text, move, resize, restyle, delete (with pop-up and replies). + does), change text, move, resize, restyle, delete (with pop-up and replies), and open, close + or move the pop-up window (`set_popup`: `/Popup` `/Open` and `/Rect`, created beside a comment + that has none; a note's own `/Open` follows). `summaries` report each comment's pop-up. ## API sketch diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 0083761288..e16803484a 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -8,19 +8,25 @@ //! itself; for the other values, callers that hold the document follow references first //! (`draw::with_direct_values`). //! -//! The note icons are PdfCraft's own drawings (AGENTS.md §1). Text boxes use the standard -//! Helvetica font with WinAnsi encoding, and draw Thai from the craft-fonts Thai face as Type 3 -//! glyphs (`pdfcraft_fonts::shaped_text`, the writer Add text uses); line breaking uses -//! [`text_width`], the standard-14 metrics and the Thai face's shaped advances. +//! The note icons are PdfCraft's own drawings (AGENTS.md §1). Text boxes use a standard text +//! font with WinAnsi encoding: Helvetica, or the face their `/DA` or `/DS` asks for +//! ([`crate::text_style`]), and draw Thai from the craft-fonts Thai face as Type 3 glyphs +//! (`pdfcraft_fonts::shaped_text`, the writer Add text uses); lines break at that face's +//! standard-14 widths and the Thai face's shaped advances (no other font program is bundled). use pdfcraft_cos::{Dict, Object, PdfString, Stream}; use pdfcraft_fonts::shaped_text; -use pdfcraft_fonts::{first_undrawable, helvetica_width, literal, win_ansi, wrap_with}; +use pdfcraft_fonts::{first_undrawable, literal, win_ansi, wrap_with}; /// The width of `text` at `size` as appearances draw it: Helvetica, with Thai shaped in the Thai /// face that text boxes draw it with. pub fn text_width(text: &str, size: f64) -> f64 { - shaped_text::thai_width(text, size, helvetica_width) + text_width_in(pdfcraft_fonts::Std14::Helvetica, text, size) +} + +/// [`text_width`] in the standard `face` a text box draws its other text in. +pub fn text_width_in(face: pdfcraft_fonts::Std14, text: &str, size: f64) -> f64 { + shaped_text::thai_width(text, size, |s, size| face.text_width(s, size)) } /// `text` wrapped `width` wide as [`text_width`] measures it; a Thai mark stays on its letter. @@ -410,13 +416,17 @@ pub fn da_border(d: &Dict) -> Option { col } -/// `/DA` of a text box: (text colour, font size). Defaults to black 12 pt. +/// `/DA` of a text box: (text colour, font size). Defaults to black 12 pt: a `Tf` without a +/// usable size (missing, zero, negative or not a number) keeps 12 pt, and sizes past 400 pt are +/// capped. A text box has no automatic size, so a zero size, which means auto-size in a form +/// field's `/DA` (ISO 32000-2 §12.7.4.3), is the default here. An operand that isn't a finite +/// number (`NaN`, `inf`, which Rust's parser would otherwise accept) is ignored with its operator. pub fn parse_da(d: &Dict) -> (Rgb, f64) { let da = d.get(b"DA").and_then(|o| o.as_string()).map(|s| String::from_utf8_lossy(&s.bytes).into_owned()).unwrap_or_default(); let toks: Vec<&str> = da.split_whitespace().collect(); let mut col = [0.0; 3]; let mut size = 12.0; - let num = |i: usize| toks.get(i).and_then(|t| t.parse::().ok()); + let num = |i: usize| toks.get(i).and_then(|t| t.parse::().ok()).filter(|v| v.is_finite()); for (i, t) in toks.iter().enumerate() { match *t { "rg" if i >= 3 => { @@ -544,7 +554,9 @@ pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> { b"FreeText" => return Some(("the comment's text", shaped_text::first_undrawable(&text(b"Contents")?)?)), b"Line" if !d.contains(b"PCMeasureValue") => ("the line's caption", line_caption(d)?), b"Line" | b"PolyLine" | b"Polygon" => ("the measurement label", text(b"PCMeasureValue")?), - b"Stamp" if matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) => ("the stamp's \"By\" line", text(b"PCByLine")?), + b"Stamp" if matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) || d.contains(b"PCPicture") => { + ("the stamp's \"By\" line", text(b"PCByLine")?) + } // Drawn in Helvetica when the redaction is applied: refused when it is marked. b"Redact" => ("the redaction's overlay text", text(b"OverlayText")?), _ => return None, @@ -554,18 +566,19 @@ pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> { /// `/Font` resources naming `/Helv`: standard Helvetica with WinAnsiEncoding. pub(crate) fn helvetica_resource() -> Object { - Object::Dict(helvetica_fonts()) + Object::Dict(font_resources(pdfcraft_fonts::Std14::Helvetica)) } -/// [`helvetica_resource`]'s dictionary. -fn helvetica_fonts() -> Dict { +/// `/Font` resources naming one standard face with WinAnsiEncoding, under the resource name +/// [`crate::text_style::resource_name`] gives it (`/Helv` for Helvetica). +fn font_resources(face: pdfcraft_fonts::Std14) -> Dict { let mut font = Dict::new(); font.set(b"Type".to_vec(), Object::name("Font")); font.set(b"Subtype".to_vec(), Object::name("Type1")); - font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); + font.set(b"BaseFont".to_vec(), Object::name(face.base_font())); font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); let mut fonts = Dict::new(); - fonts.set(b"Helv".to_vec(), Object::Dict(font)); + fonts.set(crate::text_style::resource_name(face).as_bytes().to_vec(), Object::Dict(font)); fonts } @@ -661,7 +674,8 @@ pub(crate) fn build_on_page(doc: &Document, d: &Dict, page: Option<[f64; 4]>) -> b"Text" => return draw_icon(&context, "Note", [1.0, 0.82, 0.0], drawing), b"FileAttachment" => return draw_icon(&context, "PushPin", [0.0, 0.47, 0.84], drawing), b"Stamp" => return draw_stamp(&context, drawing), - b"FreeText" => return draw_free_text(&context, drawing), + // The face a text box's `/DA` font names in the form's `/DR`, when it names one. + b"FreeText" => return draw_free_text(&context, drawing, crate::text_style::dr_face(doc, d)), _ => {} } let mut drawing = drawing; @@ -971,11 +985,31 @@ fn draw_markup(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) } fn draw_redaction(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; let stroke = context.stroke; let c = &mut drawing.content; // While marked: the outline of each area (the fill comes when applied). - let q = quads(context)?; - let col = stroke.unwrap_or([0.89, 0.13, 0.13]); + // ISO 32000-2 gives a mark no outline colour (§12.5.6.23, Table 195). An array `/OC` of + // three numbers is the one some writers give it, and is drawn (the observation is in + // `parity/acrobat-features.toml`); `/OC` as ISO 32000-2 defines it (Table 166) is an + // optional-content dictionary, which this leaves alone. + let outline = nums(d, b"OC").and_then(|c| match c.as_slice() { + [r, g, b] if c.iter().all(|x| x.is_finite()) => Some([r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)]), + _ => None, + }); + // Quadrilaterals are outlined in the `/OC` colour, else in `/C` as they always were. + let (q, col) = match d.get(b"QuadPoints") { + Some(_) => (quads(context)?, outline.or(stroke)), + // Without `/QuadPoints`, `/Rect` is the area (Table 195): outlined just inside its + // edges when the mark names an outline colour, else not drawn while marked. + None => { + let outline = outline?; + let rect = context.rect; + let [x0, y0, x1, y1] = [rect[0] + 0.5, rect[1] + 0.5, rect[2] - 0.5, rect[3] - 0.5]; + (vec![[x0, y1, x1, y1, x0, y0, x1, y0]], Some(outline)) + } + }; + let col = col.unwrap_or([0.89, 0.13, 0.13]); c.push_str(&format!("{}1 w\n", rg_stroke(col))); for quad in q.iter() { c.push_str(&format!( @@ -1230,7 +1264,8 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) - return Some(form(rect, c.as_bytes(), res)); } // A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills - // the rectangle. An image signature added to a turned page (`/PCPictureRotate`) gets + // the rectangle. An image signature or a stamp with a line added to a turned page + // (`/PCPictureRotate`) gets // the standard counter-rotation as its form `/Matrix`: viewers fit the turned // bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed. if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) { @@ -1239,14 +1274,61 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) - [w, h] if *w > 0.0 && *h > 0.0 => Some((*w, *h)), _ => None, }); - let place = picture_place(rect, image, size)?; + // A text line (#1741) takes the bottom of the rectangle, and the picture keeps its + // shape, centred above it. Without one the picture fills the rectangle. + let line = d.get(b"PCByLine").and_then(Object::as_string).map(PdfString::to_text).filter(|l| !l.trim().is_empty()); + let turn = picture_turn(d); + // A lined stamp on a quarter-turned page is laid out in its displayed box (the + // rectangle with its sides swapped) and turned into `/Rect` by the `/Matrix` below. + // Laid out in user space, the viewer's fit of the turned box to `/Rect` would scale + // the two displayed axes differently and squash the picture and its line. + let bbox = if line.is_some() && matches!(turn, 90 | 270) { [0.0, 0.0, rect[3] - rect[1], rect[2] - rect[0]] } else { rect }; + let [x0, y0, x1, y1] = bbox; + let band = if line.is_some() { (y1 - y0) * crate::CUSTOM_LINE_BAND } else { 0.0 }; + let (w, h) = (x1 - x0, y1 - y0 - band); + let place = match (&line, size) { + (Some(_), Some((sw, sh))) => { + let k = (w / sw).min(h / sh).max(0.0); + let (pw, ph) = (sw * k, sh * k); + let (px, py) = (x0 + (w - pw) / 2.0, y0 + band + (h - ph) / 2.0); + if image { + format!("{} 0 0 {} {} {} cm", n(pw), n(ph), n(px), n(py)) + } else { + format!("{} 0 0 {} {} {} cm", n(k), n(k), n(px), n(py)) + } + } + // Without a line the band is empty and this is the rectangle itself. + _ => picture_place([x0, y0 + band, x1, y1], image, size)?, + }; c.push_str(&format!("q {place} /Pic Do Q\n")); + // Bytes from here: the line is WinAnsi, not UTF-8. + let mut content = std::mem::take(c).into_bytes(); + if let Some(l) = &line { + // Centred in the band, at its usual size unless the stamp was made narrower. + let mut size = band * crate::CUSTOM_LINE_TEXT; + let tw = text_width(l, size); + if tw > w - 4.0 && tw > 0.0 { + size *= ((w - 4.0) / tw).max(0.0); + } + let tw = text_width(l, size); + let ty = y0 + (band - size * 0.72) / 2.0; + content.extend(format!("BT\n0.2 0.2 0.2 rg\n/Helv {} Tf\n1 0 0 1 {} {} Tm ", n(size), n(x0 + (w - tw) / 2.0), n(ty)).bytes()); + content.extend(literal(&win_ansi(l))); + content.extend_from_slice(b" Tj\nET\n"); + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("Font")); + f.set(b"Subtype".to_vec(), Object::name("Type1")); + f.set(b"BaseFont".to_vec(), Object::name("Helvetica")); + f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + let mut fonts = Dict::new(); + fonts.set(b"Helv".to_vec(), Object::Dict(f)); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + } let mut xo = Dict::new(); xo.set(b"Pic".to_vec(), Object::Ref(pic)); res.set(b"XObject".to_vec(), Object::Dict(xo)); - let mut stream = form(rect, c.as_bytes(), res); - let turn = picture_turn(d); - if image && turn != 0 { + let mut stream = form(bbox, &content, res); + if (image || line.is_some()) && turn != 0 { // The page's view matrix without its offset: display axes in user space. let m = pdfcraft_model::view_matrix_for(turn, [0.0; 4]); let matrix = [m[0], m[1], m[2], m[3], 0.0, 0.0]; @@ -1359,7 +1441,7 @@ fn shaped_border( Some(rect) } -fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option { +fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing, dr_face: Option) -> Option { let d = context.dict; let parts = context.parts; let rect = context.rect; @@ -1367,11 +1449,12 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin let mut c = std::mem::take(&mut drawing.content); let mut res = std::mem::take(&mut drawing.resources); let _tail = std::mem::take(&mut drawing.tail); - let (text_color, size) = parse_da(d); + let crate::text_style::TextStyle { face, size, color: text_color } = crate::text_style::text_style(d, dr_face); // The box's border and a callout's leader line: their own colour, else the text's. let line_color = da_border(d).unwrap_or(text_color); let bg = stroke; - let bw = if d.contains(b"BS") || d.contains(b"Border") { border_width(d) } else { 0.0 }; + // Without `/BS` or `/Border`, the `/Border` default [0 0 1]: a 1-pt border (Table 166). + let bw = border_width(d); // A callout: the leader line (arrowhead at its first point), and the text box inside // `/Rect` by `/RD`. // Text added to a turned page is drawn upright in its displayed box, which the form @@ -1469,37 +1552,38 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin c.push_str("/GS1 gs\n"); } c.push_str(&format!( - "{} {} {} {} re W n\nBT\n/Helv {} Tf\n{}", + "{} {} {} {} re W n\nBT\n/{} {} Tf\n{}", n(rect[0]), n(rect[1]), n(rect[2] - rect[0]), n(rect[3] - rect[1]), + crate::text_style::resource_name(face), n(size), rg(text_color) )); let mut out = c.into_bytes(); - let lines = wrap(&text, size, width); + let lines = pdfcraft_fonts::wrap_with(&text, size, width, |s, size| text_width_in(face, s, size)); let mut y = rect[3] - pad - size * reach(&lines)[0]; if round { y -= ((rect[3] - rect[1] - 2.0 * pad - text_lines_height(&lines, size)) / 2.0).max(0.0); } // Thai is shaped and drawn from the Thai face (Type 3 fonts in the form's resources, whose // streams `apply_appearance` stores in the document); the rest, and Thai it can't draw (as - // `?`, which `undrawable_text` reports), in Helvetica. - let mut fonts = helvetica_fonts(); + // `?`, which `undrawable_text` reports), in the box's standard face. + let mut fonts = font_resources(face); let (pieces, shaped) = shaped_text::appearance_lines(&lines, &mut fonts); let mut current = None; for pieces in &pieces { if y < rect[1] - size { break; } - let lw = shaped_text::pieces_width(pieces, size, 0.0, helvetica_width); + let lw = shaped_text::pieces_width(pieces, size, 0.0, |s, size| face.text_width(s, size)); let x = match q { 1 => rect[0] + pad + (width - lw) / 2.0, 2 => rect[2] - pad - lw, _ => rect[0] + pad, }; - shaped.show_line(&mut out, pieces, x, y, size, "Helv", win_ansi, |s| helvetica_width(s, size), &mut current); + shaped.show_line(&mut out, pieces, x, y, size, crate::text_style::resource_name(face), win_ansi, |s| face.text_width(s, size), &mut current); y -= size * 1.2; } out.extend_from_slice(b"ET\n"); diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index 1b55e48932..5af547970e 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -1,5 +1,5 @@ use super::draw::draw_appearance; -use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots}; +use super::lookup::{annot_dict, annot_ref, annots, change_annots, fill_sign_shape, page_ref}; use super::modify::{free_text_da, n, refuse_undrawable_thai, rich_text, set_part_keys, shape_text_box}; use super::types::{ FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, OverlayLook, Rgb, Shape, StampGroup, Style, @@ -204,14 +204,110 @@ fn subject(shape: &Shape) -> &'static str { pub fn callout_attach(rect: [f64; 4], knee: [f64; 2]) -> [f64; 2] { let [x0, y0, x1, y1] = normalize(rect); let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); - if knee[0] < x0 { - [x0, cy] - } else if knee[0] > x1 { - [x1, cy] - } else if knee[1] > y1 { - [cx, y1] + match callout_side(rect, knee) { + Side::Left => [x0, cy], + Side::Right => [x1, cy], + Side::Top => [cx, y1], + Side::Bottom => [cx, y0], + } +} + +/// A side of a callout's text box. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Side { + Left, + Right, + Top, + Bottom, +} + +impl Side { + /// Whether a knee line on this side is horizontal (it meets the left or right side). Acrobat + /// lets a knee line be resized along its own direction only (Acrobat DC Help, 11/28/2019, + /// p. 357). + pub fn horizontal(self) -> bool { + matches!(self, Side::Left | Side::Right) + } +} + +/// The side of the text box `rect` that faces `knee`: the one [`callout_attach`] attaches to. +/// +/// Every position has one side (review on #4094): +/// - left or right of the box (`x < x0`, `x > x1`): that side, whatever `y` is, so a knee off a +/// corner diagonally, or on the line of the top or bottom edge past the box, is beside it; +/// - otherwise above or below it (`y > y1`, `y < y0`): that side, so a knee on the line of the +/// left or right edge past the box is above or below it; +/// - on the box's outline, or within `CALLOUT_SLACK` (0.01 pt) inside it, since the page hands +/// positions back as `f32` values: the side whose edge it lies on, the left or right edge +/// first, so a corner takes the left or right side as a knee beside the box does. The line +/// stays out of the box (it was sent across the box to the bottom side); +/// - inside the box, or with a coordinate that isn't a number: the bottom side. +pub fn callout_side(rect: [f64; 4], knee: [f64; 2]) -> Side { + let [x0, y0, x1, y1] = normalize(rect); + let [x, y] = knee; + // Before any comparison: one with NaN is false, so the other coordinate alone would decide. + if x.is_nan() || y.is_nan() { + return Side::Bottom; + } + if x < x0 { + Side::Left + } else if x > x1 { + Side::Right + } else if y > y1 { + Side::Top + } else if y < y0 { + Side::Bottom + } else if x - x0 < CALLOUT_SLACK { + Side::Left + } else if x1 - x < CALLOUT_SLACK { + Side::Right + } else if y1 - y < CALLOUT_SLACK { + Side::Top } else { - [cx, y0] + Side::Bottom + } +} + +/// A callout's knee line when Acrobat lays it out: 12 pt, out from the middle of the side. Seen in +/// Acrobat Pro DC, a 2021 Continuous release: a new callout's knee line, and one laid out again +/// after its knee was dragged into the box or the side it meets was moved, are all 12 pt, and the +/// knee can't come nearer the box than that. +pub const CALLOUT_KNEE_LINE: f64 = 12.0; + +/// Where a knee goes on `side` of the text box `rect`: [`CALLOUT_KNEE_LINE`] out from the middle +/// of that side, so the knee line is level (left, right) or upright (top, bottom). +pub fn callout_knee_on(rect: [f64; 4], side: Side) -> [f64; 2] { + let [x0, y0, x1, y1] = normalize(rect); + let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); + match side { + Side::Left => [x0 - CALLOUT_KNEE_LINE, cy], + Side::Right => [x1 + CALLOUT_KNEE_LINE, cy], + Side::Top => [cx, y1 + CALLOUT_KNEE_LINE], + Side::Bottom => [cx, y0 - CALLOUT_KNEE_LINE], + } +} + +/// A new callout's knee: on the side of its text box `rect` that faces the arrow `point` +/// ([`callout_knee_on`]). +pub fn callout_knee(rect: [f64; 4], point: [f64; 2]) -> [f64; 2] { + callout_knee_on(rect, callout_side(rect, point)) +} + +/// How far apart, in points, two positions of a callout's box or line may be and still count as the +/// same. The page hands a box back as `f32` values, which moves a coordinate such as 323.05 by a few +/// hundred-thousandths of a point (review on #4094). +pub(crate) const CALLOUT_SLACK: f64 = 0.01; + +/// Whether `end` lies on `side` of the text box `rect`: the leader line can stay where it meets it. +pub(crate) fn on_side(rect: [f64; 4], side: Side, end: [f64; 2]) -> bool { + let [x0, y0, x1, y1] = normalize(rect); + let near = |a: f64, b: f64| (a - b).abs() < CALLOUT_SLACK; + let within = |v: f64, lo: f64, hi: f64| v > lo - CALLOUT_SLACK && v < hi + CALLOUT_SLACK; + match side { + Side::Left => near(end[0], x0) && within(end[1], y0, y1), + Side::Right => near(end[0], x1) && within(end[1], y0, y1), + Side::Top => near(end[1], y1) && within(end[0], x0, x1), + Side::Bottom => near(end[1], y0) && within(end[0], x0, x1), } } @@ -296,15 +392,20 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R let list = contours.iter().filter(|c| c.len() > 2).map(|c| num_array(&c.iter().flat_map(|p| [p[0], p[1]]).collect::>())).collect(); d.set(b"PCOutline".to_vec(), Object::Array(list)); } - Shape::CustomStamp { name, picture, image, size, .. } => { + Shape::CustomStamp { name, picture, image, size, line, .. } => { let clean: String = name.chars().filter(char::is_ascii_alphanumeric).collect(); d.set(b"Name".to_vec(), Object::name(&format!("PCCustom{clean}"))); d.set(b"PCPicture".to_vec(), Object::Ref(*picture)); d.set(b"PCPictureImage".to_vec(), Object::Bool(*image)); d.set(b"PCPictureSize".to_vec(), num_array(&[size.0, size.1])); + let lined = line.as_deref().map(str::trim).filter(|l| !l.is_empty()); + if let Some(l) = lined { + d.set(b"PCByLine".to_vec(), PdfString::text(l)); + } // An image signature's rectangle is in user space; on a turned page its appearance is - // turned back (see `appearance::build`), so it reads upright as displayed. - if *image && matches!(clean.as_str(), "Signature" | "Initials") { + // turned back (see `appearance::build`), so it reads upright as displayed. A stamp + // with a line is laid out in the displayed box, so it needs the turn too. + if lined.is_some() || (*image && matches!(clean.as_str(), "Signature" | "Initials")) { let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); if turn != 0 { d.set(b"PCPictureRotate".to_vec(), Object::Int(turn)); @@ -434,7 +535,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R let index = list.len(); list.push(Object::Ref(r)); add_popup(doc, r, popup, &mut list)?; - set_annots(doc, page, list)?; + change_annots(doc, page, list)?; Ok(index) } @@ -500,6 +601,22 @@ fn add_popup(doc: &mut Document, parent: ObjRef, rect: Option<[f64; 4]>, annots: Ok(()) } +/// The share of a custom stamp's height its text line takes (the rest is the picture). +pub const CUSTOM_LINE_BAND: f64 = 0.22; + +/// A custom stamp line's text height as a share of its band. +pub const CUSTOM_LINE_TEXT: f64 = 0.62; + +/// The natural size of a custom stamp whose picture is `picture` points (#1741): with `line`, +/// the band under the picture is added, and the stamp is as wide as the line at its usual size +/// when that is wider than the picture. +pub fn custom_stamp_size(picture: (f64, f64), line: Option<&str>) -> (f64, f64) { + let Some(line) = line else { return picture }; + let h = picture.1 / (1.0 - CUSTOM_LINE_BAND); + let text = appearance::text_width(line, h * CUSTOM_LINE_BAND * CUSTOM_LINE_TEXT) + 8.0; + (picture.0.max(text), h) +} + /// Counterrotate a newly placed picture stamp's appearance into displayed-page axes. /// The picture may be an image or a PDF page. The normal appearance's transformed bounding /// box is fitted to `/Rect` by PDF viewers. diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index 71a4fbfa32..e91bb94e0a 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -1,7 +1,8 @@ use super::create::finite; use super::draw::set_appearance; use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, drawn_upright, is_fill_sign, page_ref, page_refs}; -use super::modify::{fillable, touch, unlocked}; +use super::modify::{callout_line, callout_text_box, fillable, touch, unlocked}; +use super::text_style; use super::types::{BorderShape, FLAG_LOCKED, LineEnding, Meta, NoteIcon, Rgb}; use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance}; @@ -23,7 +24,8 @@ pub struct Summary { pub in_reply_to: Option, /// The page and `/Annots` position `/IRT` points at, by reference (`/NM` need not be unique). pub parent: Option<(usize, usize)>, - /// `/RT /Group`: grouped with the `/IRT` annotation (Replace Text's caret), not a reply to it. + /// `/RT /Group`: with an `/IRT` reference, a subordinate of the group whose primary that is + /// ([`is_group_subordinate`]), not a reply to it. pub grouped: bool, pub rect: [f32; 4], pub color: Option<[f32; 3]>, @@ -35,9 +37,66 @@ pub struct Summary { pub fill_sign: bool, /// The NoRotate flag (`/F` bit 5), as `pdfcraft_render::Annotation::no_rotate`. pub no_rotate: bool, - /// A line's two ends (`/L`), or the corners of connected lines, a polygon or a cloud - /// (`/Vertices`), in user space; empty for other comments. + /// A line's two ends (`/L`), the corners of connected lines, a polygon or a cloud + /// (`/Vertices`), or a callout's arrow point and knee (`/CL` without its end, which follows + /// the text box), in user space; empty for other comments. pub points: Vec<[f32; 2]>, + /// A callout's text box (`/Rect` inset by `/RD`), in user space; `None` for other comments. + pub text_box: Option<[f32; 4]>, + /// The comment's pop-up window (`/Popup`), when it has one. + pub popup: Option, +} + +impl Summary { + /// A subordinate of a group (`/RT /Group` with an `/IRT` to a listed comment, ISO 32000-2 + /// §12.5.6.2): part of its primary's comment, not a reply. As + /// `pdfcraft_render::Annotation::is_group_member`, which also drops a looping `/IRT`. + pub fn is_group_member(&self) -> bool { + self.grouped && self.parent.is_some() + } +} + +/// A comment's pop-up window (ISO 32000-2 §12.5.6.14): where it sits on the page and whether it +/// is shown open. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PopupInfo { + /// `/Rect` of the `/Popup` annotation, in user space. + pub rect: [f32; 4], + /// `/Open` of the `/Popup` annotation. + pub open: bool, +} + +/// The pop-up the comment `annot` (the object `own`, when it is one of its own) names in +/// `/Popup`, when it is the comment's: a pop-up annotation whose `/Parent`, if it has one, is a +/// reference to the comment. A `/Popup` naming another annotation, or a pop-up of another +/// comment, isn't. ISO 32000-2:2020 §12.5.6.14, Table 186: `/Parent` is "(Optional; shall be +/// an indirect reference)". +pub(crate) fn own_popup(doc: &Document, own: Option, annot: &Dict) -> Option { + let pr = annot.reference(b"Popup")?; + let is_own = doc.get(pr).as_dict().is_some_and(|pd| { + pd.name(b"Subtype") == Some(b"Popup") + && pd + .get(b"Parent") + .is_none_or(|parent| parent.as_ref().is_some_and(|parent| own.is_some_and(|own| final_ref(doc, parent) == final_ref(doc, own)))) + }); + is_own.then_some(pr) +} + +/// The comment's own pop-up (see [`own_popup`]), as [`PopupInfo`]: `None` when it has none or it +/// is unreadable. +pub(crate) fn popup_info(doc: &Document, own: Option, annot: &Dict) -> Option { + // Another annotation's rectangle and open state aren't this comment's (review on #2137). + let r = own_popup(doc, own, annot)?; + let p = doc.get(r); + let p = p.as_dict()?; + let rect: Vec = doc.resolve(p.get(b"Rect")?).as_array()?.iter().filter_map(|v| doc.resolve(v).as_f64().map(|x| x as f32)).collect(); + // Four numbers that are finite as `f32` (a huge one becomes infinite), corners in any order. + let [x0, y0, x1, y1] = rect[..] else { return None }; + if !rect.iter().all(|v| v.is_finite()) { + return None; + } + let open = p.get(b"Open").map(|o| doc.resolve(o)).is_some_and(|o| matches!(&*o, Object::Bool(true))); + Some(PopupInfo { rect: [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)], open }) } pub(crate) fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option { @@ -64,7 +123,7 @@ pub fn is_comment_subtype(subtype: &str) -> bool { /// Where a reference to a reference (`5 0 obj 6 0 R endobj`) ends, as `pdfcraft_render::inspect` /// follows it, within a few steps (a cycle stops there). -fn final_ref(doc: &Document, mut r: ObjRef) -> ObjRef { +pub(crate) fn final_ref(doc: &Document, mut r: ObjRef) -> ObjRef { for _ in 0..8 { match &*doc.get(r) { Object::Ref(next) => r = *next, @@ -74,6 +133,15 @@ fn final_ref(doc: &Document, mut r: ObjRef) -> ObjRef { r } +/// Whether an annotation is a subordinate of a group rather than a reply: `/RT /Group` with an +/// `/IRT` reference (ISO 32000-2 §12.5.6.2, Table 172). The group's primary is the `/IRT` +/// annotation, and the subordinate's own text, author, colour, dates and pop-up are ignored. +/// `/RT` is `R` by default, and a value other than `Group` reads as `R` too. Keep in step with +/// `pdfcraft_render::inspect`. +pub fn is_group_subordinate(d: &Dict) -> bool { + d.name(b"RT") == Some(b"Group") && d.reference(b"IRT").is_some() +} + /// Every comment (see [`is_comment_subtype`]), ordered by page and then top edge. pub fn summaries(doc: &Document) -> Vec { let mut out = Vec::new(); @@ -130,15 +198,29 @@ pub fn summaries(doc: &Document) -> Vec { pdfcraft_geom::quad_points::usable_quads(entries).into_iter().map(|q| q.map(|v| v as f32)).collect() }) .unwrap_or_default(); - // Keep in step with `pdfcraft_render::inspect`. + // Keep in step with `pdfcraft_render::inspect`: a callout gives its points only with a + // readable text box, without which they can't be changed either. + let callout = (subtype == "FreeText").then(|| callout_line(doc, d).zip(callout_text_box(doc, d))).flatten(); let points = match subtype.as_str() { "Line" => Some(nums(b"L")).filter(|l| l.len() == 4).map(|l| l.as_chunks::<2>().0.to_vec()).unwrap_or_default(), "PolyLine" | "Polygon" => nums(b"Vertices").as_chunks::<2>().0.to_vec(), + "FreeText" => callout + .as_ref() + .and_then(|(line, _)| line.split_last()) + .map(|(_, lead)| lead.iter().map(|p| [p[0] as f32, p[1] as f32]).collect()) + .unwrap_or_default(), _ => Vec::new(), }; + let text_box = callout.map(|(_, t)| t.map(|v| v as f32)); let irt = d.get(b"IRT").map(|o| o.as_ref().map_or_else(|| doc.resolve(o), |r| doc.get(final_ref(doc, r)))); let in_reply_to = irt.and_then(|o| o.as_dict().cloned()).and_then(|p| text_value(doc, &p, b"NM")); let parent = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| slots.get(&final_ref(doc, r)).copied()); + // A group is one page's, as Acrobat reads it: a subordinate whose primary is on + // another page is a comment of its own (keep in step with + // `pdfcraft_render::link_replies`). + let grouped = d.name(b"RT") == Some(b"Group"); + let cross_page = grouped && parent.is_some_and(|(p, _)| p != page); + let (parent, in_reply_to) = if cross_page { (None, None) } else { (parent, in_reply_to) }; out.push(Summary { page, index, @@ -151,7 +233,7 @@ pub fn summaries(doc: &Document) -> Vec { name: text_value(doc, d, b"NM"), in_reply_to, parent, - grouped: d.name(b"RT") == Some(b"Group"), + grouped, rect, color, // A reply that gives only `/StateModel` has the model's default state (ISO 32000-2 @@ -167,6 +249,8 @@ pub fn summaries(doc: &Document) -> Vec { fill_sign: is_fill_sign(doc, d), no_rotate: drawn_upright(doc, d), points, + text_box, + popup: popup_info(doc, entry.as_ref().map(|r| final_ref(doc, r)), d), }); } } @@ -256,10 +340,18 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { let direct = crate::draw::with_direct_values(doc, raw); let d = direct.as_ref(); let subtype = String::from_utf8_lossy(d.name(b"Subtype")?).into_owned(); - let color = if subtype == "FreeText" { Some(appearance::parse_da(d).0) } else { drawn_color(d, b"C") }; + if !is_comment_subtype(&subtype) { + return None; + } + // A text box reports the colour and size it is drawn in (`/DA`, or `/DS` where it sets one). + let style = (subtype == "FreeText").then(|| text_style::text_style(d, None)); + let color = match &style { + Some(s) => Some(s.color), + None => drawn_color(d, b"C"), + }; let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine" | "FreeText").then(|| { if subtype == "FreeText" { - if !d.contains(b"BS") && !d.contains(b"Border") { 0.0 } else { appearance::border_width(d) } + appearance::border_width(d) } else { d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0) } @@ -276,7 +368,7 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { locked: annotation_flags(doc, d) & FLAG_LOCKED != 0, fillable: fillable(&subtype), fill: fill_of(d), - font_size: (subtype == "FreeText").then(|| appearance::parse_da(d).1), + font_size: style.map(|s| s.size), border_color: (subtype == "FreeText").then(|| appearance::da_border(d)).flatten(), border_shape: (subtype == "FreeText" && !d.contains(b"CL")).then(|| BorderShape::of(d)), fill_opacity: fillable(&subtype).then(|| appearance::part_opacity(d).fill), diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 52c0aad74f..63ff5a5be0 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -29,9 +29,12 @@ mod types; #[cfg(test)] use create::num_array; -pub use create::{add_annotation, callout_attach, orient_image_stamp}; +pub use create::{ + CALLOUT_KNEE_LINE, CUSTOM_LINE_BAND, CUSTOM_LINE_TEXT, Side, add_annotation, callout_attach, callout_knee, callout_knee_on, callout_side, + custom_stamp_size, orient_image_stamp, +}; pub use draw::{AppearanceCache, set_appearance, with_missing_appearances, with_missing_appearances_cached}; -pub use info::{Props, Summary, is_comment_subtype, props, set_info, summaries}; +pub use info::{PopupInfo, Props, Summary, is_comment_subtype, is_group_subordinate, props, set_info, summaries}; #[cfg(test)] use lookup::{ANNOTS_ENTRIES_COPIED, PageWalk, annot_dict, annot_ref}; use lookup::{annots, page_ref}; @@ -41,9 +44,9 @@ pub use lookup::{ pub use model3d::{Model3d, Model3dKind, model_3d, model_3d_annotations}; pub(crate) use modify::n; pub use modify::{ - add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, - set_border_color, set_border_shape, set_contents, set_fill, set_font_size, set_locked, set_marked, set_part_opacity, set_points, set_rect, - set_review_state, set_style, with_replies_and_popups, + add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, release_members, + set_border_color, set_border_shape, set_contents, set_fill, set_font_size, set_locked, set_marked, set_part_opacity, set_points, set_popup, + set_rect, set_review_state, set_style, with_replies_and_popups, }; pub use threed::{Activation as Activation3d, MAX_MODEL_BYTES, ModelFormat, NewModel3d, PosterPicture, add_3d_model, check_model}; pub use types::{ @@ -53,6 +56,7 @@ pub use types::{ pub mod appearance; pub mod links; +pub mod text_style; #[cfg(test)] mod tests; diff --git a/crates/annot/src/lookup.rs b/crates/annot/src/lookup.rs index 9af83b0542..788774c428 100644 --- a/crates/annot/src/lookup.rs +++ b/crates/annot/src/lookup.rs @@ -1,4 +1,4 @@ -use super::info::text_value; +use super::info::{is_comment_subtype, text_value}; use super::types::{FLAG_HIDDEN, FLAG_NO_ROTATE, FLAG_NO_VIEW, Shape}; use super::{AnnotError, Dict, Document, ObjRef, Object, appearance}; @@ -89,6 +89,56 @@ thread_local! { pub(crate) static ANNOTS_ENTRIES_COPIED: std::cell::Cell = const { std::cell::Cell::new(0) }; } +/// Apple Markup's private copy of a page's markup: on the page and on each of its annotations. +const APPLE_PAGE_MARKUP: [&[u8]; 2] = [b"AAPL:PPK", b"AAPL:PPKHash"]; +const APPLE_ANNOT_MARKUP: &[u8] = b"AAPL:AKExtras"; + +/// Drop Apple Markup's private copy of the page's markup once the annotation list changes. iOS +/// draws Markup annotations from that copy, which no longer matches `/Annots`: iOS 26 showed a +/// Markup signature in its own place and again over a signature added here. Without it, Apple +/// draws every annotation from its appearance, as other viewers do. The drop is recorded in the +/// document's repair log (iOS can no longer re-edit those strokes as live ink). +fn drop_apple_markup(doc: &mut Document, page: ObjRef, list: &mut [Object]) -> Result<(), AnnotError> { + let mut dropped = false; + if doc.get(page).as_dict().is_some_and(|d| APPLE_PAGE_MARKUP.iter().any(|k| d.contains(k))) { + doc.update_dict(page, |d| { + for k in APPLE_PAGE_MARKUP { + d.remove(k); + } + })?; + dropped = true; + } + for entry in list { + match entry { + Object::Dict(d) if d.contains(APPLE_ANNOT_MARKUP) => { + d.remove(APPLE_ANNOT_MARKUP); + dropped = true; + } + Object::Ref(r) if doc.get(*r).as_dict().is_some_and(|d| d.contains(APPLE_ANNOT_MARKUP)) => { + doc.update_dict(*r, |d| { + d.remove(APPLE_ANNOT_MARKUP); + })?; + dropped = true; + } + _ => {} + } + } + if dropped { + doc.note_repair( + "dropped Apple Markup's private copy of a page's annotations after the page changed; iOS draws them from their appearance instead of as editable ink" + .into(), + ); + } + Ok(()) +} + +/// Replace a page's `/Annots` after an annotation was added, removed or answered: the point at +/// which Apple Markup's private copy of the page goes stale (see [`drop_apple_markup`]). +pub(crate) fn change_annots(doc: &mut Document, page: ObjRef, mut list: Vec) -> Result<(), AnnotError> { + drop_apple_markup(doc, page, &mut list)?; + Ok(set_annots(doc, page, list)?) +} + /// Replace a page's `/Annots`, writing into the shared array object when it is indirect. An empty /// list removes a direct `/Annots`. pub fn set_annots(doc: &mut Document, page: ObjRef, list: Vec) -> Result<(), pdfcraft_cos::CosError> { @@ -106,16 +156,23 @@ pub fn set_annots(doc: &mut Document, page: ObjRef, list: Vec) -> Result Ok(()) } -/// The annotation at `(page, index)` as an indirect object (inline dictionaries are promoted). +/// Whether the annotation dictionary `d` has a comment `/Subtype` ([`is_comment_subtype`]). +pub(crate) fn is_comment_dict(d: &Dict) -> bool { + d.name(b"Subtype").is_some_and(|s| is_comment_subtype(&String::from_utf8_lossy(s))) +} + +/// The comment annotation at `(page, index)` as an indirect object (inline dictionaries are promoted). pub(crate) fn annot_ref(doc: &mut Document, page: usize, index: usize) -> Result<(ObjRef, ObjRef), AnnotError> { let p = page_ref(doc, page)?; let mut list = annots(doc, p); let entry = list.get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?; let r = match entry { - Object::Ref(r) if doc.get(r).as_dict().is_some() => r, - Object::Dict(d) => { + Object::Ref(r) if doc.get(r).as_dict().is_some_and(is_comment_dict) => r, + Object::Dict(d) if is_comment_dict(&d) => { let r = doc.add(Object::Dict(d)); - list[index] = Object::Ref(r); + if let Some(slot) = list.get_mut(index) { + *slot = Object::Ref(r); + } set_annots(doc, p, list)?; r } diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 153be15346..ae2222b387 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -1,15 +1,17 @@ use super::create::{ - add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, ink_bounds, normalize, num_array, polygon_rect, - rect_for, rgb, + CALLOUT_SLACK, add_annotation, base_dict, bounds, callout_attach, callout_knee, callout_side, ending_draws, ending_pair, finite, grow, + ink_bounds, normalize, num_array, on_side, polygon_rect, rect_for, rgb, }; use super::draw::{draw_appearance, redraw_or_restore, set_appearance}; -use super::info::{line_endings_of, text_value}; -use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, page_ref, set_annots}; +use super::info::{is_comment_subtype, is_group_subordinate, line_endings_of, own_popup, popup_info, text_value}; +use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, change_annots, is_fill_sign, page_ref, set_annots}; +use super::text_style::{self, TextStyle}; use super::types::{ BorderShape, FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb, Shape, Style, StyleChange, TextReplacement, }; use super::{AnnotError, Dict, Document, HashMap, HashSet, ObjRef, Object, PdfString, appearance}; +use pdfcraft_fonts::Std14; /// A comment another program drew, whose text the standard fonts can't draw. Redrawing it would /// replace those characters with `?`, so the edit is refused before the dictionary changes. @@ -44,11 +46,36 @@ pub(crate) fn n(v: f64) -> String { // ── edits ─────────────────────────────────────────────────────────────────────────────────── -/// A text box's rich text (§12.7.4.3): `/DS` (default style) and `/RC` (XHTML) written from its -/// plain `/Contents`, `/DA` and `/Q`, so viewers that edit rich text show the same thing. +/// [`rich_text_in`] for a text box whose `/DA` was just written (in Helvetica, as PdfCraft writes it). pub(crate) fn rich_text(d: &mut Dict) { + let (color, size) = appearance::parse_da(d); + rich_text_in(d, TextStyle { face: Std14::Helvetica, size, color }); +} + +/// Give a text box the text `style`: `/DA` keeps the font resource it names (`/Helv` when it names +/// none) and the border colour it gives (`RG`, [`appearance::da_border`]) with the new colour and +/// size, and `/DS` and `/RC` carry the face, so the text keeps the face it is drawn in (ISO +/// 32000-2 Table 177). +fn restyle_text(d: &mut Dict, style: TextStyle) { + let border = appearance::da_border(d); + restyle_text_bordered(d, style, border); +} + +/// [`restyle_text`] with the border colour `border` (`None`: the text's). +fn restyle_text_bordered(d: &mut Dict, style: TextStyle, border: Option) { + let font = text_style::da_font_name(d).unwrap_or_else(|| "Helv".into()); + let [r, g, b] = style.color.map(|x| x.clamp(0.0, 1.0)); + let stroke = border.map(|c| c.map(|x| x.clamp(0.0, 1.0))).map(|[r, g, b]| format!("{} {} {} RG ", n(r), n(g), n(b))).unwrap_or_default(); + d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg {stroke}/{font} {} Tf", n(r), n(g), n(b), n(style.size)).into_bytes())); + rich_text_in(d, TextStyle { color: [r, g, b], ..style }); +} + +/// A text box's rich text (§12.7.4.3): `/DS` (default style) and `/RC` (XHTML) written from its +/// plain `/Contents`, its text `style` and `/Q`, so viewers that edit rich text show the same thing. +fn rich_text_in(d: &mut Dict, style: TextStyle) { let text = d.get(b"Contents").and_then(|c| c.as_string()).map(PdfString::to_text).unwrap_or_default(); - let (col, size) = appearance::parse_da(d); + let TextStyle { face, size, color: col } = style; + let (family, weight, slant) = text_style::css(face); let hex = format!( "#{:02X}{:02X}{:02X}", (col[0].clamp(0.0, 1.0) * 255.0).round() as u8, @@ -63,13 +90,15 @@ pub(crate) fn rich_text(d: &mut Dict) { let esc = |s: &str| s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """); let body: String = text.split('\n').map(|line| format!("

{}

", esc(line.trim_end_matches('\r')))).collect(); let style = format!( - "font-size:{}pt;text-align:{align};color:{hex};font-weight:normal;font-style:normal;font-family:Helvetica;font-stretch:normal", + "font-size:{}pt;text-align:{align};color:{hex};font-weight:{weight};font-style:{slant};font-family:{family};font-stretch:normal", n(size) ); let rc = format!( "{body}" ); - d.set(b"DS".to_vec(), PdfString::text(&format!("font: Helvetica {}pt; text-align:{align}; color:{hex}", n(size)))); + // "font: Helvetica 12pt" for PdfCraft's own boxes; other faces add their slant and weight. + let keywords = format!("{}{}", if slant == "normal" { "" } else { "italic " }, if weight == "normal" { "" } else { "bold " }); + d.set(b"DS".to_vec(), PdfString::text(&format!("font: {keywords}{family} {}pt; text-align:{align}; color:{hex}", n(size)))); d.set(b"RC".to_vec(), PdfString::text(&rc)); } @@ -80,33 +109,66 @@ pub(crate) fn touch(d: &mut Dict, meta: &Meta) { } /// Delete a comment together with its pop-up and its replies (and theirs), as Acrobat does. -/// Refused while it, or a reply that would go with it, is locked. +/// Refused while it, or a reply that would go with it, is locked. A member of a group (`/RT +/// /Group`), its primary included, goes alone, as Acrobat deletes the member clicked and keeps the +/// rest of its group, although ISO 32000-2 §12.5.6.2 lists deletion among the operations that act +/// on the entire group; a subordinate left without its primary is a comment of its own and +/// loses its `/IRT` ([`release_members`]). Replace Text's strikeout and caret are one comment and +/// go together from either ([`own_deletion`]). pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Result<(), AnnotError> { - deletable(doc, page, index)?; - lift_annotation(doc, page, index) + let index = deleted_index(doc, page, index)?; + lift_annotation(doc, page, index)?; + // Not in lift_annotation: a drag preview lifts on a throwaway copy and must not log a repair. + let p = page_ref(doc, page)?; + change_annots(doc, p, annots(doc, p)) } /// Whether [`delete_annotation`] deletes the annotation at `(page, index)`, or why not, without /// changing anything: what a Delete command asks before it is offered. It is refused while the /// annotation is locked, or a reply that would go with it is. pub fn deletable(doc: &Document, page: usize, index: usize) -> Result<(), AnnotError> { + deleted_index(doc, page, index).map(|_| ()) +} + +/// [`deletable`], returning the index [`delete_annotation`] deletes from: the caret for Replace +/// Text's strikeout, else `index`. +fn deleted_index(doc: &Document, page: usize, index: usize) -> Result { let list = annots(doc, page_ref(doc, page)?); - let target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; + let mut target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; + let mut index = index; + let target_obj = target.as_ref().map_or_else(|| doc.resolve(target), |r| doc.get(super::info::final_ref(doc, r))); + if !target_obj.as_dict().is_some_and(super::lookup::is_comment_dict) { + return Err(AnnotError::NoSuchAnnotation { page, index }); + } if let Some(r) = target.as_ref() { unlocked(doc, r)?; } + // Replace Text is one comment: its strikeout goes with its caret, the group's primary, and + // the caret with it, as Acrobat deletes the pair from either. + let caret = doc + .resolve(target) + .as_dict() + .filter(|d| is_group_subordinate(d) && !own_deletion(doc, d)) + .and_then(|d| d.reference(b"IRT")) + .and_then(|irt| list.iter().enumerate().find(|(_, e)| e.as_ref() == Some(irt))); + if let Some((i, e)) = caret { + (target, index) = (e, i); + } let doomed = with_replies_and_popups(doc, &list, seed(doc, target)); // A locked reply can't be deleted on its own, so it doesn't go with the comment or reply it - // answers either. Pop-ups follow their comment. - for (_, e) in list.iter().enumerate().filter(|(i, e)| *i != index && goes_with(e, &doomed)) { + // answers either (ISO 32000-2 §12.5.3, Table 167: Locked); nor does the other half of Replace + // Text. Pop-ups follow their comment. + for (i, e) in list.iter().enumerate().filter(|(i, e)| (*i != index || caret.is_some()) && goes_with(doc, e, &doomed)) { let obj = doc.resolve(e); if let Some(d) = obj.as_dict().filter(|d| d.name(b"Subtype") != Some(b"Popup".as_slice())) && annotation_flags(doc, d) & FLAG_LOCKED != 0 { - return Err(AnnotError::Invalid("a reply to it is locked".into())); + let grouped = is_group_subordinate(d) || caret.is_some_and(|(c, _)| c == i); + let what = if grouped { "a comment in its group is locked" } else { "a reply to it is locked" }; + return Err(AnnotError::Invalid(what.into())); } } - Ok(()) + Ok(index) } /// Take the annotation at `(page, index)` out of its page with its pop-up and replies, as @@ -117,10 +179,42 @@ pub fn lift_annotation(doc: &mut Document, page: usize, index: usize) -> Result< let list = annots(doc, p); let target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; let doomed = with_replies_and_popups(doc, &list, seed(doc, target)); - let kept: Vec = list.iter().enumerate().filter(|(i, e)| *i != index && !goes_with(e, &doomed)).map(|(_, e)| e.clone()).collect(); + let kept: Vec = list.iter().enumerate().filter(|(i, e)| *i != index && !goes_with(doc, e, &doomed)).map(|(_, e)| e.clone()).collect(); + let kept = release_members(doc, kept, &doomed)?; Ok(set_annots(doc, p, kept)?) } +/// The `/Annots` entries `kept` after deleting `going`, with every group subordinate that stays +/// ([`own_deletion`]) but whose `/IRT` names an annotation going made a comment of its own: its +/// `/IRT` is removed and its `/RT /Group` kept, as Acrobat does when a group's primary is deleted. +/// A deleted primary then stays reachable from nothing, so a full save leaves its text out. An +/// inline entry is rewritten in the list; a referenced one is updated in place. Each entry is +/// read once, following no chain. +pub fn release_members(doc: &mut Document, kept: Vec, going: &HashSet) -> Result, pdfcraft_cos::CosError> { + let orphaned = |d: &Dict| is_group_subordinate(d) && d.reference(b"IRT").is_some_and(|r| going.contains(&r)); + let mut out = Vec::with_capacity(kept.len()); + for e in kept { + match e.as_ref() { + Some(r) => { + if doc.get(r).as_dict().is_some_and(orphaned) { + doc.update_dict(r, |d| { + d.remove(b"IRT"); + })?; + } + out.push(e); + } + None => match e { + Object::Dict(mut d) if orphaned(&d) => { + d.remove(b"IRT"); + out.push(Object::Dict(d)); + } + other => out.push(other), + }, + } + } + Ok(out) +} + /// What deleting the `/Annots` entry `target` starts from: its reference, or for an inline /// comment (which has none to collect) the pop-up it names, which still goes. The strikeout of a /// Replace Text proposal also takes the caret it is grouped under, so the two go together @@ -144,16 +238,35 @@ fn replaced_by(doc: &Document, strike: &Object) -> Option { /// one of them, or it is written inline (a file can, though the standard asks for references) and /// replies to (`/IRT`) or pops up for (`/Parent`) one of them. An inline entry has no reference /// for the walk to collect, so it is matched here. -pub fn goes_with(entry: &Object, going: &HashSet) -> bool { +pub fn goes_with(doc: &Document, entry: &Object, going: &HashSet) -> bool { match entry.as_ref() { Some(r) => going.contains(&r), - None => entry.as_dict().is_some_and(|d| [d.reference(b"IRT"), d.reference(b"Parent")].into_iter().flatten().any(|r| going.contains(&r))), + None => entry.as_dict().is_some_and(|d| hangs_off(doc, d).into_iter().flatten().any(|r| going.contains(&r))), } } +/// What the annotation `d` goes with when it is deleted: what it replies to (`/IRT`) and what it +/// pops up for (`/Parent`). A group subordinate (`/RT /Group`) is deleted on its own +/// ([`own_deletion`]), so its `/IRT` doesn't count. +fn hangs_off(doc: &Document, d: &Dict) -> [Option; 2] { + [d.reference(b"IRT").filter(|_| !own_deletion(doc, d)), d.reference(b"Parent")] +} + +/// Whether the annotation `d` stays when its group's primary is deleted: a group subordinate +/// (`/RT /Group`) does, as Acrobat deletes only the member clicked, unless it is half of Replace +/// Text, which Acrobat deletes as one comment: the strikeout (`/IT /StrikeOutTextEdit`) under its +/// caret, or a caret under such a strikeout, as earlier PdfCraft builds wrote the pair. +fn own_deletion(doc: &Document, d: &Dict) -> bool { + let edit = |d: &Dict| d.name(b"IT") == Some(b"StrikeOutTextEdit".as_slice()); + let under_edit = + || d.name(b"Subtype") == Some(b"Caret".as_slice()) && d.reference(b"IRT").is_some_and(|r| doc.get(r).as_dict().is_some_and(edit)); + is_group_subordinate(d) && !edit(d) && !under_edit() +} + /// `doomed` and what goes with it, as Acrobat deletes a comment: the entries of `list` (one or /// more pages' `/Annots`) that reply to (`/IRT`) or pop up for (`/Parent`) anything going, their -/// own replies, and the pop-ups going annotations name (`/Popup`). An inline reply has no +/// own replies, and the pop-ups going annotations name (`/Popup`). A group subordinate stays +/// ([`own_deletion`]). An inline reply has no /// reference to collect, but the pop-up it names is. Each annotation is visited once, so a long or /// cyclic chain costs no more than its length. Filter a list with [`goes_with`], which also /// matches inline entries. @@ -165,7 +278,7 @@ pub fn with_replies_and_popups(doc: &Document, list: &[Object], doomed: impl Int // What goes with the annotation this entry hangs off: the entry, or for an inline one the // pop-up it names. let Some(goes) = e.as_ref().or_else(|| d.reference(b"Popup")) else { continue }; - for on in [d.reference(b"IRT"), d.reference(b"Parent")].into_iter().flatten() { + for on in hangs_off(doc, d).into_iter().flatten() { hanging.entry(on).or_default().push(goes); } } @@ -186,15 +299,17 @@ pub fn with_replies_and_popups(doc: &Document, list: &[Object], doomed: impl Int /// around its text stays snug around the new text, centred where it was. pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, meta: &Meta) -> Result<(), AnnotError> { let (_, r) = annot_ref(doc, page, index)?; - let d = annot_dict(doc, r); - let free_text = d.name(b"Subtype") == Some(b"FreeText"); + let before = annot_dict(doc, r); + let free_text = before.name(b"Subtype") == Some(b"FreeText"); refuse_undrawable_thai(free_text, text)?; - let snug = free_text && is_snug(&d); + let snug = free_text && is_snug(&before); + // The text keeps the look it is drawn with (`/DA`, or `/DS` where it sets one). + let style = text_style::text_style(&before, text_style::dr_face(doc, &before)); doc.update_dict(r, |d| { d.set(b"Contents".to_vec(), PdfString::text(text)); // The rich-text version follows the new plain text (text boxes), or would contradict it. if free_text { - rich_text(d); + rich_text_in(d, style); } else { d.remove(b"RC"); } @@ -227,7 +342,9 @@ pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, m fn fitted_box(doc: &Document, r: ObjRef, text: &str) -> Option<[f64; 4]> { let d = annot_dict(doc, r); let nums = |key: &[u8]| -> Option> { d.get(key)?.as_array()?.iter().map(|o| o.as_f64()).collect() }; - let (_, size) = appearance::parse_da(&d); + // Measured in the face and size the text is drawn in (`/DA`, or `/DS` where it sets one). + let TextStyle { face, size, .. } = text_style::text_style(&d, text_style::dr_face(doc, &d)); + let measure = |s: &str, size: f64| appearance::text_width_in(face, s, size); let pad = 2.0 + border_width_of(&d); let rect = nums(b"Rect").filter(|v| v.len() == 4 && v.iter().all(|x| x.is_finite()))?; let rect = [rect[0], rect[1], rect[2], rect[3]]; @@ -245,10 +362,10 @@ fn fitted_box(doc: &Document, r: ObjRef, text: &str) -> Option<[f64; 4]> { w } else { // No usable old width: fall back to the creation-time width (at most 300 pt). - let longest = text.lines().map(|l| appearance::text_width(l, size)).fold(0.0, f64::max); + let longest = text.lines().map(|l| measure(l, size)).fold(0.0, f64::max); (longest + 2.0 * pad + 4.0).clamp(40.0, 300.0) }; - let height = appearance::text_lines_height(&appearance::wrap(text, size, (w - 2.0 * pad).max(1.0)), size); + let height = appearance::text_lines_height(&pdfcraft_fonts::wrap_with(text, size, (w - 2.0 * pad).max(1.0), measure), size); Some([x0, top - height - 2.0 * pad - 2.0, x0 + w, top]) } @@ -315,7 +432,7 @@ fn reply( let mut list = annots(doc, p); list.push(Object::Ref(r)); let i = list.len() - 1; - set_annots(doc, p, list)?; + change_annots(doc, p, list)?; Ok(i) } @@ -328,6 +445,83 @@ pub(crate) fn unlocked(doc: &Document, r: ObjRef) -> Result<(), AnnotError> { if flags(doc, r) & FLAG_LOCKED != 0 { Err(AnnotError::Invalid("the comment is locked".into())) } else { Ok(()) } } +/// Open, close or move a comment's pop-up window (Acrobat: double-click a comment to open it, +/// drag its title bar to move it). `rect` is the pop-up's new `/Rect` in user space; `open` its +/// `/Open`. A comment without a `/Popup` gets one, placed to the right of the comment as new +/// notes get theirs. A text box, a Fill & Sign mark or a reply has no pop-up of its own (a reply +/// shows in its parent's). The lock does not apply: the window's place and state are not the +/// comment. The modification date is left alone: nothing of the comment changed. +pub fn set_popup(doc: &mut Document, page: usize, index: usize, rect: Option<[f64; 4]>, open: Option, meta: &Meta) -> Result<(), AnnotError> { + let (p, r) = annot_ref(doc, page, index)?; + let d = annot_dict(doc, r); + let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); + if !is_comment_subtype(&subtype) || subtype == "FreeText" || is_fill_sign(doc, &d) || d.contains(b"IRT") { + return Err(AnnotError::Invalid(format!("{subtype} comments have no pop-up of their own"))); + } + let rect = match rect { + Some(rect) => { + let rect = normalize(rect); + if !finite(&rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { + return Err(AnnotError::Invalid("invalid pop-up rectangle".into())); + } + Some(rect) + } + None => None, + }; + let _ = meta; + // Where a new pop-up goes: to the right of the comment, as new notes' do. + let own: Vec = d + .get(b"Rect") + .map(|o| doc.resolve(o)) + .and_then(|o| o.as_array().cloned()) + .unwrap_or_default() + .iter() + .filter_map(|v| doc.resolve(v).as_f64()) + .collect(); + let beside = match own[..] { + [_, _, x1, y1] if finite(&own) => [x1 + 10.0, y1 - 120.0, x1 + 210.0, y1], + _ => [0.0, 0.0, 200.0, 120.0], + }; + // Only a pop-up that is this comment's: `/Popup` naming another annotation (or a pop-up + // whose `/Parent` is another comment) would move or open that one, so it gets its own. + match own_popup(doc, Some(r), &d) { + Some(pr) => { + // One without a `/Rect` of four numbers reads as no pop-up (`summaries`), so it gets + // the new pop-up's place: opening it then shows it. + let rect = rect.or_else(|| popup_info(doc, Some(r), &d).is_none().then_some(beside)); + doc.update_dict(pr, |p| { + if let Some(rect) = rect { + p.set(b"Rect".to_vec(), num_array(&rect)); + } + if let Some(open) = open { + p.set(b"Open".to_vec(), Object::Bool(open)); + } + })? + } + None => { + let mut pd = Dict::new(); + pd.set(b"Type".to_vec(), Object::name("Annot")); + pd.set(b"Subtype".to_vec(), Object::name("Popup")); + pd.set(b"Rect".to_vec(), num_array(&rect.unwrap_or(beside))); + pd.set(b"Parent".to_vec(), Object::Ref(r)); + pd.set(b"Open".to_vec(), Object::Bool(open.unwrap_or(false))); + pd.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); + let pref = doc.add(Object::Dict(pd)); + doc.update_dict(r, |d| d.set(b"Popup".to_vec(), Object::Ref(pref)))?; + let mut list = annots(doc, p); + list.push(Object::Ref(pref)); + set_annots(doc, p, list)?; + } + } + // A note's own `/Open` says the same thing to viewers that read it there (§12.5.6.4). + if let Some(open) = open + && (subtype == "Text" || d.contains(b"Open")) + { + doc.update_dict(r, |d| d.set(b"Open".to_vec(), Object::Bool(open)))?; + } + Ok(()) +} + /// Lock or unlock a comment (the Locked flag). A locked comment can't be moved, resized, /// restyled or deleted; its text and replies stay editable, as in Acrobat. pub fn set_locked(doc: &mut Document, page: usize, index: usize, locked: bool) -> Result<(), AnnotError> { @@ -358,7 +552,7 @@ pub fn set_marked(doc: &mut Document, page: usize, index: usize, marked: bool, a let mut list = annots(doc, p); list.push(Object::Ref(r)); let i = list.len() - 1; - set_annots(doc, p, list)?; + change_annots(doc, p, list)?; Ok(i) } @@ -612,10 +806,12 @@ pub fn set_border_shape(doc: &mut Document, page: usize, index: usize, shape: Bo /// At most this many points reshape a comment (a drawn polygon has a handful). const MAX_POINTS: usize = 100_000; -/// Reshape a line, connected lines, a polygon or a cloud: its points in user space (a line's two -/// ends, the others' corners), as dragging the comment's handles sets them. `/Rect` follows the -/// points with room for the line's width and endings, as when the comment was drawn, and the -/// appearance is redrawn. A measurement keeps the points it measured. +/// Reshape a line, connected lines, a polygon, a cloud or a callout: its points in user space (a +/// line's two ends, the others' corners; a callout's arrow point and, if it has one, its knee), as +/// dragging the comment's handles sets them. `/Rect` follows the points with room for the line's +/// width and endings, as when the comment was drawn, and the appearance is redrawn. A callout's +/// text box stays where it is and its leader line re-attaches. A measurement keeps the points it +/// measured. pub fn set_points(doc: &mut Document, page: usize, index: usize, points: &[[f64; 2]], meta: &Meta) -> Result<(), AnnotError> { let (_, r) = annot_ref(doc, page, index)?; unlocked(doc, r)?; @@ -626,6 +822,15 @@ pub fn set_points(doc: &mut Document, page: usize, index: usize, points: &[[f64; if points.len() > MAX_POINTS { return Err(AnnotError::Invalid(format!("at most {MAX_POINTS} points"))); } + if d.name(b"Subtype") == Some(b"FreeText") && callout_line(doc, &d).is_some() { + // A callout's points: its arrow point, then optionally its knee (a four-number `/CL` + // gains one; ISO 32000-2 Table 177 allows both forms). The arrow point alone keeps a knee. + if !matches!(points.len(), 1 | 2) || !finite(&points.concat()) { + return Err(AnnotError::Invalid("a callout has an arrow point and, optionally, a knee".into())); + } + let text_box = callout_text_box(doc, &d).ok_or_else(|| AnnotError::Invalid("the callout's text box (/RD) can't be read".into()))?; + return place_text_box(doc, r, text_box, Some(points), meta, false); + } // The width the appearance draws with (`/BS`, else `/Border`), so the room is the drawn stroke's. let style = Style { width: appearance::border_width(&d), ..Style::default() }; let ends = line_endings_of(&d).unwrap_or_default(); @@ -700,6 +905,43 @@ fn resize_ink(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Res /// leader line re-attaches and `/Rect` grows to hold it, and the appearance is redrawn /// (`new_text`: for changed contents, see [`set_appearance`] and `draw_appearance`). fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, new_text: bool) -> Result<(), AnnotError> { + place_text_box(doc, r, rect, None, meta, new_text) +} + +/// A callout's leader line (`/CL`, ISO 32000-2 Table 177): four numbers (start, end) or six +/// (start, knee, end), as points in user space. `None` when the annotation has no usable `/CL`. +pub(crate) fn callout_line(doc: &Document, d: &Dict) -> Option> { + // All or nothing: a non-number would shift every later number into the wrong point. + let l: Vec = doc.resolve(d.get(b"CL")?).as_array()?.iter().map(|x| doc.resolve(x).as_f64()).collect::>()?; + (matches!(l.len(), 4 | 6) && finite(&l)).then(|| l.as_chunks::<2>().0.to_vec()) +} + +/// A callout's text box: `/Rect` inset by `/RD` (ISO 32000-2 Table 177). `None` without both. +pub(crate) fn callout_text_box(doc: &Document, d: &Dict) -> Option<[f64; 4]> { + let nums = |key: &[u8]| -> Option> { doc.resolve(d.get(key)?).as_array()?.iter().map(|o| doc.resolve(o).as_f64()).collect() }; + let r = nums(b"Rect").filter(|v| v.len() == 4 && finite(v))?; + let rd = nums(b"RD").filter(|v| v.len() == 4 && finite(v) && v.iter().all(|x| *x >= 0.0))?; + let r = normalize([r[0], r[1], r[2], r[3]]); + let tb = [r[0] + rd[0], r[1] + rd[1], r[2] - rd[2], r[3] - rd[3]]; + (tb[2] - tb[0] >= 1.0 && tb[3] - tb[1] >= 1.0).then_some(tb) +} + +/// As [`apply_text_box`]. For a callout, `lead` (its arrow point and optional knee, dragged by +/// their handles) replaces the leader line's own points. The arrow point never moves with the box +/// ("The text box moves around a stationary anchor point—the arrow on the end-point line", +/// Acrobat DC Help, 11/28/2019, p. 357), and the line follows the box as Acrobat Pro DC (a 2021 +/// Continuous release) was seen to: +/// - the box moved without changing size: the knee and the end move with it (also seen in +/// Acrobat Pro 2026.002.22031); +/// - the box resized with the side the line meets left where it was (the end still on it): the +/// line doesn't change; +/// - otherwise (that side moved): the line meets the middle of that side, the knee +/// [`CALLOUT_KNEE_LINE`] out from it ([`callout_knee_on`]). Acrobat also moves the box next to +/// the arrow point then; PdfCraft leaves the box where it was put. +/// +/// With `lead`, the end stays where it is while it is on the side facing the knee (dragging the +/// knee or the arrow point leaves it in Acrobat), and otherwise meets the middle of that side. +fn place_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], lead: Option<&[[f64; 2]]>, meta: &Meta, new_text: bool) -> Result<(), AnnotError> { let rect = normalize(rect); if !finite(&rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { return Err(AnnotError::Invalid("invalid rectangle (too small)".into())); @@ -708,17 +950,62 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne if !new_text { refuse_foreign_text(doc, &d)?; } - // A callout's rectangle is its text box: the leader line re-attaches and `/Rect` grows to hold it. - let callout = d.get(b"CL").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect::>()).filter(|l| l.len() == 6); - let (outer, cl) = match callout { - Some(mut l) => { - let attach = callout_attach(rect, [l[2], l[3]]); - (l[4], l[5]) = (attach[0], attach[1]); - let pad = border_width_of(&d) / 2.0 + appearance::arrow_size(border_width_of(&d)) + 1.0; - let lead = grow(bounds([[l[0], l[1]], [l[2], l[3]]].into_iter()).unwrap_or_default(), pad); - ([rect[0].min(lead[0]), rect[1].min(lead[1]), rect[2].max(lead[2]), rect[3].max(lead[3])], Some(l)) + // A callout's rectangle is its text box: `/Rect` grows to hold the leader line. A four-number + // `/CL` (no knee) stays four numbers unless `lead` adds a knee. + let old_line = callout_line(doc, &d); + let old_box = callout_text_box(doc, &d); + let old_end = old_line.as_deref().and_then(<[[f64; 2]]>::last).copied(); + // An arrow point alone moves the arrow: a knee the line has stays where it is. + let kept: [[f64; 2]; 2]; + let lead = match (lead, old_line.as_deref()) { + (Some([point]), Some([_, knee, _])) => { + kept = [*point, *knee]; + Some(&kept[..]) } - None => (rect, None), + _ => lead, + }; + let line: Option> = match (lead, old_line.as_deref()) { + (Some(lead @ [.., toward]), _) => { + let side = callout_side(rect, *toward); + let end = old_end.filter(|e| on_side(rect, side, *e)).unwrap_or_else(|| callout_attach(rect, *toward)); + Some(lead.iter().copied().chain([end]).collect()) + } + (Some(_), _) => None, + (None, Some(old @ [start, .., end])) => { + let (start, end) = (*start, *end); + let knee = old.get(1).filter(|_| old.len() == 3).copied(); + let toward = knee.unwrap_or(start); + Some(match old_box { + // Moved, not resized: the knee and the end come along. + Some(b) + if ((rect[2] - rect[0]) - (b[2] - b[0])).abs() < CALLOUT_SLACK && ((rect[3] - rect[1]) - (b[3] - b[1])).abs() < CALLOUT_SLACK => + { + let (dx, dy) = (rect[0] - b[0], rect[1] - b[1]); + let shift = |p: [f64; 2]| [p[0] + dx, p[1] + dy]; + [start].into_iter().chain(knee.map(shift)).chain([shift(end)]).collect() + } + // Resized, the side the line meets still under its end: nothing changes. + Some(b) if on_side(rect, callout_side(b, toward), end) => old.to_vec(), + // That side moved: laid out again as a new callout is, from the side facing the + // arrow point (the box may now be on the knee's other side). + Some(_) => { + let new_knee = callout_knee(rect, start); + let end = callout_attach(rect, new_knee); + [start].into_iter().chain(knee.map(|_| new_knee)).chain([end]).collect() + } + None => [start].into_iter().chain(knee).chain([callout_attach(rect, toward)]).collect(), + }) + } + (None, _) => None, + }; + let (outer, cl) = match line.as_deref() { + Some(line @ [lead @ .., _]) => { + // The end is on the box, so only the arrow point and the knee need room around them. + let pad = border_width_of(&d) / 2.0 + appearance::arrow_size(border_width_of(&d)) + 1.0; + let lead_box = grow(bounds(lead.iter().copied()).unwrap_or_default(), pad); + ([rect[0].min(lead_box[0]), rect[1].min(lead_box[1]), rect[2].max(lead_box[2]), rect[3].max(lead_box[3])], Some(line.concat())) + } + _ => (rect, None), }; let (shape, width) = (BorderShape::of(&d), border_width_of(&d)); let free_text = d.name(b"Subtype") == Some(b"FreeText"); @@ -749,8 +1036,9 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne } } -/// The border width as the appearance draws it: `/BS /W`, else the third number of `/Border` -/// (another app's), else 1. +/// The width a comment's border is drawn with ([`appearance::border_width`]: `/BS /W`, else +/// `/Border`'s third number, 1 when neither gives a usable one), so what is sized around it +/// leaves room for what is drawn. fn border_width_of(d: &Dict) -> f64 { appearance::border_width(d) } @@ -857,6 +1145,8 @@ fn style_one(doc: &mut Document, r: ObjRef, change: StyleChange, meta: &Meta) -> } refuse_foreign_text(doc, &d)?; let free_text = subtype == "FreeText"; + // The face and size the text is drawn in, which the new colour keeps. + let drawn = text_style::text_style(&d, text_style::dr_face(doc, &d)); // Typewriter text has no box of its own: a border goes around its text, so the box grows // (or shrinks) by the change on every side and the text keeps its place and its lines. // So does a text box inside an oval or a circle, which goes around it again. @@ -878,9 +1168,7 @@ fn style_one(doc: &mut Document, r: ObjRef, change: StyleChange, meta: &Meta) -> } if let Some(c) = color { if free_text { - let (_, size) = appearance::parse_da(d); - d.set(b"DA".to_vec(), free_text_da(c, appearance::da_border(d), size)); - rich_text(d); + restyle_text(d, TextStyle { color: c, ..drawn }); } else { d.set(b"C".to_vec(), rgb(c)); } @@ -939,11 +1227,11 @@ pub fn set_border_color(doc: &mut Document, page: usize, index: usize, color: Op return Err(AnnotError::Invalid("invalid colour".into())); } refuse_foreign_text(doc, &d)?; + // The face, size and colour the text is drawn in, which the border colour keeps. + let drawn = text_style::text_style(&d, text_style::dr_face(doc, &d)); let before = d; doc.update_dict(r, |d| { - let (text, size) = appearance::parse_da(d); - d.set(b"DA".to_vec(), free_text_da(text, color, size)); - rich_text(d); + restyle_text_bordered(d, drawn, color); touch(d, meta); })?; redraw_or_restore(doc, r, before) @@ -964,11 +1252,11 @@ pub fn set_font_size(doc: &mut Document, page: usize, index: usize, size: f64, m } refuse_foreign_text(doc, &d)?; let snug = is_snug(&d); + // The face and colour the text is drawn in, which the new size keeps. + let drawn = text_style::text_style(&d, text_style::dr_face(doc, &d)); let before = d; doc.update_dict(r, |d| { - let (col, _) = appearance::parse_da(d); - d.set(b"DA".to_vec(), free_text_da(col, appearance::da_border(d), size)); - rich_text(d); + restyle_text(d, TextStyle { size, ..drawn }); touch(d, meta); })?; let text = text_value(doc, &annot_dict(doc, r), b"Contents").unwrap_or_default(); @@ -1120,16 +1408,17 @@ fn pair_points(d: &Dict, key: &[u8]) -> Option> { } /// Replace Text (Acrobat's proposal): strike out `quads` and add a caret at the end of the -/// struck text holding `replacement`, the two grouped so they move, list and delete as one. -/// Returns the strikeout's index. +/// struck text holding `replacement`, grouped (ISO 32000-2 §12.5.6.2) so they list, select and +/// delete as one. Returns the caret's index, the group's comment. /// -/// The caret is the group's primary (`/IT /Replace`) and the strikeout its subordinate (`/IRT` to -/// the caret, `/RT /Group`, `/IT /StrikeOutTextEdit`): a group's text is read from its primary, -/// and the subordinates' `Contents` are ignored (ISO 32000-2 §12.5.6.2), so the replacement has -/// to be on the primary. +/// The caret is the group's primary: it holds `replacement` in `/Contents` and has `/IT +/// /Replace`. The strikeout is its subordinate (`/IRT` to the caret, `/RT /Group`, `/IT +/// /StrikeOutTextEdit`) with no `/Contents`, since a reader ignores a subordinate's text. +/// Groups written before this the other way round (the strikeout primary, the text only on +/// the caret) still list their text: see `pdfcraft_render::fill_group_text`. /// /// The two annotations need their own `/NM`: XFDF names a reply by the `/NM` of what it replies -/// to, so one id for both writes `inreplyto` equal to the caret's own `name`, and a reader that +/// to, so one id for both writes `inreplyto` equal to the strikeout's own `name`, and a reader that /// resolves names (Acrobat, and our own importer) loses the pair. `caret_meta` is a second id /// from the caller, so the edit stays deterministic. pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_>) -> Result { @@ -1168,8 +1457,9 @@ pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_> d.set(b"IT".to_vec(), Object::name("StrikeOutTextEdit")); d.set(b"IRT".to_vec(), Object::Ref(cr)); d.set(b"RT".to_vec(), Object::name("Group")); + d.remove(b"Contents"); })?; - Ok(s) + Ok(c) } /// The Eraser (Draw tools): remove the parts of drawing `(page, index)` within `radius` points diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 9651263171..ce5a8c1604 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -885,10 +885,10 @@ fn editing_a_callout_refits_the_box_and_keeps_the_leader() { let outer = rect(d); let f = |k: &[u8]| -> Vec { d.get(k).unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect() }; let cl = f(b"CL"); - // The leader line keeps its start and knee; the attach point follows the re-fitted box. - assert_eq!(&cl[..4], &[200.0, 520.0, 260.0, 560.0]); + // The box grows down from its top; the side the line meets stays where it was, so the line + // doesn't change (as Acrobat keeps it while a box is resized from its other corner). let want = expected_box(612.0, 200.0, long, 12.0, 2.0); - assert_eq!((cl[4], cl[5]), callout_attach([300.0, want[1], 500.0, 612.0], [260.0, 560.0]).into()); + assert_eq!(cl, [200.0, 520.0, 260.0, 560.0, 300.0, 606.0]); // The text box (recovered from /Rect and /RD) keeps its width and top edge. let tb = [outer[0] + f(b"RD")[0], outer[1] + f(b"RD")[1], outer[2] - f(b"RD")[2], outer[3] - f(b"RD")[3]]; assert_eq!((tb[0], tb[2], tb[3]), (300.0, 500.0, 612.0)); @@ -1609,36 +1609,43 @@ fn replace_text_groups_a_strikeout_and_a_caret() { .unwrap(); let doc = reopen(&doc); let a = list(&doc, 0); - let strike = &a[i]; - assert_eq!((strike.name(b"Subtype"), strike.name(b"IT")), (Some(&b"StrikeOut"[..]), Some(&b"StrikeOutTextEdit"[..]))); - let caret = a.iter().find(|d| d.name(b"Subtype") == Some(b"Caret")).unwrap(); - // The caret is the group's primary: a group's text is read from its primary and the - // subordinates' Contents are ignored (ISO 32000-2 §12.5.6.2), so the replacement is on it. - assert_eq!((text(caret, b"Contents").as_str(), caret.name(b"IT")), ("new words", Some(&b"Replace"[..]))); - assert!(caret.get(b"IRT").is_none() && caret.get(b"RT").is_none(), "a primary has no /IRT: {caret:?}"); - assert_eq!(strike.name(b"RT"), Some(&b"Group"[..])); - assert!(text(strike, b"Contents").is_empty()); + // The caret is the group's primary (ISO 32000-2 §12.5.6.2): it holds the text, has no + // `/IRT`, and is what the call returns. + let caret = &a[i]; + assert_eq!((caret.name(b"Subtype"), caret.name(b"IT")), (Some(&b"Caret"[..]), Some(&b"Replace"[..]))); + assert_eq!(text(caret, b"Contents").as_str(), "new words"); + assert!(caret.get(b"IRT").is_none() && caret.get(b"RT").is_none()); + // The strikeout is its subordinate, with no text of its own. + let strike = a.iter().find(|d| d.name(b"Subtype") == Some(b"StrikeOut")).unwrap(); + assert_eq!((strike.name(b"IT"), strike.name(b"RT")), (Some(&b"StrikeOutTextEdit"[..]), Some(&b"Group"[..]))); + assert!(strike.get(b"Contents").is_none()); + assert!(is_group_subordinate(strike)); // #2666: the pair needs its own `/NM` each. XFDF names a reply by the `/NM` of what it - // replies to, so sharing one id writes `inreplyto` equal to the caret's own `name` and the - // pair does not survive an export and import. + // replies to, so sharing one id writes `inreplyto` equal to the subordinate's own `name` + // and the pair does not survive an export and import. assert_eq!(text(caret, b"NM").as_str(), "r-caret"); assert_eq!(text(strike, b"NM").as_str(), "r"); - assert_ne!(text(caret, b"NM"), text(strike, b"NM"), "the caret must not carry the strikeout's name"); let irt = strike.reference(b"IRT").unwrap(); - assert_eq!(text(doc.get(irt).as_dict().unwrap(), b"NM").as_str(), "r-caret", "the strikeout is grouped under the caret"); + assert_eq!(text(doc.get(irt).as_dict().unwrap(), b"NM").as_str(), "r-caret", "the strikeout is grouped with the caret"); let r = rect(caret); assert!(r[0] < 200.0 && r[2] > 200.0 && r[1] < 706.0, "at the end of the line, below its baseline: {r:?}"); - // Deleting either one takes the other with it (the proposal is one comment). + let summary = summaries(&doc); + assert_eq!(summary.iter().filter(|s| s.page == 0 && !s.is_group_member()).count(), 2, "the fixture's Square and the caret: {summary:?}"); + // Replace Text is one comment: deleting either the caret or the strikeout deletes both, as + // Acrobat does. + for member in [i, a.iter().position(|d| d.name(b"Subtype") == Some(b"StrikeOut")).unwrap()] { + let mut doc = doc.clone(); + delete_annotation(&mut doc, 0, member).unwrap(); + assert!(!list(&doc, 0).iter().any(|d| matches!(d.name(b"Subtype"), Some(b"Caret" | b"StrikeOut"))), "{member}"); + } let marks = |doc: &Document| list(doc, 0).iter().filter(|d| matches!(d.name(b"Subtype"), Some(b"Caret" | b"StrikeOut"))).count(); - let mut by_strike = doc.clone(); - delete_annotation(&mut by_strike, 0, i).unwrap(); - assert_eq!(marks(&by_strike), 0, "deleting the strikeout deletes the caret"); + let si = a.iter().position(|d| d.name(b"Subtype") == Some(b"StrikeOut")).unwrap(); let mut doc = doc; let c = list(&doc, 0).iter().position(|d| d.name(b"Subtype") == Some(b"Caret")).unwrap(); // A locked caret keeps the strikeout too: the pair can't be broken by deleting the other. let mut locked = doc.clone(); set_locked(&mut locked, 0, c, true).unwrap(); - assert!(deletable(&locked, 0, i).is_err() && delete_annotation(&mut locked, 0, i).is_err(), "a locked caret refuses its strikeout's deletion"); + assert!(deletable(&locked, 0, si).is_err() && delete_annotation(&mut locked, 0, si).is_err(), "a locked caret refuses its strikeout's deletion"); assert_eq!(marks(&locked), 2); delete_annotation(&mut doc, 0, c).unwrap(); assert_eq!(marks(&doc), 0, "deleting the caret deletes the strikeout"); @@ -1660,6 +1667,218 @@ fn replace_text_groups_a_strikeout_and_a_caret() { ); } +/// ISO 32000-2 §12.5.6.2: `/RT /Group` makes an annotation a subordinate of the group its +/// `/IRT` annotation is primary of. Another `/RT` reads as a reply, and so does a bare `/IRT`. +/// Deleting a member deletes just it, as Acrobat does, although §12.5.6.2 lists deletion among the +/// operations that act on the entire group: the subordinate goes alone, and the primary goes with +/// its replies and leaves its subordinate, which reads as a comment of its own. +#[test] +fn a_group_reads_as_one_and_deletes_by_member() { + let mut doc = fixture(); + let square = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [100.0, 100.0, 200.0, 200.0] }), &meta("sq")).unwrap(); + let (_, sr) = annot_ref(&mut doc, 0, square).unwrap(); + let irt = |doc: &mut Document, id: &str, rt: Option<&str>| { + let i = add_annotation(doc, &new(0, Shape::Oval { rect: [300.0, 300.0, 350.0, 350.0] }), &meta(id)).unwrap(); + let (_, r) = annot_ref(doc, 0, i).unwrap(); + doc.update_dict(r, |d| { + d.set(b"IRT".to_vec(), Object::Ref(sr)); + if let Some(rt) = rt { + d.set(b"RT".to_vec(), Object::name(rt)); + } + }) + .unwrap(); + i + }; + let member = irt(&mut doc, "member", Some("Group")); + irt(&mut doc, "reply", None); + irt(&mut doc, "explicit", Some("R")); + irt(&mut doc, "odd", Some("Bogus")); + let doc = reopen(&doc); + let grouped: Vec<(String, bool)> = + summaries(&doc).into_iter().filter(|s| s.in_reply_to.is_some()).map(|s| (s.name.clone().unwrap_or_default(), s.is_group_member())).collect(); + assert_eq!( + grouped, + [("member".into(), true), ("reply".into(), false), ("explicit".into(), false), ("odd".into(), false)], + "only /RT /Group groups" + ); + let mut alone = doc.clone(); + delete_annotation(&mut alone, 0, member).unwrap(); + assert_eq!(list(&alone, 0).len(), list(&doc, 0).len() - 1, "the subordinate goes alone"); + let mut doc = doc; + delete_annotation(&mut doc, 0, square).unwrap(); + let left: Vec> = list(&doc, 0).iter().map(|d| d.get(b"NM").map(|_| text(d, b"NM"))).collect(); + assert_eq!(left, [None, Some("member".into())], "the primary and its replies are gone; the fixture's Square and the subordinate stay"); + let summary = summaries(&doc); + let member = summary.iter().find(|s| s.name.as_deref() == Some("member")).unwrap(); + assert!(member.parent.is_none() && !member.is_group_member(), "left without its primary, it is a comment of its own: {member:?}"); +} + +/// A primary with one subordinate and one reply, all on page 0 of a reopened file. Returns the +/// primary's and the subordinate's `/Annots` positions and references. +fn group_of_two(text: &str) -> (Document, (usize, ObjRef), (usize, ObjRef)) { + let mut doc = fixture(); + let primary = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [100.0, 100.0, 200.0, 200.0] }), &meta("primary")).unwrap(); + let (_, pr) = annot_ref(&mut doc, 0, primary).unwrap(); + doc.update_dict(pr, |d| d.set(b"Contents".to_vec(), pdfcraft_cos::PdfString::text(text))).unwrap(); + let member = add_annotation(&mut doc, &new(0, Shape::Oval { rect: [300.0, 300.0, 350.0, 350.0] }), &meta("member")).unwrap(); + let (_, mr) = annot_ref(&mut doc, 0, member).unwrap(); + doc.update_dict(mr, |d| { + d.set(b"IRT".to_vec(), Object::Ref(pr)); + d.set(b"RT".to_vec(), Object::name("Group")); + }) + .unwrap(); + add_reply(&mut doc, 0, primary, "a reply", "Ada", &meta("reply")).unwrap(); + (reopen(&doc), (primary, pr), (member, mr)) +} + +/// Deleting a group's primary leaves its subordinate a comment of its own, as Acrobat does: the +/// subordinate loses its `/IRT` and keeps `/RT /Group`, so nothing refers to the deleted primary +/// and a full save leaves its text out. +#[test] +fn deleting_a_primary_releases_its_subordinate() { + let (mut doc, (primary, _), (_, mr)) = group_of_two("primary secret text"); + delete_annotation(&mut doc, 0, primary).unwrap(); + let member = doc.get(mr).as_dict().cloned().unwrap(); + assert!(member.get(b"IRT").is_none(), "the subordinate no longer names the deleted primary: {member:?}"); + assert_eq!(member.name(b"RT"), Some(&b"Group"[..]), "its /RT stays, as Acrobat leaves it"); + let s = summaries(&doc); + let listed = s.iter().find(|s| s.name.as_deref() == Some("member")).unwrap(); + assert!(listed.parent.is_none() && listed.in_reply_to.is_none() && !listed.is_group_member(), "a comment of its own: {listed:?}"); + assert!(!s.iter().any(|s| s.name.as_deref() == Some("reply")), "the primary's reply went with it"); + let full = pdfcraft_cos::write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + assert!(!full.windows(19).any(|w| w == b"primary secret text"), "the deleted primary's text is not in a full save"); +} + +/// Deleting a subordinate leaves its primary as it was. +#[test] +fn deleting_a_subordinate_leaves_its_primary_untouched() { + let (mut doc, (_, pr), (member, _)) = group_of_two("primary text"); + let before = doc.get(pr).as_dict().cloned(); + delete_annotation(&mut doc, 0, member).unwrap(); + assert_eq!(doc.get(pr).as_dict().cloned(), before); + assert!(summaries(&doc).iter().any(|s| s.name.as_deref() == Some("reply")), "the primary keeps its reply"); +} + +/// An inline subordinate (`/Annots` may hold a dictionary, though the standard asks for a +/// reference) whose primary is deleted loses its `/IRT` in the page's list. +#[test] +fn an_inline_subordinate_is_released_too() { + let mut doc = fixture(); + let primary = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [100.0, 100.0, 200.0, 200.0] }), &meta("primary")).unwrap(); + let (_, pr) = annot_ref(&mut doc, 0, primary).unwrap(); + let p = page_refs(&doc).unwrap()[0]; + let mut entries = annots(&doc, p); + let mut inline = Dict::new(); + inline.set(b"Type".to_vec(), Object::name("Annot")); + inline.set(b"Subtype".to_vec(), Object::name("Square")); + inline.set(b"Rect".to_vec(), Object::Array([300, 300, 350, 350].map(Object::Int).to_vec())); + inline.set(b"IRT".to_vec(), Object::Ref(pr)); + inline.set(b"RT".to_vec(), Object::name("Group")); + entries.push(Object::Dict(inline)); + set_annots(&mut doc, p, entries).unwrap(); + delete_annotation(&mut doc, 0, primary).unwrap(); + let left = list(&doc, 0); + let inline = left.iter().find(|d| d.name(b"RT") == Some(b"Group")).expect("the inline subordinate stays"); + assert!(inline.get(b"IRT").is_none()); +} + +/// Deleting follows `/IRT` replies by visiting each annotation once, with no step limit to cut a +/// thread short: a thread of 200 replies (past the 64 steps the listings follow) goes whole, and +/// replies or group members in a loop (`/IRT` back to each other) end the walk. A loop of group +/// members deletes only the one picked; the other is released. +#[test] +fn deleting_follows_a_long_or_looping_irt_chain_once_each() { + let mut doc = fixture(); + let top = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [100.0, 100.0, 200.0, 200.0] }), &meta("top")).unwrap(); + let mut on = top; + for k in 0..200 { + on = add_reply(&mut doc, 0, on, "reply", "Ada", &meta(&format!("r{k}"))).unwrap(); + } + let before = list(&doc, 0).len(); + delete_annotation(&mut doc, 0, top).unwrap(); + assert_eq!(list(&doc, 0).len(), before - 201, "the comment and its 200-deep thread go together"); + for rt in [None, Some("Group")] { + let mut doc = fixture(); + let a = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [100.0, 100.0, 200.0, 200.0] }), &meta("a")).unwrap(); + let b = add_annotation(&mut doc, &new(0, Shape::Oval { rect: [300.0, 300.0, 350.0, 350.0] }), &meta("b")).unwrap(); + let (_, ar) = annot_ref(&mut doc, 0, a).unwrap(); + let (_, br) = annot_ref(&mut doc, 0, b).unwrap(); + for (r, to) in [(ar, br), (br, ar)] { + doc.update_dict(r, |d| { + d.set(b"IRT".to_vec(), Object::Ref(to)); + if let Some(rt) = rt { + d.set(b"RT".to_vec(), Object::name(rt)); + } + }) + .unwrap(); + } + let before = list(&doc, 0).len(); + delete_annotation(&mut doc, 0, a).unwrap(); + let left = list(&doc, 0); + match rt { + None => assert_eq!(left.len(), before - 2, "a reply loop goes whole"), + Some(_) => { + assert_eq!(left.len(), before - 1, "a group member goes alone"); + assert!(doc.get(br).as_dict().is_some_and(|d| d.get(b"IRT").is_none()), "the other member is released"); + } + } + } +} + +/// A subordinate deep in a chain of subordinates, within the 64 annotations the listing follows +/// up a chain or past them, is deleted alone: the chain above it stays. +#[test] +fn a_subordinate_deep_in_a_chain_is_deleted_alone() { + for subordinates in [63, 64, 65] { + let mut doc = fixture(); + let top = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [100.0, 100.0, 200.0, 200.0] }), &meta("top")).unwrap(); + let (_, mut up) = annot_ref(&mut doc, 0, top).unwrap(); + let mut last = top; + for k in 0..subordinates { + last = add_annotation(&mut doc, &new(0, Shape::Oval { rect: [300.0, 300.0, 350.0, 350.0] }), &meta(&format!("s{k}"))).unwrap(); + let (_, r) = annot_ref(&mut doc, 0, last).unwrap(); + doc.update_dict(r, |d| { + d.set(b"IRT".to_vec(), Object::Ref(up)); + d.set(b"RT".to_vec(), Object::name("Group")); + }) + .unwrap(); + up = r; + } + let before = list(&doc, 0).len(); + delete_annotation(&mut doc, 0, last).unwrap(); + assert_eq!(list(&doc, 0).len(), before - 1, "{subordinates}: the subordinate is gone, the rest of its group stays"); + } +} + +/// A locked member doesn't hold back the deletion of another, the primary included: each goes +/// alone (ISO 32000-2 §12.5.3, Table 167: Locked). +#[test] +fn a_locked_group_member_holds_back_no_other_deletion() { + let mut doc = fixture(); + let top = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [100.0, 100.0, 200.0, 200.0] }), &meta("top")).unwrap(); + let (_, tr) = annot_ref(&mut doc, 0, top).unwrap(); + let mut members = Vec::new(); + for id in ["a", "b"] { + let i = add_annotation(&mut doc, &new(0, Shape::Oval { rect: [300.0, 300.0, 350.0, 350.0] }), &meta(id)).unwrap(); + let (_, r) = annot_ref(&mut doc, 0, i).unwrap(); + doc.update_dict(r, |d| { + d.set(b"IRT".to_vec(), Object::Ref(tr)); + d.set(b"RT".to_vec(), Object::name("Group")); + }) + .unwrap(); + members.push(i); + } + set_locked(&mut doc, 0, members[1], true).unwrap(); + let before = list(&doc, 0).len(); + let mut alone = doc.clone(); + delete_annotation(&mut alone, 0, members[0]).unwrap(); + assert_eq!(list(&alone, 0).len(), before - 1, "the unlocked subordinate goes alone"); + delete_annotation(&mut doc, 0, top).unwrap(); + assert_eq!(list(&doc, 0).len(), before - 1, "the primary goes alone too"); + let locked = list(&doc, 0).iter().filter(|d| d.get(b"F").and_then(|f| f.as_int()).is_some_and(|f| f & 128 != 0)).count(); + assert_eq!(locked, 1, "the locked member stays"); +} + /// Review on #5689 (CodeRabbit): clicking a Replace Text strikeout selects its caret, the /// group's primary, so a restyle from Comment Properties lands on the caret. Colour and opacity /// are the proposal's, so the strikeout is restyled and redrawn in the same edit, whichever @@ -1679,8 +1898,9 @@ fn restyling_a_replace_text_pair_restyles_both_members() { strike_meta: &meta("r"), caret_meta: &meta("r-caret"), }; - let s = add_text_replacement(&mut doc, tr).unwrap(); - let c = list(&doc, 0).iter().position(|d| d.name(b"Subtype") == Some(b"Caret")).unwrap(); + // The call returns the caret, the group's primary. + let c = add_text_replacement(&mut doc, tr).unwrap(); + let s = list(&doc, 0).iter().position(|d| d.name(b"Subtype") == Some(b"StrikeOut")).unwrap(); let color = |doc: &Document, i: usize| { list(doc, 0)[i].get(b"C").and_then(|o| o.as_array().cloned()).unwrap().iter().filter_map(Object::as_f64).collect::>() }; @@ -2324,6 +2544,28 @@ fn non_markup_annotations_are_not_comments() { for s in ["Text", "FreeText", "Highlight", "Ink", "Stamp", "FileAttachment", "Redact", "Sound"] { assert!(is_comment_subtype(s), "{s}"); } + // #3916: comment edits must refuse non-comment annotations (form widgets, links, pop-ups) + // when a stale `/Annots` index points at one. + for subtype in ["Widget", "Link", "Popup", "Watermark"] { + let mut doc = fixture(); + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name(subtype)); + d.set(b"Rect".to_vec(), num_array(&[10.0, 10.0, 50.0, 30.0])); + d.set(b"Contents".to_vec(), PdfString::text("keep me")); + let w = doc.add(Object::Dict(d)); + let p = page_refs(&doc).unwrap()[0]; + let mut a = annots(&doc, p); + a.push(Object::Ref(w)); + let idx = a.len() - 1; + set_annots(&mut doc, p, a).unwrap(); + + assert_eq!(delete_annotation(&mut doc, 0, idx), Err(AnnotError::NoSuchAnnotation { page: 0, index: idx }), "{subtype}"); + assert_eq!(set_contents(&mut doc, 0, idx, "overwritten", &meta("m")), Err(AnnotError::NoSuchAnnotation { page: 0, index: idx }), "{subtype}"); + assert_eq!(move_annotation(&mut doc, 0, idx, 5.0, 5.0, &meta("m")), Err(AnnotError::NoSuchAnnotation { page: 0, index: idx }), "{subtype}"); + assert_eq!(props(&doc, 0, idx), None, "{subtype}"); + assert_eq!(text(&list(&doc, 0)[idx], b"Contents"), "keep me", "{subtype} contents must be untouched"); + } } /// A drawing (a drawn Fill & Sign signature is one) resizes by scaling its strokes into the new @@ -2574,7 +2816,8 @@ fn free_text_properties_show_legacy_or_absent_border_width() { d.remove(b"Border"); }) .unwrap(); - assert_eq!(props(&doc, 0, index).unwrap().width, Some(0.0)); + // Neither /BS nor /Border: the /Border default [0 0 1] (ISO 32000-2 Table 166), as drawn. + assert_eq!(props(&doc, 0, index).unwrap().width, Some(1.0)); doc.update_dict(reference, |d| { d.set(b"Border".to_vec(), Object::Array([0.0, 0.0, 4.0].into_iter().map(Object::Real).collect())); }) @@ -3526,6 +3769,978 @@ fn a_cloud_around_a_rotated_rectangle_bulges_outwards_either_way_round() { } } +/// PdfCraft's own text boxes, typewriter text and redaction marks write `/BS` (width 0 for text +/// boxes and typewriter text), `/DA /Helv` and a `/DS` that agrees with it, and quadrilaterals: +/// the default border, `/DA` fonts, `/DS` and the `/Rect` outline leave them exactly as they were. +#[test] +fn pdfcraft_text_boxes_and_redaction_marks_look_as_before() { + let mut doc = fixture(); + let mut tb = new(0, Shape::TextBox { rect: [100.0, 100.0, 220.0, 160.0], font_size: 12.5 }); + tb.style = Style { color: [0.1, 0.2, 0.3], fill: Some([1.0, 1.0, 0.8]), ..Style::default_for(&tb.shape) }; + tb.contents = "PdfCraft text box that wraps onto a second line".into(); + let t = add_annotation(&mut doc, &tb, &meta("t")).unwrap(); + let mut tw = new(0, Shape::Typewriter { rect: [100.0, 300.0, 300.0, 320.0], font_size: 10.0 }); + tw.style = Style::default_for(&tw.shape); + let w = add_annotation(&mut doc, &tw, &meta("w")).unwrap(); + let quads = vec![[100.0, 520.0, 200.0, 520.0, 100.0, 500.0, 200.0, 500.0]]; + let mut rd = new(0, Shape::Redact { quads, overlay: String::new(), look: OverlayLook::default() }); + rd.style = Style::default_for(&rd.shape); + let r = add_annotation(&mut doc, &rd, &meta("r")).unwrap(); + let all = list(&doc, 0); + assert_eq!( + ap_content(&doc, &all[t]), + "1 1 0.8 rg\n100 100 120 60 re f\n100 100 120 60 re W n\nBT\n/Helv 12.5 Tf\n0.1 0.2 0.3 rg\n1 0 0 1 102 146.75 Tm (PdfCraft text box) Tj\n1 0 0 1 102 131.75 Tm (that wraps onto a) Tj\n1 0 0 1 102 116.75 Tm (second line) Tj\nET\n" + ); + assert_eq!(text(&all[t], b"DS"), "font: Helvetica 12.5pt; text-align:left; color:#1A334D"); + assert_eq!(ap_content(&doc, &all[w]), "100 300 200 20 re W n\nBT\n/Helv 10 Tf\n0 0 0 rg\n1 0 0 1 102 309 Tm (hello) Tj\nET\n"); + assert_eq!(ap_content(&doc, &all[r]), "0.89 0.13 0.13 RG\n1 w\n100 520 m 200 520 l 200 500 l 100 500 l h S\n"); + // New text keeps the box's look: Helvetica, /DA's exact colour (not /DS's 8-bit one). + set_contents(&mut doc, 0, t, "Edited", &meta("")).unwrap(); + let d = &list(&doc, 0)[t]; + assert_eq!(text(d, b"DS"), "font: Helvetica 12.5pt; text-align:left; color:#1A334D"); + let ap = ap_content(&doc, d); + assert!(ap.contains("/Helv 12.5 Tf\n0.1 0.2 0.3 rg\n") && !ap.contains(" re S"), "{ap}"); +} + +/// The display appearance of the annotation dictionary `annot` on a page of a document whose +/// form `/DR` maps `Helv`, `Cour`, `TiRo`, `F1` (Courier) and `F2` (a font that isn't standard), +/// and the font resources it draws with. +fn display_of(annot: &str) -> (String, Dict) { + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /DR << /Font << /Helv 5 0 R /Cour 6 0 R /TiRo 7 0 R /F1 6 0 R /F2 9 0 R >> >> >> >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [8 0 R] >>", + b"<< /Length 0 >>\nstream\n\nendstream", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>", + annot.as_bytes(), + b"<< /Type /Font /Subtype /TrueType /BaseFont /ABCDEF+HouseGothic >>", + ]); + let shown = with_missing_appearances(&doc).expect("drawn for display"); + let d = list(&shown, 0).remove(0); + let n = d.get(b"AP").and_then(|a| a.as_dict()).and_then(|a| a.reference(b"N")).expect("/AP /N"); + let Object::Stream(s) = &*shown.get(n) else { panic!("not a stream") }; + let fonts = s.dict.get(b"Resources").and_then(|r| r.as_dict()).and_then(|r| r.get(b"Font")).and_then(|f| f.as_dict()).cloned(); + (String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(), fonts.unwrap_or_default()) +} + +fn base_fonts(fonts: &Dict) -> Vec { + fonts.iter().filter_map(|(_, f)| f.as_dict()?.name(b"BaseFont").map(|b| String::from_utf8_lossy(b).into_owned())).collect() +} + +/// A text box without `/BS` or `/Border` has the `/Border` default [0 0 1] (ISO 32000-2 +/// Table 166): a 1-pt border in the text colour. `/BS /W 0` or `/Border [0 0 0]` turn it off. +#[test] +fn free_text_without_a_border_entry_has_the_default_one() { + let (ap, _) = display_of("<< /Type /Annot /Subtype /FreeText /Rect [45 630 165 700] /DA (0 0 1 rg /Helv 12 Tf) /Contents (Hi) >>"); + assert!(ap.contains("0 0 1 RG\n1 w\n45.5 630.5 119 69 re S\n"), "{ap}"); + assert!(ap.contains("1 0 0 1 48 "), "text inset by the border: {ap}"); + for off in ["/BS << /W 0 >>", "/Border [0 0 0]"] { + let (ap, _) = display_of(&format!("<< /Type /Annot /Subtype /FreeText /Rect [45 630 165 700] /DA (0 g /Helv 12 Tf) {off} /Contents (Hi) >>")); + assert!(!ap.contains(" re S"), "{off}: {ap}"); + } +} + +/// Review on #4365: a text box's new text is fitted with the border it is drawn with. A 4-pt border +/// written as `/Border [0 0 4]` (no `/BS`) is drawn 4 pt wide, so the box grows as for `/BS /W 4`; +/// before, the fit read only `/BS` and measured it with a 1-pt border, so the drawn text, wrapped +/// 6 pt narrower, could run past the box. +#[test] +fn new_text_is_fitted_with_the_border_the_box_is_drawn_with() { + let fitted = |border: &str| { + let annot = format!("<< /Type /Annot /Subtype /FreeText /Rect [45 600 165 700] /DA (0 g /Helv 12 Tf) {border} /Contents (Hi) >>"); + let mut doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + annot.as_bytes(), + ]); + set_contents(&mut doc, 0, 0, "Several words that wrap over a few lines of this box", &meta("")).unwrap(); + list(&doc, 0).remove(0).get(b"Rect").and_then(|r| r.as_array()).map(|r| r.iter().filter_map(|x| x.as_f64()).collect::>()).unwrap() + }; + let bs = fitted("/BS << /W 4 >>"); + assert_eq!(fitted("/Border [0 0 4]"), bs, "the same 4-pt border, the same box"); + assert_ne!(fitted("/BS << /W 1 >>"), bs, "a 1-pt border fits another box"); +} + +/// `/DA` names its font in the form's `/DR` (§12.7.4.3): a standard face is drawn in that face, +/// with its widths for wrapping; any other font stays Helvetica. +#[test] +fn free_text_da_fonts_draw_in_their_standard_face() { + use pdfcraft_fonts::Std14; + let width = 120.0 - 2.0 * 2.0; + let words = "iiiiiiiiii iiiiiiiiii"; + let lines = |face: Std14| pdfcraft_fonts::wrap_with(words, 12.0, width, |s, z| face.text_width(s, z)).len(); + assert_eq!((lines(Std14::Helvetica), lines(Std14::Courier)), (1, 2), "the case tells the faces apart"); + for (da, base, key, n) in [ + ("0 g /Cour 12 Tf", "Courier", "/Cour 12 Tf", 2), + ("1 0 0 rg /TiRo 12 Tf", "Times-Roman", "/TiRo 12 Tf", 1), + ("0 g /F1 12 Tf", "Courier", "/Cour 12 Tf", 2), + ("0 g /F2 12 Tf", "Helvetica", "/Helv 12 Tf", 1), + ("0 g /Nowhere 12 Tf", "Helvetica", "/Helv 12 Tf", 1), + ("0 g /CoBo 12 Tf", "Courier-Bold", "/CoBo 12 Tf", 2), + ] { + let annot = format!("<< /Type /Annot /Subtype /FreeText /Rect [45 630 165 700] /DA ({da}) /BS << /W 0 >> /Contents ({words}) >>"); + let (ap, fonts) = display_of(&annot); + assert_eq!(base_fonts(&fonts), [base], "{da}"); + assert!(ap.contains(key), "{da}: {ap}"); + assert_eq!(ap.matches(" Tj").count(), n, "{da}: wrapped at {base}'s widths: {ap}"); + } +} + +/// Thai in a text box whose `/DA` names Times is drawn from the Thai face and the rest of its +/// text in Times: after a Thai run the text goes back to `/TiRo`, never to `/Helv`, and both +/// kinds of font are in the appearance's resources. +#[test] +fn thai_in_a_times_text_box_keeps_times_for_the_rest() { + // "สวัสดี Hi": Thai first, then Latin. + let annot = "<< /Type /Annot /Subtype /FreeText /Rect [45 630 245 700] /DA (0 g /TiRo 12 Tf) /BS << /W 0 >> /Contents >>"; + let (ap, fonts) = display_of(annot); + assert!(!ap.contains("/Helv"), "{ap}"); + assert!(base_fonts(&fonts).contains(&"Times-Roman".to_string()), "{fonts:?}"); + if pdfcraft_fonts::document_thai_font().is_none() { + return; + } + assert!(ap.contains("/PCTh1 ") && !ap.contains('?'), "the Thai is shaped: {ap}"); + assert_eq!(ap.matches("/TiRo 12 Tf").count(), 2, "Times is selected again after the Thai: {ap}"); + assert!(fonts.get(b"PCTh1").and_then(|f| f.as_dict()).is_some_and(|f| f.name(b"Subtype") == Some(&b"Type3"[..])), "{fonts:?}"); +} + +/// `/DS` (Table 177) sets the face, size and colour over `/DA`'s; a malformed one is ignored. +#[test] +fn free_text_ds_style_is_drawn() { + let annot = + "<< /Type /Annot /Subtype /FreeText /Rect [45 630 165 700] /DA (0 g /Helv 12 Tf) /DS (font: Times 14pt; color: #008000) /Contents (Hi) >>"; + let (ap, fonts) = display_of(annot); + assert_eq!(base_fonts(&fonts), ["Times-Roman"]); + assert!(ap.contains("/TiRo 14 Tf\n0 0.502 0 rg\n"), "{ap}"); + let junk = format!("font: NaNpt; font-size: inf; color: rgb(1e999, x); {}", "\u{FFFD}".repeat(20_000)); + let annot = format!("<< /Type /Annot /Subtype /FreeText /Rect [45 630 165 700] /DA (0 0 1 rg /Cour 9 Tf) /DS ({junk}) /Contents (Hi) >>"); + let (ap, fonts) = display_of(&annot); + assert_eq!(base_fonts(&fonts), ["Courier"]); + assert!(ap.contains("/Cour 9 Tf\n0 0 1 rg\n"), "{ap}"); +} + +/// New text in a box drawn in Courier stays Courier: its rich text says so. +#[test] +fn new_text_keeps_a_foreign_box_face() { + let mut doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + b"<< /Type /Annot /Subtype /FreeText /Rect [45 630 165 700] /DA (0 g /Cour 12 Tf) /Contents (Hi) >>", + ]); + set_contents(&mut doc, 0, 0, "Hello", &meta("")).unwrap(); + let d = &list(&doc, 0)[0]; + assert_eq!(text(d, b"DS"), "font: Courier 12pt; text-align:left; color:#000000"); + assert!(ap_content(&doc, d).contains("/Cour 12 Tf")); +} + +/// A text box whose `/DS` overrides `/DA` reports the colour and size it is drawn in, and a colour +/// or size change keeps the other drawn value. +#[test] +fn text_box_properties_report_the_drawn_style() { + let doc = || { + document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + b"<< /Type /Annot /Subtype /FreeText /Rect [45 500 300 700] /Contents (Hi) /DA (1 0 0 rg /Helv 12 Tf) /DS (font: Helvetica 24pt; color:#0000FF) >>", + ]) + }; + let mut d = doc(); + let p = props(&d, 0, 0).unwrap(); + assert_eq!((p.color, p.font_size), (Some([0.0, 0.0, 1.0]), Some(24.0))); + set_style(&mut d, 0, 0, StyleChange::new(Some([0.0, 1.0, 0.0]), None, None, None), &meta("")).unwrap(); + let p = props(&d, 0, 0).unwrap(); + assert_eq!((p.color, p.font_size), (Some([0.0, 1.0, 0.0]), Some(24.0)), "a colour change keeps the drawn size"); + let mut d = doc(); + set_font_size(&mut d, 0, 0, 18.0, &meta("")).unwrap(); + let p = props(&d, 0, 0).unwrap(); + assert_eq!((p.color, p.font_size), (Some([0.0, 0.0, 1.0]), Some(18.0)), "a size change keeps the drawn colour"); +} + +/// A redaction mark without `/QuadPoints` marks `/Rect` (Table 195). While marked it is outlined +/// only when it names an outline colour (an array `/OC` of three numbers); without one it draws +/// nothing. Marks with quadrilaterals and no `/OC` colour are outlined in `/C` as before. +#[test] +fn redaction_marks_without_quadpoints_outline_their_rect_in_their_oc_colour() { + let (ap, _) = display_of("<< /Type /Annot /Subtype /Redact /Rect [45 470 165 540] /IC [0 0 0] /OC [1 0 0] /C [0 0 1] >>"); + assert_eq!(ap, "1 0 0 RG\n1 w\n45.5 539.5 m 164.5 539.5 l 164.5 470.5 l 45.5 470.5 l h S\n"); + // No /OC colour: nothing to draw while marked (no /C outline, no /IC fill, no overlay text). + let blank = |annot: &str| { + let annot = format!("<< /Type /Annot /Subtype /Redact /Rect [45 470 165 540] {annot} >>"); + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + annot.as_bytes(), + ]); + with_missing_appearances(&doc).is_none() + }; + // An /OC that isn't three finite numbers is no colour; an optional-content dictionary keeps its meaning. + for annot in [ + "/C [0.9 0.1 0.1]", + "/IC [0 0 0] /OverlayText (REDACTED) /DA (1 1 1 rg /Helv 10 Tf) /C [0.9 0.1 0.1]", + "/OC [1 0]", + "/OC [(a) 0 0]", + "/OC << /Type /OCG /Name (L) >>", + ] { + assert!(blank(annot), "{annot}"); + } + // With /OC, the interior colour and overlay text still wait for the redaction to be applied. + let (ap, _) = + display_of("<< /Type /Annot /Subtype /Redact /Rect [45 470 165 540] /OC [1 0 0] /IC [0 0 0] /OverlayText (X) /DA (1 1 1 rg /Helv 10 Tf) >>"); + assert!(!ap.contains(" f\n") && !ap.contains("Tj"), "{ap}"); + // With quadrilaterals and no /OC: the /C outline, as before. + let (ap, _) = display_of("<< /Type /Annot /Subtype /Redact /Rect [45 470 165 540] /QuadPoints [45 540 165 540 45 470 165 470] /C [0 0 1] >>"); + assert_eq!(ap, "0 0 1 RG\n1 w\n45 540 m 165 540 l 165 470 l 45 470 l h S\n"); +} + +/// A redaction mark with quadrilaterals is outlined in its array `/OC` colour when it has one, +/// over `/C`; without `/OC` in `/C`, and without either in the default red. +#[test] +fn redaction_marks_with_quadpoints_take_their_outline_from_oc_then_c() { + let quad = |extra: &str| { + display_of(&format!("<< /Type /Annot /Subtype /Redact /Rect [45 470 165 540] /QuadPoints [45 540 165 540 45 470 165 470] {extra} >>")).0 + }; + let outline = "1 w\n45 540 m 165 540 l 165 470 l 45 470 l h S\n"; + assert_eq!(quad("/OC [0 0 1]"), format!("0 0 1 RG\n{outline}"), "/OC blue"); + assert_eq!(quad("/OC [0 0 1] /C [0 1 0]"), format!("0 0 1 RG\n{outline}"), "/OC over /C"); + assert_eq!(quad("/C [0 1 0]"), format!("0 1 0 RG\n{outline}"), "/C only"); + assert_eq!(quad(""), format!("0.89 0.13 0.13 RG\n{outline}"), "neither: red"); + // An optional-content /OC is not a colour. + assert_eq!(quad("/OC << /Type /OCG /Name (L) >> /C [0 1 0]"), format!("0 1 0 RG\n{outline}"), "OCG /OC"); +} + +/// The six unapplied marks of the synthetic fixture the parity note's observation used: an array +/// `/OC` outlines the mark in its colour with or without `/QuadPoints`, beside `/C` or `/IC`; a +/// mark without `/QuadPoints` or `/OC` draws nothing while marked. +#[test] +fn unapplied_redaction_marks_follow_their_oc_outline_colour() { + let quad = "/QuadPoints [60 520 260 520 60 420 260 420]"; + let cases = [ + ("/OC [0 0 1]", Some("0 0 1 RG")), + ("/C [0 1 0]", None), + ("/OC [0 0 1] /C [0 1 0]", Some("0 0 1 RG")), + ("/OC [0 0 1] /IC [0 0 0]", Some("0 0 1 RG")), + ("", None), + (&format!("/OC [0 0 1] {quad}"), Some("0 0 1 RG")), + ]; + for (extra, colour) in cases { + let annot = format!("<< /Type /Annot /Subtype /Redact /Rect [60 420 260 520] {extra} >>"); + match colour { + Some(colour) => { + let (ap, _) = display_of(&annot); + assert!(ap.starts_with(&format!("{colour}\n1 w\n")) && ap.ends_with(" h S\n") && !ap.contains(" f\n"), "{extra}: {ap}"); + } + None => { + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + annot.as_bytes(), + ]); + assert!(with_missing_appearances(&doc).is_none(), "{extra}: drawn"); + } + } + } +} + +/// New text re-fits the box in the face and size it is drawn in (`/DA`, or `/DS` where it sets +/// one), so the rectangle holds every drawn line. +#[test] +fn new_text_refits_the_box_in_its_drawn_face_and_size() { + let bottom = |annot: &str, new_text: &str| { + let mut doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + annot.as_bytes(), + ]); + set_contents(&mut doc, 0, 0, new_text, &meta("")).unwrap(); + let d = &list(&doc, 0)[0]; + let rect: Vec = d.get(b"Rect").and_then(|r| r.as_array()).unwrap().iter().filter_map(|x| x.as_f64()).collect(); + assert_eq!(rect[3], 700.0, "the top edge stays"); + (rect[1], ap_content(&doc, d).matches(" Tj").count()) + }; + // The default 1-pt border: a 3-pt pad, so 114 pt for the text; the box is lines × 1.2 × size + 8 tall. + let words = "iiiiiiiiii iiiiiiiiii"; + let fits = |b: f64, want: f64| (b - want).abs() < 1e-9; + let (b, n) = bottom("<< /Type /Annot /Subtype /FreeText /Rect [45 630 165 700] /DA (0 g /Helv 12 Tf) /Contents (Hi) >>", words); + assert!(n == 1 && fits(b, 700.0 - 14.4 - 8.0), "Helvetica: one line, bottom {b}"); + let (b, n) = bottom("<< /Type /Annot /Subtype /FreeText /Rect [45 630 165 700] /DA (0 g /Cour 12 Tf) /Contents (Hi) >>", words); + assert!(n == 2 && fits(b, 700.0 - 2.0 * 14.4 - 8.0), "Courier: two lines, bottom {b}"); + let (b, n) = + bottom("<< /Type /Annot /Subtype /FreeText /Rect [45 630 165 700] /DA (0 g /Helv 12 Tf) /DS (font: Helvetica 24pt) /Contents (Hi) >>", "Hi"); + assert!(n == 1 && fits(b, 700.0 - 28.8 - 8.0), "/DS 24 pt: bottom {b}"); +} + +/// A colour or size change keeps the face a text box is drawn in: `/DA` keeps naming its font +/// resource (here Courier, and a `/DR` font that is Times Bold), and `/DS` says the same face +/// (ISO 32000-2 Table 177). +#[test] +fn a_colour_or_size_change_keeps_the_drawn_face() { + use pdfcraft_fonts::Std14; + let doc = |da: &str| { + let annot = format!("<< /Type /Annot /Subtype /FreeText /Rect [45 500 300 700] /Contents (Hi) /DA ({da}) >>"); + document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /DR << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Times-Bold >> >> >> >> >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + annot.as_bytes(), + ]) + }; + let face = |doc: &Document| { + let d = &list(doc, 0)[0]; + text_style::text_style(d, text_style::dr_face(doc, d)).face + }; + for (da, font, want) in [("0 g /Cour 12 Tf", "/Cour", Std14::Courier), ("0 g /F1 12 Tf", "/F1", Std14::TimesBold)] { + let mut d = doc(da); + assert_eq!(face(&d), want, "{da}"); + set_style(&mut d, 0, 0, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); + assert_eq!(face(&d), want, "colour change, {da}"); + assert!(text(&list(&d, 0)[0], b"DA").contains(&format!("{font} 12 Tf")), "{da}"); + set_font_size(&mut d, 0, 0, 18.0, &meta("")).unwrap(); + assert_eq!(face(&d), want, "size change, {da}"); + let p = props(&d, 0, 0).unwrap(); + assert_eq!((p.color, p.font_size), (Some([1.0, 0.0, 0.0]), Some(18.0)), "{da}"); + } + // The Courier box's appearance draws Courier. + let mut d = doc("0 g /Cour 12 Tf"); + set_style(&mut d, 0, 0, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); + assert!(ap_content(&d, &list(&d, 0)[0]).contains("/Cour 12 Tf")); +} + +/// A text box without an appearance is drawn from its `/DA` however the file wrote it: a `Tf` +/// without a size, a zero size, a font name the form's `/DR` doesn't list, and hostile operands. +/// The stream names the face the box is drawn in at a usable size, and no number in it is `NaN` +/// or infinite. +#[test] +fn text_boxes_draw_from_any_da_string() { + let doc = |da: &str| { + let annot = format!("<< /Type /Annot /Subtype /FreeText /Rect [45 500 300 700] /Contents (Hi) /DA ({da}) >>"); + document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /DR << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Times-Bold >> >> >> >> >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + annot.as_bytes(), + ]) + }; + for (da, tf, colour) in [ + ("0 g /Cour Tf", "/Cour 12 Tf", "0 0 0 rg"), + ("0 g /Cour 0 Tf", "/Cour 12 Tf", "0 0 0 rg"), + ("0 g /Cour -5 Tf", "/Cour 12 Tf", "0 0 0 rg"), + ("NaN NaN NaN rg /Cour NaN Tf", "/Cour 12 Tf", "0 0 0 rg"), + ("inf g /Cour inf Tf", "/Cour 12 Tf", "0 0 0 rg"), + ("1 0 0 rg /Cour 1e300 Tf", "/Cour 400 Tf", "1 0 0 rg"), + // In `/DR`: Times Bold. Not in `/DR` and no standard name: Helvetica. + ("0 g /F1 Tf", "/TiBo 12 Tf", "0 0 0 rg"), + ("0 g /F9 11 Tf", "/Helv 11 Tf", "0 0 0 rg"), + // No Tf at all, or garbage. + ("0 0 1 rg", "/Helv 12 Tf", "0 0 1 rg"), + ("] [ << >> Tf", "/Helv 12 Tf", "0 0 0 rg"), + ] { + let mut d = doc(da); + set_appearance(&mut d, ObjRef::new(4, 0)).unwrap(); + let content = ap_content(&d, &list(&d, 0)[0]); + assert!(content.contains(tf), "{da:?}: {content}"); + assert!(content.contains(colour), "{da:?}: {content}"); + let lower = content.to_ascii_lowercase(); + assert!(!lower.contains("nan") && !lower.contains("inf"), "{da:?}: {content}"); + } +} + +/// A callout with its text box at [300 600 500 640], knee [260 620] and arrow point [200 520]: +/// the knee line runs level from the box's left side, as Acrobat draws it. +fn callout_fixture() -> (Document, usize) { + let mut doc = fixture(); + let c = add_annotation( + &mut doc, + &new( + 0, + Shape::Callout { + rect: [300.0, 600.0, 500.0, 640.0], + knee: [260.0, 620.0], + point: [200.0, 520.0], + font_size: 12.0, + ending: LineEnding::OpenArrow, + }, + ), + &meta("c"), + ) + .unwrap(); + (doc, c) +} + +fn nums(d: &Dict, k: &[u8]) -> Vec { + d.get(k).unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect() +} + +/// The text box a callout's `/Rect` and `/RD` describe. +fn callout_box_of(d: &Dict) -> [f64; 4] { + let (r, rd) = (rect(d), nums(d, b"RD")); + [r[0] + rd[0], r[1] + rd[1], r[2] - rd[2], r[3] - rd[3]] +} + +/// #1152: a callout's arrow point and knee move on their own, as its handles drag them (Acrobat: +/// "Callout text boxes have three parts: a text box, a knee line, and an end-point line. You can +/// resize each part by dragging a handle", Acrobat DC Help, 11/28/2019, p. 357). The text box +/// stays and the line re-attaches to it. +#[test] +fn a_callouts_arrow_point_and_knee_move_on_their_own() { + let (mut doc, c) = callout_fixture(); + // The arrow point alone: the knee and the box stay. + set_points(&mut doc, 0, c, &[[150.0, 400.0], [260.0, 620.0]], &meta("p")).unwrap(); + let doc = reopen(&doc); + let d = &list(&doc, 0)[c]; + assert_eq!(nums(d, b"CL"), [150.0, 400.0, 260.0, 620.0, 300.0, 620.0]); + assert_eq!(callout_box_of(d), [300.0, 600.0, 500.0, 640.0]); + // /Rect holds the line (padded) and the box. + let r = rect(d); + assert!(r[0] < 150.0 && r[1] < 400.0 && r[2] >= 500.0 && r[3] >= 640.0, "{r:?}"); + assert!(ap_content(&doc, d).contains("150 400 m"), "the line starts at the new arrow point"); + // The knee alone, to the right of the box: the line now meets the right side. + let mut doc = doc; + set_points(&mut doc, 0, c, &[[150.0, 400.0], [560.0, 620.0]], &meta("k")).unwrap(); + let d = &list(&doc, 0)[c]; + assert_eq!(nums(d, b"CL"), [150.0, 400.0, 560.0, 620.0, 500.0, 620.0]); + assert_eq!(callout_box_of(d), [300.0, 600.0, 500.0, 640.0]); + assert_eq!(summaries(&doc).iter().find(|s| s.index == c).map(|s| s.points.clone()), Some(vec![[150.0, 400.0], [560.0, 620.0]])); +} + +/// A callout's leader line as its box moves or is resized, as Acrobat Pro DC (a 2021 Continuous +/// release) was seen to keep it (#1152): the arrow point never moves ("The text box moves around +/// a stationary anchor point", Acrobat DC Help, 11/28/2019, p. 357); moved, the knee and the end +/// come along; +/// resized with the side the line meets left in place, nothing changes; that side moved, the line +/// is laid out again from the side facing the arrow point with a 12 pt knee line, and the box stays +/// where it was put (Acrobat moves it next to the arrow point). +#[test] +fn a_callouts_box_moves_around_its_arrow_and_keeps_the_knee_beside_it() { + let (mut doc, c) = callout_fixture(); + let cl = |doc: &Document| nums(&list(doc, 0)[c], b"CL"); + // Wider and taller, its left side where it was: the line stays. + set_rect(&mut doc, 0, c, [300.0, 580.0, 540.0, 640.0], &meta("r")).unwrap(); + assert_eq!(cl(&doc), [200.0, 520.0, 260.0, 620.0, 300.0, 620.0]); + // Moved 40 right and 10 up, same size: the knee and the end come along. + set_rect(&mut doc, 0, c, [340.0, 590.0, 580.0, 650.0], &meta("m")).unwrap(); + assert_eq!(cl(&doc), [200.0, 520.0, 300.0, 630.0, 340.0, 630.0]); + // The left side moved: the line meets its middle, the knee 12 pt out. + set_rect(&mut doc, 0, c, [380.0, 590.0, 580.0, 650.0], &meta("s")).unwrap(); + assert_eq!(cl(&doc), [200.0, 520.0, 368.0, 620.0, 380.0, 620.0]); + assert_eq!(callout_box_of(&list(&doc, 0)[c]), [380.0, 590.0, 580.0, 650.0], "the box stays where it was put"); + // Past the arrow point: the line leaves from the side facing it. + set_rect(&mut doc, 0, c, [100.0, 600.0, 180.0, 640.0], &meta("p")).unwrap(); + assert_eq!(cl(&doc), [200.0, 520.0, 192.0, 620.0, 180.0, 620.0]); +} + +/// The line is laid out again when the end no longer lies on the side it met (the box shrank past +/// it), and from the side facing the arrow point, not the old knee, which can end up over the box. +#[test] +fn a_callout_is_laid_out_again_when_its_end_leaves_the_box() { + let (mut doc, c) = callout_fixture(); + // Left side unmoved, but the box now starts above the end (300, 620). + set_rect(&mut doc, 0, c, [300.0, 625.0, 500.0, 640.0], &meta("a")).unwrap(); + assert_eq!(nums(&list(&doc, 0)[c], b"CL"), [200.0, 520.0, 288.0, 632.5, 300.0, 632.5]); + let (mut doc, c) = callout_fixture(); + // Over the old knee (260, 620), which would face the bottom; the arrow faces the left side. + set_rect(&mut doc, 0, c, [250.0, 590.0, 450.0, 640.0], &meta("b")).unwrap(); + assert_eq!(nums(&list(&doc, 0)[c], b"CL"), [200.0, 520.0, 238.0, 615.0, 250.0, 615.0]); +} + +/// Review on #4094: the page sends a callout's text box back as `f32` values, so a box moved or +/// resized on screen comes back a few hundred-thousandths of a point off. That still counts as a +/// move (the knee comes along) and as the same side (the line stays), not as a new layout. +#[test] +fn a_callout_box_sent_back_as_f32_still_moves_and_keeps_its_line() { + let mut doc = fixture(); + let c = add_annotation( + &mut doc, + &new( + 0, + Shape::Callout { + rect: [323.05, 292.14, 506.98, 374.91], + knee: [311.05, 333.53], + point: [299.05, 274.83], + font_size: 12.0, + ending: LineEnding::OpenArrow, + }, + ), + &meta("c"), + ) + .unwrap(); + let f32s = |r: [f64; 4]| r.map(|v| f64::from(v as f32)); + let cl = |doc: &Document| nums(&list(doc, 0)[c], b"CL"); + let before = cl(&doc); + // Resized from the bottom right, its left side where it was. + set_rect(&mut doc, 0, c, f32s([323.05, 250.14, 540.98, 374.91]), &meta("r")).unwrap(); + assert_eq!(cl(&doc), before, "the line stays"); + // Moved by (47.62, 26.13), same size. + let tb = callout_box_of(&list(&doc, 0)[c]); + set_rect(&mut doc, 0, c, f32s([tb[0] + 47.62, tb[1] + 26.13, tb[2] + 47.62, tb[3] + 26.13]), &meta("m")).unwrap(); + let after = cl(&doc); + assert!((after[2] - (before[2] + 47.62)).abs() < 0.01 && (after[3] - (before[3] + 26.13)).abs() < 0.01, "the knee came along: {after:?}"); + assert_eq!((after[0], after[1]), (before[0], before[1]), "the arrow point stayed"); +} + +/// Review on #4094: a `/CL` with something other than a number in it is not read at all, rather +/// than read with its numbers shifted into the wrong points. +#[test] +fn a_callout_line_with_a_non_number_is_not_read() { + let (mut doc, c) = callout_fixture(); + let (_, r) = annot_ref(&mut doc, 0, c).unwrap(); + doc.update_dict(r, |d| { + d.set( + b"CL".to_vec(), + Object::Array(vec![Object::name("x"), Object::Real(200.0), Object::Real(520.0), Object::Real(260.0), Object::Real(620.0)]), + ) + }) + .unwrap(); + assert!(matches!(set_points(&mut doc, 0, c, &[[1.0, 1.0]], &meta("x")), Err(AnnotError::Invalid(_))), "not a callout line it can reshape"); + assert_eq!(summaries(&doc).into_iter().find(|s| s.index == c).map(|s| (s.points.len(), s.text_box.is_some())), Some((0, false))); +} + +/// A new callout's knee is Acrobat's: 12 pt out from the middle of the side facing the arrow. +#[test] +fn a_new_callouts_knee_line_is_twelve_points() { + let tb = [300.0, 600.0, 500.0, 640.0]; + assert_eq!(callout_knee(tb, [200.0, 520.0]), [288.0, 620.0]); + assert_eq!(callout_knee(tb, [600.0, 700.0]), [512.0, 620.0]); + assert_eq!(callout_knee(tb, [400.0, 700.0]), [400.0, 652.0]); + assert_eq!(callout_knee(tb, [400.0, 500.0]), [400.0, 588.0]); +} + +/// ISO 32000-2 Table 177: `/CL` may have four numbers (no knee). Resizing such a callout kept its +/// line only with six, so the box lost `/RD` and the line was left outside `/Rect`. +#[test] +fn a_callout_without_a_knee_keeps_its_line() { + let (mut doc, c) = callout_fixture(); + let (_, r) = annot_ref(&mut doc, 0, c).unwrap(); + doc.update_dict(r, |d| d.set(b"CL".to_vec(), num_array(&[200.0, 520.0, 300.0, 620.0]))).unwrap(); + set_rect(&mut doc, 0, c, [300.0, 580.0, 500.0, 640.0], &meta("r")).unwrap(); + let d = &list(&doc, 0)[c]; + assert_eq!(nums(d, b"CL"), [200.0, 520.0, 300.0, 620.0], "still four numbers, the left side unmoved"); + assert_eq!(callout_box_of(d), [300.0, 580.0, 500.0, 640.0]); + assert!(rect(d)[1] < 520.0, "/Rect holds the line"); + // Its one point is the arrow; a second adds a knee. + assert_eq!(summaries(&doc).iter().find(|s| s.index == c).map(|s| s.points.clone()), Some(vec![[200.0, 520.0]])); + set_points(&mut doc, 0, c, &[[210.0, 500.0]], &meta("p")).unwrap(); + assert_eq!(nums(&list(&doc, 0)[c], b"CL"), [210.0, 500.0, 300.0, 620.0]); + set_points(&mut doc, 0, c, &[[210.0, 500.0], [250.0, 610.0]], &meta("k")).unwrap(); + assert_eq!(nums(&list(&doc, 0)[c], b"CL"), [210.0, 500.0, 250.0, 610.0, 300.0, 620.0]); + // Back to no knee, then the left side moved: the end meets its middle. + let (_, r) = annot_ref(&mut doc, 0, c).unwrap(); + doc.update_dict(r, |d| d.set(b"CL".to_vec(), num_array(&[210.0, 500.0, 300.0, 620.0]))).unwrap(); + set_rect(&mut doc, 0, c, [280.0, 590.0, 500.0, 640.0], &meta("s")).unwrap(); + assert_eq!(nums(&list(&doc, 0)[c], b"CL"), [210.0, 500.0, 280.0, 615.0]); +} + +#[test] +fn a_callout_refuses_points_it_cant_have() { + let (mut doc, c) = callout_fixture(); + let before = list(&doc, 0)[c].clone(); + for bad in [&[][..], &[[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]][..], &[[f64::NAN, 1.0]][..], &[[1e9, 1.0]][..]] { + assert!(matches!(set_points(&mut doc, 0, c, bad, &meta("x")), Err(AnnotError::Invalid(_))), "{bad:?}"); + } + assert_eq!(list(&doc, 0)[c], before, "left as it was"); + // Without a readable /RD there is no box to keep, so nothing moves. + let (_, r) = annot_ref(&mut doc, 0, c).unwrap(); + doc.update_dict(r, |d| { + d.remove(b"RD"); + }) + .unwrap(); + assert!(matches!(set_points(&mut doc, 0, c, &[[1.0, 1.0]], &meta("x")), Err(AnnotError::Invalid(_)))); + // Nor with an /RD that puts the box outside /Rect (negative) or leaves no box (under 1 pt): + // set_points refuses, and summaries offer no text box to drag. + let outer = rect(&list(&doc, 0)[c]); + for rd in [[-5.0, 50.0, 0.0, 0.0], [0.0, 0.0, outer[2] - outer[0] - 0.5, 0.0]] { + doc.update_dict(r, |d| d.set(b"RD".to_vec(), num_array(&rd))).unwrap(); + assert!(matches!(set_points(&mut doc, 0, c, &[[1.0, 1.0]], &meta("x")), Err(AnnotError::Invalid(_))), "{rd:?}"); + assert_eq!(summaries(&doc).into_iter().find(|s| s.index == c).and_then(|s| s.text_box), None, "{rd:?}"); + } +} + +/// The side a knee faces: left or right of the box first, then above or below. A knee on the line +/// of the left or right edge past the box is above or below it; the ties are in +/// `a_callouts_knee_has_one_side_for_every_tie`. +#[test] +fn a_callouts_knee_faces_the_side_it_is_beyond() { + let tb = [100.0, 100.0, 200.0, 150.0]; + assert_eq!(callout_side(tb, [99.0, 125.0]), Side::Left); + assert_eq!(callout_side(tb, [201.0, 125.0]), Side::Right); + assert_eq!(callout_side(tb, [100.0, 160.0]), Side::Top, "on the left edge's line"); + assert_eq!(callout_side(tb, [200.0, 90.0]), Side::Bottom, "on the right edge's line"); + assert_eq!(callout_side(tb, [150.0, 125.0]), Side::Bottom, "inside"); + assert!(Side::Left.horizontal() && Side::Right.horizontal() && !Side::Top.horizontal() && !Side::Bottom.horizontal()); +} + +#[test] +fn summaries_give_a_callouts_points_and_text_box() { + let (doc, c) = callout_fixture(); + let s = summaries(&doc).into_iter().find(|s| s.index == c).unwrap(); + assert_eq!(s.points, vec![[200.0, 520.0], [260.0, 620.0]]); + assert_eq!(s.text_box, Some([300.0, 600.0, 500.0, 640.0])); + let square = summaries(&doc).into_iter().find(|s| s.subtype == "Square").unwrap(); + assert_eq!((square.points.len(), square.text_box), (0, None)); +} + +/// A callout whose text box can't be read (no `/RD`, a negative inset, or no box left inside +/// `/Rect`) lists no points either, as `pdfcraft_render::inspect` does: `set_points` refuses it. +#[test] +fn summaries_give_no_callout_points_without_a_text_box() { + for rd in [None, Some([-1.0, 0.0, 0.0, 0.0]), Some([0.0, 0.0, 0.0, 1000.0])] { + let (mut doc, c) = callout_fixture(); + let (_, r) = annot_ref(&mut doc, 0, c).unwrap(); + doc.update_dict(r, |d| match rd { + Some(rd) => d.set(b"RD".to_vec(), num_array(&rd)), + None => { + d.remove(b"RD"); + } + }) + .unwrap(); + let s = summaries(&doc).into_iter().find(|s| s.index == c).unwrap(); + assert_eq!((s.points.len(), s.text_box), (0, None), "/RD {rd:?}"); + } +} + +/// Review on #4094: a callout given its arrow point alone keeps its knee. The new line was built +/// from the one point and the end, so a six-number `/CL` silently became four. +#[test] +fn a_callouts_arrow_point_alone_keeps_its_knee() { + let (mut doc, c) = callout_fixture(); + let before = nums(&list(&doc, 0)[c], b"CL"); + assert_eq!(before.len(), 6, "{before:?}"); + set_points(&mut doc, 0, c, &[[190.0, 500.0]], &meta("p")).unwrap(); + let after = nums(&list(&doc, 0)[c], b"CL"); + assert_eq!(after.len(), 6, "the knee stays: {after:?}"); + assert_eq!(after[..2], [190.0, 500.0], "the arrow point moved"); + assert_eq!(after[2..], before[2..], "the knee and the end are where they were"); +} + +/// Review on #4094: every tie has one defined side. Beside the box (left or right of it) wins over +/// above or below it, so a knee off a corner diagonally, or on the line of the top or bottom edge +/// past the box, takes the left or right side. On the box's outline a knee takes the side whose +/// edge it lies on (within 0.01 pt), and a corner the left or right side, as beside the box. +/// Inside the box, or with a coordinate that isn't a number, it takes the bottom side. +#[test] +fn a_callouts_knee_has_one_side_for_every_tie() { + let tb = [100.0, 100.0, 200.0, 150.0]; + let cases: [([f64; 2], Side, &str); 30] = [ + ([100.004, 125.0], Side::Left, "on the left edge, give or take an f32"), + ([199.996, 125.0], Side::Right, "on the right edge, give or take an f32"), + ([150.0, 149.996], Side::Top, "on the top edge, give or take an f32"), + ([100.02, 125.0], Side::Bottom, "inside, past the slack"), + ([150.0, 125.0], Side::Bottom, "inside, at the middle"), + ([110.0, 145.0], Side::Bottom, "inside, near the top-left corner"), + ([100.0, 125.0], Side::Left, "on the left edge"), + ([200.0, 125.0], Side::Right, "on the right edge"), + ([150.0, 150.0], Side::Top, "on the top edge"), + ([150.0, 100.0], Side::Bottom, "on the bottom edge"), + ([100.0, 150.0], Side::Left, "the top-left corner"), + ([200.0, 150.0], Side::Right, "the top-right corner"), + ([100.0, 100.0], Side::Left, "the bottom-left corner"), + ([200.0, 100.0], Side::Right, "the bottom-right corner"), + ([90.0, 150.0], Side::Left, "the top edge's line, left of the box"), + ([210.0, 150.0], Side::Right, "the top edge's line, right of the box"), + ([90.0, 100.0], Side::Left, "the bottom edge's line, left of the box"), + ([100.0, 160.0], Side::Top, "the left edge's line, above the box"), + ([100.0, 90.0], Side::Bottom, "the left edge's line, below the box"), + ([200.0, 160.0], Side::Top, "the right edge's line, above the box"), + ([90.0, 160.0], Side::Left, "diagonally off the top-left corner"), + ([210.0, 90.0], Side::Right, "diagonally off the bottom-right corner"), + ([f64::NAN, 125.0], Side::Bottom, "x not a number"), + ([150.0, f64::NAN], Side::Bottom, "y not a number"), + ([f64::NAN, 160.0], Side::Bottom, "x not a number, y above the box"), + ([f64::NAN, 90.0], Side::Bottom, "x not a number, y below the box"), + ([90.0, f64::NAN], Side::Bottom, "y not a number, x left of the box"), + ([210.0, f64::NAN], Side::Bottom, "y not a number, x right of the box"), + ([f64::NEG_INFINITY, 125.0], Side::Left, "infinitely far left"), + ([150.0, f64::INFINITY], Side::Top, "infinitely far up"), + ]; + for (knee, side, what) in cases { + assert_eq!(callout_side(tb, knee), side, "{what}: {knee:?}"); + // Written corners first, the box is the same box. + assert_eq!(callout_side([200.0, 150.0, 100.0, 100.0], knee), side, "{what}, corners first"); + // The line meets the middle of that side. + let want = match side { + Side::Left => [100.0, 125.0], + Side::Right => [200.0, 125.0], + Side::Top => [150.0, 150.0], + Side::Bottom => [150.0, 100.0], + }; + assert_eq!(callout_attach(tb, knee), want, "{what}"); + } +} + +/// A knee dragged exactly onto the box's left edge keeps the line on the left side, where its end +/// already is, instead of sending it across the box to the bottom; a knee dragged inside the box +/// meets the bottom side. +#[test] +fn a_callouts_knee_on_or_inside_the_box_meets_a_defined_side() { + let (mut doc, c) = callout_fixture(); + set_points(&mut doc, 0, c, &[[200.0, 520.0], [300.0, 630.0]], &meta("edge")).unwrap(); + assert_eq!(nums(&list(&doc, 0)[c], b"CL"), [200.0, 520.0, 300.0, 630.0, 300.0, 620.0]); + set_points(&mut doc, 0, c, &[[200.0, 520.0], [350.0, 620.0]], &meta("inside")).unwrap(); + assert_eq!(nums(&list(&doc, 0)[c], b"CL"), [200.0, 520.0, 350.0, 620.0, 400.0, 600.0]); + assert_eq!(callout_box_of(&list(&doc, 0)[c]), [300.0, 600.0, 500.0, 640.0], "the box stays"); +} + +/// A callout's `/CL` read as numbers, and the check every edit must pass: four numbers (arrow +/// point, end) or six (arrow point, knee, end), all finite (ISO 32000-2 Table 177), every point +/// inside `/Rect`, and a text box (`/Rect` inset by `/RD`) at least 1 pt a side. +fn assert_callout_line(d: &Dict, len: usize, what: &str) -> Vec { + let cl = nums(d, b"CL"); + assert_eq!(cl.len(), len, "{what}: {cl:?}"); + assert!(cl.iter().all(|v| v.is_finite()), "{what}: {cl:?}"); + let r = rect(d); + for p in cl.chunks(2) { + assert!(p[0] >= r[0] - 1e-6 && p[0] <= r[2] + 1e-6 && p[1] >= r[1] - 1e-6 && p[1] <= r[3] + 1e-6, "{what}: {p:?} outside {r:?}"); + } + let tb = callout_box_of(d); + assert!(tb[2] - tb[0] >= 1.0 && tb[3] - tb[1] >= 1.0, "{what}: text box {tb:?}"); + cl +} + +/// Review on #4094: every edit of a callout keeps `/CL` four numbers when it had four and six when +/// it had six: the arrow point dragged, the knee dragged, the box moved, the box resized, the whole +/// callout moved, and the file saved and opened again. +#[test] +fn every_callout_edit_keeps_four_or_six_numbers() { + for len in [4usize, 6] { + let (mut doc, c) = callout_fixture(); + if len == 4 { + let (_, r) = annot_ref(&mut doc, 0, c).unwrap(); + doc.update_dict(r, |d| d.set(b"CL".to_vec(), num_array(&[200.0, 520.0, 300.0, 620.0]))).unwrap(); + } + let cl = |doc: &Document, what: &str| assert_callout_line(&list(doc, 0)[c], len, &format!("{len}: {what}")); + set_points(&mut doc, 0, c, &[[150.0, 400.0]], &meta("tip")).unwrap(); + assert_eq!(cl(&doc, "the arrow point")[..2], [150.0, 400.0]); + if len == 6 { + set_points(&mut doc, 0, c, &[[150.0, 400.0], [260.0, 630.0]], &meta("knee")).unwrap(); + assert_eq!(cl(&doc, "the knee")[2..4], [260.0, 630.0]); + } + let tb = callout_box_of(&list(&doc, 0)[c]); + set_rect(&mut doc, 0, c, [tb[0] + 40.0, tb[1] + 10.0, tb[2] + 40.0, tb[3] + 10.0], &meta("box")).unwrap(); + assert_eq!(cl(&doc, "the box moved")[..2], [150.0, 400.0], "the arrow point stays"); + let tb = callout_box_of(&list(&doc, 0)[c]); + set_rect(&mut doc, 0, c, [tb[0] + 30.0, tb[1] - 20.0, tb[2] + 10.0, tb[3]], &meta("size")).unwrap(); + let before = cl(&doc, "the box resized"); + move_annotation(&mut doc, 0, c, 20.0, -15.0, &meta("move")).unwrap(); + let after = cl(&doc, "the callout moved"); + let shifted: Vec = before.iter().enumerate().map(|(i, v)| v + if i % 2 == 0 { 20.0 } else { -15.0 }).collect(); + assert_eq!(after, shifted, "{len}: every point moves with it"); + let doc = reopen(&doc); + cl(&doc, "saved and opened again"); + } +} + +/// Review on #4094: a `/CL` that is neither four nor six finite numbers (two, three, five or eight +/// numbers, an infinite or not-a-number value, an empty array, a nested array, a name or a string) +/// is not a callout line PdfCraft can reshape. No edit panics, the arrow point and knee are +/// refused, and none writes a `/CL` of another length: resizing leaves it as it was and moving +/// shifts its numbers in place. A `/CL` held as an indirect object is read as the line it is. +#[test] +fn a_hostile_callout_line_is_never_rewritten_into_another_shape() { + let reals = |v: &[f64]| Object::Array(v.iter().map(|x| Object::Real(*x)).collect()); + let hostile: Vec<(&str, Object)> = vec![ + ("two numbers", reals(&[200.0, 520.0])), + ("three numbers", reals(&[200.0, 520.0, 260.0])), + ("five numbers", reals(&[200.0, 520.0, 260.0, 620.0, 300.0])), + ("eight numbers", reals(&[200.0, 520.0, 260.0, 620.0, 280.0, 620.0, 300.0, 620.0])), + ("not a number", reals(&[200.0, f64::NAN, 260.0, 620.0, 300.0, 620.0])), + ("infinite", reals(&[f64::INFINITY, 520.0, 260.0, 620.0])), + ("empty", Object::Array(Vec::new())), + ("nested", Object::Array(vec![reals(&[200.0, 520.0]), reals(&[300.0, 620.0])])), + ("a name", Object::name("CL")), + ("a string", PdfString::text("200 520 300 620").into()), + ]; + for (what, cl) in hostile { + let (mut doc, c) = callout_fixture(); + let (_, r) = annot_ref(&mut doc, 0, c).unwrap(); + doc.update_dict(r, |d| d.set(b"CL".to_vec(), cl.clone())).unwrap(); + let len = cl.as_array().map(Vec::len); + for points in [&[[150.0, 400.0]][..], &[[150.0, 400.0], [260.0, 630.0]][..]] { + assert!(set_points(&mut doc, 0, c, points, &meta("p")).is_err(), "{what}: {points:?}"); + } + // Compared as written: a not-a-number value is never equal to itself. + let same = |doc: &Document| format!("{:?}", list(doc, 0)[c].get(b"CL")) == format!("{:?}", Some(&cl)); + assert!(same(&doc), "{what}: refused, so unchanged"); + let s = summaries(&doc).into_iter().find(|s| s.index == c).unwrap(); + assert_eq!((s.points.len(), s.text_box), (0, None), "{what}: nothing to drag"); + let _ = set_rect(&mut doc, 0, c, [300.0, 580.0, 520.0, 640.0], &meta("r")); + assert!(same(&doc), "{what}: resizing leaves it as it was"); + let _ = move_annotation(&mut doc, 0, c, 20.0, -15.0, &meta("m")); + assert_eq!(list(&doc, 0)[c].get(b"CL").and_then(Object::as_array).map(Vec::len), len, "{what}: moved in place"); + reopen(&doc); + } + // Held as an indirect object, a six-number line is a callout line like any other. + let (mut doc, c) = callout_fixture(); + let line = doc.add(reals(&[200.0, 520.0, 260.0, 620.0, 300.0, 620.0])); + let (_, r) = annot_ref(&mut doc, 0, c).unwrap(); + doc.update_dict(r, |d| d.set(b"CL".to_vec(), Object::Ref(line))).unwrap(); + set_points(&mut doc, 0, c, &[[150.0, 400.0]], &meta("p")).unwrap(); + assert_eq!(assert_callout_line(&list(&doc, 0)[c], 6, "indirect"), [150.0, 400.0, 260.0, 620.0, 300.0, 620.0]); +} + +/// A pop-up is opened, closed and moved through `set_popup` (#comment.popups): a note's own +/// pop-up follows, a comment without one gets one beside it, and the comment itself (its text, +/// its date) is left alone. Summaries report the pop-up. Text boxes, Fill & Sign marks and +/// replies have none of their own; a bad rectangle is refused. +#[test] +fn pop_ups_open_close_and_move_and_are_reported() { + let mut doc = fixture(); + let note = add_annotation(&mut doc, &new(0, Shape::Note { at: [20.0, 280.0], icon: NoteIcon::Comment }), &meta("n1")).unwrap(); + let before = summaries(&doc).into_iter().find(|s| s.index == note).unwrap(); + assert_eq!(before.popup.map(|p| p.open), Some(false), "new notes have a closed pop-up"); + set_popup(&mut doc, 0, note, None, Some(true), &meta("n1")).unwrap(); + let after = summaries(&doc).into_iter().find(|s| s.index == note).unwrap(); + assert_eq!(after.popup.map(|p| p.open), Some(true)); + assert_eq!(after.popup.map(|p| p.rect), before.popup.map(|p| p.rect), "opening does not move it"); + assert_eq!((after.modified, after.contents.as_deref()), (before.modified, Some("hello")), "the comment is untouched"); + let (_, r) = annot_ref(&mut doc, 0, note).unwrap(); + assert_eq!(annot_dict(&doc, r).get(b"Open"), Some(&Object::Bool(true)), "the note's own /Open follows"); + set_popup(&mut doc, 0, note, Some([150.0, 100.0, 290.0, 200.0]), Some(false), &meta("n1")).unwrap(); + let moved = summaries(&doc).into_iter().find(|s| s.index == note).unwrap(); + assert_eq!(moved.popup, Some(PopupInfo { rect: [150.0, 100.0, 290.0, 200.0], open: false })); + assert_eq!(annot_dict(&doc, r).get(b"Open"), Some(&Object::Bool(false))); + + // A highlight is created without a pop-up: the first open gives it one, beside it. + let mark = add_annotation( + &mut doc, + &new(0, Shape::TextMarkup { kind: Markup::Highlight, quads: vec![[20.0, 200.0, 120.0, 200.0, 20.0, 190.0, 120.0, 190.0]] }), + &meta("h1"), + ) + .unwrap(); + assert_eq!(summaries(&doc).into_iter().find(|s| s.index == mark).unwrap().popup, None); + set_popup(&mut doc, 0, mark, None, Some(true), &meta("h1")).unwrap(); + let given = summaries(&doc).into_iter().find(|s| s.index == mark).unwrap().popup.expect("a pop-up now"); + assert!(given.open); + assert!((given.rect[0] - 130.0).abs() < 0.01 && (given.rect[2] - 330.0).abs() < 0.01, "beside the mark: {:?}", given.rect); + + // #2137 review: a /Popup naming another annotation (here the note) isn't this comment's: the + // note is left alone and the mark gets a pop-up of its own. + let (_, mr) = annot_ref(&mut doc, 0, mark).unwrap(); + doc.update_dict(mr, |m| m.set(b"Popup".to_vec(), Object::Ref(r))).unwrap(); + let note_before = annot_dict(&doc, r); + set_popup(&mut doc, 0, mark, Some([10.0, 10.0, 110.0, 60.0]), Some(true), &meta("h1")).unwrap(); + assert_eq!(annot_dict(&doc, r), note_before, "the note isn't moved or opened"); + let own = annot_dict(&doc, mr).reference(b"Popup").expect("a pop-up of its own"); + assert_ne!(own, r); + assert_eq!(doc.get(own).as_dict().and_then(|p| p.reference(b"Parent")), Some(mr)); + // Its own pop-up, with /Parent naming it, is the one moved next time. + set_popup(&mut doc, 0, mark, Some([12.0, 10.0, 112.0, 60.0]), None, &meta("h1")).unwrap(); + assert_eq!(annot_dict(&doc, mr).reference(b"Popup"), Some(own), "the same pop-up"); + + // #2137 review: nor is it reported as the comment's (see the helper). + foreign_pop_ups_are_not_the_marks(&mut doc, r, mark, mr); + + // None of their own. + let text_box = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [10.0, 10.0, 110.0, 40.0], font_size: 12.0 }), &meta("t1")).unwrap(); + assert!(matches!(set_popup(&mut doc, 0, text_box, None, Some(true), &meta("t1")), Err(AnnotError::Invalid(_)))); + let reply = add_reply(&mut doc, 0, note, "Agreed", "Ada", &meta("r1")).unwrap(); + assert!(matches!(set_popup(&mut doc, 0, reply, None, Some(true), &meta("r1")), Err(AnnotError::Invalid(_)))); + assert!(matches!(set_popup(&mut doc, 0, note, Some([0.0, 0.0, 0.5, 0.5]), None, &meta("n1")), Err(AnnotError::Invalid(_)))); + assert!(matches!(set_popup(&mut doc, 0, note, Some([f64::NAN, 0.0, 10.0, 10.0]), None, &meta("n1")), Err(AnnotError::Invalid(_)))); + assert!(matches!(set_popup(&mut doc, 0, 99, None, Some(true), &meta("n1")), Err(AnnotError::NoSuchAnnotation { .. }))); + + // #2137 review: /Annots may list the note through an object that is only a reference to it. + // Its pop-up's /Parent names the note itself, so the pop-up is still the note's. + let p = page_ref(&doc, 0).unwrap(); + let via = doc.add(Object::Ref(r)); + let list: Vec = annots(&doc, p).into_iter().map(|o| if o.as_ref() == Some(r) { Object::Ref(via) } else { o }).collect(); + set_annots(&mut doc, p, list).unwrap(); + let listed = summaries(&doc).into_iter().find(|s| s.index == note).unwrap(); + assert_eq!(listed.popup, Some(PopupInfo { rect: [150.0, 100.0, 290.0, 200.0], open: false }), "listed through {via:?}"); +} + +/// #2137 review: a pop-up that is not the mark's own is neither reported as its pop-up nor +/// changed through it (part of `pop_ups_open_close_and_move_and_are_reported`). +fn foreign_pop_ups_are_not_the_marks(doc: &mut Document, note: ObjRef, mark: usize, mr: ObjRef) { + // #2137 review: nor is it reported as the comment's. A /Popup naming the note, or the note's + // own pop-up (its /Parent is the note), gives the mark no pop-up in the summaries. + let popup_of_mark = |doc: &Document| summaries(doc).into_iter().find(|s| s.index == mark).unwrap().popup; + let notes_popup = annot_dict(doc, note).reference(b"Popup").expect("the note's pop-up"); + for foreign in [note, notes_popup] { + doc.update_dict(mr, |m| m.set(b"Popup".to_vec(), Object::Ref(foreign))).unwrap(); + assert_eq!(popup_of_mark(doc), None, "{foreign:?} isn't the mark's"); + } + // A pop-up whose /Parent isn't a reference (it must be one) isn't the mark's either: it is + // neither reported nor changed, and the mark gets one of its own. + let mut bad = Dict::new(); + bad.set(b"Type".to_vec(), Object::name("Annot")); + bad.set(b"Subtype".to_vec(), Object::name("Popup")); + bad.set(b"Rect".to_vec(), Object::Array([0.0, 0.0, 50.0, 50.0].iter().map(|v| Object::Real(*v)).collect())); + bad.set(b"Parent".to_vec(), Object::Dict(Dict::new())); + let bad = doc.add(Object::Dict(bad)); + doc.update_dict(mr, |m| m.set(b"Popup".to_vec(), Object::Ref(bad))).unwrap(); + assert_eq!(popup_of_mark(doc), None, "a direct /Parent"); + let bad_before = doc.get(bad).as_dict().cloned(); + set_popup(doc, 0, mark, None, Some(true), &meta("h1")).unwrap(); + assert_eq!(doc.get(bad).as_dict().cloned(), bad_before, "left alone"); + assert_ne!(annot_dict(doc, mr).reference(b"Popup"), Some(bad)); + // A pop-up with no /Parent at all is taken as the comment's. + let mut orphan = Dict::new(); + orphan.set(b"Type".to_vec(), Object::name("Annot")); + orphan.set(b"Subtype".to_vec(), Object::name("Popup")); + orphan.set(b"Rect".to_vec(), Object::Array([0.0, 0.0, 50.0, 50.0].iter().map(|v| Object::Real(*v)).collect())); + let orphan = doc.add(Object::Dict(orphan)); + doc.update_dict(mr, |m| m.set(b"Popup".to_vec(), Object::Ref(orphan))).unwrap(); + assert_eq!(popup_of_mark(doc), Some(PopupInfo { rect: [0.0, 0.0, 50.0, 50.0], open: false }), "no /Parent"); +} + +/// #2137 review: a comment's own pop-up without a `/Rect` of four numbers reads as none; opening +/// it gives it the place a new pop-up gets, so the window shows again. +#[test] +fn opening_a_pop_up_without_a_rect_gives_it_one() { + let mut doc = fixture(); + let note = add_annotation(&mut doc, &new(0, Shape::Note { at: [20.0, 280.0], icon: NoteIcon::Comment }), &meta("n1")).unwrap(); + let (_, r) = annot_ref(&mut doc, 0, note).unwrap(); + let pr = annot_dict(&doc, r).reference(b"Popup").expect("new notes have a pop-up"); + doc.update_dict(pr, |p| { + p.remove(b"Rect"); + }) + .unwrap(); + assert_eq!(summaries(&doc).into_iter().find(|s| s.index == note).unwrap().popup, None, "no /Rect reads as no pop-up"); + set_popup(&mut doc, 0, note, None, Some(true), &meta("n1")).unwrap(); + let popup = summaries(&doc).into_iter().find(|s| s.index == note).unwrap().popup.expect("the pop-up reads again"); + assert!(popup.open); + assert!(popup.rect[2] - popup.rect[0] >= 100.0 && popup.rect[3] - popup.rect[1] >= 100.0, "a window's size: {:?}", popup.rect); + assert_eq!(annot_dict(&doc, r).reference(b"Popup"), Some(pr), "the same pop-up, not a new one"); +} + +/// #2137 review: a pop-up rectangle with its corners reversed is read, and saved, with them in +/// order (ISO 32000-2 §7.9.5: a reader normalises a rectangle). One whose numbers aren't finite +/// as `f32` (a file's huge number) reads as no pop-up, and opening it gives it a new place. +#[test] +fn a_pop_up_rect_with_reversed_corners_is_put_in_order_and_a_huge_one_is_none() { + let mut doc = fixture(); + let note = add_annotation(&mut doc, &new(0, Shape::Note { at: [20.0, 280.0], icon: NoteIcon::Comment }), &meta("n1")).unwrap(); + let (_, r) = annot_ref(&mut doc, 0, note).unwrap(); + let pr = annot_dict(&doc, r).reference(b"Popup").expect("new notes have a pop-up"); + let popup = |doc: &Document| summaries(doc).into_iter().find(|s| s.index == note).unwrap().popup; + let set_rect = |doc: &mut Document, rect: [f64; 4]| doc.update_dict(pr, |p| p.set(b"Rect".to_vec(), num_array(&rect))).unwrap(); + + // Reversed in the file: read in order. + set_rect(&mut doc, [290.0, 200.0, 150.0, 100.0]); + assert_eq!(popup(&doc), Some(PopupInfo { rect: [150.0, 100.0, 290.0, 200.0], open: false })); + // Reversed through `set_popup`: saved in order. + set_popup(&mut doc, 0, note, Some([300.0, 220.0, 160.0, 120.0]), None, &meta("n1")).unwrap(); + let saved: Vec = + doc.get(pr).as_dict().and_then(|p| p.get(b"Rect")).and_then(Object::as_array).unwrap().iter().filter_map(Object::as_f64).collect(); + assert_eq!(saved, [160.0, 120.0, 300.0, 220.0]); + + // A number too large for `f32` (or infinite): no pop-up, and opening it gives it a place. + for huge in [1e300, f64::INFINITY] { + set_rect(&mut doc, [0.0, 0.0, huge, 100.0]); + assert_eq!(popup(&doc), None, "{huge}"); + } + set_popup(&mut doc, 0, note, None, Some(true), &meta("n1")).unwrap(); + let given = popup(&doc).expect("the pop-up reads again"); + assert!(given.open && given.rect.iter().all(|v| v.is_finite()), "{given:?}"); +} + #[test] fn freehand_highlight_is_compatible_ink_with_marker_blending() { let shape = Shape::FreehandHighlight { strokes: vec![vec![[20.0, 100.0], [120.0, 100.0]]] }; @@ -4314,3 +5529,93 @@ fn summaries_list_only_usable_quads() { let summary = summaries(&doc).into_iter().find(|a| a.page == 0 && a.index == 0).unwrap(); assert_eq!(summary.quads, vec![QUAD.map(|v| v as f32)]); } + +/// A page as iOS Markup saves a signature: its private copy on the page and on the stamp, which +/// also carries a normal appearance. A plain note follows the stamp, at index 1. +fn apple_markup_page() -> Document { + document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 7 0 R] /AAPL:PPK 5 0 R /AAPL:PPKHash 5 0 R >>", + b"<< /Type /Annot /Subtype /Stamp /Rect [100 100 200 150] /F 644 /AP << /N 6 0 R >> /AAPL:AKExtras << /PPKType (draw) >> >>", + b"<< /Length 0 >> +stream + +endstream", + b"<< /Type /XObject /Subtype /Form /BBox [0 0 100 50] /Length 22 >> +stream +0 0 1 RG 1 1 m 9 9 l S +endstream", + b"<< /Type /Annot /Subtype /Text /Rect [400 400 420 420] /Contents (note) >>", + ]) +} + +/// Apple's copy is gone from the page and from every annotation left on it, and the drop is +/// in the repair log (fidelity: never silent). +fn assert_apple_copy_dropped(doc: &Document) { + assert!(doc.repair_log().iter().any(|l| l.contains("Apple Markup")), "{:?}", doc.repair_log()); + let doc = reopen(doc); + let page = doc.get(page_refs(&doc).unwrap()[0]); + let page = page.as_dict().unwrap(); + assert!(!page.contains(b"AAPL:PPK") && !page.contains(b"AAPL:PPKHash")); + let all = list(&doc, 0); + assert_eq!(all[0].name(b"Subtype"), Some(&b"Stamp"[..])); + assert!(all.iter().all(|d| !d.contains(b"AAPL:AKExtras"))); +} + +#[test] +fn deleting_from_an_apple_markup_page_drops_apples_stale_copy() { + let mut doc = apple_markup_page(); + delete_annotation(&mut doc, 0, 1).unwrap(); + assert_apple_copy_dropped(&doc); +} + +#[test] +fn replying_on_an_apple_markup_page_drops_apples_stale_copy() { + let mut doc = apple_markup_page(); + add_reply(&mut doc, 0, 1, "Agreed", "Bob", &meta("r")).unwrap(); + assert_apple_copy_dropped(&doc); +} + +#[test] +fn marking_on_an_apple_markup_page_drops_apples_stale_copy() { + let mut doc = apple_markup_page(); + set_marked(&mut doc, 0, 1, true, "Bob", &meta("m")).unwrap(); + assert_apple_copy_dropped(&doc); +} + +#[test] +fn adding_to_an_apple_markup_page_drops_apples_stale_copy() { + let mut doc = apple_markup_page(); + add_annotation(&mut doc, &new(0, Shape::Note { at: [300.0, 300.0], icon: NoteIcon::Comment }), &meta("n")).unwrap(); + assert_apple_copy_dropped(&doc); + // The Markup stamp itself stays, with its flags and its appearance, byte for byte. + let doc = reopen(&doc); + let all = list(&doc, 0); + assert_eq!(all[0].get(b"F").and_then(Object::as_int), Some(644)); + assert_eq!(ap_content(&doc, &all[0]), "0 0 1 RG 1 1 m 9 9 l S"); +} + +#[test] +fn a_refused_edit_on_an_apple_markup_page_drops_nothing() { + // An inline, locked Markup stamp: restyling it is refused, so Apple's copy must stay put + // (promoting the inline dictionary to an object is not a change to the markup). + let mut doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [<< /Type /Annot /Subtype /Stamp /Rect [100 100 200 150] /F 644 /AAPL:AKExtras << /PPKType (draw) >> >>] /AAPL:PPK 4 0 R /AAPL:PPKHash 4 0 R >>", + b"<< /Length 0 >> +stream + +endstream", + ]); + assert_eq!( + set_style(&mut doc, 0, 0, StyleChange { color: Some([1.0, 0.0, 0.0]), opacity: None, width: None, endings: None }, &meta("")), + Err(AnnotError::Invalid("the comment is locked".into())) + ); + let page = doc.get(page_refs(&doc).unwrap()[0]); + let page = page.as_dict().unwrap(); + assert!(page.contains(b"AAPL:PPK") && page.contains(b"AAPL:PPKHash")); + assert!(list(&doc, 0)[0].contains(b"AAPL:AKExtras")); + assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log()); +} diff --git a/crates/annot/src/text_style.rs b/crates/annot/src/text_style.rs new file mode 100644 index 0000000000..dfa4c8c1fa --- /dev/null +++ b/crates/annot/src/text_style.rs @@ -0,0 +1,480 @@ +//! The face, size and colour a text box's text is drawn in when PdfCraft draws its appearance: +//! `/DA` (ISO 32000-2 Table 177; the font named there is looked up in the interactive form's +//! `/DR` font resources, §12.7.4.3) and `/DS`, the default style string (Table 177), which is +//! read for its CSS `font`, `font-family`, `font-size`, `font-weight`, `font-style` and `color`. +//! +//! Only the twelve standard text faces are drawn (Helvetica, Times and Courier, each regular, +//! bold, italic and bold italic, in WinAnsiEncoding): a font that isn't one of them is drawn in +//! Helvetica, as before. Symbol and ZapfDingbats are not text faces for this purpose. + +use pdfcraft_cos::{Dict, Document, Object}; +use pdfcraft_fonts::{Std14, Std14Match as Match}; + +use crate::Rgb; +use crate::appearance::parse_da; + +/// `/DS` past this many bytes is read only this far (a style string is a line or two). +const MAX_DS: usize = 4096; + +/// Font sizes a text box is drawn at, as for `/DA`. +const MAX_SIZE: f64 = 400.0; + +/// How a text box's text is drawn. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TextStyle { + pub face: Std14, + pub size: f64, + pub color: Rgb, +} + +/// The style of `d`'s text: `/DA`, with what `/DS` sets taking its place. `dr_face` is the face +/// the `/DA` font resource names in the form's `/DR` ([`dr_face`]); `None` when it isn't there, +/// and the common resource names (`/Helv`, `/Cour`, `/TiRo`, …) are read instead. +pub fn text_style(d: &Dict, dr_face: Option) -> TextStyle { + let (da_color, da_size) = parse_da(d); + let da_face = dr_face.or_else(|| da_font_name(d).as_deref().and_then(resource_face)).unwrap_or(Std14::Helvetica); + let mut style = TextStyle { face: da_face, size: da_size, color: da_color }; + let Some(ds) = d.get(b"DS").and_then(Object::as_string).map(|s| s.to_text()) else { return style }; + let ds = parse_ds(&ds); + if let Some(size) = ds.size { + style.size = size; + } + // `/DS` colours are 8-bit (`#RRGGBB`): one that rounds to the same as `/DA`'s keeps `/DA`'s. + if let Some(color) = ds.color + && bytes(color) != bytes(da_color) + { + style.color = color; + } + let base = ds.face.unwrap_or(style.face); + style.face = styled(base, ds.bold.unwrap_or(is_bold(base)), ds.italic.unwrap_or(is_italic(base))); + style +} + +/// The face the `/DA` font of `d` names in the document's `/AcroForm /DR /Font` (§12.7.4.3): +/// `Some` when that resource is there — the standard text face its `/BaseFont` names, or +/// Helvetica for any other font — and `None` when it isn't. +pub fn dr_face(doc: &Document, d: &Dict) -> Option { + let name = da_font_name(d)?; + let root = doc.trailer().get(b"Root").map(|r| doc.resolve(r))?; + let form = root.as_dict()?.get(b"AcroForm").map(|f| doc.resolve(f))?; + let dr = form.as_dict()?.get(b"DR").map(|r| doc.resolve(r))?; + let fonts = dr.as_dict()?.get(b"Font").map(|f| doc.resolve(f))?; + let font = fonts.as_dict()?.get(name.as_bytes()).map(|f| doc.resolve(f))?; + let font = font.as_dict()?; + let base = font.name(b"BaseFont").map(String::from_utf8_lossy); + Some(base.as_deref().and_then(base_font_face).unwrap_or(Std14::Helvetica)) +} + +/// The font resource name of the last `Tf` in `/DA` (without the slash). `Tf` takes a name and +/// a size; a name given without its size (`/TiRo Tf`) is still read, and the size then falls back +/// as [`parse_da`] says. +pub fn da_font_name(d: &Dict) -> Option { + let da = d.get(b"DA").and_then(Object::as_string)?; + let da = String::from_utf8_lossy(&da.bytes); + let toks: Vec<&str> = da.split_whitespace().collect(); + let tf = toks.iter().rposition(|t| *t == "Tf")?; + let name = match toks.get(..tf)? { + [.., name, size] if !size.starts_with('/') => name, + [.., name] => name, + [] => return None, + }; + let name = name.strip_prefix('/')?; + (!name.is_empty()).then(|| name.to_string()) +} + +/// The face a font resource name stands for: the names PDF writers commonly give the standard +/// fonts in `/DR` (`Helv`, `HeBo`, `Cour`, `TiRo`, …), or a standard face's own name. +pub fn resource_face(name: &str) -> Option { + Some(match name { + "Helv" => Std14::Helvetica, + "HeBo" => Std14::HelveticaBold, + "HeOb" | "HeIt" => Std14::HelveticaOblique, + "HeBO" | "HeBI" => Std14::HelveticaBoldOblique, + "Cour" => Std14::Courier, + "CoBo" => Std14::CourierBold, + "CoOb" | "CoIt" => Std14::CourierOblique, + "CoBO" | "CoBI" => Std14::CourierBoldOblique, + "TiRo" => Std14::TimesRoman, + "TiBo" => Std14::TimesBold, + "TiIt" => Std14::TimesItalic, + "TiBI" => Std14::TimesBoldItalic, + _ => return base_font_face(name), + }) +} + +/// The standard text face a `/BaseFont` (or family) name asks for: the spec's names and their +/// usual aliases (Arial, Times New Roman, Courier New), not a guess from a family hint. +fn base_font_face(name: &str) -> Option { + match Std14::recognise(name)? { + (Std14::Symbol | Std14::ZapfDingbats, _) | (_, Match::FamilyHint) => None, + (face, Match::Alias) => Some(face), + } +} + +/// `CSS` family names: the generic ones, else a standard face's name or alias. +fn family_face(name: &str) -> Option { + let name = name.trim().trim_matches(|c| c == '"' || c == '\'').trim(); + match name.to_ascii_lowercase().as_str() { + "serif" => Some(Std14::TimesRoman), + "monospace" => Some(Std14::Courier), + "sans-serif" => Some(Std14::Helvetica), + _ => base_font_face(name), + } +} + +fn is_bold(f: Std14) -> bool { + matches!( + f, + Std14::HelveticaBold + | Std14::HelveticaBoldOblique + | Std14::TimesBold + | Std14::TimesBoldItalic + | Std14::CourierBold + | Std14::CourierBoldOblique + ) +} + +fn is_italic(f: Std14) -> bool { + matches!( + f, + Std14::HelveticaOblique + | Std14::HelveticaBoldOblique + | Std14::TimesItalic + | Std14::TimesBoldItalic + | Std14::CourierOblique + | Std14::CourierBoldOblique + ) +} + +/// `base`'s family in the given weight and slant. +fn styled(base: Std14, bold: bool, italic: bool) -> Std14 { + use Std14::*; + match (base, bold, italic) { + (TimesRoman | TimesBold | TimesItalic | TimesBoldItalic, false, false) => TimesRoman, + (TimesRoman | TimesBold | TimesItalic | TimesBoldItalic, true, false) => TimesBold, + (TimesRoman | TimesBold | TimesItalic | TimesBoldItalic, false, true) => TimesItalic, + (TimesRoman | TimesBold | TimesItalic | TimesBoldItalic, true, true) => TimesBoldItalic, + (Courier | CourierBold | CourierOblique | CourierBoldOblique, false, false) => Courier, + (Courier | CourierBold | CourierOblique | CourierBoldOblique, true, false) => CourierBold, + (Courier | CourierBold | CourierOblique | CourierBoldOblique, false, true) => CourierOblique, + (Courier | CourierBold | CourierOblique | CourierBoldOblique, true, true) => CourierBoldOblique, + (_, false, false) => Helvetica, + (_, true, false) => HelveticaBold, + (_, false, true) => HelveticaOblique, + (_, true, true) => HelveticaBoldOblique, + } +} + +/// The resource name an appearance stream gives `face` (the usual `/DR` names). +pub fn resource_name(face: Std14) -> &'static str { + match face { + Std14::HelveticaBold => "HeBo", + Std14::HelveticaOblique => "HeOb", + Std14::HelveticaBoldOblique => "HeBO", + Std14::TimesRoman => "TiRo", + Std14::TimesBold => "TiBo", + Std14::TimesItalic => "TiIt", + Std14::TimesBoldItalic => "TiBI", + Std14::Courier => "Cour", + Std14::CourierBold => "CoBo", + Std14::CourierOblique => "CoOb", + Std14::CourierBoldOblique => "CoBO", + _ => "Helv", + } +} + +/// `face`'s `CSS` family, weight and style, for writing `/DS` and `/RC`. +pub fn css(face: Std14) -> (&'static str, &'static str, &'static str) { + let family = match styled(face, false, false) { + Std14::TimesRoman => "Times", + Std14::Courier => "Courier", + _ => "Helvetica", + }; + (family, if is_bold(face) { "bold" } else { "normal" }, if is_italic(face) { "italic" } else { "normal" }) +} + +fn bytes(c: Rgb) -> [u8; 3] { + // Clamped to 0..=1 first, so the product is 0..=255 and the cast exact. + c.map(|x| (x.clamp(0.0, 1.0) * 255.0).round() as u8) +} + +/// What a `/DS` string sets. +#[derive(Debug, Default, PartialEq)] +struct Ds { + face: Option, + bold: Option, + italic: Option, + size: Option, + color: Option, +} + +/// Read a default style string: `;`-separated `CSS` declarations. Unknown or malformed ones are +/// skipped; later ones win. +fn parse_ds(ds: &str) -> Ds { + let end = ds.char_indices().map(|(i, _)| i).find(|i| *i >= MAX_DS).unwrap_or(ds.len()); + let ds = ds.get(..end).unwrap_or_default(); + let mut out = Ds::default(); + for decl in ds.split(';') { + let Some((prop, value)) = decl.split_once(':') else { continue }; + let value = value.trim(); + match prop.trim().to_ascii_lowercase().as_str() { + "font" => font_shorthand(value, &mut out), + "font-family" => { + if let Some(face) = value.split(',').find_map(family_face) { + out.face = Some(face); + } + } + "font-size" => { + if let Some(size) = font_size(value, true) { + out.size = Some(size); + } + } + "font-weight" => { + if let Some(bold) = weight(value) { + out.bold = Some(bold); + } + } + "font-style" => { + if let Some(italic) = slant(value) { + out.italic = Some(italic); + } + } + "color" => { + if let Some(c) = css_color(value) { + out.color = Some(c); + } + } + _ => {} + } + } + out +} + +/// `font: [style] [weight] size[/line-height] family[, family…]`, read leniently: the size is +/// the token with a unit, keywords set weight and slant, and the rest is the family list (some +/// writers put the family first). +fn font_shorthand(value: &str, out: &mut Ds) { + let mut family = Vec::new(); + for tok in value.split_whitespace() { + if let Some(size) = font_size(tok, false) { + out.size = Some(size); + } else if let Some(bold) = weight(tok).filter(|_| !tok.eq_ignore_ascii_case("normal")) { + out.bold = Some(bold); + } else if let Some(italic) = slant(tok).filter(|_| !tok.eq_ignore_ascii_case("normal")) { + out.italic = Some(italic); + } else if !matches!(tok.to_ascii_lowercase().as_str(), "normal" | "small-caps") { + family.push(tok); + } + } + if let Some(face) = family.join(" ").split(',').find_map(family_face) { + out.face = Some(face); + } +} + +/// A size in points: `14pt` (with a line height, `14pt/16pt`), or a bare number where +/// `unitless` allows it. Positive and finite only; capped as `/DA` sizes are. +fn font_size(tok: &str, unitless: bool) -> Option { + let tok = tok.split('/').next()?.trim(); + let num = match tok.strip_suffix("pt").or_else(|| tok.strip_suffix("PT")) { + Some(n) => n, + None if unitless => tok, + None => return None, + }; + num.parse::().ok().filter(|s| s.is_finite() && *s > 0.0).map(|s| s.min(MAX_SIZE)) +} + +fn weight(v: &str) -> Option { + match v.to_ascii_lowercase().as_str() { + "bold" | "bolder" | "600" | "700" | "800" | "900" => Some(true), + "normal" | "lighter" | "100" | "200" | "300" | "400" | "500" => Some(false), + _ => None, + } +} + +fn slant(v: &str) -> Option { + match v.to_ascii_lowercase().as_str() { + "italic" | "oblique" => Some(true), + "normal" => Some(false), + _ => None, + } +} + +/// `#RRGGBB`, `#RGB` or `rgb(r, g, b)` (0–255 or percentages). +fn css_color(v: &str) -> Option { + let v = v.trim(); + if let Some(hex) = v.strip_prefix('#') { + let digit = |c: char| c.to_digit(16).map(f64::from); + let ds: Vec = hex.chars().map(digit).collect::>()?; + return match ds.as_slice() { + [r, g, b] => Some([r * 17.0 / 255.0, g * 17.0 / 255.0, b * 17.0 / 255.0]), + [r1, r0, g1, g0, b1, b0] => Some([(r1 * 16.0 + r0) / 255.0, (g1 * 16.0 + g0) / 255.0, (b1 * 16.0 + b0) / 255.0]), + _ => None, + }; + } + let lower = v.to_ascii_lowercase(); + let inner = lower.strip_prefix("rgb(")?.strip_suffix(')')?; + let parts: Vec = inner + .split(',') + .map(|p| { + let p = p.trim(); + let x = match p.strip_suffix('%') { + Some(pc) => pc.trim().parse::().ok()? / 100.0, + None => p.parse::().ok()? / 255.0, + }; + x.is_finite().then(|| x.clamp(0.0, 1.0)) + }) + .collect::>()?; + match parts.as_slice() { + [r, g, b] => Some([*r, *g, *b]), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use pdfcraft_cos::PdfString; + + use super::*; + + fn dict(da: &str, ds: Option<&str>) -> Dict { + let mut d = Dict::new(); + d.set(b"DA".to_vec(), PdfString::literal(da.as_bytes().to_vec())); + if let Some(ds) = ds { + d.set(b"DS".to_vec(), PdfString::text(ds)); + } + d + } + + #[test] + fn da_resource_names_pick_the_standard_faces() { + assert_eq!(text_style(&dict("0 g /Cour 12 Tf", None), None).face, Std14::Courier); + assert_eq!(text_style(&dict("1 0 0 rg /TiRo 16 Tf", None), None).face, Std14::TimesRoman); + assert_eq!(text_style(&dict("/HeBo 9 Tf 0 g", None), None).face, Std14::HelveticaBold); + assert_eq!(text_style(&dict("/Courier-Bold 9 Tf", None), None).face, Std14::CourierBold); + // Unknown, symbolic and family-hint-only names keep Helvetica. + for da in ["/F1 12 Tf", "/ZaDb 12 Tf", "/Symbol 12 Tf", "/MySerif 12 Tf", "12 Tf", "", "/ Tf"] { + assert_eq!(text_style(&dict(da, None), None).face, Std14::Helvetica, "{da:?}"); + } + // A `/DR` lookup outranks the name. + assert_eq!(text_style(&dict("/Cour 12 Tf", None), Some(Std14::TimesRoman)).face, Std14::TimesRoman); + } + + #[test] + fn ds_font_size_and_colour_win_over_da() { + let s = text_style(&dict("0 g /Helv 12 Tf", Some("font: Times 14pt; color: #008000")), None); + assert_eq!((s.face, s.size), (Std14::TimesRoman, 14.0)); + assert!((s.color[1] - 128.0 / 255.0).abs() < 1e-9 && s.color[0] == 0.0 && s.color[2] == 0.0, "{:?}", s.color); + let s = text_style( + &dict( + "1 0 0 rg /Cour 9 Tf", + Some("font-family: 'Helvetica', sans-serif; font-size: 20; font-weight: bold; font-style: italic; color: rgb(0, 0, 255)"), + ), + None, + ); + assert_eq!((s.face, s.size, s.color), (Std14::HelveticaBoldOblique, 20.0, [0.0, 0.0, 1.0])); + // CSS order, a line height, a #RGB colour and a generic family. + let s = text_style(&dict("0 g /Helv 12 Tf", Some("font: italic bold 10pt/12pt serif; color:#f00")), None); + assert_eq!((s.face, s.size, s.color), (Std14::TimesBoldItalic, 10.0, [1.0, 0.0, 0.0])); + // Bold from the style keeps the DA family. + assert_eq!(text_style(&dict("/Cour 12 Tf", Some("font-weight:700")), None).face, Std14::CourierBold); + } + + /// PdfCraft writes `/DS` from `/DA` (8-bit colour): `/DA`'s exact colour, size and face stay. + #[test] + fn pdfcraft_written_ds_matches_da() { + let s = text_style(&dict("0.1 0.2 0.3 rg /Helv 12.5 Tf", Some("font: Helvetica 12.5pt; text-align:left; color:#1A334D")), None); + assert_eq!(s, TextStyle { face: Std14::Helvetica, size: 12.5, color: [0.1, 0.2, 0.3] }); + } + + #[test] + fn malformed_ds_falls_back_to_da() { + let da = TextStyle { face: Std14::Courier, size: 9.0, color: [1.0, 0.0, 0.0] }; + let huge = format!("color: #00ff00;{}", "x".repeat(1 << 20)); + let past_cap = format!("{}; font-size: 30pt", " ".repeat(MAX_DS)); + for ds in [ + "font-size: NaN", + "font-size: inf", + "font-size: -3pt", + "font-size: 0", + "font: NaNpt Zzz", + "font: 1e999pt", + "color: #12", + "color: #zzzzzz", + "color: rgb(1, 2)", + "color: rgb(NaN, 0, 0)", + "color: #ff0000é", + ":::;;;", + "font-family: Wingdings, 'Comic Sans'", + "é: ü; ;", + past_cap.as_str(), + ] { + assert_eq!(text_style(&dict("1 0 0 rg /Cour 9 Tf", Some(ds)), None), da, "{ds:?}"); + } + // Read up to the cap: what comes first still counts, and nothing past it is scanned. + assert_eq!(text_style(&dict("1 0 0 rg /Cour 9 Tf", Some(&huge)), None).color, [0.0, 1.0, 0.0]); + // Sizes are capped as /DA's are. + assert_eq!(text_style(&dict("/Helv 9 Tf", Some("font-size: 1e300pt")), None).size, MAX_SIZE); + } + + /// `/DA` strings as files give them: a `Tf` without a size, a zero size (auto-size in a form + /// field, which a text box doesn't have), and hostile operands. None panics, each keeps what it + /// can read, and nothing that isn't a finite number reaches the style. + #[test] + fn hostile_da_strings_keep_what_they_can() { + let style = |da: &str| text_style(&dict(da, None), None); + let black12 = |face| TextStyle { face, size: 12.0, color: [0.0; 3] }; + for (da, want) in [ + // A missing size keeps the named face at the default size. + ("/TiRo Tf", black12(Std14::TimesRoman)), + ("0 g /Cour Tf", black12(Std14::Courier)), + // Zero, negative, NaN, infinite or not a number: the default size. + ("/Cour 0 Tf", black12(Std14::Courier)), + ("/Cour -0 Tf", black12(Std14::Courier)), + ("/Cour -12 Tf", black12(Std14::Courier)), + ("/Cour NaN Tf", black12(Std14::Courier)), + ("/Cour inf Tf", black12(Std14::Courier)), + ("/Cour -inf Tf", black12(Std14::Courier)), + ("/Cour 1e999 Tf", black12(Std14::Courier)), + ("/Cour twelve Tf", black12(Std14::Courier)), + // No Tf at all, or one with nothing before it. + ("1 0 0 rg", TextStyle { color: [1.0, 0.0, 0.0], ..black12(Std14::Helvetica) }), + ("Tf", black12(Std14::Helvetica)), + ("Tf Tf Tf", black12(Std14::Helvetica)), + ("", black12(Std14::Helvetica)), + // Colour operands that aren't finite numbers leave the colour black. + ("NaN NaN NaN rg /Helv 9 Tf", TextStyle { size: 9.0, ..black12(Std14::Helvetica) }), + ("NaN g /Helv 9 Tf", TextStyle { size: 9.0, ..black12(Std14::Helvetica) }), + ("inf 0 0 rg /Helv 9 Tf", TextStyle { size: 9.0, ..black12(Std14::Helvetica) }), + ("rg g /Helv 9 Tf", TextStyle { size: 9.0, ..black12(Std14::Helvetica) }), + // Out-of-range colour components are clamped. + ("2 -1 0.5 rg /Helv 9 Tf", TextStyle { size: 9.0, color: [1.0, 0.0, 0.5], ..black12(Std14::Helvetica) }), + // Huge sizes are capped. + ("/Helv 1e300 Tf", TextStyle { size: MAX_SIZE, ..black12(Std14::Helvetica) }), + ("/Helv 100000 Tf", TextStyle { size: MAX_SIZE, ..black12(Std14::Helvetica) }), + // Garbage around a valid Tf: the last Tf is read. + ("\u{0} ] [ << /Helv 9 Tf >> /Cour 14 Tf )", TextStyle { size: 14.0, ..black12(Std14::Courier) }), + ] { + assert_eq!(style(da), want, "{da:?}"); + let s = style(da); + assert!(s.size.is_finite() && s.size > 0.0 && s.color.iter().all(|c| (0.0..=1.0).contains(c)), "{da:?}: {s:?}"); + } + // A font name that is no standard face and that no `/DR` lists keeps Helvetica. + assert_eq!(style("0 g /F9 11 Tf"), TextStyle { size: 11.0, ..black12(Std14::Helvetica) }); + // Binary bytes that aren't UTF-8 don't panic. + let mut d = Dict::new(); + d.set(b"DA".to_vec(), PdfString::literal(vec![0xFF, 0xFE, b' ', b'/', 0xC3, b' ', b'9', b' ', b'T', b'f'])); + assert_eq!(text_style(&d, None).size, 9.0); + assert_eq!(da_font_name(&dict("/TiRo Tf", None)).as_deref(), Some("TiRo")); + assert_eq!(da_font_name(&dict("12 Tf", None)), None); + assert_eq!(da_font_name(&dict("0 g Tf", None)), None); + } + + #[test] + fn css_round_trips_through_the_parser() { + for face in [Std14::Helvetica, Std14::TimesBold, Std14::CourierOblique, Std14::HelveticaBoldOblique, Std14::TimesBoldItalic] { + let (family, weight, style) = css(face); + let ds = format!("font-family:{family};font-weight:{weight};font-style:{style}"); + assert_eq!(text_style(&dict("/Helv 9 Tf", Some(&ds)), None).face, face); + } + } +} diff --git a/crates/annot/src/types.rs b/crates/annot/src/types.rs index f6bcc68a7e..d9dcba7678 100644 --- a/crates/annot/src/types.rs +++ b/crates/annot/src/types.rs @@ -417,13 +417,17 @@ pub enum Shape { contours: Vec>, }, /// A custom stamp: a picture already in the document (an image XObject, or a form XObject - /// whose `/Matrix` maps it to `size` points) filling `rect`, named `name`. + /// whose `/Matrix` maps it to `size` points) filling `rect`, named `name`. With `line`, a + /// line of text under the picture (#1741): a dynamic stamp's name, date and time, filled in + /// when it was placed; the picture then fills the rectangle above it + /// ([`CUSTOM_LINE_BAND`](crate::CUSTOM_LINE_BAND)). CustomStamp { rect: [f64; 4], name: String, picture: ObjRef, image: bool, size: (f64, f64), + line: Option, }, /// A redaction mark (§12.5.6.23) over quadrilaterals (text) or one rectangle as a quad /// (areas, pages). `overlay` is the text shown on the box once applied, drawn with `look`. diff --git a/crates/automation/README.md b/crates/automation/README.md index e25ff9b8b2..82a00c509a 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -207,8 +207,9 @@ mutually exclusive with `hide_text_block`. When the renderer reports a warning for the page, `page_render` and `render_preview` return a JSON part after the PNG, `{ "page": 1, "warnings": [...] }`, with the sentences -`pdfcraft-cli render` prints: "content past the page's safety budget was skipped" and "a colour -that could not be computed was painted in a fallback colour". A page without warnings returns +`pdfcraft-cli render` prints, such as "content past the page's safety budget, or a picture whose +JBIG2 mask is past a decoder limit, was skipped" and "a colour that could not be computed was +painted in a fallback colour". A page without warnings returns the PNG alone. @@ -332,6 +333,14 @@ navigation-history tool; use the opt-in UI control channel to navigate the displ FreeText appearance and survives reopening. Other mark/signature types reject `color`; their appearance controls are separate. +## AcroForm script interactions + +`js_run` accepts optional `answers` (strings or null, at most 32), consumed in popup, response and alert order. Missing responses cancel menus/input; explicit viewer requests remain data in automation. See [the runtime contract](../../docs/acroform-script-workflows.md). + +For distinct PDF fields sharing a name, form_fields exposes a document-local integer field_id. Pass it with field to js_run to bind event.target to that exact field (including ordered answers); omit it for existing first-match behavior. Mismatched names/IDs, missing names and non-integer/out-of-range IDs are refused before execution. + +`form_set_image` accepts the optional document-local `field_id` from `form_fields` alongside the matching `field` name, selecting an exact duplicate-name image button. Omit it for the legacy first-name match. IDs are unsigned 48-bit integers and must be rediscovered after structural edits or reopening. + ## Reviewing OCR headlessly `ocr_words {doc, retain_review: true}` reads words without editing the document and returns diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index c5692ff9a7..b17192aab0 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -9,7 +9,7 @@ use std::collections::BTreeSet; use crate::{Args, Automation, Result, ToolError, child, failed, write_atomic}; mod documents; -mod form_tools; +pub(crate) mod form_tools; mod ocr; fn status_id(s: Status) -> &'static str { diff --git a/crates/automation/src/a11y/documents.rs b/crates/automation/src/a11y/documents.rs index 137c98c5d4..bfc8b3379a 100644 --- a/crates/automation/src/a11y/documents.rs +++ b/crates/automation/src/a11y/documents.rs @@ -118,6 +118,9 @@ impl Automation { Ok(json!({ "actions": actions, "steps": steps })) } + /// `action_run`: run a named action or a list of steps on each file in `paths`, writing the + /// results into `folder`; each file is processed in its own session that keeps this + /// session's JavaScript preference. pub(crate) fn action_run(&mut self, a: &Args) -> Result { use pdfcraft_engine::actions::{Action, Step, builtin, run_on}; let action = if let Some(name) = a.opt_str("action")? { @@ -138,6 +141,8 @@ impl Automation { }; let folder = self.resolve(a.str("folder")?, true)?; std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?; + // Each file runs in its own session; it keeps this one's JavaScript preference. + let javascript = self.session.javascript(); let mut out = Vec::new(); for p in a.strs("paths")? { let name = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into()); @@ -145,7 +150,7 @@ impl Automation { .resolve(p, false) .map_err(|e| e.to_string()) .and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string())) - .and_then(|bytes| run_on(&action, &name, std::sync::Arc::new(bytes), |_, _| {})); + .and_then(|bytes| run_on(&action, &name, std::sync::Arc::new(bytes), javascript, |_, _| {})); out.push(match result { Ok(r) => { let target = crate::batch::publish(&child(&folder, &name), &r.bytes)?; diff --git a/crates/automation/src/a11y/form_tools.rs b/crates/automation/src/a11y/form_tools.rs index 5f8a7021c4..0ef6b92821 100644 --- a/crates/automation/src/a11y/form_tools.rs +++ b/crates/automation/src/a11y/form_tools.rs @@ -1,7 +1,7 @@ use super::{Args, Automation, Result, ToolError, Value, failed, json, with_warnings, write_atomic}; -/// A script's request to the viewer as the JSON that running a script reports; a side panel -/// goes by its fixed id. +/// A script's request to the viewer as the JSON that running a script reports (page numbers +/// 1-based); a side panel goes by its fixed id. fn request_json(r: &pdfcraft_engine::js::Request) -> Value { use pdfcraft_engine::js::Request as R; match r { @@ -11,26 +11,66 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value { R::LaunchUrl(u) => json!({ "url": u }), R::Submit(u) => json!({ "submit": u }), R::Focus(f) => json!({ "focus": f }), + R::FocusField { name, object_id } => json!({ "focus": name, "field_id": object_id }), + R::Zoom(z) => json!({"zoom":z}), R::Beep => json!({ "beep": true }), + R::Highlight(on) => json!({ "highlight": on }), R::SaveAs => json!({ "save_as": true }), + R::OpenAttachment { name, open } => json!({ "attachment": name, "open": open }), R::ShowPanel(p) => json!({ "panel": p.id() }), } } +/// The optional `field_id` argument: a field's identity from form_fields, selecting one of +/// fields sharing a name. +fn field_id(a: &Args) -> Result> { + a.0.get("field_id") + .map(|v| { + v.as_u64() + .filter(|id| *id < 1 << 48) + .ok_or_else(|| ToolError::InvalidArgs("field_id must be an integer between 0 and 281474976710655".into())) + }) + .transpose() +} + /// A message a script showed, as `js_run` reports it: its text, title (null when it gave none), /// icon and buttons. -fn alert_json(m: &pdfcraft_engine::js::Message) -> Value { +pub(crate) fn alert_json(m: &pdfcraft_engine::js::Message) -> Value { json!({ "message": m.text, "title": m.title, "icon": m.icon.name(), "buttons": m.buttons.name() }) } +/// What scripts that ran unasked (a document's Will Save or Will Print actions) showed, said +/// or asked for, as tools report it. +pub(crate) fn js_output_json(out: &pdfcraft_engine::js::JsOutput) -> Value { + json!({ + "alerts": out.alerts.iter().map(alert_json).collect::>(), + "console": out.console, + "requests": out.requests.iter().map(request_json).collect::>(), + "errors": out.errors, + }) +} + impl Automation { /// `js_run`: run `script` in the document, or as button `field`'s script, and report its /// messages, console lines, requests, error, the failing document scripts' errors and result. pub(crate) fn js_run(&mut self, a: &Args) -> Result { let id = self.doc(a)?.id; + let answers = match a.0.get("answers") { + None => Vec::new(), + Some(Value::Array(values)) if values.len() <= 32 => values + .iter() + .map(|v| match v { + Value::Null => Ok(None), + Value::String(s) if s.len() <= 4096 => Ok(Some(s.clone())), + _ => Err(ToolError::InvalidArgs("answers must contain strings or null".into())), + }) + .collect::>>()?, + _ => return Err(ToolError::InvalidArgs("answers must be an array of at most 32 strings or null".into())), + }; + let identity = field_id(a)?; let (script, field) = (a.str("script")?, a.opt_str("field")?); - // Applying the script's field changes runs the field scripts again; their output is reported with the script's. - let (mut o, edit) = self.session.with_own_js_output(id, |s| s.run_javascript(id, script, field)).map_err(failed)?; + let (mut o, edit) = + self.session.with_own_js_output(id, |s| s.run_javascript_answers_target(id, script, field, identity, &answers)).map_err(failed)?; o.alerts.extend(edit.alerts); o.console.extend(edit.console); o.requests.extend(edit.requests); @@ -47,6 +87,8 @@ impl Automation { Ok(json!({ "scripts": list })) } + /// `js_set_document_script`: set the document-level script `name` to `script`, or remove it + /// when `script` is left out, as one undoable edit; returns the document's scripts. pub(crate) fn js_set_document_script(&mut self, a: &Args) -> Result { let edit = pdfcraft_engine::Edit::SetDocumentScript { name: a.str("name")?.into(), script: a.opt_str("script")?.map(str::to_string) }; let id = self.doc(a)?.id; @@ -54,6 +96,8 @@ impl Automation { Ok(with_warnings(self.js_document_scripts(a)?, warnings)) } + /// `form_set_script`: set field `field`'s JavaScript for `event` to `script`, or remove it when + /// `script` is left out, as one undoable edit; returns the console output and errors. pub(crate) fn form_set_script(&mut self, a: &Args) -> Result { let edit = pdfcraft_engine::Edit::SetFieldScript { name: a.str("field")?.into(), @@ -65,6 +109,70 @@ impl Automation { Ok(json!({ "field": a.str("field")?, "event": a.str("event")?, "console": out.console, "errors": out.errors })) } + /// `form_focus`: field `field` (its 1-based `widget`) gains (`enter`) or loses (`exit`) + /// focus, running its XFA scripts for that event; returns whether the form changed and what + /// the scripts showed or asked for. + pub(crate) fn form_focus(&mut self, a: &Args) -> Result { + use pdfcraft_engine::js::FieldFocus; + let focus = match a.str("event")? { + "enter" => FieldFocus::Enter, + "exit" => FieldFocus::Exit, + other => return Err(ToolError::InvalidArgs(format!("event must be enter or exit, not {other:?}"))), + }; + let name = a.str("field")?; + let doc = self.doc(a)?; + let field = doc.form.iter().find(|f| f.name == name).ok_or_else(|| failed(format!("there is no field named {name:?} (see form_fields)")))?; + // A field with no widget (one only scripts or calculations use) has nothing to focus. + let widgets = field.widgets.len(); + let widget = a.opt_int("widget")?.unwrap_or(1); + let widget = usize::try_from(widget).ok().and_then(|w| w.checked_sub(1)).filter(|w| *w < widgets).ok_or_else(|| match widgets { + 0 => ToolError::InvalidArgs(format!("{name:?} has no widget to focus")), + n => ToolError::InvalidArgs(format!("widget must be 1 to {n} for {name:?}")), + })?; + let id = doc.id; + let changed = self.session.field_focus(id, name, widget, focus).map_err(failed)?; + let out = self.session.take_js_output(id); + let reqs: Vec = out.requests.iter().map(request_json).collect(); + Ok( + json!({ "changed": changed, "alerts": out.alerts.iter().map(alert_json).collect::>(), "console": out.console, "requests": reqs, "errors": out.errors }), + ) + } + + /// `js_event`: run the JavaScript widget `widget` (1-based) of field `field` has for `event` + /// (a trigger id: mouse_enter, mouse_exit, mouse_down, mouse_up, on_focus, on_blur); returns + /// whether the form changed and what the script showed or asked for. + pub(crate) fn js_event(&mut self, a: &Args) -> Result { + let event = a.str("event")?; + let trigger = pdfcraft_engine::FieldTrigger::from_id(event).ok_or_else(|| { + ToolError::InvalidArgs(format!("event must be mouse_enter, mouse_exit, mouse_down, mouse_up, on_focus or on_blur, not {event:?}")) + })?; + let name = a.str("field")?; + let identity = field_id(a)?; + let doc = self.doc(a)?; + let field = + doc.form.iter().find(|f| f.name == name && identity.is_none_or(|i| pdfcraft_engine::js::field_object_id(f.obj) == i)).ok_or_else( + || match identity { + Some(i) => failed(format!("there is no field named {name:?} with field_id {i} (see form_fields)")), + None => failed(format!("there is no field named {name:?} (see form_fields)")), + }, + )?; + let widgets = field.widgets.len(); + let widget = a.opt_int("widget")?.unwrap_or(1); + let widget = usize::try_from(widget).ok().and_then(|w| w.checked_sub(1)).filter(|w| *w < widgets).ok_or_else(|| match widgets { + 0 => ToolError::InvalidArgs(format!("{name:?} has no widget")), + n => ToolError::InvalidArgs(format!("widget must be 1 to {n} for {name:?}")), + })?; + let (id, object) = (doc.id, pdfcraft_engine::js::field_object_id(field.obj)); + // Only this event's output: what opening or an earlier tool left waiting stays. + let (changed, out) = + self.session.with_own_js_output(id, |s| s.field_trigger_target(id, name, Some(object), widget, trigger)).map_err(failed)?; + let mut result = js_output_json(&out); + result["changed"] = json!(changed); + Ok(result) + } + + /// `form_merge_data`: merge the form data files at `paths` into one CSV at `path`, a row per + /// file; returns the path and the row and column counts. pub(crate) fn form_merge_data(&mut self, a: &Args) -> Result { let mut files = Vec::new(); for p in a.strs("paths")? { diff --git a/crates/automation/src/a11y/ocr.rs b/crates/automation/src/a11y/ocr.rs index 6d1eaaa389..9f21fc285e 100644 --- a/crates/automation/src/a11y/ocr.rs +++ b/crates/automation/src/a11y/ocr.rs @@ -164,6 +164,8 @@ impl Automation { } // The engines are built (and named if they cannot be) before the first file is read. let recognizers = pdfcraft_engine::ocr::recognizers(&settings).map_err(failed)?; + // Each file opens in its own session; it keeps this one's JavaScript preference. + let javascript = self.session.javascript(); let mut out = Vec::new(); let (mut ok, mut skipped_files, mut words) = (0usize, 0usize, 0usize); for p in paths { @@ -171,7 +173,15 @@ impl Automation { let result = self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string())).and_then( |bytes| { - pdfcraft_engine::ocr::recognize_file(&name, std::sync::Arc::new(bytes), None, settings.clone(), &recognizers, |_, _| true) + pdfcraft_engine::ocr::recognize_file( + &name, + std::sync::Arc::new(bytes), + None, + settings.clone(), + &recognizers, + javascript, + |_, _| true, + ) }, ); match result { diff --git a/crates/automation/src/assemble.rs b/crates/automation/src/assemble.rs index 5fd9d9b0be..b091e54d19 100644 --- a/crates/automation/src/assemble.rs +++ b/crates/automation/src/assemble.rs @@ -239,6 +239,8 @@ impl Automation { Ok(json!({ "files": out })) } + /// `page_extract`: the chosen pages as one new PDF, or with `separate` one file per page in + /// `out_dir`; with `delete` the pages are then removed from the original. pub(crate) fn page_extract(&mut self, a: &Args) -> Result { let doc = self.doc(a)?; let (id, name) = (doc.id, format!("{} (extract)", doc.name)); @@ -246,16 +248,8 @@ impl Automation { let pages = self.pages(a, "pages")?; let mut out = if a.opt_bool("separate")?.unwrap_or(false) { // Each page as its own file. - let dir = self.resolve(a.str("out_dir").map_err(|_| ToolError::InvalidArgs("separate files need out_dir".into()))?, true)?; - std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; - let mut files = Vec::new(); - for &p in &pages { - let bytes = self.session.extract(id, &[p]).map_err(failed)?; - let path = child(&dir, &format!("{stem}-page{}.pdf", p + 1)); - write_atomic(&path, &bytes)?; - files.push(path.to_string_lossy().into_owned()); - } - json!({ "files": files }) + let out_dir = a.str("out_dir").map_err(|_| ToolError::InvalidArgs("separate files need out_dir".into()))?; + json!({ "files": self.extract_separate(id, &pages, out_dir, &stem)? }) } else { let bytes = self.session.extract(id, &pages).map_err(failed)?; self.deliver(a, &name, bytes)? @@ -267,6 +261,45 @@ impl Automation { Ok(out) } + /// Each of `pages` as its own `-page.pdf` in `out_dir`, extracted and written one at a + /// time, so only one page's PDF is held at once. The first page is extracted before `out_dir` is + /// resolved and made, so a refusal (the permissions refuse every page alike, #5081) leaves nothing + /// behind; if a later page fails, the files this call created, and the directory if it made it, + /// are removed. + fn extract_separate(&self, id: pdfcraft_engine::DocId, pages: &[usize], out_dir: &str, stem: &str) -> Result> { + let Some(&first) = pages.first() else { return Ok(Vec::new()) }; + let mut first_bytes = Some(self.session.extract(id, &[first]).map_err(failed)?); + let dir = self.resolve(out_dir, true)?; + let made_dir = !dir.exists(); + std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; + let (mut files, mut created) = (Vec::new(), Vec::new()); + let outcome = pages.iter().try_for_each(|&p| -> Result<()> { + let bytes = match first_bytes.take() { + Some(bytes) => bytes, + None => self.session.extract(id, &[p]).map_err(failed)?, + }; + let path = child(&dir, &format!("{stem}-page{}.pdf", p.saturating_add(1))); + let existed = path.exists(); + write_atomic(&path, &bytes)?; + if !existed { + created.push(path.clone()); + } + files.push(path.to_string_lossy().into_owned()); + Ok(()) + }); + if let Err(e) = outcome { + // Best effort: whatever can't be removed stays, and the error reported is the extraction's. + for path in &created { + let _ = std::fs::remove_file(path); + } + if made_dir { + let _ = std::fs::remove_dir(&dir); + } + return Err(e); + } + Ok(files) + } + pub(crate) fn doc_combine(&mut self, a: &Args) -> Result { let paths = a.strs("paths")?; if paths.len() < 2 { diff --git a/crates/automation/src/batch.rs b/crates/automation/src/batch.rs index 14a98f23e2..9359cb50b8 100644 --- a/crates/automation/src/batch.rs +++ b/crates/automation/src/batch.rs @@ -19,9 +19,13 @@ struct Recipe<'a> { images: optimize::ImageSettings, mono: optimize::MonoSettings, ocr: ocr::OcrSettings, + /// The calling session's JavaScript preference, carried into each file's own session. + javascript: bool, } impl<'a> Recipe<'a> { + /// Read and validate `doc_batch`'s steps and settings, taking the JavaScript preference from + /// `auto`'s session. fn parse(auto: &Automation, args: &'a Args) -> Result { let steps = args.strs("steps")?; if steps.is_empty() || steps.len() > 3 { @@ -60,7 +64,7 @@ impl<'a> Recipe<'a> { if args.opt_num("dpi")?.is_some_and(|dpi| !(72.0..=600.0).contains(&dpi)) { return Err(Args::wrong("dpi", "72 to 600")); } - Ok(Self { steps, images, mono, ocr: auto.ocr_settings(args)? }) + Ok(Self { steps, images, mono, ocr: auto.ocr_settings(args)?, javascript: auto.session.javascript() }) } fn accepts(&self, path: &Path) -> bool { @@ -68,9 +72,13 @@ impl<'a> Recipe<'a> { extension == "pdf" || (self.steps.contains(&"convert") && pdfcraft_engine::CONVERTIBLE.contains(&extension.as_str())) } + /// Run the recipe's steps on the file at `path` in a fresh session; returns the output bytes + /// and a report entry per step. fn process(&self, path: &Path) -> Result<(Arc>, Vec)> { // A fresh session per file bounds retained document state and never touches caller tabs. + // It keeps the caller's JavaScript preference, set before any open runs open-time scripts. let mut session = Session::new(); + session.set_javascript(self.javascript); let mut input = Vec::new(); std::fs::File::open(path).map_err(failed)?.take(MAX_INPUT + 1).read_to_end(&mut input).map_err(failed)?; if input.len() as u64 > MAX_INPUT { @@ -85,7 +93,8 @@ impl<'a> Recipe<'a> { "convert" => bytes = session.convert_to_pdf(name, &bytes)?.1, "ocr" => { let recognizers = ocr::recognizers(&self.ocr).map_err(failed)?; - let result = ocr::recognize_file(name, bytes, None, self.ocr.clone(), &recognizers, |_, _| true).map_err(failed)?; + let result = + ocr::recognize_file(name, bytes, None, self.ocr.clone(), &recognizers, self.javascript, |_, _| true).map_err(failed)?; log.push( json!({"step":"ocr","words":result.words(),"skipped_pages":result.pages.iter().filter(|p| p.skipped.is_some()).count()}), ); diff --git a/crates/automation/src/bates.rs b/crates/automation/src/bates.rs index 71ae6b3b91..931b9cfd90 100644 --- a/crates/automation/src/bates.rs +++ b/crates/automation/src/bates.rs @@ -366,7 +366,9 @@ mod tests { assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0); } - #[cfg(unix)] + // Not on macOS: APFS keeps only UTF-8 file names, so no output there can have such a name, and + // creating the probe fails before the check runs. + #[cfg(all(unix, not(target_os = "macos")))] #[test] fn output_probe_paths_preserve_non_utf8_names() { use std::os::unix::ffi::OsStringExt; diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 3174e68dbd..e4da78ab09 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -179,7 +179,21 @@ impl Automation { let doc = self.doc(a)?; let only = a.opt_int("page")?.map(|p| (p.max(1) - 1) as usize); let all = self.comments(a)?; + // A group (`/RT /Group`, ISO 32000-2 §12.5.6.2) lists as its primary, with the primary's + // attributes: the subordinates are neither comments nor replies, only its `group`. + let mut members: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new(); + for (m, p) in all.iter().zip(pdfcraft_render::group_primaries(&all)) { + if let Some(p) = p.and_then(|p| all.get(p)) { + members.entry((p.page, p.index)).or_default().push(m); + } + } let view = |c: &Annotation| -> Value { + let group: Vec = members + .get(&(c.page, c.index)) + .into_iter() + .flatten() + .map(|m| json!({ "id": m.name, "page": m.page + 1, "index": m.index + 1, "type": m.subtype })) + .collect(); let mut v = json!({ "id": c.name, "page": c.page + 1, @@ -191,6 +205,9 @@ impl Automation { "modified": c.modified, "rect": crate::view_rect(&doc.info, c.page, c.shown_rect(doc.info.pages.get(c.page).map_or(0, |p| p.rotation))), "color": c.color.map(hex), + "group": group, + // Unchanged on a page the viewer could not read, as `rect` is. + "popup": c.popup.map(|w| json!({ "rect": crate::view_rect(&doc.info, c.page, w.rect), "open": w.open })), }); // A text box's or typed text's oval or circle (a rectangle is not reported). if c.subtype == "FreeText" @@ -211,8 +228,8 @@ impl Automation { v["text_opacity"] = json!(t); } } - // A line's ends, or the corners of connected lines, a polygon or a cloud, as - // comment_edit's `points` takes them. + // A line's ends, the corners of connected lines, a polygon or a cloud, or a callout's + // arrow point and knee, as comment_edit's `points` takes them. // Unchanged on a page the viewer could not read, as `rect` is. if !c.points.is_empty() { let round = |v: f32| (v * 100.0).round() / 100.0; @@ -220,15 +237,20 @@ impl Automation { v["points"] = json!(c.points.iter().map(|q| page.map_or([q[0], q[1]], |p| p.user_to_view(q[0], q[1])).map(round)).collect::>()); } + // A callout's text box, as comment_edit's `rect` takes it (`rect` also holds the line). + if let Some(tb) = c.text_box { + v["text_box"] = json!(crate::view_rect(&doc.info, c.page, tb)); + } v }; // Every reply under the comment its thread starts from, at any depth, in document order: // a reply to a reply keeps its `/IRT` on the direct parent, so following one level would // drop it. Parents are found by reference, as `/NM` need not be unique. Status and - // checkmark replies are read through `review` instead. + // checkmark replies are read through `review` instead. A group subordinate is in no + // thread, and a reply to one is the group's, its primary's. let tops = pdfcraft_render::thread_tops(&all); let mut threads: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new(); - for r in all.iter().filter(|r| r.state.is_none()) { + for r in all.iter().filter(|r| r.state.is_none() && !r.is_group_member()) { let Some(&top) = tops.get(&(r.page, r.index)) else { continue }; threads.entry(top).or_default().push(r); } @@ -408,18 +430,8 @@ impl Automation { let point = to_user(&info, t[0], t[1]); let knee = match a.nums::<2>("knee")? { Some(k) => to_user(&info, k[0], k[1]), - // Halfway between the point and the box, level with the box's middle. - None => { - let mid = (rect[1] + rect[3]) / 2.0; - let side = if point[0] < rect[0] { - rect[0] - } else if point[0] > rect[2] { - rect[2] - } else { - (rect[0] + rect[2]) / 2.0 - }; - [(point[0] + side) / 2.0, mid] - } + // Acrobat's: 12 pt out from the middle of the side facing the point. + None => pdfcraft_engine::callout_knee(rect, point), }; Shape::Callout { rect, @@ -700,6 +712,17 @@ impl Automation { self.apply(a, Edit::MarkAnnotation { page, index, marked, author }) } + pub(crate) fn comment_popup(&mut self, a: &Args) -> Result { + let (page, index) = self.comment_target(a)?; + let info = self.doc(a)?.info.pages[page].clone(); + let open = a.opt_bool("open")?; + let rect = a.nums::<4>("rect")?.map(|r| rect_to_user(&info, r)); + if open.is_none() && rect.is_none() { + return Err(ToolError::InvalidArgs("nothing to change: pass open and/or rect".into())); + } + self.apply(a, Edit::SetAnnotationPopup { page, index, rect, open }) + } + pub(crate) fn comment_lock(&mut self, a: &Args) -> Result { let (page, index) = self.comment_target(a)?; let locked = a.opt_bool("locked")?.unwrap_or(true); @@ -873,6 +896,7 @@ impl Automation { let bytes = crate::read_input(&path)?; let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let name = a.opt_str("name")?.map(str::to_owned).unwrap_or_else(|| file.rsplit_once('.').map_or(file.clone(), |(s, _)| s.to_owned())); + let author = a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string(); let edit = Edit::AddCustomStamp { page, rect, @@ -882,7 +906,16 @@ impl Automation { bytes: std::sync::Arc::new(bytes), page: a.opt_int("file_page")?.unwrap_or(1).max(1) as usize - 1, }, - author: a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string(), + line: None, + author: author.clone(), + }; + // A dynamic line (#1741): `{name}`, `{date}` and `{time}` filled in now. + let edit = match (edit, a.opt_str("line")?) { + (Edit::AddCustomStamp { page, rect, name, file, author, .. }, Some(t)) if !t.trim().is_empty() => { + let line = Some(self.session().stamp_line(t, &author)); + Edit::AddCustomStamp { page, rect, name, file, author, line } + } + (edit, _) => edit, }; self.apply(a, edit) } diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs index a77ef98b8b..75a4e7fd5b 100644 --- a/crates/automation/src/content.rs +++ b/crates/automation/src/content.rs @@ -21,7 +21,7 @@ fn to_view(r: [f64; 4], ph: f64) -> [f64; 4] { [r[0], ph - r[3], r[2], ph - r[1]] } -fn rect_arg(a: &Args, key: &str) -> Result> { +pub(crate) fn rect_arg(a: &Args, key: &str) -> Result> { a.get(key) .map(|v| { let r: Vec = v.as_array().map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 43eacc7f95..4a91942fe4 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -74,6 +74,7 @@ impl Automation { "text_extract" => self.text_extract(&a)?, "text_word_count" => self.text_word_count(&a)?, "text_find" => self.text_find(&a)?, + "text_replace" => self.text_replace(&a)?, "page_rotate" => { let degrees = a.int("degrees")?; if degrees % 90 != 0 { @@ -700,7 +701,7 @@ impl Automation { summary(self.session.get(new).ok_or_else(|| failed("the document vanished"))?) } "doc_export_images" | "doc_export_text" | "doc_export_all_images" => self.export(name, &a)?, - "doc_header_footer" | "doc_watermark" | "doc_background" | "doc_remove_marks" => self.marks(name, &a)?, + "doc_header_footer" | "doc_watermark" | "doc_background" | "doc_printer_marks" | "doc_remove_marks" => self.marks(name, &a)?, "doc_backdrops" => self.backdrops(&a)?, "doc_unprotect" => { let mut out = self.apply(&a, Edit::RemoveProtection)?; @@ -709,11 +710,8 @@ impl Automation { } "form_fields" => self.form_fields(&a)?, "form_fill" => self.form_fill(&a)?, - "form_set_image" => { - let path = self.resolve(a.str("path")?, false)?; - let bytes = read_input(&path)?; - self.apply(&a, Edit::SetFieldImage { name: a.str("field")?.to_owned(), image: Arc::new(bytes) })? - } + "form_focus" => self.form_focus(&a)?, + "form_set_image" => self.form_set_image(&a)?, "form_reset" => self.form_reset(&a)?, "form_add_field" => self.form_add_field(&a)?, "form_add_field_table" => self.form_add_field_table(&a)?, @@ -729,6 +727,7 @@ impl Automation { "doc_hidden_info" => self.doc_hidden_info(&a)?, "printers" => self.printers()?, "printer_options" => self.printer_options(&a)?, + "printer_papers" => self.printer_papers(&a)?, "link_list" => self.link_list(&a)?, "link_open" => self.link_open(&a)?, "link_add" => self.link_add(&a)?, @@ -767,6 +766,49 @@ impl Automation { let edit = Edit::DeleteAttachment { key: a.str("name")?.into() }; self.apply(&a, edit)? } + "doc_convert_colours" => self.convert_colours(&a)?, + "attachment_edit" => { + let key = a.opt_str("key")?; + let name = a.opt_str("name")?; + if key.is_some() == name.is_some() { + return Err(ToolError::InvalidArgs("provide exactly one of key or name".into())); + } + let keys: Vec = self + .doc(&a)? + .info + .attachments + .iter() + .filter_map(|attachment| match &attachment.source { + pdfcraft_render::AttachmentSource::Document { key: attachment_key } + if key.is_some_and(|key| key == attachment_key) || name.is_some_and(|name| name == attachment.name) => + { + Some(attachment_key.clone()) + } + pdfcraft_render::AttachmentSource::Document { .. } => None, + pdfcraft_render::AttachmentSource::Annotation { .. } => None, + }) + .collect(); + let key = match keys.as_slice() { + [key] => key.clone(), + [] if key.is_some() => { + return Err(ToolError::InvalidArgs(format!("there is no document attachment with key {:?}", key.unwrap_or_default()))); + } + [] => { + return Err(ToolError::InvalidArgs(format!( + "there is no document attachment named {:?}; use a name from doc_info", + name.unwrap_or_default() + ))); + } + _ => { + return Err(ToolError::InvalidArgs(format!( + "more than one document attachment is named {:?}; provide its key from doc_info", + name.unwrap_or_default() + ))); + } + }; + let edit = Edit::EditAttachmentDescription { key, description: Some(a.str("description")?.into()) }; + self.apply(&a, edit)? + } "doc_remove_hidden" => self.doc_remove_hidden(&a)?, "doc_audit_redactions" => self.doc_audit_redactions(&a)?, "fill_sign_add" => self.fill_sign_add(&a)?, @@ -793,6 +835,7 @@ impl Automation { "accessibility_autotag_form_fields" => self.accessibility_autotag_form_fields(&a)?, "ocr_recognize" => self.ocr_recognize(&a)?, "js_run" => self.js_run(&a)?, + "js_event" => self.js_event(&a)?, "js_document_scripts" => self.js_document_scripts(&a)?, "js_set_document_script" => self.js_set_document_script(&a)?, "js_enabled" => self.js_enabled(&a)?, @@ -873,6 +916,7 @@ impl Automation { "sign_trust" => self.sign_trust(&a)?, "comment_mark" => self.comment_mark(&a)?, "comment_lock" => self.comment_lock(&a)?, + "comment_popup" => self.comment_popup(&a)?, "comments_hide" => self.comments_hide(&a)?, "layer_set" => self.layer_set(&a)?, "view_reading_colors" => self.view_reading_colors(&a)?, diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs index 823bc078c0..7dd78cff98 100644 --- a/crates/automation/src/documents.rs +++ b/crates/automation/src/documents.rs @@ -104,12 +104,27 @@ impl Automation { } pub(crate) fn doc_open(&mut self, a: &Args) -> Result { - let path = self.resolve(a.str("path")?, false)?; + let requested = a.str("path")?; + // `manual.pdf#nameddest=Intro`, as a help system starts a PDF reader (#3425): the file under + // --root, and where it opens. A path that names a file is never split. + let parameters = pdfcraft_engine::open_parameters::split(requested, |p| self.resolve(p, false).is_ok_and(|p| p.is_file())); + let (requested, destination) = match ¶meters { + Some((file, destination)) => (file.as_str(), Some(destination.clone())), + None => (requested, None), + }; + let path = self.resolve(requested, false)?; let bytes = read_input(&path)?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let id = self.session.open(name, Some(path.to_string_lossy().into_owned()), Arc::new(bytes), a.opt_str("password")?).map_err(failed)?; let doc = self.session.get(id).ok_or_else(|| failed("the document vanished"))?; - Ok(summary(doc)) + let mut value = summary(doc); + if let Some(destination) = destination { + let (applied, destination, warning) = crate::links::destination_report(doc, destination); + value["destination_applied"] = json!(applied); + value["destination"] = destination; + value["warning"] = json!(warning); + } + Ok(value) } pub(crate) fn doc_close(&mut self, a: &Args) -> Result { @@ -153,11 +168,19 @@ impl Automation { if flatten_fill_sign { self.apply(a, Edit::FlattenFillSign)?; } + // The document's Will Save actions run first: what they change is saved (#269). + let scripts = self.session.run_document_actions(id, pdfcraft_engine::js::DocumentScriptEvent::WillSave); let bytes = if full { self.session.save_full_bytes(id) } else { self.session.save_bytes(id) }.map_err(failed)?; // A save that adds or removes encryption, or must not keep the old revision (Sanitize, // redaction), is rewritten in full even when an incremental save was asked for. let incremental = !full && bytes.starts_with(&self.doc(a)?.saved_bytes()); - self.publish_save(id, &target, bytes, incremental, write) + let mut out = self.publish_save(id, &target, bytes, incremental, write)?; + if !scripts.is_empty() + && let Some(o) = out.as_object_mut() + { + o.insert("will_save".into(), crate::a11y::form_tools::js_output_json(&scripts)); + } + Ok(out) } /// Validate the prepared bytes before publishing them or marking the document saved. @@ -360,6 +383,7 @@ impl Automation { ("discard_thumbnails", &mut settings.discard_thumbnails), ("discard_alternate_images", &mut settings.discard_alternate_images), ("discard_tags", &mut settings.discard_tags), + ("discard_search_index", &mut settings.discard_search_index), ("discard_print_settings", &mut settings.discard_print_settings), ("discard_form_actions", &mut settings.discard_form_actions), ("flate_unencoded", &mut settings.flate_unencoded), @@ -367,6 +391,7 @@ impl Automation { ("lzw_to_flate", &mut settings.lzw_to_flate), ("remove_invalid_links", &mut settings.remove_invalid_links), ("remove_unreferenced_dests", &mut settings.remove_unreferenced_dests), + ("discard_external_refs", &mut settings.discard_external_refs), ] { if let Some(b) = a.opt_bool(key)? { *flag = b; @@ -471,6 +496,7 @@ impl Automation { "image_bytes_after": o.image_bytes_after, "thumbnails": o.thumbnails, "alternate_images": o.alternate_images, + "search_indexes": o.search_indexes, "tags_removed": o.tags_removed, "print_settings": o.print_settings, "form_actions": o.form_actions, @@ -479,6 +505,8 @@ impl Automation { "invalid_links": o.invalid_links, "invalid_bookmarks": o.invalid_bookmarks, "unreferenced_dests": o.unreferenced_dests, + "external_links": o.external_links, + "external_bookmarks": o.external_bookmarks, "unused_xobjects": o.unused_xobjects, "merged_objects": r.merged, "discarded": r.discarded.iter().map(|(h, n)| json!({ "category": h.id(), "count": n })).collect::>(), @@ -626,11 +654,33 @@ impl Automation { replace, file: self.mark_file(a)?, }, + "doc_printer_marks" => { + use pdfcraft_engine::PrinterMarks; + let flag = |k: &str, default: bool| -> Result { Ok(a.opt_bool(k)?.unwrap_or(default)) }; + let (y, m, d) = self.session.today(); + let settings = PrinterMarks { + trim: flag("trim", true)?, + bleed: flag("bleed", true)?, + registration: flag("registration", true)?, + color_bars: flag("color_bars", true)?, + page_info: flag("page_info", true)?, + line_weight: a.opt_num("line_weight")?.unwrap_or(0.25), + make_room: match a.opt_num("make_room")? { + Some(r) if r < 0.0 => None, + Some(r) => Some(r), + None => Some(36.0), + }, + label: a.opt_str("label")?.map(str::to_string).unwrap_or_else(|| self.doc(a).map(|d| d.name.clone()).unwrap_or_default()), + date: a.opt_str("date")?.map(str::to_string).unwrap_or_else(|| format!("{y:04}-{m:02}-{d:02}")), + }; + Edit::AddPrinterMarks { pages, settings, replace: a.opt_bool("replace")?.unwrap_or(true) } + } _ => { let kind = match a.str("kind")? { "header_footer" => MarkKind::HeaderFooter, "watermark" => MarkKind::Watermark, "background" => MarkKind::Background, + "printer_marks" => MarkKind::PrinterMarks, other => return Err(ToolError::InvalidArgs(format!("unknown kind {other:?}"))), }; Edit::RemoveMarks { kind } diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 15c8975dd6..f1f34836ad 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -176,7 +176,7 @@ fn calculate_json(f: &FormField) -> Option { match (&f.actions.calculate, &f.actions.scripts.calculate) { (Calculate::Simple { op, fields }, _) => Some(json!({ "op": op_name(*op), "fields": fields })), (Calculate::Notation(n), _) => Some(json!({ "notation": n })), - (Calculate::None, Some(js)) => Some(json!({ "script": js })), + (Calculate::Custom(js), _) | (Calculate::None, Some(js)) => Some(json!({ "script": js })), (Calculate::None, None) => None, } } @@ -208,6 +208,24 @@ fn kind_name(k: FormFieldKind) -> &'static str { } } +/// A JSON number as the radio option it names: its integer spelling when it is a whole number +/// (`1`, `1.0` and `1e0` are all `1`; `-0` is `0`), otherwise JSON's own spelling (`1.5`), which +/// names only an option exported as exactly that text. A whole number at or past 2^53, where a +/// float no longer holds every integer, keeps JSON's spelling too (`1e300`), so it names no option +/// by accident. +fn number_option(n: &serde_json::Number) -> String { + // Integers as parsed (u64, i64) print exactly. + if n.is_u64() || n.is_i64() { + return n.to_string(); + } + const EXACT: f64 = 9_007_199_254_740_992.0; // 2^53 + match n.as_f64() { + // Finite, whole and below 2^53 in magnitude, so the cast is exact and in range. + Some(x) if x.is_finite() && x.fract() == 0.0 && x.abs() < EXACT => (x as i64).to_string(), + _ => n.to_string(), + } +} + /// A JSON value → the value for this field (booleans for check boxes, arrays for lists…). fn value_for(f: &FormField, v: &Value) -> Result { let wrong = |what: &str| ToolError::InvalidArgs(format!("{}: {what}", f.name)); @@ -224,7 +242,24 @@ fn value_for(f: &FormField, v: &Value) -> Result { FieldValue::Widget(i) } (FormFieldKind::Radio, Value::Null) => FieldValue::Radio(None), - (FormFieldKind::Radio, Value::String(s)) => FieldValue::Radio(Some(s.clone())), + // An option as `form_fields` lists it (an export value) names its button's on state. The + // export values are matched first and the on-state names after them + // (`Field::state_for_export`): with `/Opt [(1) (2)]` and states `/0` `/1`, `1` is the first + // button, not the second one's state. A value matching neither is refused when written, + // with the options listed. + (FormFieldKind::Radio, Value::String(s)) => FieldValue::Radio(Some(f.state_for_export(s).unwrap_or(s).to_owned())), + // A number is the same option written without quotes (`1` for the export value `1`). + // Below 2^53 it is compared as a number with the export values first, so `1.0` names the button + // exported as `1.0` (`/Opt [(1.0) (2.0)]`, states `/0 /1`) and not the one whose state is + // `/1`; then as text, as a string would be. + (FormFieldKind::Radio, Value::Number(n)) => { + if let Some(state) = n.as_f64().and_then(|x| f.state_for_number(x)) { + FieldValue::Radio(Some(state.to_owned())) + } else { + let s = number_option(n); + FieldValue::Radio(Some(f.state_for_export(&s).map_or_else(|| s.clone(), str::to_owned))) + } + } (FormFieldKind::Combo | FormFieldKind::List, Value::Array(a)) => FieldValue::Choice( a.iter().map(|x| x.as_str().map(str::to_owned).ok_or_else(|| wrong("list values must be strings"))).collect::>()?, ), @@ -237,6 +272,27 @@ fn value_for(f: &FormField, v: &Value) -> Result { } impl Automation { + /// Import an image using an optional exact field ID, preserving the legacy name-only tool. + pub(crate) fn form_set_image(&mut self, a: &Args) -> Result { + let name = a.str("field")?.to_owned(); + let object = a + .0 + .get("field_id") + .map(|value| -> Result { + let key = + value.as_u64().filter(|key| *key < 1u64 << 48).ok_or_else(|| bad("field_id must be an integer between 0 and 281474976710655"))?; + Ok(pdfcraft_cos::ObjRef::new((key >> 16) as u32, key as u16)) + }) + .transpose()?; + let path = self.resolve(a.str("path")?, false)?; + let image = std::sync::Arc::new(crate::paths::read_input(&path)?); + let edit = match object { + Some(object) => Edit::SetFieldImageTarget { name, object, image }, + None => Edit::SetFieldImage { name, image }, + }; + self.apply(a, edit) + } + pub(crate) fn form_fields(&self, a: &Args) -> Result { let doc = self.doc(a)?; let out: Vec = doc @@ -251,6 +307,7 @@ impl Automation { }); let mut v = json!({ "name": f.name, + "field_id": pdfcraft_engine::js::field_object_id(f.obj), "type": kind_name(f.kind), "value": match f.kind { FormFieldKind::CheckBox => json!(!f.value.is_empty()), @@ -310,8 +367,35 @@ impl Automation { json!((0..f.widgets.len()).filter(|&i| f.widget_on(i)).map(|i| i.saturating_add(1)).collect::>()), ); } + // A check box whose kids have different on states (ISO 32000-2 §12.7.5.2.3): + // which one is ticked, and the names form_fill takes to tick each. One option + // per distinct on state: kids sharing a state tick together (EXAMPLE 2), so + // their `/Opt` exports are not separate choices. Exports name the options + // unless two states share one (Table 230 allows it) or an export would tick + // another state (form_fill reads a name as an on state first); then the + // states do. + FormFieldKind::CheckBox => { + let mut seen = std::collections::HashSet::new(); + let states: Vec<&str> = f.widgets.iter().filter_map(|w| w.on_state.as_deref()).filter(|s| seen.insert(*s)).collect(); + if states.len() > 1 { + let mut exports = std::collections::HashSet::new(); + let by_export = states.iter().all(|s| { + let e = f.export_for_state(s); + exports.insert(e) && f.state_for(e) == Some(*s) + }); + let name = |s: &str| if by_export { f.export_for_state(s).to_owned() } else { s.to_owned() }; + // The ticked kid is the one whose `/AS` is its on state: §12.7.5.2.3 + // makes `/AS` decide where it and `/V` differ. + let on = (0..f.widgets.len()).find(|&i| f.widget_on(i)).and_then(|i| f.widgets.get(i)?.on_state.as_deref()); + o.insert("options".into(), json!(states.iter().map(|s| name(s)).collect::>())); + o.insert("checked".into(), json!(on.map(name))); + } + } FormFieldKind::Combo | FormFieldKind::List => { o.insert("options".into(), f.options.iter().map(|(e, d)| json!({ "value": e, "label": d })).collect()); + // The selected options as 1-based positions in `options`: two options can share + // an export value, and `value` alone does not tell them apart. + o.insert("selected".into(), json!(f.selected.iter().map(|i| i.saturating_add(1)).collect::>())); o.insert("multi_select".into(), json!(f.has(field_flags::MULTI_SELECT))); o.insert("editable".into(), json!(f.has(field_flags::EDIT))); } @@ -583,6 +667,7 @@ impl Automation { Ok((wi, caption.to_owned())) }) .transpose()?, + caption_all: None, read_only: a.opt_bool("read_only")?, required: a.opt_bool("required")?, multiline: a.opt_bool("multiline")?, diff --git a/crates/automation/src/json.rs b/crates/automation/src/json.rs index 5231132421..f612c3050d 100644 --- a/crates/automation/src/json.rs +++ b/crates/automation/src/json.rs @@ -256,7 +256,7 @@ pub(crate) fn security(d: &Document) -> Value { "method": s.method, "pending": s.pending, "printing": if !p.print() { "none" } else if p.print_high_quality() { "high" } else { "low" }, - "modify": p.modify(), "assemble": p.assemble(), "copy": p.copy(), "annotate": p.annotate(), + "modify": p.modify(), "assemble": p.assemble(), "extract_pages": p.extract_pages(), "copy": p.copy(), "annotate": p.annotate(), "fill_forms": p.fill_forms(), "accessibility": p.extract_for_accessibility(), }) } @@ -334,6 +334,12 @@ pub(crate) fn info(d: &Document) -> Value { "target": link_target(i, &l.target), })).collect::>(), "layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible, "initial": l.initial })).collect::>(), + // The Layers panel's rows, the default configuration's /Order (ISO 32000-2 Table 99): a + // layer by its 1-based position in `layers`, or a label; `depth` 0 at the top. + "layer_order": i.layer_order.iter().map(|r| match &r.item { + pdfcraft_render::LayerItem::Layer(n) => json!({ "depth": r.depth, "layer": n.saturating_add(1), "name": i.layers.get(*n).map(|l| l.name.as_str()) }), + pdfcraft_render::LayerItem::Label(s) => json!({ "depth": r.depth, "label": s }), + }).collect::>(), // Full-screen presentation settings a page carries (ISO 32000-2 §12.4.4): only pages with // a `/Trans` or `/Dur` appear; `style` is null for a display time alone. "transitions": i.transitions.iter().map(|t| json!({ @@ -341,7 +347,12 @@ pub(crate) fn info(d: &Document) -> Value { "horizontal": t.horizontal, "inward": t.inward, "direction": t.direction, "scale": t.scale, "opaque": t.opaque, "display": t.display, })).collect::>(), - "attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::>(), + "attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "key": match &a.source { pdfcraft_render::AttachmentSource::Document { key } => Some(key), pdfcraft_render::AttachmentSource::Annotation { .. } => None }, "description": a.description, "size": a.size })).collect::>(), + // A PDF portfolio (`/Collection`, ISO 32000-2 §7.11.6): its pages are a cover sheet and its + // documents are `attachments`; `initial` names the one presented first, null for the cover. + "portfolio": i.portfolio.as_ref().map(|p| json!({ + "initial": p.initial.and_then(|n| i.attachments.get(n)).map(|a| &a.name), "view": p.view.name(), + })), // `substitute` names the face standing in for a program the document does not embed, and // `substitution` says why. Both are null for an embedded font: nothing stands in. A null // `substitute` with a non-null `substitution` means no face was available at all, so diff --git a/crates/automation/src/links.rs b/crates/automation/src/links.rs index 546413f6fc..a876a77c91 100644 --- a/crates/automation/src/links.rs +++ b/crates/automation/src/links.rs @@ -11,6 +11,26 @@ use std::path::Path; use crate::comments::parse_color; use crate::{Args, Automation, Result, ToolError, failed}; +/// Where `destination` puts `doc`, a document just opened from a link or with open parameters: +/// whether it applies, the destination as reported, and a warning when it doesn't. A named +/// destination is looked up in the document, as the app does. +pub(crate) fn destination_report(doc: &pdfcraft_engine::Document, destination: RemoteDestination) -> (bool, Value, Option<&'static str>) { + let (name, page, view) = match destination { + RemoteDestination::Default => return (true, json!({ "kind": "default" }), None), + RemoteDestination::Page { page, view } => (None, page, view), + RemoteDestination::Named(name) => match pdfcraft_render::named_destination(doc.bytes.clone(), doc.password.as_deref(), &name) { + Some((page, view)) => (Some(name), page, view), + None => return (false, json!({ "kind": "named", "name": name }), Some("The PDF opened, but the named destination was not applied.")), + }, + }; + let mut value = json!({ "kind": "page", "page": page.saturating_add(1), "view": dest_view_value(view) }); + if let Some(name) = name { + value["kind"] = json!("named"); + value["name"] = json!(name); + } + if page < doc.info.pages.len() { (true, value, None) } else { (false, value, Some("The PDF opened, but the page destination is out of range.")) } +} + fn bad(m: impl Into) -> ToolError { ToolError::InvalidArgs(m.into()) } @@ -77,7 +97,8 @@ impl Automation { pub(crate) fn link_list(&self, a: &Args) -> Result { let doc = self.doc(a)?; - let targets: std::collections::HashMap<(usize, usize), &LinkTarget> = doc.info.links.iter().map(|t| ((t.page, t.index), &t.target)).collect(); + let viewed: std::collections::HashMap<(usize, usize), &pdfcraft_render::Link> = + doc.info.links.iter().map(|t| ((t.page, t.index), t)).collect(); let items: Vec = doc .links() .iter() @@ -85,14 +106,49 @@ impl Automation { // The link's page may be one the viewer could not read (#2660): rect unchanged then. let rect = crate::view_rect(&doc.info, l.page, l.rect.map(|x| x as f32)); let mut v = json!({ "page": l.page + 1, "index": l.index + 1, "rect": rect, "visible": l.style.visible }); + let link = viewed.get(&(l.page, l.index)); + // Where the link is active when that isn't its whole rect: its /QuadPoints + // (ISO 32000-2 Table 176), as the bounding box of each quadrilateral. + if let Some(link) = link.filter(|k| !k.quads.is_empty()) { + let areas: Vec = link + .quads + .iter() + .map(|q| { + let (xs, ys) = ([q[0], q[2], q[4], q[6]], [q[1], q[3], q[5], q[7]]); + let fold = |v: [f32; 4], f: fn(f32, f32) -> f32, start: f32| v.into_iter().fold(start, f); + let box_ = [ + fold(xs, f32::min, f32::MAX), + fold(ys, f32::min, f32::MAX), + fold(xs, f32::max, f32::MIN), + fold(ys, f32::max, f32::MIN), + ]; + json!(crate::view_rect(&doc.info, l.page, box_)) + }) + .collect(); + v["active_areas"] = json!(areas); + } // Where an in-document link goes, as the viewer resolves it: named destinations // included, with the position on the page. - let target = targets.get(&(l.page, l.index)).and_then(|t| match t { + let target = link.and_then(|t| match &t.target { LinkTarget::Page(page, view) => Some((*page, view)), _ => None, }); match (&l.action, target) { - (LinkAction::Uri(u), _) => v["url"] = json!(u), + (LinkAction::Uri(u), _) => { + v["url"] = json!(u); + // A relative address, resolved against the catalog's /URI /Base + // (ISO 32000-2 Table 211): what the viewer offers to open. That is the + // address inspection kept, which stays raw once its text budget is spent; + // resolve afresh only for a link inspection did not list. + let opened = match link.map(|t| &t.target) { + Some(LinkTarget::Uri(kept)) => Some(kept.clone()), + Some(_) => None, + None => pdfcraft_render::resolve_uri(doc.info.uri_base.as_deref(), u), + }; + if let Some(opened) = opened.filter(|o| o != u) { + v["resolved_url"] = json!(opened); + } + } (action, Some((page, view))) => { v["to_page"] = json!(page + 1); if let Some(d) = doc.info.pages.get(page).and_then(|p| crate::destination(view, p)) { @@ -186,20 +242,7 @@ impl Automation { let id = self.session.open(name, Some(path.to_string_lossy().into_owned()), std::sync::Arc::new(bytes), a.opt_str("password")?).map_err(failed)?; let opened = self.session.get(id).ok_or_else(|| failed("the linked document vanished"))?; - let (destination_applied, destination_value, warning) = match destination { - RemoteDestination::Default => (true, json!({ "kind": "default" }), None), - RemoteDestination::Named(name) => { - (false, json!({ "kind": "named", "name": name }), Some("The PDF opened, but the named destination was not applied.")) - } - RemoteDestination::Page { page, view } if page < opened.info.pages.len() => { - (true, json!({ "kind": "page", "page": page.saturating_add(1), "view": dest_view_value(view) }), None) - } - RemoteDestination::Page { page, view } => ( - false, - json!({ "kind": "page", "page": page.saturating_add(1), "view": dest_view_value(view) }), - Some("The PDF opened, but the link's page destination is out of range."), - ), - }; + let (destination_applied, destination_value, warning) = destination_report(opened, destination); let mut display_path = path.to_string_lossy().into_owned(); if let Some(root) = &self.root && let Ok(relative) = path.strip_prefix(root) diff --git a/crates/automation/src/pages.rs b/crates/automation/src/pages.rs index f9ddec40a3..b89dbc483d 100644 --- a/crates/automation/src/pages.rs +++ b/crates/automation/src/pages.rs @@ -1,6 +1,6 @@ use super::dispatch::Content; use super::error::{Result, ToolError, failed}; -use super::json::{Args, one_based}; +use super::json::{Args, one_based, summary}; use super::{Arc, Automation, DocId, HashMap, PageRenderer, PageText, RenderConfig, RenderRequest, RequestKind, Value, commands, json, tools}; /// Default and maximum resolution for `page_render`. @@ -356,6 +356,103 @@ impl Automation { Ok(out) } + /// Find and replace (Edit text ▸ Replace): every match (or the first) rewritten inside its + /// own paragraph through the same engine plan as the window's Replace all, one undo step. + /// The page text is searched too (as `text_find` does), so matches no paragraph holds are + /// counted as not editable and the counts add up to what `text_find` reports. + pub(crate) fn text_replace(&mut self, a: &Args) -> Result { + self.text_replace_with(a, &mut extract_parallel) + } + + pub(crate) fn text_replace_with( + &mut self, + a: &Args, + extract: &mut impl FnMut(&Arc>, Option>, &[usize]) -> PageTexts, + ) -> Result { + let doc = self.doc(a)?; + let (id, n) = (doc.id, doc.info.pages.len()); + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..n).collect(), + }; + let query = pdfcraft_engine::find_replace::Replace { + find: a.str("find")?.to_owned(), + with: a.str("replace")?.to_owned(), + case_sensitive: a.opt_bool("case_sensitive")?.unwrap_or(false), + whole_words: a.opt_bool("whole_words")?.unwrap_or(false), + }; + let all = a.opt_bool("all")?.unwrap_or(true); + let mut pages = pages; + pages.sort_unstable(); + pages.dedup(); + // Pages whose text the find bar couldn't read, reported with the plan, not dropped. + let mut unreadable: Vec<(usize, String)> = Vec::new(); + let (which, found) = if all { + let mut found = std::collections::BTreeMap::new(); + for (p, text) in self.page_text_results(id, &pages, &mut *extract)? { + match text { + Ok(text) => { + found.insert(p, text.find_opts(&query.find, query.case_sensitive, query.whole_words).len()); + } + Err(e) => unreadable.push((p, e)), + } + } + unreadable.sort(); + (Some(pdfcraft_engine::find_replace::Which::All), Some(found)) + } else { + // The first match the find bar shows, and that one only: pages are read in order until + // one holds a match, and a page that can't be read stops the search there. + let mut texts = Vec::new(); + let (mut start, mut batch) = (0, FIND_FIRST_BATCH.max(1)); + 'search: while start < pages.len() { + let end = pages.len().min(start.saturating_add(batch)); + let mut results: std::collections::HashMap = + self.page_text_results(id, &pages[start..end], &mut *extract)?.into_iter().collect(); + for p in &pages[start..end] { + match results.remove(p) { + Some(Ok(text)) if text.find_opts_limited(&query.find, query.case_sensitive, query.whole_words, 1).is_empty() => {} + Some(Ok(text)) => { + texts.push((*p, text)); + break 'search; + } + Some(Err(e)) => { + unreadable.push((*p, e)); + break 'search; + } + None => {} + } + } + start = end; + batch = batch.saturating_mul(2).min(FIND_MAX_BATCH); + } + (pdfcraft_engine::find_replace::Which::first_in(texts.iter().map(|(p, t)| (*p, t.as_ref())), &query), None) + }; + let mut plan = match which { + Some(which) => self.doc(a)?.plan_replace(&pages, &query, which, found.as_ref()).map_err(failed)?, + None => { + // Nothing to replace: still the permission and query checks of a plan. + self.doc(a)?.plan_replace(&[], &query, pdfcraft_engine::find_replace::Which::All, None).map_err(failed)? + } + }; + for (p, e) in &unreadable { + plan.unreadable_page(*p, e); + } + let mut out = match plan.edit.clone() { + Some(edit) => self.apply(a, edit)?, + None => summary(self.doc(a)?), + }; + let skipped: serde_json::Map = plan.skipped.iter().map(|(why, k)| (why.key().to_owned(), json!(k))).collect(); + out["replaced"] = json!(plan.replaced); + out["changed"] = json!(plan.edit.is_some()); + out["pages"] = json!(plan.pages.iter().map(|p| p + 1).collect::>()); + out["skipped"] = json!(plan.skipped_total()); + out["skipped_by"] = Value::Object(skipped); + if !plan.refusals.is_empty() { + out["refusals"] = json!(plan.refusals); + } + Ok(out) + } + pub(crate) fn command_list(&self, a: &Args) -> Result { let active = match a.opt_int("doc")? { Some(_) => Some(self.doc(a)?.id), @@ -412,8 +509,11 @@ pub(crate) fn rendered_content(out: &pdfcraft_render::RenderedPage, page: usize) fn render_warning_text(warning: pdfcraft_render::RenderWarning) -> &'static str { use pdfcraft_render::RenderWarning; match warning { - RenderWarning::ContentTruncated => "content past the page's safety budget was skipped", + RenderWarning::ContentTruncated => { + "content past the page's safety budget, or a picture whose JBIG2 mask is past a decoder limit, was skipped" + } RenderWarning::ColourFallback => "a colour that could not be computed was painted in a fallback colour", + RenderWarning::MalformedIndexedImage => "an image with a malformed Indexed colour table may be drawn wrongly or not at all", RenderWarning::Type3WidthsMissing => "a Type 3 font has no /Widths; its glyphs were spaced by the widths their glyph procedures give", } } diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 2f1cf5e136..3d7a76865a 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -1,7 +1,7 @@ //! Printing tools: list printers, and print (or save the print-ready PDF) with Acrobat's Print //! dialog options. -use pdfcraft_engine::print::{self, Binding, BookletSubset, Content, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, spool}; +use pdfcraft_engine::print::{self, Binding, BookletSubset, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, spool}; use serde_json::{Value, json}; use crate::{Args, Automation, Result, ToolError, failed, write_atomic}; @@ -23,45 +23,104 @@ fn duplex(value: &str, cut_stack: bool) -> Result> { } /// [`duplex`] for a job laid out as `layout`: the name is checked either way, and a booklet -/// decides two-sided itself (#2707), as the Print dialog does (`print::booklet_duplex`). -fn job_duplex(value: &str, layout: &Layout) -> Result> { +/// decides two-sided itself (#2707) as far as the printer's driver takes it, as the Print dialog +/// does (`print::booklet_job_duplex`). `two_sided` says whether it does; it is only asked for a +/// booklet's both sides, the one job it changes. +fn job_duplex(value: &str, layout: &Layout, two_sided: impl FnOnce() -> Option) -> Result> { let chosen = duplex(value, matches!(layout, Layout::Multiple { order: PageOrder::CutStack, .. }))?; Ok(match layout { - Layout::Booklet { subset, .. } => Some(print::booklet_duplex(*subset)), + Layout::Booklet { subset: BookletSubset::BothSides, .. } => print::booklet_job_duplex(BookletSubset::BothSides, two_sided()), + Layout::Booklet { subset, .. } => print::booklet_job_duplex(*subset, None), _ => chosen, }) } +/// `printer` (a name, or "default" for the default printer) in `printers`; `None` when the list +/// has no such printer. +fn printer_in(printers: Vec, printer: &str) -> Option { + printers.into_iter().find(|p| if printer == "default" { p.default } else { p.name == printer }) +} + /// An invalid-arguments error saying `m`. fn bad(m: impl Into) -> ToolError { ToolError::InvalidArgs(m.into()) } -/// `doc_print`'s `paper`, by its name or a short form ("Letter", "ARCH D"), as its `PAPERS` -/// index and size; US Letter when none is given. -fn paper_named(name: Option<&str>) -> Result<(usize, (f64, f64))> { - let mut papers = PAPERS.iter().enumerate(); - let found = match name { - None => papers.next(), - Some(p) => papers.find(|(_, (n, _))| { - n.eq_ignore_ascii_case(p) - || n.replace("US ", "").eq_ignore_ascii_case(p) - || n.split(" (").next().is_some_and(|name| name.eq_ignore_ascii_case(p)) - }), +/// Parse explicit custom dimensions like "300x500", "300 x 500", "300x500 pt", "300pt x 500pt", "Custom.300x500". +fn parse_custom_dimensions(s: &str) -> Option<(f64, f64)> { + let s = s.trim().trim_start_matches("Custom.").trim_start_matches("custom.").trim(); + let (w_str, h_str) = s.split_once(['x', 'X', '*'])?; + let parse_dim = |d: &str| { + let d = d.trim().trim_end_matches("points").trim_end_matches("pt").trim(); + d.parse::().ok() }; - found.map(|(index, (_, size))| (index, *size)).ok_or_else(|| { - bad(format!( - "unknown paper {:?}; choose one of {}", - name.unwrap_or_default(), + let w = parse_dim(w_str)?; + let h = parse_dim(h_str)?; + if w.is_finite() && h.is_finite() && w >= 72.0 && h >= 72.0 && w <= 14400.0 && h <= 14400.0 { Some((w, h)) } else { None } +} + +/// `doc_print`'s `paper`, by its name, short form, driver-detected paper name, or custom dimensions. +/// Returns `((width, height), media_name)`. +fn resolve_paper(name: Option<&str>, printer: Option<&str>) -> Result<((f64, f64), Option)> { + if let Some(p) = name { + // 1. Match standard PAPERS presets. + for (index, (n, size)) in PAPERS.iter().enumerate() { + if n.eq_ignore_ascii_case(p) + || n.replace("US ", "").eq_ignore_ascii_case(p) + || n.split(" (").next().is_some_and(|label| label.eq_ignore_ascii_case(p)) + { + let media = print::cups_media(index).map(String::from); + return Ok((*size, media)); + } + } + // 2. Match detected driver papers for the given printer. + if let Some(printer_name) = printer { + let driver_papers = spool::printer_papers(printer_name); + if let Some((name, size)) = driver_papers.iter().find(|(n, _)| n.eq_ignore_ascii_case(p)) { + return Ok((*size, Some(print::paper_media(Some(name), *size)))); + } + } + // 3. Match custom dimensions ("x"). + if let Some(size) = parse_custom_dimensions(p) { + return Ok((size, Some(print::paper_media(None, size)))); + } + // 4. Explicit units request the same stock as point dimensions. + if let Some(size) = paper_size(p) { + return Ok((size, Some(print::paper_media(None, size)))); + } + return Err(bad(format!( + "unknown paper {p:?}; choose one of {}, a paper from printer_papers, or a size such as 8.5x14in or 210x297mm (1 to 200 inches a side)", PAPERS.iter().map(|(name, _)| *name).collect::>().join(", ") - )) - }) + ))); + } + + // When paper is omitted and a printer is given, use the printer's configured default paper if readable. + if let Some(printer_name) = printer + && let Some((name, size)) = spool::printer_default_paper(printer_name) + { + return Ok((size, Some(print::paper_media(Some(&name), size)))); + } + + let (name, size) = PAPERS[0]; + let media = print::paper_media(Some(name), size); + Ok((size, Some(media))) } /// The spooler job for `doc_print` to `printer`. It feeds the paper the PDF was laid out on -/// (`PAPERS[paper]`, as `media` after the driver's `options`) rather than the queue's default -/// paper, which a job naming none got (A4 under Letter sheets on the queue in #3752). -fn spool_job(a: &Args, printer: &str, title: String, duplex: Option, paper: usize) -> Result { +/// (as `media` after the driver's `options`) rather than the queue's default paper, which a job +/// naming none got (A4 under Letter sheets on the queue in #3752), and tells CUPS which way the +/// laid-out `sheets` run, as the Print dialog does (#2713): an orientation the caller chose in +/// `options` stands. It asks the driver for grayscale only when the pages aren't grey pictures +/// already ([`print::driver_needs_grayscale`]). +fn spool_job( + a: &Args, + printer: &str, + title: String, + duplex: Option, + media: Option<&str>, + raster: Option, + sheets: &[print::Sheet], +) -> Result { let options = match a.get("options") { None => Vec::new(), Some(Value::Object(m)) => m @@ -79,13 +138,58 @@ fn spool_job(a: &Args, printer: &str, title: String, duplex: Option = options.into_iter().chain(media.map(|m| ("media".to_string(), m.to_string()))).collect(); + print::add_cups_orientation(&mut options, sheets); + options + }, }) } impl Automation { + /// Print production ▸ Convert colours: every colour on the pages in gray, rgb or cmyk. + pub(crate) fn convert_colours(&mut self, a: &Args) -> Result { + let n = self.doc(a)?.info.pages.len(); + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..n).collect(), + }; + let target = match a.opt_str("to")?.unwrap_or("gray") { + "gray" | "grayscale" => pdfcraft_engine::ColourTarget::Gray, + "rgb" => pdfcraft_engine::ColourTarget::Rgb, + "cmyk" => pdfcraft_engine::ColourTarget::Cmyk, + other => return Err(ToolError::InvalidArgs(format!("unknown target {other:?} (gray, rgb, cmyk)"))), + }; + let settings = pdfcraft_engine::ConvertColours { + target, + preserve_black: a.opt_bool("preserve_black")?.unwrap_or(true), + images: a.opt_bool("images")?.unwrap_or(true), + annotations: a.opt_bool("annotations")?.unwrap_or(false), + }; + let id = self.doc(a)?.id; + let found = self.session.count_colours(id, &pages, &settings); + if found.colours == 0 && found.images == 0 { + return Ok(json!({ "colours": 0, "images": 0, "images_left": found.images_left, "pages": 0, "loses_conformance": [] })); + } + // PDF/X has no declaration of its own here: it always carries an output intent. + let declared = self.doc(a)?.standards(); + let pdfa = declared.pdfa.map(|(part, level)| format!("PDF/A-{part}{}", level.to_lowercase())); + let loses: Vec = pdfa.into_iter().chain(declared.output_intents.iter().map(|i| format!("output intent {i}"))).collect(); + let r = self.apply(a, pdfcraft_engine::Edit::ConvertColours { pages, settings })?; + Ok(json!({ + "colours": found.colours, + "images": found.images, + "images_left": found.images_left, + "pages": found.pages, + "loses_conformance": loses, + "undo": r["undo"], + })) + } + /// `printers`: the system's printers, the default marked, and each one's two-sided support /// where the spooler reports it. pub(crate) fn printers(&self) -> Result { @@ -114,6 +218,21 @@ impl Automation { Ok(json!({ "printer": printer, "count": list.len(), "options": list })) } + /// `printer_papers`: the paper sizes (names and dimensions in points) and default paper + /// reported by the printer driver. + pub(crate) fn printer_papers(&self, a: &Args) -> Result { + let printer = a.str("printer")?; + let resolved = if printer == "default" { + spool::printers().into_iter().find(|p| p.default).map(|p| p.name).unwrap_or_else(|| printer.to_string()) + } else { + printer.to_string() + }; + let (papers, default_paper) = spool::printer_paper_info(&resolved); + let list: Vec = papers.into_iter().map(|(name, (width, height))| json!({ "name": name, "width": width, "height": height })).collect(); + let default_val = default_paper.map(|(name, (width, height))| json!({ "name": name, "width": width, "height": height })); + Ok(json!({ "printer": printer, "count": list.len(), "papers": list, "default": default_val })) + } + /// `doc_print`: lay the document out with the Print dialog's options, then save the /// print-ready PDF to `path` or send it to `printer` with the chosen paper. pub(crate) fn doc_print(&mut self, a: &Args) -> Result { @@ -129,9 +248,6 @@ impl Automation { let pages = print::select_pages(count, a.opt_str("pages")?, &labels, subset, a.opt_bool("reverse")?.unwrap_or(false)) .map_err(|e| bad(e.to_string()))?; let layout = match a.opt_str("layout")?.unwrap_or("fit") { - "fit" => Layout::Size(SizeMode::Fit), - "actual" => Layout::Size(SizeMode::Actual), - "shrink" => Layout::Size(SizeMode::Shrink), "custom" => Layout::Size(SizeMode::Custom(a.opt_num("scale")?.ok_or_else(|| bad("custom needs scale (percent)"))?)), "multiple" => { // Custom pages per sheet: `cols` across by `rows` down, both or neither. @@ -176,15 +292,24 @@ impl Automation { s => return Err(bad(format!("unknown booklet_subset {s:?}"))), }, binding: if a.opt_str("binding")? == Some("right") { Binding::Right } else { Binding::Left }, + creep: a.opt_num("creep")?.unwrap_or(0.0), }, "poster" => Layout::Poster { scale: a.opt_num("scale")?.unwrap_or(200.0), overlap: a.opt_num("overlap")?.unwrap_or(18.0), cut_marks: a.opt_bool("cut_marks")?.unwrap_or(true), }, - l => return Err(bad(format!("unknown layout {l:?}"))), + l => Layout::Size(print::names::parse_size_mode(l, 100.0).ok_or_else(|| bad(format!("unknown layout {l:?}")))?), }; - let duplex = job_duplex(a.opt_str("duplex")?.unwrap_or("off"), &layout)?; + let printer = a.opt_str("printer")?; + // The printer whose driver a booklet's both sides were decided for. The job goes to it by + // name, so "default" can't become another printer while the document is rendered. The + // queues are listed without asking every driver (#2192); only this one is asked. + let mut asked = None; + let duplex = job_duplex(a.opt_str("duplex")?.unwrap_or("off"), &layout, || { + asked = printer.and_then(|p| printer_in(spool::printer_queues(), p)); + spool::two_sided(&asked.as_ref()?.name) + })?; if (a.opt_int("cols")?.is_some() || a.opt_int("rows")?.is_some()) && !matches!(layout, Layout::Multiple { .. }) { return Err(bad("cols and rows need layout multiple")); } @@ -197,14 +322,20 @@ impl Automation { "landscape" => Orientation::Landscape, o => return Err(bad(format!("unknown orientation {o:?}"))), }; - let content = match a.opt_str("comments_forms")?.unwrap_or("document-and-markups") { - "document" => Content::Document, - "document-and-markups" => Content::DocumentAndMarkups, - "document-and-stamps" => Content::DocumentAndStamps, - "form-fields-only" => Content::FormFieldsOnly, - c => return Err(bad(format!("unknown comments_forms {c:?}"))), - }; - let (paper_index, paper) = paper_named(a.opt_str("paper")?)?; + let content = a.opt_str("comments_forms")?.unwrap_or("document-and-markups"); + let content = print::names::parse_content(content).ok_or_else(|| bad(format!("unknown comments_forms {content:?}")))?; + // Use the same queue for duplex detection, paper discovery and submission, even if the + // system default changes while the document is rendered. + let resolved_printer = printer.map(|p| { + if let Some(asked) = &asked { + asked.name.clone() + } else if p == "default" { + spool::printer_queues().into_iter().find(|pr| pr.default).map(|pr| pr.name).unwrap_or_else(|| p.to_string()) + } else { + p.to_string() + } + }); + let (paper, media) = resolve_paper(a.opt_str("paper")?, resolved_printer.as_deref())?; // Print as image (Acrobat's Advanced Print Setup): pictures at `dpi`, grey when // `grayscale` is also asked for. let raster = if a.opt_bool("as_image")?.unwrap_or(false) { @@ -216,42 +347,117 @@ impl Automation { } else { None }; - let settings = print::Settings { pages, paper, orientation, layout, content, raster, crops: Vec::new() }; let sizes: Vec<(f64, f64)> = self.doc(a)?.info.pages.iter().map(|p| (p.width as f64, p.height as f64)).collect(); - let sheets = print::layout(&sizes, &settings).map_err(|e| bad(e.to_string()))?.len(); + // Print a selected area: points from the top-left of the displayed page, as other tools + // take rectangles; the print crate takes it y up. + let area = match crate::content::rect_arg(a, "area")? { + None => None, + Some(r) => { + let [page] = pages.as_slice() else { return Err(bad("area prints one page: give pages as a single page")) }; + let Some(&(_, h)) = sizes.get(*page) else { return Err(bad("area's page is not in the document")) }; + Some([r[0].min(r[2]), h - r[1].max(r[3]), r[0].max(r[2]), h - r[1].min(r[3])]) + } + }; + let settings = print::Settings { pages, paper, orientation, layout, content, raster, crops: Vec::new(), area }; + let laid_out = print::layout(&sizes, &settings).map_err(|e| bad(e.to_string()))?; + let sheets = laid_out.len(); + // Where it goes is checked before the Will Print actions run, so a call that can't print + // doesn't change the document. + let destination = match (a.opt_str("path")?, printer) { + (Some(p), None) => Destination::File(self.resolve(p, true)?), + (None, Some(printer)) => { + let actual_printer = resolved_printer.as_deref().unwrap_or(printer); + Destination::Printer(spool_job(a, actual_printer, self.doc(a)?.name.clone(), duplex, media.as_deref(), settings.raster, &laid_out)?) + } + _ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")), + }; + // The document's Will Print actions run first: what they change is printed (#269). + let scripts = if self.doc(a)?.allows_printing() { + self.session.run_document_actions(id, pdfcraft_engine::js::DocumentScriptEvent::WillPrint) + } else { + Default::default() + }; let bytes = self.session.print_pdf(id, &settings).map_err(failed)?; let mut out = json!({ "sheets": sheets, "pages": settings.pages.len(), "bytes": bytes.len() }); - match (a.opt_str("path")?, a.opt_str("printer")?) { - (Some(p), None) => { - let path = self.resolve(p, true)?; + if !scripts.is_empty() { + out["will_print"] = crate::a11y::form_tools::js_output_json(&scripts); + } + match destination { + Destination::File(path) => { write_atomic(&path, &bytes)?; out["path"] = json!(path.to_string_lossy()); } - (None, Some(printer)) => { - let job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index)?; + Destination::Printer(mut job) => { + // A GDI (v3) driver gets transparent sheets flattened (#6155, #5838, #5940); XPS + // (v4) drivers print them as they are. + let flatten = job.printer.as_deref().is_some_and(spool::gdi_driver); + let bytes = pdfcraft_engine::spooler::spooler_copy(bytes, &mut job, flatten); out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?); } - _ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")), } Ok(out) } } +/// Where `doc_print` sends the sheets: a print-ready PDF file, or a printer's queue. +enum Destination { + File(std::path::PathBuf), + Printer(spool::Job), +} + +/// "WxH" with a unit (in, mm, cm or pt) as a portrait paper size in points; each side 1 to 200 +/// inches (PDF's own page limit). +fn paper_size(spec: &str) -> Option<(f64, f64)> { + let s = spec.trim().to_ascii_lowercase(); + let (body, per_point) = [("in", 1.0 / 72.0), ("mm", 25.4 / 72.0), ("cm", 2.54 / 72.0), ("pt", 1.0)] + .into_iter() + .find_map(|(unit, per_point)| s.strip_suffix(unit).map(|b| (b, per_point)))?; + let (w, h) = body.split_once('x')?; + let side = |t: &str| t.trim().parse::().ok().map(|v| v / per_point).filter(|v| (72.0..=14_400.0).contains(v)); + let (w, h) = (side(w)?, side(h)?); + Some((w.min(h), w.max(h))) +} + #[cfg(test)] mod tests { use super::*; /// #2707: a booklet sets two-sided itself whatever `duplex` asks; a bad name is still an error. + /// #5772: its both sides leave two-sided to a driver that doesn't take the setting, and only + /// they look the printer up. #[test] fn a_booklet_decides_two_sided_itself() { - let booklet = |subset| Layout::Booklet { subset, binding: Binding::Left }; + let booklet = |subset| Layout::Booklet { subset, binding: Binding::Left, creep: 0.0 }; + let unread = || -> Option { panic!("the printer's two-sided support is read for nothing") }; for asked in ["printer", "off", "long-edge", "short-edge"] { - assert_eq!(job_duplex(asked, &booklet(BookletSubset::BothSides)).ok(), Some(Some(spool::Duplex::ShortEdge)), "{asked}"); - assert_eq!(job_duplex(asked, &booklet(BookletSubset::FrontOnly)).ok(), Some(Some(spool::Duplex::Off)), "{asked}"); - assert_eq!(job_duplex(asked, &booklet(BookletSubset::BackOnly)).ok(), Some(Some(spool::Duplex::Off)), "{asked}"); + assert_eq!(job_duplex(asked, &booklet(BookletSubset::BothSides), || Some(true)).ok(), Some(Some(spool::Duplex::ShortEdge)), "{asked}"); + assert_eq!(job_duplex(asked, &booklet(BookletSubset::BothSides), || None).ok(), Some(Some(spool::Duplex::ShortEdge)), "{asked}"); + assert_eq!(job_duplex(asked, &booklet(BookletSubset::BothSides), || Some(false)).ok(), Some(None), "{asked}"); + assert_eq!(job_duplex(asked, &booklet(BookletSubset::FrontOnly), unread).ok(), Some(Some(spool::Duplex::Off)), "{asked}"); + assert_eq!(job_duplex(asked, &booklet(BookletSubset::BackOnly), unread).ok(), Some(Some(spool::Duplex::Off)), "{asked}"); } - assert!(job_duplex("sideways", &booklet(BookletSubset::BothSides)).is_err()); - assert_eq!(job_duplex("long-edge", &Layout::Size(SizeMode::Fit)).ok(), Some(Some(spool::Duplex::LongEdge)), "other layouts keep it"); + assert!(job_duplex("sideways", &booklet(BookletSubset::BothSides), || None).is_err()); + assert_eq!(job_duplex("long-edge", &Layout::Size(SizeMode::Fit), unread).ok(), Some(Some(spool::Duplex::LongEdge)), "other layouts keep it"); + } + + /// #5772: a booklet's printer is found by name, or as the default for "default", and only + /// what the list says about it counts. "default" is found by its name, which the job is sent + /// to. + #[test] + fn a_booklets_printer_comes_from_the_list() { + let printer = |name: &str, default, two_sided| spool::Printer { name: name.into(), default, two_sided, description: String::new() }; + let list = [printer("Epson", false, Some(false)), printer("Laser", true, Some(true)), printer("Network", false, None)]; + let found = |list: &[spool::Printer], name| printer_in(list.to_vec(), name).map(|p| (p.name, p.two_sided)); + assert_eq!(found(&list, "Epson"), Some(("Epson".into(), Some(false)))); + assert_eq!(found(&list, "Laser"), Some(("Laser".into(), Some(true)))); + assert_eq!(found(&list, "default"), Some(("Laser".into(), Some(true))), "the default printer"); + assert_eq!(found(&list, "Network"), Some(("Network".into(), None)), "the list doesn't say"); + assert_eq!(found(&list, "Gone"), None, "no such printer"); + assert_eq!(found(&list, "epson"), None, "names are exact"); + let default_one_sided = [printer("Epson", true, Some(false)), printer("Laser", false, Some(true))]; + assert_eq!(found(&default_one_sided, "default"), Some(("Epson".into(), Some(false))), "the default printer"); + let without_default = [printer("Epson", false, Some(false))]; + assert_eq!(found(&without_default, "default"), None); } /// `printer` leaves two-sided to the printer's own default (#2422); cut and stack still asks @@ -267,13 +473,31 @@ mod tests { assert!(duplex("sideways", false).is_err()); } + /// #2713, #3739: `doc_print` to a printer tells CUPS when every sheet runs landscape, after + /// the paper, so a landscape sheet is turned onto the portrait paper rather than printed + /// unrotated and cut off. Portrait and mixed jobs say nothing, and an orientation passed in + /// `options` stands and isn't sent twice. + #[test] + fn printing_landscape_sheets_to_a_printer_asks_cups_to_turn_them() { + let sheets = |sizes: &[(f64, f64)]| sizes.iter().map(|&size| print::Sheet { size, ..Default::default() }).collect::>(); + let job = |args: Value, s: &[print::Sheet]| spool_job(&Args(&args), "Office", "a.pdf".into(), None, Some("Letter"), None, s).unwrap().options; + let media = ("media".to_string(), "Letter".to_string()); + let landscape = sheets(&[(792.0, 612.0), (792.0, 612.0)]); + let turned = if cfg!(windows) { vec![media.clone()] } else { vec![media.clone(), ("orientation-requested".to_string(), "4".to_string())] }; + assert_eq!(job(json!({}), &landscape), turned); + assert_eq!(job(json!({}), &sheets(&[(612.0, 792.0)])), vec![media.clone()], "portrait sheets"); + assert_eq!(job(json!({}), &sheets(&[(792.0, 612.0), (612.0, 792.0)])), vec![media.clone()], "a job that mixes them"); + let chosen = ("orientation-requested".to_string(), "5".to_string()); + assert_eq!(job(json!({ "options": { "orientation-requested": "5" } }), &landscape), [chosen, media], "the caller's own choice, once"); + } + /// #3752: `doc_print` to a printer sent no paper, so the queue's default paper stood in for /// the one the sheets were laid out on (A4 under Letter sheets on the report's queue). #[test] fn printing_to_a_printer_feeds_the_laid_out_paper() { let job = |args: Value, paper_name: Option<&str>| { - let (index, _) = paper_named(paper_name).unwrap(); - spool_job(&Args(&args), "Office", "a.pdf".into(), None, index).unwrap() + let (_, media) = resolve_paper(paper_name, None).unwrap(); + spool_job(&Args(&args), "Office", "a.pdf".into(), None, media.as_deref(), None, &[]).unwrap() }; let media = |m: &str| ("media".to_string(), m.to_string()); // US Letter when no paper is asked for, after the driver's own options. @@ -282,14 +506,59 @@ mod tests { assert_eq!((letter.printer.as_deref(), letter.title.as_str()), (Some("Office"), "a.pdf")); // Every paper, by its full name, goes out as its own media name. for (i, (name, size)) in PAPERS.iter().enumerate() { - assert_eq!(paper_named(Some(name)).ok(), Some((i, *size)), "{name}"); + assert_eq!(resolve_paper(Some(name), None).ok(), Some((*size, Some(print::cups_media(i).unwrap().to_string()))), "{name}"); assert_eq!(job(json!({}), Some(name)).options, print::media_option(i).into_iter().collect::>(), "{name}"); } // Short forms find the same paper. assert_eq!(job(json!({}), Some("letter")).options, [media("Letter")]); assert_eq!(job(json!({}), Some("ARCH D")).options, [media("na_arch-d_24x36in")]); - assert!(matches!(paper_named(Some("Quarto")), Err(ToolError::InvalidArgs(m)) if m.contains("US Letter"))); - assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, 0).ok().map(|j| j.printer), Some(None)); - assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, 0).is_err()); + // Non-preset dimensions and driver papers carry their media selection. + assert_eq!(job(json!({}), Some("300x500")).options, [media("Custom.300x500")]); + assert_eq!(job(json!({}), Some("300 x 500 pt")).options, [media("Custom.300x500")]); + assert_eq!(job(json!({}), Some("300pt x 500pt")).options, [media("Custom.300x500")]); + assert_eq!(job(json!({}), Some("300 pt x 500 pt")).options, [media("Custom.300x500")]); + assert_eq!(job(json!({}), Some("Custom.300x500")).options, [media("Custom.300x500")]); + assert_eq!(job(json!({}), Some("Custom.300x500pt")).options, [media("Custom.300x500")]); + + assert!(matches!(resolve_paper(Some("Quarto"), None), Err(ToolError::InvalidArgs(m)) if m.contains("US Letter"))); + // Every accepted dimension syntax requests the same stock from the queue. + for (input, size) in [ + ("8.5x14in", (612.0, 1008.0)), + ("210x297mm", (210.0 * 72.0 / 25.4, 297.0 * 72.0 / 25.4)), + ("10x15cm", (10.0 * 72.0 / 2.54, 15.0 * 72.0 / 2.54)), + ("300x500pt", (300.0, 500.0)), + ] { + let expected = print::paper_media(None, size); + let (resolved_size, resolved_media) = resolve_paper(Some(input), None).unwrap(); + assert!((resolved_size.0 - size.0).abs() < 0.01 && (resolved_size.1 - size.1).abs() < 0.01, "{input}"); + assert_eq!(resolved_media.as_deref(), Some(expected.as_str()), "{input}"); + assert_eq!(job(json!({}), Some(input)).options, [media(&expected)], "{input}"); + } + assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, Some("Letter"), None, &[]).ok().map(|j| j.printer), Some(None)); + assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, Some("Letter"), None, &[]).is_err()); + } + + /// #5806: grayscale with Print as image sends grey pictures, so the driver isn't asked for + /// grayscale, which Microsoft Print to PDF keeps no setting for and the job was refused. + #[test] + fn grey_pictures_dont_ask_the_driver_for_grayscale() { + let grey = print::Raster { dpi: 300.0, grayscale: true }; + let colour = print::Raster { dpi: 300.0, grayscale: false }; + let job = |args: Value, raster: Option| { + spool_job(&Args(&args), "Office", String::new(), None, Some("Letter"), raster, &[]).unwrap().grayscale + }; + assert!(job(json!({ "grayscale": true }), None), "vector pages: the driver makes them grey"); + assert!(!job(json!({ "grayscale": true }), Some(grey)), "grey pictures: nothing for the driver to do"); + assert!(job(json!({ "grayscale": true }), Some(colour)), "colour pictures still need the driver"); + assert!(!job(json!({}), None)); + } + + #[test] + fn a_paper_size_reads_its_numbers_and_needs_a_unit() { + assert_eq!(paper_size("8.5 x 14 in"), Some((612.0, 1008.0))); + assert_eq!(paper_size("1e3x1e3pt"), Some((1000.0, 1000.0))); + for spec in ["0x0mm", "-5x10cm", "5x-10cm", "10x15"] { + assert_eq!(paper_size(spec), None, "{spec}"); + } } } diff --git a/crates/automation/src/redact.rs b/crates/automation/src/redact.rs index a793487d9f..1ed8fcbde0 100644 --- a/crates/automation/src/redact.rs +++ b/crates/automation/src/redact.rs @@ -3,7 +3,7 @@ use pdfcraft_engine::{ Edit, HIDDEN, Hidden, NewAnnotation, REDACT_MAX_WORDS, REDACT_VALIDATORS, REDACTION_CODE_SETS, RedactPattern, RedactRegex, RedactValidator, - RedactionCodeSet, Shape, Style, find_pattern, rect_quad, redact_word_list, + RedactionCodeSet, Shape, Style, find_pattern, rect_quad, redact_word_list, redacted_file_name, }; use serde_json::{Value, json}; @@ -200,6 +200,26 @@ impl Automation { let after = self.doc(a)?.redaction_marks(); out["applied"] = json!(before - after); out["marks_pending"] = json!(after); + // Where the removed text is still held that applying leaves as it is. + let left = self.doc(a)?.redaction_elsewhere().unwrap_or_default(); + out["still_holding"] = json!({ + "bookmarks": left.bookmarks, + "attachments": left.attachments, + "comments": left.comments, + "xmp": left.xmp, + "total": left.total(), + "unsearched": left.unsearched, + }); + // What became of a PDF/A or PDF/UA claim in the document XMP the scrub removed. + let claim = |c: pdfcraft_engine::RedactionClaim| match c { + pdfcraft_engine::RedactionClaim::None => "none", + pdfcraft_engine::RedactionClaim::Kept => "kept", + pdfcraft_engine::RedactionClaim::Lost => "lost", + }; + let id = self.doc(a)?.redaction_identification().unwrap_or_default(); + out["identification"] = json!({ "pdfa": claim(id.pdfa), "pdfua": claim(id.pdfua) }); + // The name to save the redacted copy under, as Save does in the app. + out["save_as"] = json!(redacted_file_name(&self.doc(a)?.name)); Ok(out) } diff --git a/crates/automation/src/signing.rs b/crates/automation/src/signing.rs index c78baf25e9..f2b583c070 100644 --- a/crates/automation/src/signing.rs +++ b/crates/automation/src/signing.rs @@ -2,6 +2,7 @@ //! document, and manage trusted certificates. Rectangles are points from the top-left of the //! displayed page. +use pdfcraft_engine::js::SignEvent; use pdfcraft_engine::sign::{self, Certificate, DigitalId, Modification, Name, PrivateKey}; use pdfcraft_engine::{SignOptions, SignatureInfo, SignatureStatus}; use serde_json::{Value, json}; @@ -175,6 +176,8 @@ impl Automation { Ok(json!({ "path": path.to_string_lossy(), "certificate": cert_json(&id.certificate) })) } + /// The digital ID `id` names: a .p12/.pfx file opened with `password`, or an OS key store + /// identity (`keychain:` on macOS, `windows:` on Windows). fn open_id(&self, a: &Args) -> Result { // A macOS Keychain identity: "keychain:" or "keychain:". if let Some(r) = a.str("id")?.strip_prefix("keychain:") { @@ -197,6 +200,8 @@ impl Automation { }) } + /// `sign_document`: sign the document with a digital ID into `out`, visibly (in a field or a + /// rectangle) or not, running a dynamic form's signing scripts around it. pub(crate) fn sign_document(&mut self, a: &Args) -> Result { let id = self.open_id(a)?; let doc = self.doc(a)?; @@ -246,30 +251,94 @@ impl Automation { Some(v) => labels_arg(v)?, }; let out = self.resolve(a.str("out")?, true)?; - let opts = SignOptions { - field, - page, - rect, - reason: a.opt_str("reason")?.map(str::to_string), - location: a.opt_str("location")?.map(str::to_string), - contact: a.opt_str("contact")?.map(str::to_string), - certify, - appearance, - appearance_labels, - graphic, - ..SignOptions::default() + // A dynamic form's signing scripts run only when asked: what preSign changes is signed, + // and the caller hasn't seen it. What they changed is listed. + let run_scripts = a.opt_bool("run_form_scripts")?.unwrap_or(false); + // Every argument is read before the preSign scripts change anything, so a bad one is + // refused with the document as it was. + let reason = a.opt_str("reason")?.map(str::to_string); + let location = a.opt_str("location")?.map(str::to_string); + let contact = a.opt_str("contact")?.map(str::to_string); + let signing = field.clone(); + let scripted = signing.as_deref().is_some_and(|f| { + self.session.sign_event_scripted(doc_id, f, SignEvent::Before) || self.session.sign_event_scripted(doc_id, f, SignEvent::After) + }); + // Each field's value and whether it is read-only, to tell what the preSign scripts changed. + let values = |me: &Automation| -> std::collections::BTreeMap, bool)> { + me.session.get(doc_id).map(|d| d.form.iter().map(|f| (f.name.clone(), (f.value.clone(), f.read_only()))).collect()).unwrap_or_default() }; + let was = values(self); + let before = match &signing { + Some(f) if run_scripts => self.session.run_sign_event(doc_id, f, SignEvent::Before).map_err(failed)?, + _ => false, + }; + let changed: Vec = if before { + values(self) + .into_iter() + .filter(|(name, now)| was.get(name) != Some(now)) + .map(|(name, (value, read_only))| { + let (old, old_ro) = was.get(&name).cloned().unwrap_or_default(); + json!({ "field": name, "before": old, "after": value, "read_only_before": old_ro, "read_only": read_only }) + }) + .collect() + } else { + Vec::new() + }; + let opts = + SignOptions { field, page, rect, reason, location, contact, certify, appearance, appearance_labels, graphic, ..SignOptions::default() }; let signed = self.session.sign(doc_id, &id, opts).map_err(|e| match e { // A graphic the signer refuses is the caller's to fix. pdfcraft_engine::EditError::Sign(m) if m.starts_with("the signature graphic") => bad(m), e => failed(e), - })?; - write_atomic(&out, &signed)?; + }); + let signed = match signed { + Ok(s) => s, + Err(e) => { + // Not signed: what the preSign scripts did is taken back. + if before { + let _ = self.session.undo(doc_id); + let _ = self.session.take_js_output(doc_id); + } + return Err(e); + } + }; + if let Err(e) = write_atomic(&out, &signed) { + // Not saved, so not signed: what the preSign scripts did is taken back too. + if before { + let _ = self.session.undo(doc_id); + let _ = self.session.take_js_output(doc_id); + } + return Err(e); + } let path = out.to_string_lossy().into_owned(); self.session.mark_signed(doc_id, signed.clone(), Some(path.clone())).map_err(failed)?; + // Then its postSign scripts; what they change is a change after signing, not saved yet. + // The file is signed and saved by now: a postSign step that fails is reported, not an error. + let (after, after_error) = match &signing { + Some(f) if run_scripts => match self.session.run_sign_event(doc_id, f, SignEvent::After) { + Ok(ran) => (ran, None), + Err(e) => (true, Some(format!("the postSign step couldn't finish: {e}"))), + }, + _ => (false, None), + }; + let mut scripts = self.session.take_js_output(doc_id); + scripts.errors.extend(after_error); let doc = self.doc(a)?; let newest = doc.signatures.iter().filter(|s| s.signed).max_by_key(|s| s.revision).cloned(); - Ok(json!({ "path": path, "bytes": signed.len(), "signature": newest.map(|s| self.sig_json(a, &s)).transpose()? })) + let mut result = json!({ "path": path, "bytes": signed.len(), "signature": newest.map(|s| self.sig_json(a, &s)).transpose()? }); + if before || after { + result["form_scripts"] = json!({ + "before_signing": before, + "after_signing": after, + "changed": changed, + "alerts": scripts.alerts.iter().map(crate::a11y::form_tools::alert_json).collect::>(), + "errors": scripts.errors, + "unsaved_changes": self.doc(a)?.dirty, + }); + } else if scripted { + result["form_scripts"] = json!({ "skipped": true, "why": "the field has signing scripts; pass run_form_scripts: true to run them" }); + } + Ok(result) } /// `{"image": path}` (PNG or JPEG), `{"text": name}` (typed in the script font) or diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 408cb7cac0..1f3a13cbf0 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -38,6 +38,29 @@ fn search_document(a: &mut Automation, pages: usize) -> DocId { a.session.open("synthetic.pdf", None, Arc::new(numbered_pages(pages)), None).unwrap() } +#[test] +fn form_fill_cannot_print_but_js_run_can() { + let mut a = Automation::new(); + let doc = search_document(&mut a, 1); + json(&mut a, "form_add_field", json!({ "doc": doc.0, "page": 1, "type": "text", "rect": [20, 20, 180, 42], "name": "Qty" })); + json( + &mut a, + "form_set_script", + json!({ "doc": doc.0, "field": "Qty", "event": "validate", "script": "this.print(); console.println('validated');" }), + ); + json(&mut a, "form_fill", json!({ "doc": doc.0, "values": { "Qty": "4" } })); + let output = a.session.take_js_output(doc); + assert!(output.requests.is_empty(), "{output:?}"); + assert!(output.console.contains(&"validated".to_string()), "{output:?}"); + assert!(output.console.iter().any(|line| line.contains("Print") && line.contains("ignored")), "{output:?}"); + assert!(output.errors.is_empty(), "{output:?}"); + let fields = json(&mut a, "form_fields", json!({ "doc": doc.0 })); + let qty = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "Qty").unwrap(); + assert_eq!(qty["value"], "4"); + let explicit = json(&mut a, "js_run", json!({ "doc": doc.0, "script": "this.print();" })); + assert_eq!(explicit["requests"], json!([{ "print": true }])); +} + #[test] fn search_cache_stays_bounded_reextracts_and_follows_edits_and_close() { let mut a = Automation::new(); @@ -707,3 +730,27 @@ fn text_extract_html_has_one_budget_across_pages() { assert_eq!(capped["pages"][1]["html"], "", "no HTML after the budget ran out: {capped}"); assert_eq!(capped["pages"][1]["styles_incomplete"], true); } + +/// Replace through the tool when a page's text can't be read for the find bar: the page is named +/// and counted as refused, in the result, and the readable pages are still replaced. +#[test] +fn replace_all_reports_a_page_whose_text_cannot_be_read() { + let mut a = Automation::new(); + let id = search_document(&mut a, 3); + let mut extract = |bytes: &Arc>, password, pages: &[usize]| { + extract_parallel(bytes, password, pages) + .into_iter() + .map(|(p, result)| (p, if p == 1 { Err("synthetic page 2 failure".to_owned()) } else { result })) + .collect() + }; + let r = a.text_replace_with(&Args(&json!({ "doc": id.0, "find": "Page", "replace": "Sheet" })), &mut extract).unwrap(); + assert_eq!(r["replaced"], 3, "{r}"); + assert_eq!((r["skipped"].clone(), r["skipped_by"]["refused"].clone()), (json!(1), json!(1)), "{r}"); + let why = r["refusals"][0].as_str().unwrap(); + assert!(why.starts_with("page 2:") && why.contains("synthetic page 2 failure"), "{why}"); + // Replacing the first match stops at an unreadable page before it: nothing is replaced. + let r = + a.text_replace_with(&Args(&json!({ "doc": id.0, "find": "Page", "replace": "Sheet", "all": false, "pages": [2, 3] })), &mut extract).unwrap(); + assert_eq!((r["replaced"].clone(), r["changed"].clone(), r["skipped_by"]["refused"].clone()), (json!(0), json!(false), json!(1)), "{r}"); + assert!(r["refusals"][0].as_str().unwrap().starts_with("page 2:"), "{r}"); +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index c51dbdfbee..fa29d07634 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -171,11 +171,23 @@ pub fn tools() -> Vec { t("attachment_delete", "Delete an attachment", "Remove the document-level attachment with this name (as doc_info lists it). Undoable.") .destructive() .with(schema(json!({"doc": doc(), "name": {"type": "string"}}), &["doc", "name"])), - t("doc_open", "Open a PDF", "Open a PDF file and return its document id, page count and whether it can be edited.") + t("doc_convert_colours", "Convert colours", "Print production: every colour operator in the pages' content and the artwork it draws rewritten in the target space (to: gray, rgb or cmyk) by the standard device formulas (device spaces, ICC by component count, Lab, Indexed, Separation and DeviceN tints; patterns, shadings and inline images stay). Not a prepress separation: no ICC profile and no output intent is used. Spot colours are converted, not kept: exact through a Type 2 tint, otherwise a gray of their darkness; /None stays. preserve_black (default true) keeps pure black as 100% K / gray 0; images (default true) re-encodes 8-bit gray and RGB pictures (JPEG stays JPEG, compressed again at quality 80; CMYK written lossless; soft masks kept), and leaves indexed, CMYK, masked and other pictures as they are; annotations also converts the pages' comment and field appearances. Returns how many colours and pictures changed on how many pages, images_left (pictures not in the target that stayed), and loses_conformance (the PDF/A level and output intents the file declared: it may no longer meet them, and still declares them; standards_remove_id takes that off). Undoable.") + .cmd("prepress.convert_colors") + .with(schema( + json!({ + "doc": doc(), "pages": pages("to convert (default all)"), + "to": { "type": "string", "enum": ["gray", "rgb", "cmyk"] }, + "preserve_black": { "type": "boolean" }, "images": { "type": "boolean" }, "annotations": { "type": "boolean" }, + }), + &["doc"], + )), + t("attachment_edit", "Edit an attachment description", "Change the description of a document attachment by its exact key from doc_info, or by a unique displayed name. An empty description clears it. The file and other attachment data are preserved. Undoable.") + .with(schema(json!({"doc": doc(), "key": {"type": "string"}, "name": {"type": "string"}, "description": {"type": "string"}}), &["doc", "description"])), + t("doc_open", "Open a PDF", "Open a PDF file and return its document id, page count and whether it can be edited. A path that ends in open parameters, as help systems pass them (`manual.pdf#page=12`, `#nameddest=Intro` or `#Intro`), opens the file and reports its destination as link_open does; a path that names a file opens as it is.") .cmd("file.open") .with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])), t("doc_list", "List open documents", "List the open documents with their ids, page counts and unsaved state.").ro().with(schema(json!({}), &[])), - t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, page transitions and display times, the destination it opens at, attachments, fonts, security, repair notes and the objects that could not be read.") + t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers and the Layers panel's tree (layer_order), page transitions and display times, the destination it opens at, attachments (and, for a PDF portfolio, the one it presents first), fonts, security, repair notes and the objects that could not be read.") .ro() .cmd("file.properties") .with(schema(json!({ "doc": doc() }), &["doc"])), @@ -200,7 +212,7 @@ pub fn tools() -> Vec { )), t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords, or a custom property under any other name (no spaces, delimiters, / or #; at most 127 bytes; doc_info lists them under custom); an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value, and a custom property is kept there as pdfx: when its name can be an XML name (in a PDF/A file only one already there is changed). Undoable.") .with(schema(json!({ "doc": doc(), "key": { "type": "string" }, "value": { "type": "string" } }), &["doc", "key", "value"])), - t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600). When the picture is incomplete or approximate (content cut at the page's safety budget, a colour painted in a fallback colour), a JSON part follows the image: { page, warnings: [sentence, ...] }.") + t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600). When the picture is incomplete or approximate (content cut at the page's safety budget, a picture left out because its JBIG2 mask is past a decoder limit, a colour painted in a fallback colour), a JSON part follows the image: { page, warnings: [sentence, ...] }.") .ro() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "dpi": { "type": "number", "minimum": 1, "maximum": 600 }, "hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (number from text_paragraphs, resolved as text_edit does: refused once an edit merged that paragraph into a neighbour). Does not modify the document." }, @@ -264,6 +276,17 @@ pub fn tools() -> Vec { "start_page": { "type": "integer", "minimum": 1, "description": "Page to start searching from (default 1)." }, "skip": { "type": "integer", "minimum": 0, "description": "Matches to pass over on start_page before returning any (default 0)." }, "stop_at_limit": { "type": "boolean", "description": "Stop at the limit-th match without looking for another (default false). The answer then has stopped_at_limit; when true, complete is false and next is only where to resume, so following it may find nothing more." } }), &["doc", "query"])), + t("text_replace", "Replace text", "Find and replace (Edit text ▸ Replace): each match of find, with the find bar's rules (whitespace-normalised; case-insensitive unless case_sensitive; whole_words: no letter or digit may adjoin a match), is rewritten inside its own paragraph, which keeps its fonts, sizes and colours and rewraps; the replacement takes the style of the run it lands in. A longer replacement is measured in the paragraph's fonts and is not shrunk or hyphenated: one row grows right to the page's margin, then wraps; several rows keep their width. New rows go below and nothing else moves, so they can overlap what follows, unreported; a word wider than the width overruns it. replace may be empty (deletes the match). all (default true) replaces every match on pages (default all) as one undo step; false only the first match the find bar shows (page text order; when no paragraph holds it nothing is replaced and it is reported as not_editable). Matches are left alone and counted in skipped_by when they span two paragraphs (spans_paragraphs), are invisible text over a scan (scanned_text), would need another font because the paragraph's own can't draw the new text (missing_glyphs), the paragraph can't be rewritten (refused, with refusals), or the page text holds them outside any editable paragraph, such as form fields, comments or text inside a form XObject (not_editable, counted when all). A page whose text can't be read is counted once as refused and named in refusals. Returns replaced, skipped, skipped_by, the 1-based pages changed and whether the document changed.") + .cmd("edit.replace") + .with(schema( + json!({ + "doc": doc(), "find": { "type": "string", "minLength": 1 }, "replace": { "type": "string" }, + "pages": pages("to search (default: all)"), + "case_sensitive": { "type": "boolean" }, "whole_words": { "type": "boolean" }, + "all": { "type": "boolean", "description": "Every match (default) or only the first." }, + }), + &["doc", "find", "replace"], + )), t("page_rotate", "Rotate pages", "Rotate pages by a multiple of 90 degrees (positive is clockwise): the listed pages (default all), filtered like Acrobat's Rotate Pages by subset (all, even, odd page numbers) and orientation (all, landscape, portrait). Undoable.") .cmd("page.rotate") .with(schema( @@ -324,7 +347,7 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "pages": pages("to extract"), "out": save_out.clone(), "open": open.clone(), "separate": { "type": "boolean" }, "out_dir": { "type": "string" }, "delete": { "type": "boolean" } }), &["doc", "pages"], )), - t("doc_combine", "Combine files", "Combine files, in order, into one PDF (bookmarks are kept under one entry per file). Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) and .txt files are made into PDFs first. pages optionally chooses each file's pages, in step with paths: a range such as \"1-3, 6\" or null for all pages. passwords, also in step with paths, opens encrypted files (the open password, or the permissions password where a file's security doesn't allow copying pages; null for none). The result is not encrypted. Passwords are never echoed back. groups, also in step with paths, splits a file around others: entries with the same number (they must name the same path) are one file, copied once, so it keeps one bookmark at its first page, its links and its form fields; null for a file of its own. A group uses its first entry's password. When two files' forms name the same field, field_conflicts says what happens: \"rename\" (the default) keeps the fields separate by renaming the later one (Text1 becomes Text1-1) and the result lists renamed_fields as [[old, new], …] — scripts that refer to the old name are not rewritten; \"merge\" makes them one field, as before.") + t("doc_combine", "Combine files", "Combine files, in order, into one PDF (bookmarks are kept under one entry per file). Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000), .txt files and Office documents (Word, Excel, PowerPoint, OpenDocument, RTF; through an installed LibreOffice) are made into PDFs first. pages optionally chooses each file's pages, in step with paths: a range such as \"1-3, 6\" or null for all pages. passwords, also in step with paths, opens encrypted files (the open password, or the permissions password where a file's security doesn't allow copying pages; null for none). The result is not encrypted. Passwords are never echoed back. groups, also in step with paths, splits a file around others: entries with the same number (they must name the same path) are one file, copied once, so it keeps one bookmark at its first page, its links and its form fields; null for a file of its own. A group uses its first entry's password. When two files' forms name the same field, field_conflicts says what happens: \"rename\" (the default) keeps the fields separate by renaming the later one (Text1 becomes Text1-1) and the result lists renamed_fields as [[old, new], …] — scripts that refer to the old name are not rewritten; \"merge\" makes them one field, as before.") .cmd("page.combine") .with(schema( json!({ @@ -338,7 +361,7 @@ pub fn tools() -> Vec { }), &["paths"], )), - t("doc_create_multiple", "Create PDF from multiple files", "Convert several files (PDFs, PNG/JPEG/JPEG 2000/TIFF/GIF/BMP images, .txt files) to PDF in one run. mode \"combine\" (default) joins them, in order, into one PDF with a bookmark per file; pages optionally chooses each file's pages, in step with paths (a range such as \"1-3, 6\" or null). mode \"separate\" writes one PDF per file into out_dir (files that are already PDFs are skipped; existing files are never overwritten) and reports each file's result.") + t("doc_create_multiple", "Create PDF from multiple files", "Convert several files (PDFs, PNG/JPEG/JPEG 2000/TIFF/GIF/BMP images, .txt files, Office documents through an installed LibreOffice) to PDF in one run. mode \"combine\" (default) joins them, in order, into one PDF with a bookmark per file; pages optionally chooses each file's pages, in step with paths (a range such as \"1-3, 6\" or null). mode \"separate\" writes one PDF per file into out_dir (files that are already PDFs are skipped; existing files are never overwritten) and reports each file's result.") .cmd("create.multiple") .with(schema( json!({ @@ -508,15 +531,29 @@ pub fn tools() -> Vec { }), &["doc", "from", "to"], )), - t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. A calculated field includes calculate: {op, fields} or {notation} as form_set_props takes them, or {script} for a custom Calculate script (set with form_set_script). Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based). Radio groups include on_widgets, the 1-based numbers of the buttons that are on (buttons sharing an option tell apart only there).") + t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. A calculated field includes calculate: {op, fields} or {notation} as form_set_props takes them, or {script} for a custom Calculate script (set with form_set_script). Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based). Radio groups include on_widgets, the 1-based numbers of the buttons that are on (buttons sharing an option tell apart only there). A check box whose widgets have different on states also lists them as options, and which one is checked (null when none). Combo and list boxes include selected: the 1-based positions in options of the selected ones, which tells apart options that share an export value.") .ro() .with(schema(json!({ "doc": doc() }), &["doc"])), t( "form_fill", "Fill in form fields", - "Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes, an array of strings for multi-select lists; {\"widget\": n} turns on button n (1-based) of a check box or radio group and never turns it off (to clear, use null for a radio group that allows it, false for a check box); of radio buttons sharing an option only that one is on unless the group has RadiosInUnison. Appearances are regenerated so every viewer shows the values. Thai the field's font can't hold is drawn from PdfCraft's built-in Thai face (a build without one refuses it). Any other value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.", + "Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes (or one of a check box's options, to check that widget and clear the others), an array of strings for multi-select lists; {\"widget\": n} turns on button n (1-based) of a check box or radio group and never turns it off (to clear, use null for a radio group that allows it, false for a check box); of radio buttons sharing an option only that one is on unless the group has RadiosInUnison. A radio option is an export value as form_fields lists it in options; where one button's on-state name equals another button's export value, the export value wins, and an on-state name that no export value matches still selects its button. A radio option may also be a JSON number: a whole number names the option spelled as that integer (1, 1.0 and 1e0 all name \"1\"), any other number only an option spelled exactly as JSON prints it (1.5). A value that names no option is refused with the options listed. Appearances are regenerated so every viewer shows the values. Thai the field's font can't hold is drawn from PdfCraft's built-in Thai face (a build without one refuses it). Any other value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.", ) .with(schema(json!({ "doc": doc(), "values": { "type": "object", "description": "Field name → value." } }), &["doc", "values"])), + t( + "form_focus", + "Move the focus into or out of a field", + "A field gains (event enter) or loses (event exit) focus, as when someone clicks or tabs into or out of it, and the dynamic XFA form's enter or exit scripts for it run; on another form, the widget's own On Focus or On Blur JavaScript runs, if it has one (see also js_event). widget picks the radio button or copy of the field (1-based, default 1; see form_fields). What the scripts change is one undoable edit, and none when they change nothing. Changing a field's value (form_fill) runs its exit scripts already, so the next exit of that field doesn't run them again. Returns changed, and the scripts' alerts, console output, requests and errors.", + ) + .with(schema( + json!({ + "doc": doc(), + "field": { "type": "string", "description": "Field name (from form_fields)." }, + "event": { "type": "string", "enum": ["enter", "exit"] }, + "widget": { "type": "integer", "minimum": 1, "description": "Widget of the field (1-based, default 1): a radio button of the group, or a copy of the field." }, + }), + &["doc", "field", "event"], + )), t("form_reset", "Clear form", "Reset fields to their default values: all of them, or only those listed. Undoable.") .destructive() .with(schema(json!({ "doc": doc(), "fields": { "type": "array", "items": { "type": "string" } } }), &["doc"])), @@ -563,7 +600,7 @@ pub fn tools() -> Vec { }), &["doc", "page", "rect", "rows"], )), - t("stamp_custom", "Add a custom stamp", "Stamp a picture: a PDF page (file_page, default 1, kept as vector content in a form XObject) or an image file. Either centred at at [x, y] (points from the top-left of the displayed page) at its natural size, at most 200 pt, or fitted inside rect [x0, y0, x1, y1] (the same coordinates; each side 1–14400 pt), scaled to keep its shape and centred in the box. Like at, rect is not clipped to the page. Pass at or rect, not both. name labels it (default: the file name). Undoable.") + t("stamp_custom", "Add a custom stamp", "Stamp a picture: a PDF page (file_page, default 1, kept as vector content in a form XObject) or an image file. Either centred at at [x, y] (points from the top-left of the displayed page) at its natural size, at most 200 pt, or fitted inside rect [x0, y0, x1, y1] (the same coordinates; each side 1–14400 pt), scaled to keep its shape and centred in the box. Like at, rect is not clipped to the page. Pass at or rect, not both. name labels it (default: the file name). line adds a text line under the picture with {name} (the author), {date} and {time} filled in now, as a dynamic stamp. Undoable.") .cmd("comment.stamp") .with(schema( json!({ @@ -575,6 +612,7 @@ pub fn tools() -> Vec { "file_page": { "type": "integer", "minimum": 1 }, "name": { "type": "string" }, "author": { "type": "string" }, + "line": { "type": "string", "maxLength": 200 }, }), &["doc", "page", "path"], )), @@ -593,11 +631,11 @@ pub fn tools() -> Vec { &["doc", "page", "path", "rect"], )), t("form_set_image", "Set an image field's picture", "Show an image file (PNG, JPEG, TIFF, GIF or BMP) in an image field or button, scaled to fit and centred (what clicking an image field does). On an XFA form the picture is kept in its data too (base64, as Designer saves it) and the form shows it from there. Undoable.") - .with(schema(json!({ "doc": doc(), "field": { "type": "string" }, "path": { "type": "string" } }), &["doc", "field", "path"])), + .with(schema(json!({ "doc": doc(), "field": { "type": "string" }, "field_id": {"type":"integer","minimum":0,"maximum":281474976710655u64,"description":"Optional exact document-local identity from form_fields; requires matching field name."}, "path": { "type": "string" } }), &["doc", "field", "path"])), t( "form_set_props", "Field properties", - "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename: a dotted name such as MASTER.first is the full name and moves the field under those parent fields; a plain name renames it within its parent), rect (position), rotation and caption require a single field. A rename also renames the field in the calculations that read it (calculate field lists and notation, and getField calls naming it in a string literal; names built at run time are not rewritten), in the same Undo step, and formulas_updated lists the fields whose calculation changed. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.", + "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename: a dotted name such as MASTER.first is the full name and moves the field under those parent fields; a plain name renames it within its parent), rect (position), rotation and caption require a single field. A rename also renames the field in the calculations that read it (calculate field lists and notation, and getField calls naming it in a string literal; names built at run time are not rewritten), in the same Undo step, and formulas_updated lists the fields whose calculation changed. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise; the field keeps its rectangle and its content turns inside it. Undoable.", ) .with(schema( json!({ @@ -621,7 +659,7 @@ pub fn tools() -> Vec { "flags": { "type": "object", "additionalProperties": { "type": "boolean" } }, "font_size": { "type": "number", "minimum": 0 }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "Move/resize: points from the top-left of the displayed page." }, - "rotation": { "type": "integer", "enum": [0, 90, 180, 270], "description": "Widget rotation in degrees, counterclockwise. Changing between horizontal and vertical swaps the rectangle around its center." }, + "rotation": { "type": "integer", "enum": [0, 90, 180, 270], "description": "Widget rotation in degrees, counterclockwise. The field keeps its rectangle; its content turns inside it." }, "appearance": { "type": "object", "description": "Appearance tab: border, fill, text_color (#RRGGBB, a name or \"none\"), width (0–12 points; 1 thin, 2 medium, 3 thick), style (solid, dashed, beveled, inset, underline), font (helvetica, times, courier)." }, "format": { "description": "Format tab: {\"type\": \"number\", \"decimals\": 2, \"currency\": \"$\", \"separator\": 0-4, \"negative\": 0-3}, {\"type\": \"percent\"}, {\"type\": \"date\"|\"time\", \"pattern\": \"mm/dd/yyyy\"}, {\"type\": \"zip\"|\"zip4\"|\"phone\"|\"ssn\"}, {\"type\": \"mask\", \"mask\": \"AA-9999\"}, or \"none\"." }, "validate": { "description": "Validate tab: {\"min\": 0, \"max\": 100} (either may be left out, or null) or \"none\"." }, @@ -678,7 +716,7 @@ pub fn tools() -> Vec { t( "redact_apply", "Apply redactions", - "Apply the redaction marks (all, or those on pages): text, images, vectors, comments and form fields under them are removed for good and boxes are drawn in their place. A verification pass fails the operation if anything readable remains. Undoable until saved; the saved file no longer contains the content.", + "Apply the redaction marks (all, or those on pages): text, images, vectors, comments and form fields under them are removed for good and boxes are drawn in their place. A verification pass fails the operation if anything readable remains. Document metadata (Info entries, the document's and pages' XMP) that repeats the removed text is removed too; when the document XMP goes, identification says what became of its PDF/A and PDF/UA claims (pdfa, pdfua: none, kept when written back in a packet holding only the identification, or lost). still_holding counts the places applying leaves as they are that still hold the removed text: bookmarks (titles), attachments (names, file names, descriptions), comments (text, rich text, subject or author of those that remain) and xmp (XMP on images, fonts, forms, annotations and other objects), with their total; doc_remove_hidden removes them. unsearched: true means the removed text couldn't be searched for, so every such place holding any text is counted. Undoable until saved; the saved file no longer contains the content. The result's save_as is the document's name with _Redacted before the extension (contract.pdf gives contract_Redacted.pdf; a name that already ends in _Redacted is unchanged); pass it to doc_save as path to save under that name.", ) .destructive() .with(schema(json!({ "doc": doc(), "pages": pages("whose marks to apply (default: all)") }), &["doc"])), @@ -688,7 +726,7 @@ pub fn tools() -> Vec { t( "doc_hidden_info", "Find hidden information", - "What Remove Hidden Information would remove, by category (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data) with counts.", + "What Remove Hidden Information would remove, by category (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data) with counts. A page that can't be read in full (its content streams don't decode, or they or a form they draw hold bytes the parser can't read or an inline image whose end is a guess) counts as one item of hidden-text and of hidden-layers: removing either category then fails on that page. With form-fields removed too, a field's appearance becomes page content and is read the same way, though it isn't counted here.", ) .ro() .with(schema(json!({ "doc": doc() }), &["doc"])), @@ -716,10 +754,17 @@ pub fn tools() -> Vec { t("printer_options", "List printer options", "A printer driver's own job options (CUPS: from its PPD), such as the paper tray, paper type or finishing: key, label, group, default and choices. Pass chosen values to doc_print as options. Empty on Windows, where the driver's preferences window holds them.") .ro() .with(schema(json!({ "printer": { "type": "string" } }), &["printer"])), + t( + "printer_papers", + "List printer paper sizes", + "The media sizes (names and dimensions in points) and default paper supported by a printer driver (CUPS: its PPD's, by the driver's names, the default being its *DefaultPageSize; a job on one, or on a standard size of one, is fed it under the PPD's own keyword). Pass printer: a name from printers, or \"default\".", + ) + .ro() + .with(schema(json!({ "printer": { "type": "string" } }), &["printer"])), t( "doc_print", "Print", - "Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed, \"(n)\" for the nth page whatever its label, unless a page is labelled exactly \"(n)\", since labels are looked up first; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, which turns the sheet to suit the grid, or a custom grid of cols across by rows down, each 1-99, on the sheet as oriented (auto prints portrait); order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).", + "Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed, \"(n)\" for the nth page whatever its label, unless a page is labelled exactly \"(n)\", since labels are looked up first; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, which turns the sheet to suit the grid, or a custom grid of cols across by rows down, each 1-99, on the sheet as oriented (auto prints portrait); order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, US #10 Envelope, a driver paper name from printer_papers, or dimensions \"x\"; when printer is given and paper is omitted, uses the printer's default paper if available; use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).", ) .with(schema( json!({ @@ -737,18 +782,20 @@ pub fn tools() -> Vec { "auto_rotate": { "type": "boolean" }, "booklet_subset": { "type": "string", "enum": ["both", "front", "back"] }, "binding": { "type": "string", "enum": ["left", "right"] }, + "creep": { "type": "number", "minimum": 0, "maximum": 10, "description": "Booklet creep compensation: points each sheet's pages move toward the fold, times the sheet's place from the outside (default 0)." }, "overlap": { "type": "number", "minimum": 0 }, "cut_marks": { "type": "boolean" }, "orientation": { "type": "string", "enum": ["auto", "portrait", "landscape"] }, "comments_forms": { "type": "string", "enum": ["document", "document-and-markups", "document-and-stamps", "form-fields-only"] }, - "paper": { "type": "string" }, + "paper": { "type": "string", "description": "A paper name (US Letter, A4…) or a size of its own such as 8.5x14in, 210x297mm, 10x15cm or 612x792pt (1 to 200 inches a side)." }, "path": { "type": "string" }, "printer": { "type": "string" }, "copies": { "type": "integer", "minimum": 1, "maximum": 999 }, "collate": { "type": "boolean" }, - "duplex": { "type": "string", "enum": ["off", "long-edge", "short-edge", "printer"], "description": "Two-sided printing (default off); printer sends no setting, so the printer's own default applies." }, + "duplex": { "type": "string", "enum": ["off", "long-edge", "short-edge", "printer"], "description": "Two-sided printing (default off); printer uses the printer's current default: Windows sends its duplex setting when supported, otherwise preserves the driver default, including manual two-sided preferences; CUPS leaves the setting unspecified." }, "grayscale": { "type": "boolean" }, - "as_image": { "type": "boolean", "description": "Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at dpi before it is placed on the paper, for printers that skip text, shading or transparency; the print is no longer searchable text, and with grayscale the pictures are grey." }, + "area": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "Print a selected area: [x0, y0, x1, y1] in points from the top-left of the displayed page, of the one page in pages, sized by layout fit/actual/shrink/custom." }, + "as_image": { "type": "boolean", "description": "Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at dpi before it is placed on the paper, for printers that skip text, shading or transparency; the print is no longer searchable text, and with grayscale the pictures are grey, so the printer isn't asked for grayscale (a driver without that setting still prints them)." }, "dpi": { "type": "number", "minimum": 72, "maximum": 600, "description": "Resolution for as_image (default 300)." }, "options": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Printer driver options from printer_options: key → choice value." }, }), @@ -824,10 +871,10 @@ pub fn tools() -> Vec { t("content_delete", "Delete added content", "Delete an added item (page, index from content_list). Undoable.") .destructive() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 } }), &["doc", "page", "index"])), - t("link_list", "List links", "Every link: page, 1-based index (for link_edit/link_delete), rect (points from the top-left of the page), and where it goes (url, or to_page with its zoom). With text_links: true, also the web and email addresses written as plain page text with no link over them, which the viewer opens on a click: page, rects (one per line), url, and blocked (why it won't open) when the link check refuses it. Only http, https and mailto addresses are found. At most 100 pages are read per call, from text_links_from (default 1); text_links_next_page then gives the page to continue from.") + t("link_list", "List links", "Every link: page, 1-based index (for link_edit/link_delete), rect (points from the top-left of the page), and where it goes (url, or to_page with its zoom). A relative url also has resolved_url, the address resolved against the document's base URI (catalog /URI /Base); a link active only in its /QuadPoints has active_areas, one rect per quadrilateral. With text_links: true, also the web and email addresses written as plain page text with no link over them, which the viewer opens on a click: page, rects (one per line), url, and blocked (why it won't open) when the link check refuses it. Only http, https and mailto addresses are found. At most 100 pages are read per call, from text_links_from (default 1); text_links_next_page then gives the page to continue from.") .ro() .with(schema(json!({ "doc": doc(), "text_links": { "type": "boolean" }, "text_links_from": { "type": "integer", "minimum": 1 } }), &["doc"])), - t("link_open", "Open a linked PDF", "Follow a local GoToR link in PdfCraft: open the referenced PDF as a new document, respecting --root. page and index use link_list numbering. Named destinations are reported but not applied. Absolute file specifications require allow_absolute: true after reviewing the target; relative links need no opt-in.") + t("link_open", "Open a linked PDF", "Follow a local GoToR link in PdfCraft: open the referenced PDF as a new document, respecting --root. page and index use link_list numbering. A named destination is looked up in the target, as the app does; one the target doesn't define is reported with a warning. Absolute file specifications require allow_absolute: true after reviewing the target; relative links need no opt-in.") .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, "password": { "type": "string", "description": "Optional password for the linked PDF." }, "allow_absolute": { "type": "boolean", "description": "Explicitly allow an absolute local GoToR target (default false). Existing validation and --root confinement still apply." } }), &["doc", "page", "index"], @@ -880,7 +927,7 @@ pub fn tools() -> Vec { .ro().cmd("measure.snap").with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"at":point(),"tolerance":{"type":"number","minimum":0,"maximum":10000},"endpoints":{"type":"boolean"},"midpoints":{"type":"boolean"},"intersections":{"type":"boolean"},"paths":{"type":"boolean"}}), &["doc","page","at"])), t("measure_export", "Export measurements as CSV", "Atomically write saved measurement values, labels, authors and scale ratios as spreadsheet-safe CSV. Returns how many unsupported measurements were left out.") .cmd("measure.export").with(schema(json!({"doc":doc(),"out":path_arg()}), &["doc","out"])), - t("comment_list", "List comments", "Every comment (annotation other than links, form widgets and pop-ups) with its page, index, id, type, author, text, date, rectangle, colour, review status, checkmark and replies (each with its own status and checkmark).") + t("comment_list", "List comments", "Every comment (annotation other than links, form widgets and pop-ups) with its page, index, id, type, author, text, date, rectangle, colour, review status, checkmark and replies (each with its own status and checkmark); the points that shape a line, polygon or callout, and a callout's text box. Annotations grouped with a comment (/RT /Group) are listed once, as that comment with its attributes, and named in its `group`. comment_delete deletes just the member named, as Acrobat does (Replace Text's strikeout and caret go together).") .ro() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1, "description": "Only this page." } }), &["doc"])), t( @@ -958,7 +1005,7 @@ pub fn tools() -> Vec { .ro() .cmd("sign.digital") .with(schema(json!({}), &[])), - t("sign_document", "Sign a document", "Sign with a digital ID (a .p12/.pfx path, or on macOS a Keychain identity: \"keychain:\" from sign_keychain_ids, or on Windows a store identity: \"windows:\" from sign_windows_ids) and save the signed file to `out` (signing always saves, as in Acrobat; the document then shows the signed file). Sign an existing empty signature field (`field`), or a new one on `page` at `rect` (omit rect for an invisible signature). certify: no_changes, form_fill or comments makes a certification signature. graphic shows a picture where the signer's name would be large (Acrobat: Configure Signature Appearance): {\"image\": path} (PNG or JPEG), {\"text\": name} (typed in the Fill & Sign script font) or {\"strokes\": [[[x, y], …], …]} (y up, the drawing pad 1 wide); it needs a visible signature. appearance chooses what a visible signature shows (default: name, date and labels; reason, location and distinguished_name are off unless turned on). labels gives the words of its labels, e.g. in another language (default English: \"Digitally signed by\", \"DN: \", \"Reason: \", \"Location: \", \"Date: \"; include the separator); each must be in Western European characters (Windows-1252), the only ones the signature's font draws, or the call is refused naming the label and character. PAdES B-B, SHA-256 (SHA-384 for P-384 keys).") + t("sign_document", "Sign a document", "Sign with a digital ID (a .p12/.pfx path, or on macOS a Keychain identity: \"keychain:\" from sign_keychain_ids, or on Windows a store identity: \"windows:\" from sign_windows_ids) and save the signed file to `out` (signing always saves, as in Acrobat; the document then shows the signed file). Sign an existing empty signature field (`field`), or a new one on `page` at `rect` (omit rect for an invisible signature). certify: no_changes, form_fill or comments makes a certification signature. graphic shows a picture where the signer's name would be large (Acrobat: Configure Signature Appearance): {\"image\": path} (PNG or JPEG), {\"text\": name} (typed in the Fill & Sign script font) or {\"strokes\": [[[x, y], …], …]} (y up, the drawing pad 1 wide); it needs a visible signature. appearance chooses what a visible signature shows (default: name, date and labels; reason, location and distinguished_name are off unless turned on). labels gives the words of its labels, e.g. in another language (default English: \"Digitally signed by\", \"DN: \", \"Reason: \", \"Location: \", \"Date: \"; include the separator); each must be in Western European characters (Windows-1252), the only ones the signature's font draws, or the call is refused naming the label and character. PAdES B-B, SHA-256 (SHA-384 for P-384 keys). On a dynamic (XFA) form, run_form_scripts: true runs the field's own signing scripts: preSign just before signing, so what it changes is signed too (form_scripts.changed lists each field it changed, with the values before and after), and postSign after, left unsaved. Without it they don't run, and form_scripts.skipped says the field has them.") .cmd("sign.digital") .with(schema( json!({ @@ -1009,6 +1056,7 @@ pub fn tools() -> Vec { "additionalProperties": false, }, "out": { "type": "string", "description": "Where to save the signed document." }, + "run_form_scripts": { "type": "boolean", "description": "Run a dynamic form's preSign and postSign scripts for the field (default false)." }, }), &["doc", "id", "out"], )), @@ -1051,7 +1099,7 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "sort": { "type": "string", "enum": ["page", "author", "date", "type"] }, "out": save_out, "open": open }), &["doc"], )), - t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity (the whole comment's, or its fill's and text's own), line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), border colour (`border_color`, text box/typewriter/callout; \"text\" gives it the text's colour again), rectangle (rectangle/oval/text box/stamp), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, or the corners of connected lines, a polygon or a cloud). Stamps keep their original appearance when resized. One undo step.").with(schema( + t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity (the whole comment's, or its fill's and text's own), line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), border colour (`border_color`, text box/typewriter/callout; \"text\" gives it the text's colour again), rectangle (rectangle/oval/text box/stamp, or a callout's text box: its arrow point stays and its leader line re-attaches), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, the corners of connected lines, a polygon or a cloud, or a callout's arrow point and knee, as comment_list gives them). Stamps keep their original appearance when resized. One undo step.").with(schema( comment_ref(json!({ "contents": { "type": "string" }, "color": color(), @@ -1065,13 +1113,18 @@ pub fn tools() -> Vec { "font_size": { "type": "number", "minimum": 1, "maximum": 400, "description": "Font size in points of a text box, typewriter text or callout; the box grows to hold the text." }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "move": point(), - "points": { "type": "array", "items": point(), "minItems": 2, "description": "A line's two ends, or every corner of connected lines, a polygon or a cloud, in page coordinates." }, + "points": { "type": "array", "items": point(), "minItems": 1, "description": "A line's two ends, every corner of connected lines, a polygon or a cloud, or a callout's arrow point and (optionally) knee, in page coordinates. A callout given its arrow point alone keeps the knee it has." }, })), &["doc"], )), t("comment_image_preview", "Preview image signature layers", "Return a PNG of the page without the selected image signature/initials (layer=background, default), or its embedded image with alpha (layer=image). Includes the displayed rectangle, document rotation, image_rotation (how far clockwise the image is shown turned) and annotation opacity. Cache these layers for live placement/resizing; commit once with comment_edit. Does not change the document or undo history.") .ro().with(schema(comment_ref(json!({ "layer": { "type": "string", "enum": ["background", "image"], "default": "background" }, "dpi": { "type": "number", "minimum": 1, "maximum": 600, "default": 96 } })), &["doc"])), t("comment_delete", "Delete a comment", "Delete a comment with its pop-up and replies, or one reply (its id, or page and index, from comment_list) with its pop-up and the replies to it; the comment and its other replies stay. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])), + t("comment_popup", "Open, close or move a comment's pop-up", "Open or close a comment's pop-up window (open), or move it (rect, in page view coordinates as comment_list reports). A comment without a pop-up gets one beside it. Text boxes, Fill & Sign marks and replies have none of their own. Undoable.") + .with(schema(comment_ref(json!({ + "open": { "type": "boolean" }, + "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, + })), &["doc"])), t( "comments_copy", "Copy comments", @@ -1206,13 +1259,27 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "pages": pages("to fill (default: all)"), "color": { "type": "string" }, "file": { "type": "string" }, "file_page": { "type": "integer", "minimum": 1 }, "scale": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, "replace": { "type": "boolean" }, "replace_backdrop": { "type": "boolean", "description": "Replace a white backdrop the page paints itself, which would hide the background. Default true." } }), &["doc"], )), + t("doc_printer_marks", "Add printer marks", "Print production: trim marks, bleed marks (dashed, at the bleed box), registration targets, colour bars and a page-information line (label, page n of N, date) drawn outside the trim box as a removable page mark, in registration colour (Separation All, so on every plate) except the process-ink colour bars; the page is widened by make_room points a side when the marks would fall outside it (the trim box stays). replace (default true) replaces marks added before. Undoable; doc_remove_marks kind printer_marks takes them off.") + .cmd("prepress.marks") + .with(schema( + json!({ + "doc": doc(), "pages": pages("the pages to mark (default all)"), + "trim": { "type": "boolean" }, "bleed": { "type": "boolean" }, "registration": { "type": "boolean" }, "color_bars": { "type": "boolean" }, "page_info": { "type": "boolean" }, + "line_weight": { "type": "number", "minimum": 0.1, "maximum": 4, "description": "Points (Acrobat offers 0.25, 0.5 and 1)." }, + "make_room": { "type": "number", "maximum": 288, "description": "Points to widen the page by on each side when needed (default 36); negative leaves the page as it is." }, + "label": { "type": "string", "description": "The page-information line's label (default: the file's name)." }, + "date": { "type": "string", "description": "The page-information line's date (default: today)." }, + "replace": { "type": "boolean" }, + }), + &["doc"], + )), t("doc_backdrops", "List page backdrops", "Pages that paint their own backdrop, which hides a background added behind the content: `fill` (an opaque white fill doc_background can replace) or `image` (a page-sized picture, e.g. a scan, that still hides it). Pages are 1-based; pages without one are left out.") .ro() .cmd("edit.background") .with(schema(json!({ "doc": doc(), "pages": pages("to check (default: all)") }), &["doc"])), - t("doc_remove_marks", "Remove header & footer, watermark or background", "Remove every header and footer, watermark or background PdfCraft (or a compatible tool) added. Undoable.") + t("doc_remove_marks", "Remove header & footer, watermark, background or printer marks", "Remove every header and footer, watermark, background or printer marks PdfCraft (or a compatible tool) added. Undoable.") .destructive() - .with(schema(json!({ "doc": doc(), "kind": { "type": "string", "enum": ["header_footer", "watermark", "background"] } }), &["doc", "kind"])), + .with(schema(json!({ "doc": doc(), "kind": { "type": "string", "enum": ["header_footer", "watermark", "background", "printer_marks"] } }), &["doc", "kind"])), t("doc_export_images", "Export pages as images", "Write pages as PNG, JPEG or TIFF files (`_page_.png|jpg|tif`) into a folder, at a resolution (default 150 dpi). JPEG and TIFF are flattened onto white paper. A page too large for the renderer at that resolution is drawn at the most it allows, listed in `lower_dpi` ({page, dpi}). Includes unsaved edits.") .cmd("export.image") .with(schema( @@ -1348,12 +1415,27 @@ pub fn tools() -> Vec { t("form_merge_data", "Merge data files into spreadsheet", "Collect the field values of form data files (FDF, XFDF) or filled-in PDF forms into one CSV file at path: a column per field name, a row per file. Returns the row and column counts.") .cmd("form.merge_data") .with(schema(json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, "path": { "type": "string" } }), &["paths", "path"])), - t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the alerts (each with its message, title, icon and buttons, in order; every question is still answered OK), console output and requests (print, page, url, submit) of the script and of the field scripts its changes ran, the script's error, the errors of document-level scripts that failed (the rest still ran), and in warnings the errors of applying the changes, such as values of an XFA form that could not be written to its data.") + t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the script's and subsequent field scripts' alerts (each with its message, title, icon and buttons, in order; AcroForm replies consume ordered answers; missing replies cancel input or use the alert button set's safe default), console output, requests (print, page, url, submit, attachment), error, and the errors of document-level scripts that failed (the rest still ran), plus warnings from applying the changes.") .cmd("tools.js_console") .with(schema( - json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." } }), + json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." }, "field_id": {"type":"integer","minimum":0,"maximum":281474976710655u64,"description":"Optional exact field identity from form_fields; requires matching field name and is scoped to this document."}, "answers": {"type":"array","maxItems":32,"items":{"type":["string","null"],"maxLength":4096},"description":"Ordered popup, response and alert answers; strings are limited to 4096 UTF-8 bytes at runtime; null cancels a popup or response, and selects an alert default (OK, Cancel or No, by button set)."} }), &["doc", "script"], )), + t( + "js_event", + "Run a field's pointer or focus script", + "Run the JavaScript action widget `widget` of AcroForm field `field` has for `event`, as when the pointer enters (mouse_enter), leaves (mouse_exit), presses (mouse_down) or releases (mouse_up) it, or it gains (on_focus) or loses (on_blur) the focus, in the document's script realm (variables document scripts and earlier scripts left are kept), with event.name Mouse Enter, Mouse Exit, Mouse Down, Mouse Up, Focus or Blur. Nothing runs when the widget has no JavaScript for that event, JavaScript is off, or the form is a laid-out XFA form (use form_focus and js_run there). What it changes is one undoable edit. Pointer-over and focus scripts run unasked in a viewer, so their requests to print, open Save As or show a panel are dropped (noted on the console); mouse_down and mouse_up keep them. Returns changed, and the script's alerts, console output, requests and errors.", + ) + .with(schema( + json!({ + "doc": doc(), + "field": { "type": "string", "description": "Field name (from form_fields)." }, + "event": { "type": "string", "enum": ["mouse_enter", "mouse_exit", "mouse_down", "mouse_up", "on_focus", "on_blur"] }, + "field_id": {"type":"integer","minimum":0,"maximum":281474976710655u64,"description":"Optional exact field identity from form_fields: of fields sharing the name, the one to run."}, + "widget": { "type": "integer", "minimum": 1, "description": "Widget of the field (1-based, default 1): a radio button of the group, or a copy of the field." }, + }), + &["doc", "field", "event"], + )), t("js_document_scripts", "Document JavaScripts", "List the document-level JavaScripts (name and source), which define functions field scripts use.") .ro() .cmd("tools.document_js") @@ -1678,7 +1760,7 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.") .with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])), - t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, up to 16 JBIG2 images sharing one symbol dictionary (/JBIG2Globals) where that is smaller still (image masks and DeviceGray images named directly, of at most 64 Mi pixels, which redaction can store again without it), Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings (entries removed: print_settings), form_actions (submit, import and reset actions removed: form_actions; the fields and links stay, and the actions chained after one still run); flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. target_bytes (optional): if the copy is larger, retry with up to 5 progressively stronger image settings (downsample to at most 110, 96, 72, 54, 36 ppi, applied to colour and grey images; images set to JPEG also get JPEG quality at most 45, 35, 28, 20, 12, while zip and retain images keep their encoding and only get the lower ppi) and stop at the first copy that fits; reports target_reached, attempts and target_settings, and writes the smallest copy with target_reached false when none fits. Without it nothing changes. The open document is unchanged.") + t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, up to 16 JBIG2 images sharing one symbol dictionary (/JBIG2Globals) where that is smaller still (image masks and DeviceGray images named directly, of at most 64 Mi pixels, which redaction can store again without it), Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; a picture's stencil /Mask and one-bit /SMask are stored again the same way, at their size, not resampled and sharing no dictionary, counted as images; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, search_index (removes the catalog's /PieceInfo entries whose application name contains \"index\" in any letter case; search_indexes), print_settings (entries removed: print_settings), form_actions (submit, import and reset actions removed: form_actions; the fields and links stay, and the actions chained after one still run); flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); discard_external_refs takes GoToR, GoToE, Launch and ImportData actions, and Thread actions with /F, out of links and bookmarks, removes a link left with nothing to do, and keeps links within the document and web links (external_links, external_bookmarks); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. target_bytes (optional): if the copy is larger, retry with up to 5 progressively stronger image settings (downsample to at most 110, 96, 72, 54, 36 ppi, applied to colour and grey images; images set to JPEG also get JPEG quality at most 45, 35, 28, 20, 12, while zip and retain images keep their encoding and only get the lower ppi) and stop at the first copy that fits; reports target_reached, attempts and target_settings, and writes the smallest copy with target_reached false when none fits. Without it nothing changes. The open document is unchanged.") .cmd("optimize.advanced") .with(schema( json!({ @@ -1694,6 +1776,7 @@ pub fn tools() -> Vec { "discard_thumbnails": { "type": "boolean" }, "discard_alternate_images": { "type": "boolean" }, "discard_tags": { "type": "boolean" }, + "discard_search_index": { "type": "boolean" }, "discard_print_settings": { "type": "boolean" }, "discard_form_actions": { "type": "boolean" }, "flate_unencoded": { "type": "boolean" }, @@ -1701,6 +1784,7 @@ pub fn tools() -> Vec { "lzw_to_flate": { "type": "boolean" }, "remove_invalid_links": { "type": "boolean" }, "remove_unreferenced_dests": { "type": "boolean" }, + "discard_external_refs": { "type": "boolean" }, "discard": { "type": "array", "items": { "type": "string" } }, }), &["doc", "path"], diff --git a/crates/automation/tests/it/action_failures.rs b/crates/automation/tests/it/action_failures.rs index b487103a21..cae72832ab 100644 --- a/crates/automation/tests/it/action_failures.rs +++ b/crates/automation/tests/it/action_failures.rs @@ -19,14 +19,14 @@ fn one_step(step: Step) -> Action { /// The standard fonts can't draw the character, so the watermark step fails and no file is produced. #[test] fn failing_watermark_step_fails_the_file_and_publishes_nothing() { - let r = run_on(&one_step(Step::AddWatermark("中".into())), "one.pdf", Arc::new(pdf()), |_, _| {}); + let r = run_on(&one_step(Step::AddWatermark("中".into())), "one.pdf", Arc::new(pdf()), true, |_, _| {}); assert!(r.is_err(), "a failed step must fail its file: {r:?}"); } /// A comment step on a document with no comments has nothing to do, which is not a failure. #[test] fn nothing_to_do_step_still_succeeds() { - let r = run_on(&one_step(Step::FlattenComments), "one.pdf", Arc::new(pdf()), |_, _| {}); + let r = run_on(&one_step(Step::FlattenComments), "one.pdf", Arc::new(pdf()), true, |_, _| {}); assert!(r.is_ok(), "a step with nothing to do is not a failure: {r:?}"); } @@ -61,6 +61,6 @@ fn action_run_marks_the_failing_file_as_error_without_output() { /// A removal step with nothing to remove keeps its existing note and succeeds. #[test] fn removal_step_with_nothing_to_remove_still_succeeds() { - let r = run_on(&one_step(Step::RemoveHiddenInformation), "one.pdf", Arc::new(pdf()), |_, _| {}); + let r = run_on(&one_step(Step::RemoveHiddenInformation), "one.pdf", Arc::new(pdf()), true, |_, _| {}); assert!(r.is_ok(), "nothing to remove is not a failure: {r:?}"); } diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 19c26bf87a..db139844a7 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9,7 +9,16 @@ use serde_json::{Value, json}; /// A PDF with `n` 200×300 pt pages reading "Page 1", "Page 2", … fn fixture(n: usize) -> Vec { - let mut objs: Vec> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()]; + fixture_with(n, "") +} + +/// Named destinations for [`fixture_with`]: `chapter` (page 2) in the catalog's /Dests and +/// `Load Flow` (page 3) in the /Names /Dests tree. +const NAMED_DESTS: &str = "/Dests << /chapter [6 0 R /Fit] >> /Names << /Dests << /Names [(Load Flow) [8 0 R /XYZ 0 300 0]] >> >>"; + +/// [`fixture`] with `catalog` added to the catalog dictionary. Page `i` (0-based) is object `4 + 2i`. +fn fixture_with(n: usize, catalog: &str) -> Vec { + let mut objs: Vec> = vec![format!("<< /Type /Catalog /Pages 2 0 R {catalog} >>").into_bytes()]; let kids: Vec = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect(); objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" ")).into_bytes()); objs.push(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec()); @@ -1217,7 +1226,7 @@ fn combine_takes_images_and_text_files() { std::fs::write(dir.join("scan.png"), png).unwrap(); std::fs::write(dir.join("notes.txt"), "Some notes").unwrap(); std::fs::write(dir.join("broken.png"), b"\x89PNG\r\n\x1a\nbroken").unwrap(); - std::fs::write(dir.join("report.docx"), b"PK\x03\x04").unwrap(); + std::fs::write(dir.join("archive.zip"), b"PK\x03\x04").unwrap(); let mut a = auto(&dir); let combined = ok(&mut a, "doc_combine", json!({ "paths": ["a.pdf", "scan.png", "notes.txt"], "pages": [null, null, "1"], "open": true })); let doc = combined["document"]["doc"].as_u64().unwrap(); @@ -1227,7 +1236,7 @@ fn combine_takes_images_and_text_files() { let titles: Vec<&str> = marks.as_array().unwrap().iter().map(|b| b["title"].as_str().unwrap()).collect(); assert_eq!(titles, ["a", "scan", "notes"]); // A file that can't be made into a PDF stops the combine, and says which. - for bad in ["broken.png", "report.docx"] { + for bad in ["broken.png", "archive.zip"] { let refused = a.call("doc_combine", &json!({ "paths": ["a.pdf", bad] })); assert!(matches!(&refused, Err(ToolError::Failed(e)) if e.contains(bad)), "{bad}: {refused:?}"); } @@ -2263,6 +2272,93 @@ fn text_links_leave_out_addresses_on_hidden_layers() { assert_eq!(created["created"], json!(["https://example.com/shown", "https://example.com/layered"]), "{created}"); } +/// Table 211), and a link with `/QuadPoints` is active only there (§12.5.6.5, Table 176): both as +/// link_list and doc_info report them. +#[test] +fn links_resolve_relative_addresses_and_report_their_quad_points() { + let dir = workdir("link-base-quads"); + let objs: Vec<&str> = vec![ + "<< /Type /Catalog /Pages 2 0 R /URI << /Base (https://example.com/base/) >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [72 700 300 716] /Border [0 0 0] /A << /S /URI /URI (page.html) >> >>", + "<< /Type /Annot /Subtype /Link /Rect [72 670 300 686] /QuadPoints [72 670 120 670 120 686 72 686] /Border [0 0 0] /A << /S /URI /URI (https://example.org/q) >> >>", + "<< /Type /Annot /Subtype /Link /Rect [72 640 300 656] /Border [0 0 0] /A << /S /URI /URI (javascript:alert\\(1\\)) >> >>", + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + std::fs::write(dir.join("links.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "links.pdf" }))["doc"].as_u64().unwrap(); + let links = ok(&mut a, "link_list", json!({ "doc": doc })); + let [relative, quads, script] = [&links["links"][0], &links["links"][1], &links["links"][2]]; + assert_eq!( + (relative["url"].clone(), relative["resolved_url"].clone()), + (json!("page.html"), json!("https://example.com/base/page.html")), + "{links}" + ); + assert_eq!(relative.get("active_areas"), None, "{links}"); + // 792 − 686 = 106 points from the top: only the first word, not the whole rect. + assert_eq!(quads["active_areas"], json!([[72.0, 106.0, 120.0, 122.0]]), "{links}"); + assert_eq!(quads.get("resolved_url"), None, "an absolute address is not resolved"); + // A base never turns a script into a web address. + assert_eq!((script["url"].clone(), script.get("resolved_url")), (json!("javascript:alert(1)"), None), "{links}"); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + assert_eq!(info["links"][0]["target"]["uri"], "https://example.com/base/page.html", "{info}"); +} + +/// Once inspection's text budget is spent, a relative address stays raw, and the viewer opens it +/// as written: `link_list` reports that address, not a second resolution against `/URI /Base`. +#[test] +fn link_list_reports_the_address_the_viewer_opens_past_the_text_budget() { + let dir = workdir("link-base-budget"); + // Each link spends what the base adds to its address; well past 16 MiB of it in all. + let base = format!("https://example.com/{}/", "b".repeat(8000)); + let count = (16 << 20) / base.len() + 20; + let annots = "<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /Border [0 0 0] /A 4 0 R >> ".repeat(count); + let catalog = format!("<< /Type /Catalog /Pages 2 0 R /URI << /Base ({base}) >> >>"); + let page = format!("<< /Type /Page /Parent 2 0 R /Annots [{annots}] >>"); + let objs = [catalog.as_str(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", page.as_str(), "<< /S /URI /URI (p.html) >>"]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + std::fs::write(dir.join("links.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "links.pdf" }))["doc"].as_u64().unwrap(); + let listed = ok(&mut a, "link_list", json!({ "doc": doc })); + let links = listed["links"].as_array().unwrap(); + assert_eq!(links.len(), count); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + let opened = info["links"].as_array().unwrap(); + assert_eq!(opened.len(), count); + let resolved = format!("{base}p.html"); + assert_eq!(links[0]["resolved_url"], json!(resolved), "the first links resolve"); + assert_eq!(opened[0]["target"]["uri"], json!(resolved)); + // Past the budget the viewer keeps (and opens) the raw address; the listing says so. + let last = count - 1; + assert_eq!(opened[last]["target"]["uri"], json!("p.html")); + assert_eq!((links[last]["url"].clone(), links[last].get("resolved_url")), (json!("p.html"), None)); +} + #[test] fn bookmarks_from_structure_through_tools() { let dir = workdir("bookmarks-structure"); @@ -2757,6 +2853,195 @@ trailer << /Root 1 0 R >> assert_eq!((&reply["marked"], &reply["status"]), (&json!(true), &json!("Accepted")), "{reply}"); } +// Contributor-original PDF: a Square, a Circle grouped with it (`/RT /Group`) that keeps its own +// author, text, colour, dates and subject, an ordinary reply, a reply to the Circle, a review +// status reply and a reply with a `/RT` name that is neither `R` nor `Group`. +const GROUP_FIXTURE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R] >> endobj +5 0 obj << /Type /Annot /Subtype /Square /Rect [20 20 100 80] /NM (sq-primary) /T (Alice) /Contents (Primary) /C [1 0 0] >> endobj +6 0 obj << /Type /Annot /Subtype /Circle /Rect [180 120 260 180] /NM (circ-sub) /T (Bob) /Contents (Subordinate) /C [0 0 1] /IRT 5 0 R /RT /Group /M (D:20260101000000) /CreationDate (D:20260101000000) /Subj (Oval) >> endobj +7 0 obj << /Type /Annot /Subtype /Text /Rect [120 20 140 40] /NM (reply) /T (Carol) /Contents (Reply) /IRT 5 0 R >> endobj +8 0 obj << /Type /Annot /Subtype /Text /Rect [120 50 140 70] /NM (sub-reply) /T (Dan) /Contents (Reply to the subordinate) /IRT 6 0 R >> endobj +9 0 obj << /Type /Annot /Subtype /Text /Rect [120 80 140 100] /NM (status) /T (Eve) /IRT 5 0 R /StateModel /Review /State /Accepted >> endobj +10 0 obj << /Type /Annot /Subtype /Text /Rect [150 20 170 40] /NM (odd) /T (Fay) /Contents (Odd reply type) /IRT 5 0 R /RT /Bogus >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// ISO 32000-2 §12.5.6.2: an annotation with `/RT /Group` is a subordinate of the group whose +/// primary its `/IRT` names. It lists as one comment, with the primary's attributes; the +/// subordinate's own are ignored, and a reply to it joins the primary's thread. +#[test] +fn a_group_lists_as_its_primary() { + let dir = workdir("rt-group"); + std::fs::write(dir.join("group.pdf"), GROUP_FIXTURE).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "group.pdf" }))["doc"].as_u64().unwrap(); + let check = |list: &Value, when: &str| { + assert_eq!(list["count"], 1, "{when}: one comment, the group's primary: {list}"); + let c = &list["comments"][0]; + assert_eq!((c["id"].as_str(), c["author"].as_str(), c["contents"].as_str()), (Some("sq-primary"), Some("Alice"), Some("Primary")), "{when}"); + assert_eq!(c["color"], "#FF0000", "{when}: the primary's colour"); + let group: Vec<&str> = c["group"].as_array().unwrap().iter().filter_map(|m| m["id"].as_str()).collect(); + assert_eq!(group, ["circ-sub"], "{when}: the subordinate is a member of the group"); + assert_eq!(c["group"][0]["type"], "Circle", "{when}"); + let mut replies: Vec<&str> = c["replies"].as_array().unwrap().iter().filter_map(|r| r["id"].as_str()).collect(); + replies.sort_unstable(); + assert_eq!(replies, ["odd", "reply", "sub-reply"], "{when}: an unknown /RT reads as a reply, a reply to the subordinate joins the thread"); + assert_eq!(c["status"], "Accepted", "{when}: a status reply is still a reply"); + let text = list.to_string(); + for leak in ["Bob", "Subordinate", "#0000FF"] { + assert!(!text.contains(leak), "{when}: the subordinate's own {leak:?} is ignored: {list}"); + } + }; + // As opened (the inspector), and after an edit (the engine's own list). + check(&ok(&mut a, "comment_list", json!({ "doc": doc })), "opened"); + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [280, 20], "contents": "Other" })); + let after = ok(&mut a, "comment_list", json!({ "doc": doc })); + let primary = after["comments"].as_array().unwrap().iter().find(|c| c["id"] == "sq-primary").cloned().unwrap(); + check(&json!({ "count": 1, "comments": [primary] }), "edited"); + assert_eq!(after["count"], 2, "{after}"); + + // Deleting a member deletes just it, as Acrobat does: the subordinate, or the primary, which + // leaves the subordinate a comment of its own. One undo step each. + ok(&mut a, "comment_delete", json!({ "doc": doc, "id": "circ-sub" })); + let left = ok(&mut a, "comment_list", json!({ "doc": doc })); + assert_eq!(left["count"], 2, "the primary and the new note are left: {left}"); + assert!(left.to_string().contains("sq-primary") && !left.to_string().contains("circ-sub"), "{left}"); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + ok(&mut a, "comment_delete", json!({ "doc": doc, "id": "sq-primary" })); + let left = ok(&mut a, "comment_list", json!({ "doc": doc })); + let ids: Vec<&str> = left["comments"].as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect(); + assert!(ids.contains(&"circ-sub") && !ids.contains(&"sq-primary") && ids.len() == 2, "the new note and the Circle, on its own: {left}"); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + let back = ok(&mut a, "comment_list", json!({ "doc": doc })); + let primary = back["comments"].as_array().unwrap().iter().find(|c| c["id"] == "sq-primary").cloned().unwrap(); + check(&json!({ "count": 1, "comments": [primary] }), "undone"); +} + +// Contributor-original PDF: group subordinates whose `/IRT` chains loop (each other, and one +// itself), and a chain of two subordinates that leads to a primary. +const GROUP_LOOP_FIXTURE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R] >> endobj +5 0 obj << /Type /Annot /Subtype /Circle /Rect [10 10 30 30] /NM (a) /Contents (Loop A) /IRT 6 0 R /RT /Group >> endobj +6 0 obj << /Type /Annot /Subtype /Circle /Rect [40 10 60 30] /NM (b) /Contents (Loop B) /IRT 5 0 R /RT /Group >> endobj +7 0 obj << /Type /Annot /Subtype /Circle /Rect [70 10 90 30] /NM (self) /Contents (Itself) /IRT 7 0 R /RT /Group >> endobj +8 0 obj << /Type /Annot /Subtype /Square /Rect [10 100 60 150] /NM (top) /Contents (Top) >> endobj +9 0 obj << /Type /Annot /Subtype /Circle /Rect [70 100 90 120] /NM (mid) /Contents (Middle) /IRT 8 0 R /RT /Group >> endobj +10 0 obj << /Type /Annot /Subtype /Circle /Rect [100 100 120 120] /NM (low) /Contents (Lowest) /IRT 9 0 R /RT /Group >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// Looping `/IRT` chains between group subordinates neither hang nor panic, and list as comments +/// of their own, as looping replies do; a subordinate of a subordinate joins the group of the +/// primary the chain leads to. +#[test] +fn looping_groups_end_and_chained_groups_reach_their_primary() { + let dir = workdir("rt-group-loop"); + std::fs::write(dir.join("loop.pdf"), GROUP_LOOP_FIXTURE).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "loop.pdf" }))["doc"].as_u64().unwrap(); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + let comments = list["comments"].as_array().unwrap(); + let mut ids: Vec<&str> = comments.iter().filter_map(|c| c["id"].as_str()).collect(); + ids.sort_unstable(); + assert_eq!(ids, ["a", "b", "self", "top"], "{list}"); + let top = comments.iter().find(|c| c["id"] == "top").unwrap(); + let group: Vec<&str> = top["group"].as_array().unwrap().iter().filter_map(|m| m["id"].as_str()).collect(); + assert_eq!(group, ["mid", "low"], "{list}"); + assert_eq!(top["replies"], json!([]), "{list}"); + // Deleting a looping member ends too and takes just it, as any group member goes alone; so + // does the lowest subordinate of the chain. + ok(&mut a, "comment_delete", json!({ "doc": doc, "id": "a" })); + ok(&mut a, "comment_delete", json!({ "doc": doc, "id": "self" })); + ok(&mut a, "comment_delete", json!({ "doc": doc, "id": "low" })); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + let mut ids: Vec<&str> = list["comments"].as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect(); + ids.sort_unstable(); + assert_eq!(ids, ["b", "top"], "{list}"); + let top = list["comments"].as_array().unwrap().iter().find(|c| c["id"] == "top").cloned().unwrap_or_default(); + let group: Vec<&str> = top["group"].as_array().into_iter().flatten().filter_map(|m| m["id"].as_str()).collect(); + assert_eq!(group, ["mid"], "{list}"); +} + +// Contributor-original PDF: a Replace Text group as earlier PdfCraft builds wrote it, the +// strikeout (primary) with empty `/Contents` and the replacement only on the caret (subordinate). +const OLD_REPLACE_FIXTURE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 6 0 R] >> endobj +5 0 obj << /Type /Annot /Subtype /StrikeOut /Rect [20 100 120 120] /QuadPoints [20 120 120 120 20 100 120 100] /NM (strike) /T (Ada) /Contents () /IT /StrikeOutTextEdit >> endobj +6 0 obj << /Type /Annot /Subtype /Caret /Rect [115 95 125 105] /NM (caret) /T (Ada) /Contents (red) /IRT 5 0 R /RT /Group /Subj (Replace Text) >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +// Contributor-original PDF: a Replace Text group with the caret as the primary (holding the +// text, `/IT /Replace`) and the strikeout as its subordinate, each with its own author, colour +// and dates. +const REPLACE_FIXTURE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 6 0 R] >> endobj +5 0 obj << /Type /Annot /Subtype /Caret /Rect [115 95 125 105] /NM (caret) /T (Ada) /C [0 0 1] /Contents (busy) /IT /Replace /Subj (Replace Text) /M (D:20260101000000) >> endobj +6 0 obj << /Type /Annot /Subtype /StrikeOut /Rect [20 100 120 120] /QuadPoints [20 120 120 120 20 100 120 100] /NM (strike) /T (Grace) /C [1 0 0] /IRT 5 0 R /RT /Group /IT /StrikeOutTextEdit /Subj (Cross-Out) /M (D:20260101000000) >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// A Replace Text comment lists once, with its replacement text: one made now (the caret is the +/// primary and holds it), one with that orientation from another writer, and one saved by an +/// earlier PdfCraft (the strikeout primary with no text, the caret holding it), which is read +/// leniently and not rewritten. +#[test] +fn replace_text_lists_its_replacement() { + let dir = workdir("rt-group-replace"); + std::fs::write(dir.join("old.pdf"), OLD_REPLACE_FIXTURE).unwrap(); + std::fs::write(dir.join("other.pdf"), REPLACE_FIXTURE).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "replace", "find": "Page 1", "contents": "red" })); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + assert_eq!(list["count"], 1, "{list}"); + let c = &list["comments"][0]; + assert_eq!( + (c["type"].as_str(), c["contents"].as_str(), c["group"][0]["type"].as_str()), + (Some("Caret"), Some("red"), Some("StrikeOut")), + "{list}" + ); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "new.pdf" })); + let mut b = auto(&dir); + for (path, text, primary) in [("new.pdf", "red", "Caret"), ("other.pdf", "busy", "Caret"), ("old.pdf", "red", "StrikeOut")] { + let doc = ok(&mut b, "doc_open", json!({ "path": path }))["doc"].as_u64().unwrap(); + let list = ok(&mut b, "comment_list", json!({ "doc": doc })); + let c = &list["comments"][0]; + assert_eq!( + (list["count"].as_u64(), c["type"].as_str(), c["contents"].as_str()), + (Some(1), Some(primary), Some(text)), + "{path} as opened: {list}" + ); + assert!(!list.to_string().contains("Grace"), "{path}: the subordinate's author is ignored: {list}"); + // And from the engine's own list, after an edit. + ok(&mut b, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [280, 20], "contents": "Other" })); + let list = ok(&mut b, "comment_list", json!({ "doc": doc })); + assert_eq!(list["count"], 2, "{path}: {list}"); + let c = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == primary).cloned().unwrap(); + assert_eq!(c["contents"], text, "{path} after an edit: {list}"); + // Replace Text is one comment: deleting either half deletes both, as Acrobat does, in + // either orientation. + for id in [c["id"].clone(), c["group"][0]["id"].clone()] { + ok(&mut b, "comment_delete", json!({ "doc": doc, "id": id })); + let left = ok(&mut b, "comment_list", json!({ "doc": doc })); + assert_eq!(left["count"], 1, "{path}: deleting {id} leaves only the note: {left}"); + assert!(!left.to_string().contains("StrikeOut") && !left.to_string().contains("Caret"), "{path}: {left}"); + ok(&mut b, "edit_undo", json!({ "doc": doc })); + } + } +} + +/// Protect through the tools, then reopen: the open password is required, the restrictions +/// (including page extraction) hold for the user, and the permissions password lifts them. #[test] fn protecting_through_tools() { let dir = workdir("protect"); @@ -2774,12 +3059,14 @@ fn protecting_through_tools() { (r["security"]["printing"].as_str(), r["security"]["annotate"].as_bool(), r["security"]["copy"].as_bool()), (Some("low"), Some(true), Some(false)) ); + assert_eq!(r["security"]["extract_pages"], false); assert!(!r.to_string().contains("boss"), "passwords are never echoed"); ok(&mut a, "doc_save", json!({ "doc": doc, "path": "locked.pdf" })); // Another session: the open password is required, and the restrictions hold. let mut b = auto(&dir); assert!(matches!(b.call("doc_open", &json!({ "path": "locked.pdf" })), Err(ToolError::Failed(_)))); let re = ok(&mut b, "doc_open", json!({ "path": "locked.pdf", "password": "open" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut b, "doc_info", json!({ "doc": re }))["security"]["extract_pages"], false); assert!(matches!(b.call("page_delete", &json!({ "doc": re, "pages": [1] })), Err(ToolError::Failed(_)))); assert!(matches!(b.call("doc_unprotect", &json!({ "doc": re })), Err(ToolError::Failed(_)))); ok(&mut b, "comment_add", json!({ "doc": re, "page": 1, "type": "note", "at": [10, 10], "contents": "allowed" })); @@ -2803,6 +3090,7 @@ fn protecting_through_tools() { assert_eq!(ok(&mut b, "text_find", json!({ "doc": re, "query": "Page" }))["count"], 3); // With the permissions password everything is possible, including removing security. let owner = ok(&mut b, "doc_open", json!({ "path": "locked.pdf", "password": "boss" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut b, "doc_info", json!({ "doc": owner }))["security"]["extract_pages"], true); assert!(ok(&mut b, "text_extract", json!({ "doc": owner }))["pages"][0]["text"].as_str().is_some_and(|t| t.contains("Page"))); assert_eq!(ok(&mut b, "doc_unprotect", json!({ "doc": owner }))["security"]["protected"], false); ok(&mut b, "doc_save", json!({ "doc": owner, "path": "open.pdf" })); @@ -2810,6 +3098,45 @@ fn protecting_through_tools() { ok(&mut c, "doc_open", json!({ "path": "open.pdf" })); } +/// A refused `doc_split` or separate-file `page_extract` leaves no output directory behind +/// (#5081), whether the protection allows no page changes ("Any except extracting pages") or +/// page changes without copying; with copying allowed as well, the user may extract. +#[test] +fn refused_extraction_writes_no_split_or_page_output() { + let dir = workdir("refused-extraction-output"); + // (changes, copy, assemble, extract_pages) as the user sees them. + let cases = [("any-except-extract", false, false, false), ("pages", false, true, false), ("pages", true, true, true)]; + for (i, (changes, copy, assemble, extract)) in cases.into_iter().enumerate() { + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let protect = json!({ "doc": doc, "open_password": "open", "permissions_password": "boss", "changes": changes, "copy": copy }); + ok(&mut a, "doc_protect", protect); + let path = format!("restricted-{i}.pdf"); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": path })); + + let mut b = auto(&dir); + let restricted = ok(&mut b, "doc_open", json!({ "path": path, "password": "open" }))["doc"].as_u64().unwrap(); + let security = ok(&mut b, "doc_info", json!({ "doc": restricted }))["security"].clone(); + assert_eq!((security["assemble"].as_bool(), security["extract_pages"].as_bool()), (Some(assemble), Some(extract)), "{changes}, copy {copy}"); + let (split_dir, extract_dir) = (format!("split-{i}"), format!("extract-{i}")); + let split = b.call("doc_split", &json!({ "doc": restricted, "every": 1, "out_dir": split_dir })); + let pages = b.call("page_extract", &json!({ "doc": restricted, "pages": [1, 2], "separate": true, "out_dir": extract_dir })); + if extract { + assert!(split.is_ok() && dir.join(&split_dir).exists(), "{changes}, copy {copy}"); + let files = match pages.as_deref() { + Ok([Content::Json(v), ..]) => v["files"].as_array().map(Vec::len), + other => panic!("{changes}, copy {copy}: page_extract: {other:?}"), + }; + assert_eq!(files, Some(2), "{changes}, copy {copy}"); + } else { + assert!(matches!(split, Err(ToolError::Failed(_))), "{changes}, copy {copy}"); + assert!(!dir.join(&split_dir).exists(), "refused split creates no output directory"); + assert!(matches!(pages, Err(ToolError::Failed(_))), "{changes}, copy {copy}"); + assert!(!dir.join(&extract_dir).exists(), "refused extraction creates no output directory"); + } + } +} + /// A document that allows changes but not copying: the tools that return a page's text refuse, /// or leave the text out where the tool also changes the page (a restyled paragraph, recognized /// words). @@ -3066,6 +3393,166 @@ fn form_fill_warns_when_a_value_does_not_fit() { assert!(r.get("warnings").is_none(), "{r}"); } +/// `form_fill` takes a radio option as `form_fields` lists it: the export value. With `/Opt [(1) +/// (2) (3)]` and on states named by position (`/0`, `/1`, `/2`; ISO 32000-2 Table 230), `1` is the +/// first button. It used to be read as the state `/1` and turn the second one on. +#[test] +fn form_fill_picks_a_radio_option_by_its_export_value() { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>", + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R] >>", + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>", + "<< /FT /Btn /T (title) /Ff 49152 /Opt [(1) (2) (3)] /V /Off /Kids [6 0 R 7 0 R 8 0 R] >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [10 250 22 262] /P 3 0 R /AP << /N << /0 9 0 R /Off 9 0 R >> >> >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [40 250 52 262] /P 3 0 R /AP << /N << /1 9 0 R /Off 9 0 R >> >> >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [70 250 82 262] /P 3 0 R /AP << /N << /2 9 0 R /Off 9 0 R >> >> >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 12 12] /Length 0 >>\nstream\n\nendstream", + ]; + let dir = workdir("radio-export"); + std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["options"], json!(["1", "2", "3"])); + for pick in ["1", "2", "3"] { + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "title": pick } })); + assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], pick); + // The button drawn on is the one picked. + let f = a.session().get(pdfcraft_engine::DocId(doc)).unwrap().form[0].clone(); + let on: Vec = f.widgets.iter().enumerate().filter(|(_, w)| w.state.as_deref().is_some_and(|s| s != "Off")).map(|(i, _)| i).collect(); + assert_eq!(on, [pick.parse::().unwrap() - 1], "picked {pick}"); + } + // A number is the same option: `1` is the first button, not the state `/1`. + for pick in [1, 2, 3] { + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "title": pick } })); + assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], pick.to_string(), "picked {pick}"); + } + // An on-state name the options do not list still selects its button. + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "title": "0" } })); + assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], "1"); +} + +/// The ambiguous pair: the second button's on state is named `1` and the first button exports +/// `1`. The export value wins, however the number is written in JSON (`1`, `1.0`, `1e0`, `0.1e1`); +/// `-0` is `0`, the first button's state. A string is matched as written, so `"1.0"` names no +/// option, and neither does a number that is not whole (`1.5`) or past 2^53 (`1e300`): each is +/// refused with the options listed and the selection kept. +#[test] +fn form_fill_reads_a_radio_number_by_its_integer_spelling() { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>", + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R] >>", + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>", + "<< /FT /Btn /T (title) /Ff 49152 /Opt [(1) (2) (3)] /V /Off /Kids [6 0 R 7 0 R 8 0 R] >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [10 250 22 262] /P 3 0 R /AP << /N << /0 9 0 R /Off 9 0 R >> >> >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [40 250 52 262] /P 3 0 R /AP << /N << /1 9 0 R /Off 9 0 R >> >> >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [70 250 82 262] /P 3 0 R /AP << /N << /2 9 0 R /Off 9 0 R >> >> >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 12 12] /Length 0 >>\nstream\n\nendstream", + ]; + let dir = workdir("radio-number-spelling"); + std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + // The values as JSON text, so each number keeps the spelling a client sends. + let fill = |lit: &str| -> Value { serde_json::from_str(&format!(r#"{{"doc": {doc}, "values": {{"title": {lit}}}}}"#)).unwrap() }; + let on = |a: &Automation| -> Vec { + let f = a.session().get(pdfcraft_engine::DocId(doc)).unwrap().form[0].clone(); + f.widgets.iter().enumerate().filter(|(_, w)| w.state.as_deref().is_some_and(|s| s != "Off")).map(|(i, _)| i).collect() + }; + for (lit, button) in [("2", 1), ("1", 0), ("3.0", 2), ("1.0", 0), ("2e0", 1), ("1e0", 0), ("0.1e1", 0), ("-0", 0), ("\"1\"", 0), ("2.000", 1)] { + // From another button, so a fill that does nothing would show. + ok(&mut a, "form_fill", fill(if button == 2 { "1" } else { "3" })); + ok(&mut a, "form_fill", fill(lit)); + assert_eq!(on(&a), [button], "{lit} selects button {}", button + 1); + } + ok(&mut a, "form_fill", fill("2")); + for lit in ["\"1.0\"", "1.5", "-1", "1e300", "18446744073709551615", "9007199254740993.0"] { + let e = err(&mut a, "form_fill", fill(lit)).to_string(); + assert!(e.contains("has no option") && e.contains("options: 1, 2, 3"), "{lit}: {e}"); + assert_eq!(on(&a), [1], "{lit} keeps the selection"); + } +} + +/// Export values written as decimals (`/Opt [(1.0) (2.0)]`, states `/0 /1`): a number is compared +/// with them as a number first, so `1`, `1.0` and `1e0` all pick the first button and `2.0` the +/// second, where `1.0` had become `"1"`, matched no export value and picked the button whose state +/// is `/1`. A string is still matched as written, and a number no export value equals is refused. +#[test] +fn form_fill_matches_a_radio_number_to_decimal_export_values() { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>", + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>", + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>", + "<< /FT /Btn /T (rate) /Ff 49152 /Opt [(1.0) (2.0)] /V /Off /Kids [6 0 R 7 0 R] >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [10 250 22 262] /P 3 0 R /AP << /N << /0 8 0 R /Off 8 0 R >> >> >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [40 250 52 262] /P 3 0 R /AP << /N << /1 8 0 R /Off 8 0 R >> >> >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 12 12] /Length 0 >> +stream + +endstream", + ]; + let dir = workdir("radio-decimal-exports"); + std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + let fill = |lit: &str| -> Value { serde_json::from_str(&format!(r#"{{"doc": {doc}, "values": {{"rate": {lit}}}}}"#)).unwrap() }; + let on = |a: &Automation| -> Vec { + let f = a.session().get(pdfcraft_engine::DocId(doc)).unwrap().form[0].clone(); + f.widgets.iter().enumerate().filter(|(_, w)| w.state.as_deref().is_some_and(|s| s != "Off")).map(|(i, _)| i).collect() + }; + for (lit, button) in [("1.0", 0), ("2.0", 1), ("1", 0), ("2", 1), ("1e0", 0), ("\"1.0\"", 0), ("\"2.0\"", 1)] { + // From the other button, so a fill that does nothing would show. + ok(&mut a, "form_fill", fill(if button == 0 { "2.0" } else { "1.0" })); + ok(&mut a, "form_fill", fill(lit)); + assert_eq!(on(&a), [button], "{lit} selects button {}", button + 1); + } + let e = err(&mut a, "form_fill", fill("3")).to_string(); + assert!(e.contains("has no option"), "{e}"); +} + +/// Past 2^53 a float no longer holds every integer, so `9007199254740992` and `9007199254740993` +/// are the same `f64`. A number there is matched only by its exact text, never compared as a +/// number with the export values: with `/Opt [(9007199254740992) (2)]` the number +/// `9007199254740993` names no option, nor does `9007199254740992` with +/// `/Opt [(9007199254740993) (2)]`. Each is refused and the selection kept, while the number +/// spelled as its export value still picks that button. +#[test] +fn form_fill_matches_a_radio_number_past_2_53_only_by_its_text() { + for (export, other) in [("9007199254740992", "9007199254740993"), ("9007199254740993", "9007199254740992")] { + let field = format!("<< /FT /Btn /T (big) /Ff 49152 /Opt [({export}) (2)] /V /Off /Kids [6 0 R 7 0 R] >>"); + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>", + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>", + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>", + field.as_str(), + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [10 250 22 262] /P 3 0 R /AP << /N << /0 8 0 R /Off 8 0 R >> >> >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [40 250 52 262] /P 3 0 R /AP << /N << /1 8 0 R /Off 8 0 R >> >> >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 12 12] /Length 0 >> +stream + +endstream", + ]; + let dir = workdir(&format!("radio-past-2-53-{export}")); + std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + let fill = |lit: &str| -> Value { serde_json::from_str(&format!(r#"{{"doc": {doc}, "values": {{"big": {lit}}}}}"#)).unwrap() }; + let on = |a: &Automation| -> Vec { + let f = a.session().get(pdfcraft_engine::DocId(doc)).unwrap().form[0].clone(); + f.widgets.iter().enumerate().filter(|(_, w)| w.state.as_deref().is_some_and(|s| s != "Off")).map(|(i, _)| i).collect() + }; + ok(&mut a, "form_fill", fill(export)); + assert_eq!(on(&a), [0], "{export} selects the button exported as {export}"); + ok(&mut a, "form_fill", fill("2")); + let e = err(&mut a, "form_fill", fill(other)).to_string(); + assert!(e.contains("has no option"), "{other} with /Opt [({export}) (2)]: {e}"); + assert_eq!(on(&a), [1], "{other} keeps the selection"); + } +} + /// `form_fill` with {"widget": n} turns on that radio button alone when others share its on /// state (ISO 32000-2 Table 229, RadiosInUnison clear), and `form_fields` says which is on. #[test] @@ -3091,6 +3578,113 @@ fn form_fill_picks_one_of_radio_buttons_sharing_an_on_state() { assert!(matches!(a.call("form_fill", &json!({ "doc": doc, "values": { "pick": bad } })), Err(ToolError::InvalidArgs(_))), "{bad}"); } } + +/// `form_fill` ticks one kid of a check box whose kids have different on states by naming it +/// (ISO 32000-2 §12.7.5.2.3, Table 230), and `form_fields` lists the names and the ticked one. +#[test] +fn form_fill_ticks_one_kid_of_a_check_box_by_its_on_state() { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>", + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>", + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>", + "<< /FT /Btn /T (pair) /V /Off /Kids [6 0 R 7 0 R] >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [10 250 26 266] /P 3 0 R /AP << /N << /A 8 0 R /Off 8 0 R >> >> >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [40 250 56 266] /P 3 0 R /AP << /N << /B 8 0 R /Off 8 0 R >> >> >>", + "<< /Length 0 >>\nstream\n\nendstream", + ]; + let dir = workdir("check-box-kids"); + std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + let before = ok(&mut a, "form_fields", json!({ "doc": doc })); + assert_eq!((&before["fields"][0]["options"], &before["fields"][0]["checked"]), (&json!(["A", "B"]), &Value::Null), "{before}"); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "pair": "A" } })); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "pair": "B" } })); + let after = ok(&mut a, "form_fields", json!({ "doc": doc })); + assert_eq!((&after["fields"][0]["value"], &after["fields"][0]["checked"]), (&json!(true), &json!("B")), "{after}"); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "pair": false } })); + assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["checked"], Value::Null); +} + +/// The options `form_fields` reports fill back through `form_fill` to the kid they name, also +/// where one kid's `/Opt` export is the other kid's on state: B exports `(A)`, and filling `A` +/// still ticks the kid whose on state is A (ISO 32000-2 §12.7.5.2.3, Table 230). +#[test] +fn form_fill_round_trips_the_reported_options_of_a_check_box_with_a_crossed_export() { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>", + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>", + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>", + "<< /FT /Btn /T (pair) /V /Off /Opt [(x) (A)] /Kids [6 0 R 7 0 R] >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [10 250 26 266] /P 3 0 R /AP << /N << /A 8 0 R /Off 8 0 R >> >> >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [40 250 56 266] /P 3 0 R /AP << /N << /B 8 0 R /Off 8 0 R >> >> >>", + "<< /Length 0 >> +stream + +endstream", + ]; + let dir = workdir("check-box-crossed-export-fill"); + std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + let before = ok(&mut a, "form_fields", json!({ "doc": doc })); + let options = before["fields"][0]["options"].clone(); + assert_eq!(options, json!(["A", "B"]), "{before}"); + for option in ["A", "B"] { + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "pair": option } })); + let after = ok(&mut a, "form_fields", json!({ "doc": doc })); + assert_eq!(after["fields"][0]["checked"], json!(option), "filling {option}: {after}"); + } +} + +/// `form_fields` on a check box's kids: one option per distinct on state, named by its `/Opt` +/// export unless two states share one, and `checked` follows the kid whose `/AS` is on, which +/// ISO 32000-2 §12.7.5.2.3 makes decide where `/AS` and `/V` differ. +#[test] +fn form_fields_lists_a_check_box_by_on_state_and_reports_the_kid_that_is_on() { + // Each kid is (on state, /AS). + let listed = |name: &str, field: &str, (sa, asa): (&str, &str), (sb, asb): (&str, &str)| { + let kid = |on: &str, state: &str, x: u32| { + format!( + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /{state} /Rect [{x} 250 {} 266] /P 3 0 R /AP << /N << /{on} 8 0 R /Off 8 0 R >> >> >>", + x + 16 + ) + }; + let (wa, wb) = (kid(sa, asa, 10), kid(sb, asb, 40)); + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>", + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>", + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>", + field, + &wa, + &wb, + "<< /Length 0 >>\nstream\n\nendstream", + ]; + let dir = workdir(name); + std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "form_fields", json!({ "doc": doc })); + (r["fields"][0]["options"].clone(), r["fields"][0]["checked"].clone()) + }; + // Kids sharing the on state Yes tick together, whatever their exports: not two options. + let shared = + listed("check-box-shared-state", "<< /FT /Btn /T (pair) /V /Off /Opt [(x) (y)] /Kids [6 0 R 7 0 R] >>", ("Yes", "Off"), ("Yes", "Off")); + assert_eq!(shared, (Value::Null, Value::Null)); + // /V names B, but A's /AS is on: A is the one ticked. + let differ = listed("check-box-as-wins", "<< /FT /Btn /T (pair) /V /B /Kids [6 0 R 7 0 R] >>", ("A", "A"), ("B", "Off")); + assert_eq!(differ, (json!(["A", "B"]), json!("A"))); + // Two on states with one export: the states name the options. + let same = listed("check-box-same-export", "<< /FT /Btn /T (pair) /V /B /Opt [(x) (x)] /Kids [6 0 R 7 0 R] >>", ("A", "Off"), ("B", "B")); + assert_eq!(same, (json!(["A", "B"]), json!("B"))); + // B's export is A's on state, so naming it would tick A: the states name the options. + let crossed = listed("check-box-crossed-export", "<< /FT /Btn /T (pair) /V /B /Opt [(x) (A)] /Kids [6 0 R 7 0 R] >>", ("A", "Off"), ("B", "B")); + assert_eq!(crossed, (json!(["A", "B"]), json!("B"))); +} + /// A form whose fields are only page widgets (the `/Fields` list is empty) still lists, fills and /// resets through the tools, and the leniency shows up as a repair note. #[test] @@ -3847,6 +4441,37 @@ fn accessibility_review_sidecar_tracks_human_decisions_for_exact_pdf_revision() } } +/// Find and replace through text_replace: every match (one undo step), the first only, the find +/// options, and counts that add up to what text_find reports. +#[test] +fn replacing_text_through_tools() { + let dir = workdir("replace-text"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let found = ok(&mut a, "text_find", json!({ "doc": doc, "query": "page" }))["count"].as_u64(); + let r = ok(&mut a, "text_replace", json!({ "doc": doc, "find": "page", "replace": "Sheet" })); + assert_eq!((r["replaced"].as_u64(), r["skipped"].as_u64(), r["changed"].as_bool()), (Some(3), Some(0), Some(true))); + assert_eq!(found, r["replaced"].as_u64(), "what text_find found is what was replaced"); + assert_eq!(r["pages"], json!([1, 2, 3])); + assert_eq!(page_text(&mut a, doc), ["Sheet 1", "Sheet 2", "Sheet 3"]); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Replace all"); + assert_eq!(page_text(&mut a, doc), ["Page 1", "Page 2", "Page 3"], "one undo step"); + // The first match only, on the pages asked for. + let r = ok(&mut a, "text_replace", json!({ "doc": doc, "find": "page", "replace": "Sheet", "all": false, "pages": [2, 3] })); + assert_eq!((r["replaced"].as_u64(), r["pages"].clone()), (Some(1), json!([2]))); + assert_eq!(page_text(&mut a, doc), ["Page 1", "Sheet 2", "Page 3"]); + // Case-sensitive and whole words follow the find bar's options; nothing found changes nothing. + let r = ok(&mut a, "text_replace", json!({ "doc": doc, "find": "page", "replace": "x", "case_sensitive": true })); + assert_eq!((r["replaced"].as_u64(), r["changed"].as_bool()), (Some(0), Some(false))); + let r = ok(&mut a, "text_replace", json!({ "doc": doc, "find": "Pag", "replace": "x", "whole_words": true })); + assert_eq!(r["replaced"].as_u64(), Some(0)); + // An empty replacement deletes the match. + ok(&mut a, "text_replace", json!({ "doc": doc, "find": "Page ", "replace": "" })); + assert_eq!(page_text(&mut a, doc), ["1", "Sheet 2", "3"]); + assert!(matches!(a.call("text_replace", &json!({ "doc": doc, "find": "x" })), Err(ToolError::InvalidArgs(_))), "replace is required"); + assert!(matches!(a.call("text_replace", &json!({ "doc": doc, "find": "x", "replace": "y", "pages": [9] })), Err(ToolError::InvalidArgs(_)))); +} + #[test] fn editing_existing_text_through_tools() { let dir = workdir("edit-text"); @@ -4051,6 +4676,51 @@ fn a_line_split_across_content_streams_is_listed_and_edited() { assert_eq!(page_text(&mut a, doc)[0].split_whitespace().collect::>(), ["Target", "Later"]); } +#[test] +fn mixed_script_paragraph_keeps_source_fonts_through_tools_and_reopen() { + if pdfcraft_fonts::document_japanese_font().is_none() { + eprintln!("skipping mixed-script fallback: build with CRAFT_FONTS_DIR"); + return; + } + let dir = workdir("mixed-script-paragraph"); + let mut cos = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(1))).unwrap(); + cos.update_dict(pdfcraft_cos::ObjRef::new(2, 0), |d| { + d.set(b"MediaBox".to_vec(), pdfcraft_cos::Object::Array([0, 0, 612, 792].map(pdfcraft_cos::Object::Int).to_vec())); + }) + .unwrap(); + let body = + b"BT /F1 12 Tf 14.4 TL 72 700 Td (The quick brown fox jumps over the lazy dog) Tj T* (and keeps running through the long field.) Tj ET"; + cos.set(pdfcraft_cos::ObjRef::new(5, 0), pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::flate(pdfcraft_cos::Dict::new(), body))); + std::fs::write(dir.join("mixed.pdf"), pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"mixed.pdf"}))["doc"].as_u64().unwrap(); + let draft = "The quick brown fox jumps over the lazy dog шалом and keeps running through the long field."; + ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"text":draft})); + let check = |a: &mut Automation, doc, expected: &str| { + let listed = ok(a, "text_lines", json!({"doc":doc,"page":1})); + let lines = listed["lines"].as_array().unwrap(); + assert!(lines.iter().any(|l| l["font"] == "Helvetica" && l["text"].as_str().unwrap().contains("The quick brown fox")), "{listed}"); + for line in lines.iter().filter(|l| l["text"].as_str().unwrap().chars().any(|c| c.is_ascii_alphabetic())) { + assert_eq!(line["font"], "Helvetica", "{line}"); + } + let text = page_text(a, doc).join(" "); + assert_eq!(text.split_whitespace().collect::>(), expected.split_whitespace().collect::>()); + }; + check(&mut a, doc, draft); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert!(!page_text(&mut a, doc).join(" ").contains("шалом")); + ok(&mut a, "edit_redo", json!({"doc":doc})); + check(&mut a, doc, draft); + ok(&mut a, "doc_save", json!({"doc":doc,"path":"saved.pdf"})); + ok(&mut a, "doc_close", json!({"doc":doc})); + let doc = ok(&mut a, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap(); + check(&mut a, doc, draft); + let extended = format!("{draft} More Latin."); + ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"text":extended})); + check(&mut a, doc, &extended); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn paragraph_bold_without_font_keeps_the_source_family() { let dir = workdir("paragraph-bold"); @@ -4782,7 +5452,7 @@ fn creating_from_multiple_files_through_tools() { } std::fs::write(dir.join("scan.png"), png).unwrap(); std::fs::write(dir.join("notes.txt"), "hello").unwrap(); - std::fs::write(dir.join("report.docx"), b"PK\x03\x04").unwrap(); + std::fs::write(dir.join("archive.zip"), b"PK\x03\x04").unwrap(); let mut a = auto(&dir); // Combine: every file converted, in the order given, with a bookmark per file. @@ -4802,8 +5472,8 @@ fn creating_from_multiple_files_through_tools() { assert_eq!(titles, ["notes", "a", "scan"]); assert!(dir.join("all.pdf").is_file()); // A file that can't be converted fails the whole combine, naming it. - let err = a.call("doc_create_multiple", &json!({ "paths": ["a.pdf", "report.docx"] })).unwrap_err(); - assert!(matches!(&err, ToolError::Failed(m) if m.contains("report.docx") && m.contains("can't be converted")), "{err}"); + let err = a.call("doc_create_multiple", &json!({ "paths": ["a.pdf", "archive.zip"] })).unwrap_err(); + assert!(matches!(&err, ToolError::Failed(m) if m.contains("archive.zip") && m.contains("can't be converted")), "{err}"); assert!(matches!(a.call("doc_create_multiple", &json!({ "paths": [] })), Err(ToolError::InvalidArgs(_)))); assert!(matches!(a.call("doc_create_multiple", &json!({ "paths": ["a.pdf"], "mode": "zip" })), Err(ToolError::InvalidArgs(_)))); assert!(matches!(a.call("doc_create_multiple", &json!({ "paths": ["a.pdf"], "mode": "separate" })), Err(ToolError::InvalidArgs(_)))); @@ -4812,7 +5482,7 @@ fn creating_from_multiple_files_through_tools() { // existing file is never overwritten. std::fs::create_dir_all(dir.join("out")).unwrap(); std::fs::write(dir.join("out/notes.pdf"), b"mine").unwrap(); - let args = json!({ "paths": ["notes.txt", "report.docx", "a.pdf", "scan.png", "missing.txt"], "mode": "separate", "out_dir": "out" }); + let args = json!({ "paths": ["notes.txt", "archive.zip", "a.pdf", "scan.png", "missing.txt"], "mode": "separate", "out_dir": "out" }); let made = ok(&mut a, "doc_create_multiple", args); let files = made["files"].as_array().unwrap(); assert!(files[0]["output"].as_str().unwrap().ends_with("notes (2).pdf"), "{files:?}"); @@ -5038,12 +5708,8 @@ fn rotating_a_field_through_tools() { ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "City", "rotation": 90 })); let (rot, turned) = rect_of(&mut a); assert_eq!(rot, 90); - let (bw, bh) = (before[2] - before[0], before[3] - before[1]); - let (tw, th) = (turned[2] - turned[0], turned[3] - turned[1]); - assert!((tw - bh).abs() < 0.2 && (th - bw).abs() < 0.2, "swapped {before:?} -> {turned:?}"); - let center = |r: &[f64]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0); - let (bc, tc) = (center(&before), center(&turned)); - assert!((bc.0 - tc.0).abs() < 0.2 && (bc.1 - tc.1).abs() < 0.2, "center moved {bc:?} -> {tc:?}"); + // The field stays where it is; only its content turns (#4296). + assert!(turned.iter().zip(&before).all(|(t, b)| (t - b).abs() < 0.2), "the field moved: {before:?} -> {turned:?}"); assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Change field properties"); let (rot, back) = rect_of(&mut a); assert_eq!((rot, back), (0, before)); @@ -5162,6 +5828,95 @@ fn redacting_through_tools() { assert!(matches!(a.call("redact_mark", &json!({ "doc": doc })), Err(ToolError::InvalidArgs(_)))); } +/// #2539 review: `redact_apply` says where the removed text is still held that applying leaves as +/// it is (a bookmark title and a note's text here), and changes neither. +#[test] +fn redact_apply_reports_places_that_still_hold_the_removed_text() { + let dir = workdir("redact-still-holding"); + std::fs::write(dir.join("memo.txt"), "Codename Bluefin report\nPublic line").unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "text", "path": "memo.txt" }))["doc"].as_u64().unwrap(); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "Bluefin plans", "page": 1 })); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "Annual figures", "page": 1 })); + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [300, 400], "contents": "Ask about bluefin" })); + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [300, 500], "contents": "Looks good" })); + ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "Bluefin" })); + let r = ok(&mut a, "redact_apply", json!({ "doc": doc })); + assert_eq!(r["still_holding"], json!({ "bookmarks": 1, "attachments": 0, "comments": 1, "xmp": 0, "total": 2, "unsearched": false }), "{r}"); + // Said in the warnings too, with the other edit notices. + assert_eq!( + r["warnings"], + json!([ + "the redacted text also appears in 1 bookmark title and 1 comment, which redaction doesn't change (Remove hidden information removes them)" + ]), + "{r}" + ); + assert!(!page_text(&mut a, doc)[0].contains("Bluefin")); + let titles: Vec = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"] + .as_array() + .unwrap() + .iter() + .map(|b| b["title"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(titles, ["Bluefin plans", "Annual figures"], "reported, not changed"); + assert_eq!(r["identification"], json!({ "pdfa": "none", "pdfua": "none" }), "{r}"); +} + +/// #2539 review: when `redact_apply` removes the document's XMP packet because it names the +/// redacted word, `identification` says the PDF/A-2b and PDF/UA-1 identification it held was +/// written back, and the warnings say it too. +#[test] +fn redact_apply_reports_the_identification_written_back() { + let dir = workdir("redact-identification"); + let xmp = r#"Bluefin report"#; + let body = "BT /F1 18 Tf 20 150 Td (Codename BLUEFIN report) Tj ET"; + let content = format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()); + let metadata = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()); + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /Metadata 6 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + &content, + &metadata, + ]; + std::fs::write(dir.join("pdfa.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "pdfa.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "BLUEFIN" })); + let r = ok(&mut a, "redact_apply", json!({ "doc": doc })); + assert_eq!(r["identification"], json!({ "pdfa": "kept", "pdfua": "kept" }), "{r}"); + assert_eq!( + r["warnings"], + json!([ + "the document's XMP metadata held the redacted text and was removed; its PDF/A and PDF/UA identification was written back on its own" + ]), + "{r}" + ); +} + +#[test] +fn applying_redactions_proposes_the_name_with_redacted() { + let dir = workdir("redact-save-as"); + std::fs::write(dir.join("memo.txt"), "Call 555-123-4567 today").unwrap(); + let mut a = auto(&dir); + let made = ok(&mut a, "doc_create", json!({ "from": "text", "path": "memo.txt" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_save", json!({ "doc": made, "path": "contract.pdf" })); + let original = std::fs::read(dir.join("contract.pdf")).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "contract.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "redact_mark", json!({ "doc": doc, "pattern": "phone" })); + let r = ok(&mut a, "redact_apply", json!({ "doc": doc })); + assert_eq!(r["save_as"], "contract_Redacted.pdf"); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": r["save_as"] })); + assert_eq!(std::fs::read(dir.join("contract.pdf")).unwrap(), original, "the opened file is left as it was"); + let bytes = std::fs::read(dir.join("contract_Redacted.pdf")).unwrap(); + assert!(!bytes.windows(4).any(|w| w == b"4567"), "the copy has no trace of the number"); + // Redacting the redacted copy again proposes the same name, with one suffix. + let copy = ok(&mut a, "doc_open", json!({ "path": "contract_Redacted.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "redact_mark", json!({ "doc": copy, "find": "today" })); + assert_eq!(ok(&mut a, "redact_apply", json!({ "doc": copy }))["save_as"], "contract_Redacted.pdf"); +} + #[test] fn redacting_with_a_caller_regex_through_tools() { let dir = workdir("redact-regex"); @@ -5574,10 +6329,32 @@ fn printing_through_tools() { assert_eq!(ok(&mut a, "doc_info", json!({ "doc": printed }))["document"]["pages"].as_u64(), Some(n.div_ceil(4))); let r = ok(&mut a, "doc_print", json!({ "doc": doc, "pages": "1", "layout": "poster", "scale": 400, "path": "poster.pdf" })); assert!(r["sheets"].as_u64().unwrap() > 1); + let r = ok(&mut a, "doc_print", json!({ "doc": doc, "layout": "booklet", "creep": 1.5, "path": "booklet.pdf" })); + assert_eq!(r["sheets"].as_u64(), Some(n.div_ceil(4) * 2), "creep keeps the booklet's sheets"); + assert!(a.call("doc_print", &json!({ "doc": doc, "layout": "booklet", "creep": -1, "path": "x.pdf" })).is_err(), "negative creep"); assert!(matches!(a.call("doc_print", &json!({ "doc": doc })), Err(ToolError::InvalidArgs(_)))); assert!(matches!(a.call("doc_print", &json!({ "doc": doc, "pages": "99", "path": "x.pdf" })), Err(ToolError::InvalidArgs(_)))); } +#[test] +fn printer_papers_through_tools() { + let dir = workdir("printer-papers"); + let mut a = auto(&dir); + let none = ok(&mut a, "printer_papers", json!({ "printer": "No_Such_Queue_PdfCraft" })); + assert_eq!((none["count"].as_u64(), none["papers"].as_array().map(Vec::len)), (Some(0), Some(0))); + assert!(none["default"].is_null()); + assert!(matches!(a.call("printer_papers", &json!({})), Err(ToolError::InvalidArgs(_)))); + + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "doc_print", json!({ "doc": doc, "pages": "1", "paper": "300x500", "path": "custom-paper.pdf" })); + assert_eq!(r["sheets"].as_u64(), Some(1)); + let opened = ok(&mut a, "doc_open", json!({ "path": "custom-paper.pdf" }))["doc"].as_u64().unwrap(); + let info = ok(&mut a, "doc_info", json!({ "doc": opened })); + let page = &info["pages"][0]; + assert!((page["width"].as_f64().unwrap() - 300.0).abs() <= 1.0); + assert!((page["height"].as_f64().unwrap() - 500.0).abs() <= 1.0); +} + #[test] fn adding_content_through_tools() { let dir = workdir("content"); @@ -6724,6 +7501,43 @@ fn local_gotor_link_open_refuses_directories_devices_and_non_pdf_targets() { } } +#[test] +fn doc_open_reads_open_parameters_after_the_file_name() { + // #3425: help systems start a PDF reader with `manual.pdf#nameddest=…` or `#page=…`. + let (_base, root) = sandbox("open-parameters"); + std::fs::write(root.join("manual.pdf"), fixture_with(3, NAMED_DESTS)).unwrap(); + std::fs::write(root.join("odd#page=2.pdf"), fixture(1)).unwrap(); + let mut a = auto(&root); + for (path, kind, page) in [("manual.pdf#page=3", "page", 3), ("manual.pdf#nameddest=Load Flow", "named", 3), ("manual.pdf#chapter", "named", 2)] { + let opened = ok(&mut a, "doc_open", json!({ "path": path })); + assert_eq!((opened["name"].as_str(), opened["pages"].as_u64()), (Some("manual.pdf"), Some(3)), "{path}"); + assert_eq!(opened["destination_applied"], true, "{path}: {opened}"); + assert_eq!((opened["destination"]["kind"].as_str(), opened["destination"]["page"].as_u64()), (Some(kind), Some(page)), "{path}"); + } + for (path, warning) in + [("manual.pdf#nameddest=Nowhere", "named destination"), ("manual.pdf#page=9", "out of range"), ("manual.pdf#page=99999", "out of range")] + { + let opened = ok(&mut a, "doc_open", json!({ "path": path })); + assert_eq!(opened["destination_applied"], false, "{path}"); + assert!(opened["warning"].as_str().is_some_and(|w| w.contains(warning)), "{path}: {opened}"); + } + // A parameter that can't be applied isn't a failure: the file opens at its default view. + let opened = ok(&mut a, "doc_open", json!({ "path": "manual.pdf#page=0" })); + assert_eq!(opened["pages"].as_u64(), Some(3)); + assert_eq!((opened["destination_applied"].as_bool(), opened["destination"]["kind"].as_str()), (Some(true), Some("default")), "{opened}"); + assert!(opened["warning"].is_null(), "{opened}"); + // A file whose own name has a `#` opens as itself, and a plain path reports no destination. + let opened = ok(&mut a, "doc_open", json!({ "path": "odd#page=2.pdf" })); + assert_eq!((opened["name"].as_str(), opened["pages"].as_u64()), (Some("odd#page=2.pdf"), Some(1))); + assert!(opened.get("destination").is_none(), "{opened}"); + // The file before the `#` must exist, inside --root. + let before = a.session().docs().len(); + for path in ["missing.pdf#page=2", "../outside/secret.pdf#page=1"] { + err(&mut a, "doc_open", json!({ "path": path })); + assert_eq!(a.session().docs().len(), before, "{path}"); + } +} + #[test] fn local_gotor_link_open_applies_destinations_and_honors_root() { let root = local_gotor_fixtures(); @@ -6731,6 +7545,7 @@ fn local_gotor_link_open_applies_destinations_and_honors_root() { assert_relative_gotor_destination(&mut a); assert_absolute_gotor_policy(&mut a); assert_unapplied_gotor_destinations(&mut a); + assert_named_gotor_destination(&mut a); assert_encrypted_gotor_passwords(&mut a); assert_unsupported_gotor_targets(&mut a); assert_gotor_root_boundaries(&mut a); @@ -6745,6 +7560,8 @@ fn local_gotor_fixtures() -> PathBuf { std::fs::write(root.join("sub/source.pdf"), remote_link_pdf("../target.pdf", Some("[2 /FitH 180]"))).unwrap(); std::fs::write(root.join("sub/escape.pdf"), remote_link_pdf("../../outside/secret.pdf", Some("[0 /Fit]"))).unwrap(); std::fs::write(root.join("named.pdf"), remote_link_pdf("target.pdf", Some("(chapter)"))).unwrap(); + std::fs::write(root.join("named-target.pdf"), fixture_with(3, NAMED_DESTS)).unwrap(); + std::fs::write(root.join("named-found.pdf"), remote_link_pdf("named-target.pdf", Some("(chapter)"))).unwrap(); std::fs::write(root.join("range.pdf"), remote_link_pdf("target.pdf", Some("[99 /Fit]"))).unwrap(); std::fs::write(root.join("url.pdf"), remote_link_pdf("https://example.org/target.pdf", Some("[0 /Fit]"))).unwrap(); std::fs::write(root.join("missing-source.pdf"), remote_link_pdf("does-not-exist.pdf", Some("[0 /Fit]"))).unwrap(); @@ -6793,6 +7610,14 @@ fn assert_unapplied_gotor_destinations(a: &mut Automation) { } } +fn assert_named_gotor_destination(a: &mut Automation) { + // A name the target defines is looked up there, as the app does. + let source = ok(a, "doc_open", json!({ "path": "named-found.pdf" }))["doc"].as_u64().unwrap(); + let opened = ok(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })); + assert_eq!(opened["destination_applied"], true, "{opened}"); + assert_eq!(opened["destination"], json!({ "kind": "named", "name": "chapter", "page": 2, "view": { "kind": "fit" } })); +} + fn assert_encrypted_gotor_passwords(a: &mut Automation) { let password_source = ok(a, "doc_open", json!({ "path": "password-source.pdf" }))["doc"].as_u64().unwrap(); let documents_before = a.session().docs().len(); @@ -7240,6 +8065,7 @@ fn object_has_name(obj: &pdfcraft_cos::Object, name: &[u8]) -> bool { } } +/// Polygons, polylines and other drawing comments are added and listed through the tools. #[test] fn drawing_comments_through_tools() { let dir = workdir("drawing"); @@ -7274,17 +8100,26 @@ fn drawing_comments_through_tools() { ok(&mut a, "comment_add", json!({ "doc": doc, "page": 2, "type": "replace", "find": "page 2", "contents": "second page", "color": "green" })); let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 2 })); let caret = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "Caret").unwrap().clone(); - assert_eq!(caret["contents"], "second page", "the replacement is on the caret"); - assert_eq!(caret["replies"][0]["type"], "StrikeOut", "the strikeout is grouped under the caret"); + // One comment, the caret (the group's primary, ISO 32000-2 §12.5.6.2), holding the new text; + // the strikeout is in its group, not a reply or a comment of its own. + assert_eq!(caret["contents"], "second page", "{list}"); + assert_eq!(caret["replies"], json!([]), "{list}"); + assert_eq!(caret["group"][0]["type"], "StrikeOut", "{list}"); + assert!(!list["comments"].as_array().unwrap().iter().any(|c| c["type"] == "StrikeOut"), "{list}"); // Review on #5689 (CodeRabbit): the requested colour is the group's, so the primary has it. - assert_eq!(caret["replies"][0]["color"], caret["color"], "one colour for the pair: {caret}"); - assert_eq!(caret["color"], json!("#2E9E5C"), "the requested green, not the caret's default blue: {caret}"); - // Restyling the caret restyles the proposal, its strikeout too, as one undo step. + // The list gives a group the primary's colour alone, so the strikeout's own (`/C`, what it is + // drawn in) is read from an XFDF export. let pair = |a: &mut Automation| { let list = ok(a, "comment_list", json!({ "doc": doc, "page": 2 })); let c = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "Caret").unwrap().clone(); - (c["color"].clone(), c["replies"][0]["color"].clone()) + ok(a, "doc_export_data", json!({ "doc": doc, "path": "pair.xfdf", "what": "comments" })); + let xfdf = std::fs::read_to_string(dir.join("pair.xfdf")).unwrap(); + let strike = xfdf.split("').next()).unwrap_or_default().to_owned(); + let color = strike.split("color=\"").nth(1).and_then(|v| v.split('"').next()).map(|v| json!(v)).unwrap_or(Value::Null); + (c["color"].clone(), color) }; + assert_eq!(pair(&mut a), (json!("#2E9E5C"), json!("#2E9E5C")), "the requested green on both, not the caret's default blue: {caret}"); + // Restyling the caret restyles the proposal, its strikeout too, as one undo step. ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 2, "index": caret["index"], "color": "purple" })); assert_eq!(pair(&mut a), (json!("#8A3FD1"), json!("#8A3FD1")), "the strikeout follows its caret"); ok(&mut a, "edit_undo", json!({ "doc": doc })); @@ -7337,6 +8172,8 @@ fn lines_and_corners_are_held_level_or_plumb_through_the_tool() { assert!(matches!(refused, Err(ToolError::InvalidArgs(_))), "ortho is for lines and corners: {refused:?}"); } +/// A visible signature drawn from an image, typed text or strokes through sign_document; bad +/// graphics are refused before anything is written. #[test] fn signing_with_an_image_typed_or_drawn_graphic_through_tools() { let dir = workdir("signing-graphic"); @@ -7406,6 +8243,111 @@ fn signing_with_an_image_typed_or_drawn_graphic_through_tools() { assert!(inked(60, 110), "the strokes are drawn"); } +/// Signing a dynamic form's signature box through sign_document runs its XFA signing scripts only +/// when asked (run_form_scripts): preSign first, so what it changes is signed, listed field by field, +/// and postSign after, left as unsaved changes; the result carries their messages. Not asked, they +/// don't run and the result says the box has them; a box without them reports nothing. +#[test] +fn signing_an_xfa_box_runs_its_signing_scripts_through_tools() { + let dir = workdir("xfa-sign-events"); + std::fs::write(dir.join("form.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::signing_events_template())).unwrap(); + let mut a = auto(&dir); + ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret12", "path": "ada.p12" })); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + let name = |n: &str| { + fields["fields"] + .as_array() + .unwrap() + .iter() + .filter_map(|f| f["name"].as_str()) + .find(|f| *f == n || f.ends_with(&format!(".{n}[0]"))) + .unwrap() + .to_string() + }; + // Not asked: the scripts don't run, and the result says the box has them. + let r = ok(&mut a, "sign_document", json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "field": name("sign"), "out": "unasked.pdf" })); + assert_eq!(r["form_scripts"]["skipped"], json!(true), "{r}"); + let unasked = ok(&mut a, "doc_open", json!({ "path": "unasked.pdf" }))["doc"].as_u64().unwrap(); + let unasked = ok(&mut a, "form_fields", json!({ "doc": unasked })); + assert!( + unasked["fields"] + .as_array() + .unwrap() + .iter() + .any(|f| f["name"].as_str().is_some_and(|n| n == "when" || n.ends_with(".when[0]")) && f["value"].is_null()), + "{unasked}" + ); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + // A bad argument is refused before the preSign scripts run. + let bad = a.call( + "sign_document", + &json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "field": name("sign"), "out": "bad.pdf", "run_form_scripts": true, "reason": 5 }), + ); + assert!(matches!(bad, Err(ToolError::InvalidArgs(_))), "{bad:?}"); + let now = ok(&mut a, "form_fields", json!({ "doc": doc })); + assert!( + now["fields"] + .as_array() + .unwrap() + .iter() + .any(|f| f["name"].as_str().is_some_and(|n| n == "when" || n.ends_with(".when[0]")) && f["value"].is_null()), + "{now}" + ); + // A signed file that can't be written (`out` is a folder): not signed, and what preSign did + // is taken back. + std::fs::create_dir_all(dir.join("taken")).unwrap(); + let failed = a.call( + "sign_document", + &json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "field": name("sign"), "out": "taken", "run_form_scripts": true }), + ); + assert!(failed.is_err(), "{failed:?}"); + let now = ok(&mut a, "form_fields", json!({ "doc": doc })); + assert!( + now["fields"] + .as_array() + .unwrap() + .iter() + .any(|f| f["name"].as_str().is_some_and(|n| n == "when" || n.ends_with(".when[0]")) && f["value"].is_null()), + "{now}" + ); + let r = ok( + &mut a, + "sign_document", + json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "field": name("sign"), "out": "signed.pdf", "run_form_scripts": true }), + ); + let scripts = &r["form_scripts"]; + let changed = scripts["changed"].as_array().unwrap(); + assert_eq!(changed.len(), 1, "only what preSign changed: {r}"); + assert_eq!((&changed[0]["after"], &changed[0]["read_only"]), (&json!(["signed"]), &json!(true)), "{r}"); + assert_eq!((scripts["before_signing"].as_bool(), scripts["after_signing"].as_bool()), (Some(true), Some(true)), "{r}"); + assert_eq!(scripts["alerts"], json!([{ "message": "Signing notice", "title": null, "icon": "error", "buttons": "ok" }]), "{r}"); + assert_eq!(scripts["unsaved_changes"], json!(true), "the postSign change isn't in the signed file: {r}"); + // The signed file holds what the preSign script did, not what the postSign one did. + let signed = ok(&mut a, "doc_open", json!({ "path": "signed.pdf" }))["doc"].as_u64().unwrap(); + let values = ok(&mut a, "form_fields", json!({ "doc": signed })); + let value = |n: &str| { + values["fields"] + .as_array() + .unwrap() + .iter() + .find(|f| f["name"].as_str().is_some_and(|f| f == n || f.ends_with(&format!(".{n}[0]")))) + .map(|f| f["value"].clone()) + }; + assert_eq!(value("when"), Some(json!("signed")), "{values}"); + assert_eq!(value("after"), Some(json!(null)), "{values}"); + let list = ok(&mut a, "sign_list", json!({ "doc": signed })); + assert!(list["signatures"].as_array().unwrap().iter().any(|s| s["signed"] == true && s["status"] == "unknown"), "intact: {list}"); + // A box without signing scripts: nothing reported. + let r = ok( + &mut a, + "sign_document", + json!({ "doc": signed, "id": "ada.p12", "password": "secret12", "field": name("plain"), "out": "both.pdf", "run_form_scripts": true }), + ); + assert!(r.get("form_scripts").is_none(), "{r}"); + let _ = std::fs::remove_dir_all(&dir); +} + /// Signing a laid-out XFA form's signature box through the tools signs the field named by its /// SOM path, which is how another viewer, laying the form out again, finds the signature; the /// signed file reopens with the signature intact and on that field. @@ -7428,6 +8370,8 @@ fn a_dynamic_xfa_forms_signature_box_is_signed_under_its_som_path() { let _ = std::fs::remove_dir_all(&dir); } +/// Digital IDs are created and opened, documents signed with them and their signatures validated, +/// all through the tools. #[test] fn named_layer_verdicts_are_reported_and_rendered_through_tools() { use pdfcraft_cos::{Dict, Document, Lexer, ObjRef, Object, SaveOptions, Stream}; @@ -7637,6 +8581,36 @@ fn signing_with_translated_labels_through_tools() { assert!(stamped.contains("(Genehmigt)") && !stamped.contains("Reason") && !stamped.contains("Date"), "{stamped}"); } +#[test] +fn optimizing_discards_an_embedded_search_index_through_tools() { + use pdfcraft_cos::{Dict, Object}; + let dir = workdir("optimize-search-index"); + let mut cos = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(1))).unwrap(); + let mut info = Dict::new(); + for name in ["IBM-ODIndexes", "Illustrator"] { + let mut data = Dict::new(); + data.set(b"Private".to_vec(), Object::Int(1)); + info.set(name.as_bytes().to_vec(), Object::Dict(data)); + } + let root = cos.root().unwrap(); + cos.update_dict(root, |c| c.set(b"PieceInfo".to_vec(), Object::Dict(info))).unwrap(); + std::fs::write(dir.join("indexed.pdf"), pdfcraft_cos::write_full(&cos, &pdfcraft_cos::SaveOptions::default()).unwrap()).unwrap(); + let piece_info = |name: &str| { + let d = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join(name)).unwrap())).unwrap(); + let cat = d.get(d.root().unwrap()).as_dict().cloned().unwrap(); + let info = d.dict(cat.get(b"PieceInfo").unwrap()).unwrap(); + info.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).collect::>() + }; + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "indexed.pdf" }))["doc"].as_u64().unwrap(); + let kept = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "kept.pdf" })); + assert_eq!(kept["search_indexes"], 0, "{kept}"); + assert_eq!(piece_info("kept.pdf"), ["IBM-ODIndexes", "Illustrator"]); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "small.pdf", "discard_search_index": true })); + assert_eq!(r["search_indexes"], 1, "{r}"); + assert_eq!(piece_info("small.pdf"), ["Illustrator"]); +} + #[test] fn optimizing_through_tools() { let dir = workdir("optimize"); @@ -8012,6 +8986,61 @@ fn doc_optimize_stores_black_and_white_images_as_ccitt_group_4() { } } +/// A mixed-raster page: a flat colour layer drawn through a one-bit soft mask, [`scanned_page`]'s +/// scan, which the page no longer draws by name. `doc_optimize` stores the mask as Group 4, at its +/// size, still the picture's soft mask, and counts it as an image; with `mono: retain` it stays. +#[test] +fn doc_optimize_stores_a_pictures_one_bit_soft_mask_as_ccitt_group_4() { + use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; + let dir = workdir("optimize-mono-mask"); + let mut doc = Document::open(std::sync::Arc::new(scanned_page())).unwrap(); + let find = |doc: &Document, kind: &[u8], key: &[u8]| { + doc.object_numbers().into_iter().map(|num| ObjRef { num, generation: doc.generation(num) }).find(|r| match &*doc.get(*r) { + Object::Stream(s) => s.dict.name(key) == Some(kind), + Object::Dict(d) => d.name(key) == Some(kind), + _ => false, + }) + }; + let scan = find(&doc, b"Image", b"Subtype").unwrap(); + let page = find(&doc, b"Page", b"Type").unwrap(); + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(250)); + d.set(b"Height".to_vec(), Object::Int(100)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + d.set(b"SMask".to_vec(), Object::Ref(scan)); + let picture = doc.add(Object::Stream(Stream::flate(d, &[0, 102, 204].repeat(250 * 100)))); + doc.update_dict(page, |p| { + let mut x = Dict::new(); + x.set(b"Scan".to_vec(), Object::Ref(picture)); + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(x)); + p.set(b"Resources".to_vec(), Object::Dict(res)); + }) + .unwrap(); + std::fs::write(dir.join("masked.pdf"), write_full(&doc, &SaveOptions::default()).unwrap()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "masked.pdf" }))["doc"].as_u64().unwrap(); + let kept = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "kept.pdf", "mono": { "compression": "retain" } })); + assert_eq!(kept["images_recompressed"].as_u64(), Some(0), "{kept}"); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "small.pdf" })); + assert_eq!((r["images"].as_u64(), r["images_recompressed"].as_u64()), (Some(2), Some(1)), "the picture and its mask: {r}"); + // In the copy written, the picture's soft mask is Group 4, at its size. + let small = Document::open(std::sync::Arc::new(std::fs::read(dir.join("small.pdf")).unwrap())).unwrap(); + let picture = small + .object_numbers() + .into_iter() + .map(|num| small.get(ObjRef { num, generation: small.generation(num) })) + .find(|o| matches!(&**o, Object::Stream(s) if s.dict.contains(b"SMask"))) + .unwrap(); + let Object::Stream(picture) = &*picture else { unreachable!() }; + let Object::Stream(mask) = &*small.get(picture.dict.reference(b"SMask").unwrap()) else { panic!("the soft mask is a stream") }; + assert_eq!(mask.dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..])); + assert_eq!((mask.dict.int(b"Width"), mask.dict.int(b"Height")), (Some(1000), Some(400))); +} + /// A scan drawn at 600 ppi is averaged down to 300 ppi by default. `mono: { downsample: false }` /// keeps its size, `ppi` and `above_ppi` choose others, and values of the wrong type are refused. #[test] @@ -8550,9 +9579,26 @@ fn javascript_through_tools() { ]) ); assert_eq!(r["requests"][0]["page"], 2); + let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "JSON.stringify(this.getPageBox())" })); + assert_eq!(r["result"], "[0,300,200,0]"); + ok(&mut a, "page_set_box", json!({ "doc":doc, "pages":[1], "box":"crop", "margins":[10,20,30,40] })); + let cropped = ok(&mut a, "js_run", json!({ "doc":doc, "script":"JSON.stringify(getPageBox('Crop',0))" })); + assert_eq!(cropped["result"], "[0,240,160,0]"); + ok(&mut a, "page_set_box", json!({ "doc":doc, "pages":[2], "box":"media", "margins":[0,0,40,50] })); + let other_boxes = ok(&mut a, "js_run", json!({ "doc":doc, "script":"JSON.stringify([getPageBox('Media',0),getPageBox('Crop',1)])" })); + assert_eq!(other_boxes["result"], "[[-10,280,190,-20],[0,250,160,0]]"); + ok(&mut a, "page_set_box", json!({ "doc":doc, "pages":[1], "box":"crop" })); let f = ok(&mut a, "form_fields", json!({ "doc": doc })); let total = f["fields"].as_array().unwrap().iter().find(|x| x["name"] == "Total").unwrap().clone(); assert_eq!(total["value"], "3750"); + let calculated = ok( + &mut a, + "js_run", + json!({ "doc": doc, "script": "getField('Price').value = 3; getField('Qty').value = 2; calculateNow(); console.println(getField('Total').value);" }), + ); + assert!(calculated["console"].as_array().unwrap().iter().any(|line| line == "6"), "{calculated}"); + let f = ok(&mut a, "form_fields", json!({ "doc": doc })); + assert_eq!(f["fields"].as_array().unwrap().iter().find(|x| x["name"] == "Total").unwrap()["value"], "6"); let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "nope()" })); assert!(r["error"].as_str().unwrap().contains("nope")); assert_eq!(r["document_errors"], json!([])); @@ -8561,6 +9607,26 @@ fn javascript_through_tools() { let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "money(5)" })); assert_eq!((r["error"].clone(), r["result"].clone()), (json!(null), json!("EUR 5,00"))); assert!(r["document_errors"][0].as_str().unwrap().starts_with("document script 2: TypeError"), "{r}"); + ok( + &mut a, + "form_add_field", + json!({ "doc": doc, "page": 1, "type": "button", "rect": [20, 150, 120, 180], "name": "CaptionButton", "caption": "Before" }), + ); + let r = ok( + &mut a, + "js_run", + json!({ "doc": doc, "field": "CaptionButton", "script": "getField('CaptionButton').buttonSetCaption('Continue'); getField('CaptionButton').buttonGetCaption();" }), + ); + assert_eq!(r["result"], "Continue"); + let routed = ok(&mut a, "js_run", json!({ "doc":doc, "field":"CaptionButton", "script":"event.name+'|'+event.targetName" })); + assert_eq!(routed["result"], "Mouse Up|CaptionButton"); + assert_eq!( + ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"].as_array().unwrap().iter().find(|f| f["name"] == "CaptionButton").unwrap()["caption"], + "Continue" + ); + let bad_face = + ok(&mut a, "js_run", json!({ "doc": doc, "field": "CaptionButton", "script": "getField('CaptionButton').buttonSetCaption('Ignored', 1);" })); + assert!(bad_face["error"].as_str().is_some_and(|e| e.contains("normal button face"))); assert_eq!(ok(&mut a, "js_enabled", json!({ "enabled": false }))["enabled"], false); assert!(a.call("js_run", &json!({ "doc": doc, "script": "1" })).is_err()); } @@ -8649,6 +9715,14 @@ fn detecting_form_fields_preserves_content_piece_comments() { } else { json!([]) }; + let check_inventory = |a: &mut Automation, doc| { + let mut fields = ok(a, "form_fields", json!({"doc": doc}))["fields"].clone(); + for field in fields.as_array_mut().unwrap() { + assert!(field["field_id"].as_u64().is_some()); + field.as_object_mut().unwrap().remove("field_id"); + } + assert_eq!(fields, inventory); + }; let check_text = |a: &mut Automation, doc| { let text = page_text(a, doc).join(" "); assert_eq!(text.split_whitespace().collect::>(), ["FIRST", "LAST"]); @@ -8669,12 +9743,12 @@ fn detecting_form_fields_preserves_content_piece_comments() { assert_eq!(ok(&mut a, "form_detect_fields", json!({"doc": doc, "add": false}))["fields"], candidates); assert_eq!(ok(&mut a, "form_fields", json!({"doc": doc}))["fields"], json!([])); assert_eq!(ok(&mut a, "form_detect_fields", json!({"doc": doc}))["fields"], candidates); - assert_eq!(ok(&mut a, "form_fields", json!({"doc": doc}))["fields"], inventory); + check_inventory(&mut a, doc); assert_eq!(ok(&mut a, "form_detect_fields", json!({"doc": doc}))["fields"], json!([])); ok(&mut a, "doc_save", json!({"doc": doc, "path": "saved.pdf"})); let mut reopened = auto(&dir); let again = ok(&mut reopened, "doc_open", json!({"path": "saved.pdf"}))["doc"].as_u64().unwrap(); - assert_eq!(ok(&mut reopened, "form_fields", json!({"doc": again}))["fields"], inventory); + check_inventory(&mut reopened, again); check_text(&mut reopened, again); check_render(&mut reopened, again); std::fs::remove_dir_all(dir).unwrap(); @@ -8736,6 +9810,8 @@ fn comparing_documents_through_tools() { assert!(a.call("doc_compare", &json!({ "doc": v2, "other": 999 })).is_err()); } +/// `action_list` and `action_run`: built-in actions by name and ad-hoc steps run on files, +/// missing inputs are reported per file, and an unknown step is refused. #[test] fn actions_through_tools() { let dir = workdir("actions"); @@ -8753,6 +9829,71 @@ fn actions_through_tools() { assert!(a.call("action_run", &json!({ "steps": [{ "step": "fly" }], "paths": ["a.pdf"], "folder": "x" })).is_err()); } +/// JavaScript stays off in the short-lived sessions used by the three file-processing entry +/// points (#5080). The input's initialize script changes `qty` to 42, which is visible in the +/// saved XFA data if any entry point opens it with JavaScript enabled. +#[test] +fn file_processing_keeps_javascript_off_for_xfa_open_scripts() { + let dir = workdir("batch-javascript-off-xfa"); + let mut seed = 0x1234_5678_u32; + let padding: String = (0..32_000) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + char::from(b'a' + (seed % 26) as u8) + }) + .collect(); + let template = pdfcraft_xfa::fixtures::scripted_template() + .replace("if (qty.rawValue === null) qty.rawValue = 2;", "qty.rawValue = 42; pad.rawValue = '';") + .replace( + "{padding}\n ().unwrap(); + let orphan = "x".repeat(64_000); + let orphan_offset = source.len(); + source.extend_from_slice(format!("999 0 obj\n<< /Length {} >>\nstream\n{orphan}\nendstream\nendobj\n", orphan.len()).as_bytes()); + let xref = source.len(); + source.extend_from_slice( + format!("xref\n999 1\n{orphan_offset:010} 00000 n \ntrailer\n<< /Size 1000 /Root 1 0 R /Prev {previous_xref} >>\nstartxref\n{xref}\n%%EOF\n") + .as_bytes(), + ); + std::fs::write(dir.join("scripted.pdf"), &source).unwrap(); + + let mut a = auto(&dir); + ok(&mut a, "js_enabled", json!({ "enabled": false })); + + ok(&mut a, "action_run", json!({ "steps": [{ "step": "set_title", "arg": "Processed" }], "paths": ["scripted.pdf"], "folder": "actions" })); + ok(&mut a, "doc_batch", json!({ "paths": ["scripted.pdf"], "steps": ["optimize"], "out_dir": "batch" })); + + // Open each result through the same JavaScript-off session and inspect the field data. + for path in ["actions/scripted.pdf", "batch/scripted.pdf"] { + let doc = ok(&mut a, "doc_open", json!({ "path": path }))["doc"].as_u64().unwrap(); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + let qty = fields["fields"].as_array().unwrap().iter().find(|field| field["name"] == "qty").unwrap(); + assert_ne!(qty["value"], "42", "{path} ran the XFA initialize script"); + } + + // OCR needs its language models. When present, it uses the same per-file open path and must + // also keep the script from changing the saved field value. Without them, the engine's + // `ocr::tests::files_open_for_recognition_with_the_callers_javascript_preference` still + // checks the preference `recognize_file` opens each file with. + if ok(&mut a, "ocr_status", json!({}))["available"] == true { + ok(&mut a, "ocr_recognize_files", json!({ "paths": ["scripted.pdf"], "folder": "ocr", "skip_text_pages": false })); + let doc = ok(&mut a, "doc_open", json!({ "path": "ocr/scripted.pdf" }))["doc"].as_u64().unwrap(); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + let qty = fields["fields"].as_array().unwrap().iter().find(|field| field["name"] == "qty").unwrap(); + assert_ne!(qty["value"], "42", "OCR ran the XFA initialize script"); + } +} + #[test] fn action_run_keeps_outputs_with_duplicate_input_basenames() { let dir = workdir("action-duplicate-basenames"); @@ -9804,6 +10945,8 @@ fn growable_xfa_text_paginates_after_fill_save_and_print() { let _ = std::fs::remove_dir_all(dir); } +/// A print button repeated on every page of an XFA form runs its own XFA click script (a print +/// request) through js_run, from each copy, before and after saving and reopening. #[test] fn repeated_xfa_print_buttons_work_through_tools_and_after_reopen() { let dir = workdir("xfa-print-buttons"); @@ -9831,6 +10974,93 @@ fn repeated_xfa_print_buttons_work_through_tools_and_after_reopen() { check(&mut a, doc); } +/// form_focus runs a field's XFA enter and exit scripts on every focus change; scripts that +/// change nothing leave no edit, a radio button's own scripts run for its widget, and unknown +/// fields, widgets and events are refused. +#[test] +fn xfa_focus_scripts_run_through_form_focus() { + let dir = workdir("xfa-focus"); + std::fs::write(dir.join("focus.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::focus_template())).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "focus.pdf" }))["doc"].as_u64().unwrap(); + let value = |a: &mut Automation, n: &str| { + let f = ok(a, "form_fields", json!({ "doc": doc })); + f["fields"].as_array().unwrap().iter().find(|f| f["name"] == n).map(|f| f["value"].clone()).unwrap() + }; + for _ in 0..3 { + assert_eq!(ok(&mut a, "form_focus", json!({ "doc": doc, "field": "probe", "event": "enter" }))["changed"], true); + assert_eq!(ok(&mut a, "form_focus", json!({ "doc": doc, "field": "probe", "event": "exit" }))["changed"], true); + } + assert_eq!((value(&mut a, "enters"), value(&mut a, "exits")), (json!("3"), json!("3"))); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + assert_eq!(value(&mut a, "exits"), "2", "each run is one undo step"); + // A script that only shows a message changes nothing: no edit (the document stays saved and + // what was undone can still be redone), and the message comes back. + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "saved.pdf" })); + let quiet = ok(&mut a, "form_focus", json!({ "doc": doc, "field": "quiet", "event": "exit" })); + assert_eq!( + (quiet["changed"].clone(), quiet["alerts"].clone()), + (json!(false), json!([{ "message": "left quiet", "title": null, "icon": "error", "buttons": "ok" }])), + "{quiet}" + ); + let state = &ok(&mut a, "doc_info", json!({ "doc": doc }))["document"]; + assert_eq!((state["dirty"].clone(), state["redo"].clone()), (json!(false), json!("Run form script")), "{state}"); + // The second radio button has an enter script of its own. + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + let group = fields["fields"].as_array().unwrap().iter().find(|f| f["type"] == "radio").unwrap()["name"].clone(); + assert_eq!(ok(&mut a, "form_focus", json!({ "doc": doc, "field": group, "event": "enter" }))["changed"], false); + assert_eq!(ok(&mut a, "form_focus", json!({ "doc": doc, "field": group, "event": "enter", "widget": 2 }))["changed"], true); + assert_eq!(value(&mut a, "picks"), "1"); + for bad in [ + json!({ "doc": doc, "field": "nowhere", "event": "enter" }), + json!({ "doc": doc, "field": group, "event": "enter", "widget": 3 }), + json!({ "doc": doc, "field": "probe", "event": "enter", "widget": 0 }), + json!({ "doc": doc, "field": "probe", "event": "click" }), + ] { + assert!(a.call("form_focus", &bad).is_err(), "{bad}"); + } +} + +/// form_focus refuses a field with no widget, which nothing can focus, instead of reporting a +/// focus change that ran nothing. +#[test] +fn form_focus_refuses_a_field_with_no_widget() { + let dir = workdir("focus-no-widget"); + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [60 60 200 80] /P 3 0 R >>", + "<< /FT /Tx /T (total) /V (3) >>", + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + std::fs::write(dir.join("total.pdf"), bytes).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "total.pdf" }))["doc"].as_u64().unwrap(); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + assert!(fields["fields"].as_array().unwrap().iter().any(|f| f["name"] == "total"), "the field without a widget is listed: {fields}"); + for args in [json!({ "doc": doc, "field": "total", "event": "enter" }), json!({ "doc": doc, "field": "total", "event": "enter", "widget": 1 })] { + let e = err(&mut a, "form_focus", args.clone()).to_string(); + assert!(e.contains("\"total\" has no widget to focus"), "{args}: {e}"); + } + assert_eq!( + ok(&mut a, "form_focus", json!({ "doc": doc, "field": "name", "event": "enter" }))["changed"], + false, + "a field with a widget still takes it" + ); +} + /// A dynamic XFA form's scripts run on open, on fills and for buttons through the tools, with undo, /// and `js_run` reports their messages. #[test] @@ -9946,6 +11176,38 @@ fn doc_info_describes_set_layer_links() { }) ); assert_eq!(info["layers"], json!([{ "name": "Red", "visible": true, "initial": true }, { "name": "Green", "visible": false, "initial": false }])); + // No /Order: the Layers panel presents none (ISO 32000-2 Table 99), though both are layers. + assert_eq!(info["layer_order"], json!([])); +} + +/// `doc_info`'s `layer_order` follows the default configuration's /Order (ISO 32000-2 Table 99): +/// a group by its 1-based position in `layers` and its name, a nested array one level deeper, +/// a label that starts one, and a group /Order does not list left out. +#[test] +fn doc_info_lists_the_layer_order_tree() { + let dir = workdir("layer-order"); + let pdf = pdf_of(&[ + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [4 0 R 5 0 R 6 0 R 7 0 R] /D << /Order [6 0 R [(Inks) 4 0 R [5 0 R]]] >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>", + "<< /Type /OCG /Name (Red) >>", + "<< /Type /OCG /Name (Green) >>", + "<< /Type /OCG /Name (Blue) >>", + "<< /Type /OCG /Name (Unlisted) >>", + ]); + std::fs::write(dir.join("order.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "order.pdf" }))["doc"].as_u64().unwrap(); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + assert_eq!( + info["layer_order"], + json!([ + { "depth": 0, "layer": 3, "name": "Blue" }, + { "depth": 0, "label": "Inks" }, + { "depth": 1, "layer": 1, "name": "Red" }, + { "depth": 2, "layer": 2, "name": "Green" }, + ]) + ); } mod close_argument_tests { @@ -10525,6 +11787,22 @@ fn a_deleted_image_is_not_kept_by_reduce_or_optimize() { assert_eq!(reopened["pages"], 1); } +#[test] +fn optimizing_discards_links_to_other_documents_through_tools() { + let dir = workdir("optimize-external-refs"); + std::fs::write(dir.join("remote.pdf"), remote_link_pdf("other.pdf", None)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "remote.pdf" }))["doc"].as_u64().unwrap(); + let kept = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "kept.pdf" })); + assert_eq!(kept["external_links"], 0, "{kept}"); + let kept = ok(&mut a, "doc_open", json!({ "path": "kept.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "link_list", json!({ "doc": kept }))["count"], 1); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "small.pdf", "discard_external_refs": true })); + assert_eq!((r["external_links"].as_u64(), r["external_bookmarks"].as_u64()), (Some(1), Some(0)), "{r}"); + let small = ok(&mut a, "doc_open", json!({ "path": "small.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "link_list", json!({ "doc": small }))["count"], 0); +} + /// A two-page PDF with the same three-row table ("Name Qty / Apple 12 / Pear 7"); the second /// page is turned with /Rotate 90. fn table_pdf() -> Vec { @@ -11161,6 +12439,32 @@ fn a_missing_path_under_the_root_names_where_it_was_looked_for() { let _ = std::fs::remove_dir_all(base); } +/// `doc_print` takes a paper size of its own besides the named ones, in inches, millimetres, +/// centimetres or points, either way round; a size it can't read or outside 1 to 200 inches is +/// refused. +#[test] +fn doc_print_takes_a_paper_size_of_its_own() { + let dir = workdir("print-paper-size"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for (paper, want) in + [("8.5x14in", (612.0, 1008.0)), ("297x210mm", (595.28, 841.89)), ("10 x 15 cm", (283.46, 425.2)), ("288x432PT", (288.0, 432.0))] + { + let file = format!("{}.pdf", want.0); + ok(&mut a, "doc_print", json!({ "doc": doc, "pages": "1", "paper": paper, "orientation": "portrait", "path": file })); + let bytes = std::fs::read(dir.join(&file)).unwrap(); + let out = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap(); + let page = pdfcraft_model::pages(&out)[0].display_size(&out); + assert!((page.0 - want.0).abs() < 0.5 && (page.1 - want.1).abs() < 0.5, "{paper}: {page:?}"); + } + for paper in ["8.5xbroad", "8.5x14", "0.5x11in", "8.5x300in", "-8.5x11in", "NaNx11in"] { + match a.call("doc_print", &json!({ "doc": doc, "paper": paper, "path": "x.pdf" })) { + Err(ToolError::InvalidArgs(m)) => assert!(m.contains("unknown paper"), "{paper}: {m}"), + other => panic!("{paper}: {other:?}"), + } + } +} + /// `doc_print` prints as image: pictures at `dpi`, grey with `grayscale`; a resolution outside /// 72–600 is refused. #[test] @@ -11199,6 +12503,107 @@ fn a_root_less_write_output_is_atomic_too() { let _ = std::fs::remove_dir_all(&dir); } +/// `doc_print` prints a selected area (Acrobat's "print selected area"): one page's rectangle, +/// in points from the top-left, at actual size on its own sheet; several pages, an empty area or +/// a layout that isn't a size choice are refused. +#[test] +fn doc_print_a_selected_area() { + let dir = workdir("print-area"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let r = ok( + &mut a, + "doc_print", + json!({ "doc": doc, "pages": "1", "area": [36, 36, 236, 136], "layout": "actual", "paper": "Letter", "path": "area.pdf" }), + ); + assert_eq!((r["sheets"].as_u64(), r["pages"].as_u64()), (Some(1), Some(1))); + let out = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("area.pdf")).unwrap())).unwrap(); + let pages = pdfcraft_model::pages(&out); + assert_eq!(pages.len(), 1); + // The sheet is the paper (Letter, turned to suit the wide area); the page inside it is cut to the area: a clip to the + // area's rectangle, then the source page drawn through it, and nothing else of the page. + let media: Vec = + out.resolve(pages[0].dict.get(b"MediaBox").unwrap()).as_array().unwrap().iter().map(|v| out.resolve(v).as_f64().unwrap()).collect(); + assert_eq!(media, [0.0, 0.0, 792.0, 612.0], "a landscape area takes a landscape Letter sheet"); + let contents = out.resolve(pages[0].dict.get(b"Contents").unwrap()); + let pdfcraft_cos::Object::Stream(stream) = &*contents else { panic!("sheet content") }; + let content = String::from_utf8(stream.decoded().unwrap()).unwrap(); + let tokens: Vec<&str> = content.split_whitespace().collect(); + let at = tokens.iter().position(|t| *t == "re").expect("the sheet clips to a rectangle"); + let rect: Vec = tokens[at - 4..at].iter().map(|t| t.parse().unwrap()).collect(); + assert_eq!(&tokens[at + 1..at + 3], ["W", "n"], "the rectangle is the clip: {content}"); + assert_eq!(rect, [36.0, 164.0, 164.0, 100.0], "the clip is the area (36..200 wide, 36..136 from the top, cut to the 200 x 300 page): {content}"); + assert_eq!(tokens.iter().filter(|t| **t == "re").count(), 1, "one clip, nothing drawn outside it: {content}"); + assert!(tokens.contains(&"Do"), "the page is drawn inside the clip: {content}"); + for (args, why) in [ + (json!({ "doc": doc, "pages": "1-2", "area": [36, 36, 236, 136], "path": "x.pdf" }), "one page"), + (json!({ "doc": doc, "pages": "1", "area": [36, 36, 36.5, 136], "path": "x.pdf" }), "empty"), + (json!({ "doc": doc, "pages": "1", "area": [36, 36, 36, 136], "path": "x.pdf" }), "empty"), + (json!({ "doc": doc, "pages": "1", "area": [5000, 36, 5200, 136], "path": "x.pdf" }), "empty"), + (json!({ "doc": doc, "pages": "1", "area": [36, 36, 236], "path": "x.pdf" }), "4 numbers"), + (json!({ "doc": doc, "pages": "1", "area": [36, 36, 236, 136], "layout": "multiple", "per_sheet": 2, "path": "x.pdf" }), "Fit"), + ] { + match a.call("doc_print", &args) { + Err(ToolError::InvalidArgs(m)) => assert!(m.contains(why), "{why}: {m}"), + other => panic!("{why}: {other:?}"), + } + } +} + +/// Printer marks through the tools: added around the trim box (the page widened to make room), +/// removable by kind. +#[test] +fn printer_marks_through_tools() { + let dir = workdir("printer-marks"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let width_before = ok(&mut a, "doc_info", json!({ "doc": doc }))["pages"][0]["width"].as_f64().unwrap_or(0.0); + let r = ok(&mut a, "doc_printer_marks", json!({ "doc": doc, "pages": [1], "color_bars": false, "line_weight": 0.5 })); + assert_eq!(r["undo"], "Add printer marks"); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + let width_after = info["pages"][0]["width"].as_f64().unwrap_or(0.0); + assert!(width_after > width_before + 70.0 || width_before == 0.0, "widened to make room: {width_before} → {width_after}; {info}"); + let r = ok(&mut a, "doc_remove_marks", json!({ "doc": doc, "kind": "printer_marks" })); + assert_eq!(r["undo"], "Remove printer marks"); + for bad in [json!({ "line_weight": -1 }), json!({ "line_weight": 0 }), json!({ "line_weight": 10000 }), json!({ "make_room": 1000000 })] { + let mut args = bad.clone(); + args["doc"] = json!(doc); + assert!(matches!(a.call("doc_printer_marks", &args), Err(ToolError::Failed(_) | ToolError::InvalidArgs(_))), "{bad}"); + } + assert!(matches!( + a.call( + "doc_printer_marks", + &json!({ "doc": doc, "trim": false, "bleed": false, "registration": false, "color_bars": false, "page_info": false }) + ), + Err(ToolError::Failed(_) | ToolError::InvalidArgs(_)) + )); +} + +/// Convert colours through the tools: counts, converts, reports nothing to do. +#[test] +fn convert_colours_through_tools() { + let dir = workdir("convert-colours"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + // The fixture sets no colour of its own: nothing to convert yet. + let r = ok(&mut a, "doc_convert_colours", json!({ "doc": doc, "to": "gray" })); + assert_eq!((r["colours"].as_u64(), r["undo"].is_null()), (Some(0), true), "{r}"); + // Red text added as page content converts. + ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": "Red", "at": [20, 20], "color": "#FF0000" })); + let r = ok(&mut a, "doc_convert_colours", json!({ "doc": doc, "to": "gray", "pages": [1] })); + assert!(r["colours"].as_u64().unwrap_or(0) >= 1 && r["pages"].as_u64() == Some(1), "{r}"); + assert_eq!(r["undo"], "Convert colours"); + assert_eq!(r["loses_conformance"], json!([]), "{r}"); + assert!(matches!(a.call("doc_convert_colours", &json!({ "doc": doc, "to": "lab" })), Err(ToolError::InvalidArgs(_)))); + // A file that declares a standard is told what it may have lost. + ok(&mut a, "pdfa_convert", json!({ "doc": doc, "level": "3b" })); + ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": "Blue", "at": [20, 40], "color": "#0000FF" })); + let r = ok(&mut a, "doc_convert_colours", json!({ "doc": doc, "to": "cmyk", "pages": [1] })); + let loses = r["loses_conformance"].as_array().unwrap(); + assert_eq!(loses[0], "PDF/A-3b", "{r}"); + assert!(loses[1].as_str().unwrap().starts_with("output intent "), "{r}"); +} + /// Fix hairlines through the tools: counts what changed, and reports zero without an edit. #[test] fn fix_hairlines_through_tools() { @@ -11263,6 +12668,80 @@ fn attaching_and_deleting_files_through_the_tools() { assert!(a.call("attachment_add", &json!({ "doc": doc, "path": "missing.txt" })).is_err(), "a file that is not there is refused"); } +#[test] +fn attachment_descriptions_can_be_edited_cleared_undone_and_saved() { + let dir = workdir("attachment-description"); + let bytes = b"original attachment bytes"; + std::fs::write(dir.join("notes.txt"), bytes).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path": "a.pdf"}))["doc"].as_u64().unwrap(); + ok(&mut a, "attachment_add", json!({"doc": doc, "path": "notes.txt", "description": "Original"})); + + ok(&mut a, "attachment_edit", json!({"doc": doc, "name": "notes.txt", "description": "Résumé 東京"})); + assert_eq!(ok(&mut a, "doc_info", json!({"doc": doc}))["attachments"][0]["description"], "Résumé 東京"); + ok(&mut a, "edit_undo", json!({"doc": doc})); + assert_eq!(ok(&mut a, "doc_info", json!({"doc": doc}))["attachments"][0]["description"], "Original"); + ok(&mut a, "edit_redo", json!({"doc": doc})); + assert_eq!(ok(&mut a, "doc_info", json!({"doc": doc}))["attachments"][0]["description"], "Résumé 東京"); + + ok(&mut a, "attachment_edit", json!({"doc": doc, "name": "notes.txt", "description": ""})); + assert!(ok(&mut a, "doc_info", json!({"doc": doc}))["attachments"][0]["description"].is_null()); + ok(&mut a, "edit_undo", json!({"doc": doc})); + ok(&mut a, "doc_save", json!({"doc": doc, "path": "edited.pdf", "full": true})); + let saved = std::fs::read(dir.join("edited.pdf")).unwrap(); + let info = pdfcraft_render::inspect(std::sync::Arc::new(saved.clone()), None).unwrap(); + let attachment = info.attachments.iter().find(|item| item.name == "notes.txt").unwrap(); + assert_eq!(attachment.description.as_deref(), Some("Résumé 東京")); + assert_eq!(pdfcraft_render::attachment_data(&saved, None, attachment).unwrap(), bytes); + let reopened = ok(&mut a, "doc_open", json!({"path": "edited.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "doc_info", json!({"doc": reopened}))["attachments"][0]["description"], "Résumé 東京"); +} + +#[test] +fn attachment_edit_resolves_the_listed_name_to_its_name_tree_key() { + let dir = workdir("attachment-name-tree-key"); + std::fs::write(dir.join("payload.txt"), b"payload").unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap(); + ok(&mut a, "attachment_add", json!({"doc":doc,"path":"payload.txt","name":"storage-key.txt"})); + ok(&mut a, "doc_save", json!({"doc":doc,"path":"seed.pdf","full":true})); + let mut source = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("seed.pdf")).unwrap())).unwrap(); + let key = "storage-key.txt"; + let root = source.root().unwrap(); + let catalog = source.get(root).as_dict().unwrap().clone(); + let names = source.resolve(catalog.get(b"Names").unwrap()).as_dict().unwrap().clone(); + let tree = source.resolve(names.get(b"EmbeddedFiles").unwrap()).as_dict().unwrap().clone(); + let pairs = source.resolve(tree.get(b"Names").unwrap()).as_array().unwrap().clone(); + let spec_ref = pairs.get(1).and_then(pdfcraft_cos::Object::as_ref).unwrap(); + source + .update_dict(spec_ref, |spec| { + let name = pdfcraft_cos::Object::String(pdfcraft_cos::PdfString::text("visible-name.txt")); + spec.set(b"F".to_vec(), name.clone()); + spec.set(b"UF".to_vec(), name); + }) + .unwrap(); + let bytes = pdfcraft_cos::write_full(&source, &pdfcraft_cos::SaveOptions::default()).unwrap(); + std::fs::write(dir.join("named.pdf"), bytes).unwrap(); + + let doc = ok(&mut a, "doc_open", json!({"path":"named.pdf"}))["doc"].as_u64().unwrap(); + let attachment = &ok(&mut a, "doc_info", json!({"doc":doc}))["attachments"][0]; + assert_eq!(attachment["name"], "visible-name.txt"); + assert_eq!(attachment["key"], key); + ok(&mut a, "attachment_edit", json!({"doc":doc,"key":key,"description":"Updated"})); + assert_eq!(ok(&mut a, "doc_info", json!({"doc":doc}))["attachments"][0]["description"], "Updated"); + assert!(matches!( + a.call("attachment_edit", &json!({"doc":doc,"name":"missing.txt","description":"x"})), + Err(ToolError::InvalidArgs(message)) if message.contains("no document attachment named") + )); + ok(&mut a, "doc_save", json!({"doc":doc,"path":"edited.pdf","full":true})); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("edited.pdf")).unwrap())).unwrap(); + let catalog = saved.get(saved.root().unwrap()).as_dict().unwrap().clone(); + let names = saved.resolve(catalog.get(b"Names").unwrap()).as_dict().unwrap().clone(); + let tree = saved.resolve(names.get(b"EmbeddedFiles").unwrap()).as_dict().unwrap().clone(); + let pairs = saved.resolve(tree.get(b"Names").unwrap()).as_array().unwrap().clone(); + assert_eq!(pairs.first().and_then(pdfcraft_cos::Object::as_string).map(pdfcraft_cos::PdfString::to_text).as_deref(), Some(key)); +} + #[test] fn fdf_nested_comment_values_survive_export_import_and_save_through_tools() { use pdfcraft_cos::{Dict, Document, Object, PdfString, SaveOptions, write_full}; @@ -11353,6 +12832,49 @@ fn text_box_and_callout_fill_and_border_through_tools() { } } +/// A custom stamp can carry a dynamic line (#1741): `{name}`, `{date}` and `{time}` are filled in +/// when it is placed and drawn under the picture, which keeps its shape; the stamp widens to fit +/// the line; a line too long is cut. +#[test] +fn a_custom_stamp_line_is_filled_in_when_placed() { + let dir = workdir("stamp_line"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "stamp_custom", json!({ "doc": doc, "page": 2, "path": "b.pdf", "file_page": 2, "at": [100, 150], "name": "Plain" })); + ok( + &mut a, + "stamp_custom", + json!({ "doc": doc, "page": 1, "path": "b.pdf", "file_page": 2, "at": [100, 150], "name": "Logo", "author": "Ada", "line": "Received {date} {time} by {name}" }), + ); + // The clock is 2023-11-14 22:13:20, taken as local time. + let text = page_text(&mut a, doc)[0].clone(); + assert!(text.contains("Received Nov 14, 2023 10:13 pm by Ada"), "{text}"); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + let rect = |page: u64| { + let c = list["comments"].as_array().unwrap().iter().find(|c| c["page"] == page).unwrap(); + let r: Vec = c["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + (r[2] - r[0], r[3] - r[1]) + }; + let ((pw, ph), (lw, lh)) = (rect(2), rect(1)); + // The stamp stays within the natural 200 pt. Its line, at its usual size, is wider than the + // picture, so the stamp is wider than the picture's shape with the band added. + assert!(lw / lh > pw / ph * (1.0 - 0.22) + 0.01, "widened for the line: {pw}x{ph} vs {lw}x{lh}"); + // A template within the schema's 200 characters whose filled-in line runs past the limit + // (33 times of 8 characters) is cut to exactly 200, not refused. + let long = "{time}".repeat(33); + assert!(long.chars().count() <= 200); + ok(&mut a, "stamp_custom", json!({ "doc": doc, "page": 3, "path": "b.pdf", "file_page": 2, "at": [100, 150], "line": long })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "stamped.pdf" })); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("stamped.pdf")).unwrap())).unwrap(); + let lines: Vec = saved + .scan_objects() + .filter_map(|(_, o)| o.as_dict()?.get(b"PCByLine")?.as_string().map(pdfcraft_cos::PdfString::to_text)) + .filter(|l| l.starts_with("10:13 pm")) + .collect(); + assert_eq!(lines.len(), 1, "{lines:?}"); + assert_eq!(lines[0].chars().count(), 200, "{:?}", lines[0]); +} + /// Three paragraphs: Alpha (two rows), Bravo (one row) and Charlie (two rows), at the leading /// of #2516. Alpha's last baseline is 35.6 pt above Bravo's; moving Alpha down 21.2 pt leaves /// 14.4 pt, Alpha's own leading, so the next read groups the two as one paragraph. @@ -12190,6 +13712,118 @@ fn text_extract_says_when_a_pages_content_was_cut() { let _ = std::fs::remove_dir_all(&dir); } +// Contributor-original PDF: a Square on page 1 and, on page 2, a Circle with `/IRT` to it and +// `/RT /Group`. +const SPREAD_GROUP_FIXTURE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R] >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [6 0 R] >> endobj +5 0 obj << /Type /Annot /Subtype /Square /Rect [20 20 100 80] /NM (sq-primary) /T (Alice) /Contents (Primary) /C [1 0 0] >> endobj +6 0 obj << /Type /Annot /Subtype /Circle /Rect [180 120 260 180] /NM (circ-other-page) /T (Bob) /Contents (Member on page 2) /C [0 0 1] /IRT 5 0 R /RT /Group >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// A group is one page's, as Acrobat reads it: a subordinate whose primary is on another page +/// lists as a comment of its own, with its own author and text, not as part of the group or a +/// reply, and deleting the primary leaves it. ISO 32000-2 §12.5.6.2 doesn't limit a group to a +/// page; Acrobat (desktop and iPad) and PDF-XChange all delete the primary without such a +/// subordinate, and Acrobat desktop lists it on its own. +#[test] +fn a_group_member_on_another_page_is_a_comment_of_its_own() { + let dir = workdir("rt-group-spread"); + std::fs::write(dir.join("group.pdf"), SPREAD_GROUP_FIXTURE).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "group.pdf" }))["doc"].as_u64().unwrap(); + let check = |list: &Value, when: &str| { + assert_eq!(list["count"], 2, "{when}: the Square and the Circle, each a comment: {list}"); + let circle = list["comments"].as_array().unwrap().iter().find(|c| c["id"] == "circ-other-page").cloned().unwrap(); + assert_eq!((circle["author"].as_str(), circle["contents"].as_str()), (Some("Bob"), Some("Member on page 2")), "{when}"); + let square = list["comments"].as_array().unwrap().iter().find(|c| c["id"] == "sq-primary").cloned().unwrap(); + assert!(square["group"].as_array().unwrap().is_empty() && square["replies"].as_array().unwrap().is_empty(), "{when}: {square}"); + }; + check(&ok(&mut a, "comment_list", json!({ "doc": doc })), "opened"); + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [280, 20], "contents": "Other" })); + let after = ok(&mut a, "comment_list", json!({ "doc": doc })); + let kept: Vec = after["comments"].as_array().unwrap().iter().filter(|c| c["contents"] != "Other").cloned().collect(); + check(&json!({ "count": kept.len(), "comments": kept }), "edited"); + ok(&mut a, "comment_delete", json!({ "doc": doc, "id": "sq-primary" })); + let left = ok(&mut a, "comment_list", json!({ "doc": doc })); + assert!(left.to_string().contains("circ-other-page"), "deleting the primary leaves the Circle on page 2: {left}"); +} + +/// #1152 headless: comment_list gives a callout's arrow point, knee and text box; comment_edit's +/// `points` moves the arrow point and knee, and `rect` resizes the text box with the arrow left +/// where it is. +#[test] +fn a_callouts_parts_are_listed_and_changed_through_the_tools() { + let dir = workdir("callout-parts"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok( + &mut a, + "comment_add", + json!({ "doc": doc, "page": 1, "type": "callout", "rect": [110, 200, 190, 240], "to": [40, 160], "knee": [80, 220], "contents": "Look" }), + ); + let first = |a: &mut Automation| ok(a, "comment_list", json!({ "doc": doc }))["comments"][0].clone(); + let near = |v: &Value, want: &[f64]| v.as_array().unwrap().iter().zip(want).all(|(x, w)| (x.as_f64().unwrap() - w).abs() < 0.6); + let c = first(&mut a); + assert!(near(&c["points"][0], &[40.0, 160.0]) && near(&c["points"][1], &[80.0, 220.0]), "{c}"); + assert!(near(&c["text_box"], &[110.0, 200.0, 190.0, 240.0]), "{c}"); + let (page, index) = (c["page"].clone(), c["index"].clone()); + ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "points": [[30, 150], [80, 220]] })); + let c = first(&mut a); + assert!(near(&c["points"][0], &[30.0, 150.0]) && near(&c["text_box"], &[110.0, 200.0, 190.0, 240.0]), "{c}"); + ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "rect": [110, 200, 230, 260] })); + let c = first(&mut a); + assert!(near(&c["text_box"], &[110.0, 200.0, 230.0, 260.0]) && near(&c["points"][0], &[30.0, 150.0]), "{c}"); + // Three points, or none, aren't a callout's. + assert!(matches!( + a.call("comment_edit", &json!({ "doc": doc, "page": page, "index": index, "points": [[1, 1], [2, 2], [3, 3]] })), + Err(ToolError::Failed(_) | ToolError::InvalidArgs(_)) + )); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "callout.pdf" })); + let saved = ok(&mut a, "doc_open", json!({ "path": "callout.pdf" }))["doc"].as_u64().unwrap(); + let c = ok(&mut a, "comment_list", json!({ "doc": saved }))["comments"][0].clone(); + assert!(near(&c["points"][0], &[30.0, 150.0]) && near(&c["text_box"], &[110.0, 200.0, 230.0, 260.0]), "after reopening: {c}"); + // Without a knee, comment_add puts it where Acrobat does: 12 pt out from the middle of the side + // facing the point. + ok( + &mut a, + "comment_add", + json!({ "doc": saved, "page": 1, "type": "callout", "rect": [300, 200, 380, 240], "to": [250, 160], "contents": "Auto" }), + ); + let list = ok(&mut a, "comment_list", json!({ "doc": saved }))["comments"].clone(); + let auto = list.as_array().unwrap().iter().find(|c| c["contents"] == "Auto").unwrap(); + assert!(near(&auto["points"][1], &[288.0, 220.0]), "{auto}"); +} + +/// comment_popup opens, closes and moves a comment's pop-up, comment_list reports it, and each +/// change is one undo step with its own label. +#[test] +fn comment_popups_are_opened_moved_and_listed() { + let dir = workdir("popup"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [30.0, 40.0], "contents": "Look here" })); + let listed = ok(&mut a, "comment_list", json!({ "doc": doc })); + let note = &listed["comments"][0]; + assert_eq!(note["popup"]["open"], json!(false), "{listed}"); + let id = note["id"].as_str().unwrap().to_string(); + let opened = ok(&mut a, "comment_popup", json!({ "doc": doc, "id": id, "open": true })); + assert_eq!(opened["undo"], json!("Open comment pop-up"), "{opened}"); + let listed = ok(&mut a, "comment_list", json!({ "doc": doc })); + assert_eq!(listed["comments"][0]["popup"]["open"], json!(true)); + let moved = ok(&mut a, "comment_popup", json!({ "doc": doc, "id": id, "rect": [60.0, 60.0, 180.0, 140.0] })); + assert_eq!(moved["undo"], json!("Move comment pop-up")); + let listed = ok(&mut a, "comment_list", json!({ "doc": doc })); + assert_eq!(listed["comments"][0]["popup"]["rect"], json!([60.0, 60.0, 180.0, 140.0]), "{listed}"); + let closed = ok(&mut a, "comment_popup", json!({ "doc": doc, "id": id, "open": false })); + assert_eq!(closed["undo"], json!("Close comment pop-up")); + assert!(matches!(err(&mut a, "comment_popup", json!({ "doc": doc, "id": id })), ToolError::InvalidArgs(_))); + let _ = std::fs::remove_dir_all(&dir); +} + /// #3005: a table and a small signature drawn inside one transparency-group Form XObject (how /// HTML-to-PDF converters write a block with a translucent picture). Edit a PDF sees one piece /// of artwork and no text; `image_edit ungroup` draws it in place with the same pixels, its diff --git a/crates/automation/tests/it/button_caption.rs b/crates/automation/tests/it/button_caption.rs index cb1a22a026..0290df7343 100644 --- a/crates/automation/tests/it/button_caption.rs +++ b/crates/automation/tests/it/button_caption.rs @@ -12,6 +12,7 @@ fn tool(a: &mut Automation, name: &str, args: Value) -> Value { } #[test] +/// Changing an existing caption regenerates the saved appearance and preserves form values. fn button_caption_can_be_changed_after_creation() { let dir = std::env::temp_dir().join(format!("pdfcraft-button-caption-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); @@ -34,11 +35,16 @@ fn button_caption_can_be_changed_after_creation() { let id = tool(&mut a, "doc_open", json!({"path": "buttons.pdf"}))["doc"].as_u64().unwrap(); let before = a.session().save_bytes(DocId(id)).unwrap(); let (_, _, empty_pixels) = a.render_pixels(id, 1, 1.0).unwrap(); - tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": "Clear Form"})); + let result = tool( + &mut a, + "js_run", + json!({ "doc": id, "field": "clear_button", "script": "getField('clear_button').value='ignored'; getField('clear_button').buttonSetCaption('Clear Form'); getField('clear_button').buttonGetCaption();" }), + ); + assert_eq!(result["result"], "Clear Form"); let fields = tool(&mut a, "form_fields", json!({"doc": id})); assert_eq!(fields["fields"][0]["caption"], "Clear Form"); assert_eq!(fields["fields"][0]["captions"], json!([{"widget": 1, "caption": "Clear Form"}])); - assert_eq!(a.session().get(DocId(id)).unwrap().can_undo(), Some("Change field properties")); + assert_eq!(a.session().get(DocId(id)).unwrap().can_undo(), Some("Run JavaScript")); let (_, _, labelled_pixels) = a.render_pixels(id, 1, 1.0).unwrap(); assert_ne!(empty_pixels, labelled_pixels, "the label must reach the actual page appearance"); tool(&mut a, "edit_undo", json!({"doc": id})); @@ -89,3 +95,69 @@ fn button_caption_can_be_changed_after_creation() { assert_eq!(pixels, empty_pixels); std::fs::remove_dir_all(dir).unwrap(); } + +/// An unindexed setter reaches both widgets even when the first caption already matches. +#[test] +fn javascript_caption_updates_all_widgets_with_undo_and_save() { + use pdfcraft_cos::{Dict, Document, Object, PdfString, SaveOptions, write_full}; + + let dir = std::env::temp_dir().join(format!("pdfcraft-js-multi-caption-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut session = Session::new(); + let bytes = session.create_blank(300.0, 400.0, 1).unwrap(); + let id = session.open("buttons.pdf", None, bytes, None).unwrap(); + for (name, caption, y) in [("first", "First", 300.0), ("second", "Second", 240.0)] { + session + .apply( + id, + Edit::AddField { + page: 0, + rect: [40.0, y, 200.0, y + 30.0], + kind: NewField::Button { caption: caption.into() }, + name: Some(name.into()), + }, + ) + .unwrap(); + } + let mut doc = Document::open(session.save_bytes(id).unwrap()).unwrap(); + let af = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"AcroForm").unwrap(); + let form = doc.get(af); + let fields = doc.resolve(form.as_dict().unwrap().get(b"Fields").unwrap()); + let widgets: Vec<_> = fields.as_array().unwrap().iter().map(|w| w.as_ref().unwrap()).collect(); + let mut parent = Dict::new(); + parent.set(b"FT".to_vec(), Object::name("Btn")); + parent.set(b"Ff".to_vec(), Object::Int(1 << 16)); + parent.set(b"T".to_vec(), PdfString::text("go")); + parent.set(b"Kids".to_vec(), Object::Array(widgets.iter().copied().map(Object::Ref).collect())); + let parent = doc.add(parent); + for w in widgets { + doc.update_dict(w, |d| { + for key in [b"FT".as_slice(), b"Ff", b"T"] { + d.remove(key); + } + d.set(b"Parent".to_vec(), Object::Ref(parent)); + }) + .unwrap(); + } + doc.update_dict(af, |d| d.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(parent)]))).unwrap(); + std::fs::write(dir.join("buttons.pdf"), write_full(&doc, &SaveOptions::default()).unwrap()).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let id = tool(&mut a, "doc_open", json!({"path": "buttons.pdf"}))["doc"].as_u64().unwrap(); + let (_, _, before) = a.render_pixels(id, 1, 1.0).unwrap(); + let result = tool(&mut a, "js_run", json!({"doc": id, "script": "getField('go').buttonSetCaption('First'); getField('go').buttonGetCaption();"})); + assert_eq!(result["error"], Value::Null); + assert_eq!(result["result"], "First"); + let expected = json!([{"widget": 1, "caption": "First"}, {"widget": 2, "caption": "First"}]); + assert_eq!(tool(&mut a, "form_fields", json!({"doc": id}))["fields"][0]["captions"], expected); + let (_, _, after) = a.render_pixels(id, 1, 1.0).unwrap(); + assert_ne!(before, after, "the second widget must visibly change"); + tool(&mut a, "edit_undo", json!({"doc": id})); + assert_eq!(a.render_pixels(id, 1, 1.0).unwrap().2, before); + tool(&mut a, "edit_redo", json!({"doc": id})); + assert_eq!(a.render_pixels(id, 1, 1.0).unwrap().2, after); + tool(&mut a, "doc_save", json!({"doc": id, "path": "saved.pdf"})); + let reopened = tool(&mut a, "doc_open", json!({"path": "saved.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(tool(&mut a, "form_fields", json!({"doc": reopened}))["fields"][0]["captions"], expected); + assert_eq!(a.render_pixels(reopened, 1, 1.0).unwrap().2, after); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/automation/tests/it/document_actions.rs b/crates/automation/tests/it/document_actions.rs new file mode 100644 index 0000000000..a8646a4437 --- /dev/null +++ b/crates/automation/tests/it/document_actions.rs @@ -0,0 +1,125 @@ +//! A form's document actions and pointer/focus scripts through the tool table (#269, ported +//! from #344): the open action runs on doc_open, Will Save on doc_save, Will Print on +//! doc_print, and js_event runs a widget's pointer and focus scripts, all in one realm. + +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +fn tool(a: &mut Automation, name: &str, args: Value) -> Value { + match a.call(name, &args).unwrap().remove(0) { + Content::Json(value) => value, + other => panic!("expected JSON: {other:?}"), + } +} + +fn pdf_of(objs: &[String]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// `log` records what ran; `hot` has Mouse Enter and On Blur scripts. The open action counts +/// in a variable the Will Save and Will Print actions and `hot`'s scripts read. +fn form() -> Vec { + pdf_of(&[ + "<< /Type /Catalog /Pages 2 0 R /OpenAction 6 0 R /AA << /WS 7 0 R /WP 8 0 R >> /AcroForm << /Fields [4 0 R 9 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 9 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /S /JavaScript /JS (var n = 1; getField\\('log'\\).value = 'open' + n;) >>".into(), + "<< /S /JavaScript /JS (n++; getField\\('log'\\).value += ' save' + n; console.println\\('saving'\\);) >>".into(), + "<< /S /JavaScript /JS (n++; getField\\('log'\\).value += ' print' + n;) >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (hot) /V () /Rect [20 200 180 220] /P 3 0 R /F 4 /AA << /E << /S /JavaScript /JS (n++; getField\\('log'\\).value += ' ' + event.name + n;) >> /Bl << /S /JavaScript /JS (this.print\\(\\); getField\\('log'\\).value += ' ' + event.name;) >> >> >>".into(), + ]) +} + +fn log(a: &mut Automation, doc: u64) -> Value { + let f = tool(a, "form_fields", json!({ "doc": doc })); + f["fields"].as_array().unwrap().iter().find(|f| f["name"] == "log").map(|f| f["value"].clone()).unwrap() +} + +#[test] +fn open_will_save_will_print_and_field_events_share_the_document_realm() { + let dir = std::env::temp_dir().join(format!("pdfcraft-document-actions-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("form.pdf"), form()).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap().with_clock(|| 1_700_000_000); + let doc = tool(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(log(&mut a, doc), "open1"); + + let entered = tool(&mut a, "js_event", json!({ "doc": doc, "field": "hot", "event": "mouse_enter" })); + assert_eq!(entered["changed"], true, "{entered}"); + assert_eq!(log(&mut a, doc), "open1 Mouse Enter2"); + // Losing the focus happens unasked: its print request is dropped and noted. + let blurred = tool(&mut a, "js_event", json!({ "doc": doc, "field": "hot", "event": "on_blur" })); + assert_eq!(blurred["requests"], json!([]), "{blurred}"); + assert_eq!(log(&mut a, doc), "open1 Mouse Enter2 Blur"); + let none = tool(&mut a, "js_event", json!({ "doc": doc, "field": "log", "event": "mouse_down" })); + assert_eq!(none["changed"], false, "no script: {none}"); + assert!(a.call("js_event", &json!({ "doc": doc, "field": "hot", "event": "keystroke" })).is_err()); + assert!(a.call("js_event", &json!({ "doc": doc, "field": "hot", "event": "mouse_up", "widget": 2 })).is_err()); + + // Saved in place (incrementally): object numbers stay, so the realm is kept. A full + // rewrite starts a fresh realm for the next event. + let saved = tool(&mut a, "doc_save", json!({ "doc": doc })); + assert_eq!(saved["incremental"], true, "{saved}"); + std::fs::copy(dir.join("form.pdf"), dir.join("saved.pdf")).unwrap(); + assert_eq!(saved["will_save"]["console"], json!(["saving"]), "{saved}"); + assert_eq!(log(&mut a, doc), "open1 Mouse Enter2 Blur save3"); + + let printed = tool(&mut a, "doc_print", json!({ "doc": doc, "path": "printed.pdf" })); + assert!(printed.get("will_print").is_none(), "nothing to report: {printed}"); + assert_eq!(log(&mut a, doc), "open1 Mouse Enter2 Blur save3 print4"); + // A print with nowhere to go fails before Will Print runs. + assert!(a.call("doc_print", &json!({ "doc": doc })).is_err()); + assert_eq!(log(&mut a, doc), "open1 Mouse Enter2 Blur save3 print4"); + + // What Will Save changed is in the saved file. + tool(&mut a, "js_enabled", json!({ "enabled": false })); + let reopened = tool(&mut a, "doc_open", json!({ "path": "saved.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(log(&mut a, reopened), "open1 Mouse Enter2 Blur save3"); +} + +/// Of two fields sharing a name, `field_id` (from form_fields) picks the one js_event runs; by +/// name alone it is the first. +#[test] +fn js_event_runs_the_field_its_field_id_names() { + let enter = |text: &str| format!("/AA << /E << /S /JavaScript /JS (getField\\('log'\\).value += '{text}';) >> >>"); + let pdf = pdf_of(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R 7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 200 180 220] /P 3 0 R /F 4 {} >>", enter("first;")), + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 150 180 170] /P 3 0 R /F 4 {} >>", enter("second;")), + ]); + let dir = std::env::temp_dir().join(format!("pdfcraft-js-event-dup-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("dup.pdf"), pdf).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = tool(&mut a, "doc_open", json!({ "path": "dup.pdf" }))["doc"].as_u64().unwrap(); + let fields = tool(&mut a, "form_fields", json!({ "doc": doc })); + let ids: Vec = fields["fields"].as_array().unwrap().iter().filter(|f| f["name"] == "dup").map(|f| f["field_id"].as_u64().unwrap()).collect(); + assert_eq!(ids.len(), 2, "{fields}"); + tool(&mut a, "js_event", json!({ "doc": doc, "field": "dup", "field_id": ids[1], "event": "mouse_enter" })); + assert_eq!(log(&mut a, doc), "second;"); + tool(&mut a, "js_event", json!({ "doc": doc, "field": "dup", "event": "mouse_enter" })); + assert_eq!(log(&mut a, doc), "second;first;"); + assert!( + a.call("js_event", &json!({ "doc": doc, "field": "log", "field_id": ids[1], "event": "mouse_enter" })).is_err(), + "the id is another field's" + ); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/automation/tests/it/find_text.rs b/crates/automation/tests/it/find_text.rs new file mode 100644 index 0000000000..4ba2441305 --- /dev/null +++ b/crates/automation/tests/it/find_text.rs @@ -0,0 +1,84 @@ +//! `text_find` on a ligature glyph named in the encoding and on a word divided by a hyphen at a +//! line end. + +use std::path::PathBuf; + +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +/// A fresh directory for one test. +fn tmp(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("pdfcraft-find-text-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn ok(a: &mut Automation, tool: &str, args: Value) -> Value { + match a.call(tool, &args) { + Ok(mut c) => match c.remove(0) { + Content::Json(v) => v, + other => panic!("{tool}: expected JSON, got {other:?}"), + }, + Err(e) => panic!("{tool} {args}: {e}"), + } +} + +/// One page: `ofce` in a Helvetica whose code 128 is named `/fi`, and `exam-` at the end of +/// one line with `ple` starting the next. +fn find_pdf() -> Vec { + let body = "BT /F2 14 Tf 90 700 Td (of\\200ce) Tj ET\nBT /F1 14 Tf 90 650 Td (exam-) Tj 0 -16 Td (ple) Tj ET\n"; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>".to_string(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding << /Type /Encoding /BaseEncoding /WinAnsiEncoding /Differences [128 /fi] >> >>" + .to_string(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// "office" finds the ligature, and "example" both halves of the divided word, highlighted line by +/// line; the hyphenated form is still found as written. +#[test] +fn text_find_matches_ligatures_and_words_divided_at_a_line_end() { + let dir = tmp("forms"); + std::fs::write(dir.join("find.pdf"), find_pdf()).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "find.pdf" }))["doc"].as_u64().unwrap(); + let find = |a: &mut Automation, q: &str| ok(a, "text_find", json!({ "doc": doc, "query": q })); + + let office = find(&mut a, "office"); + assert_eq!(office["count"], 1, "{office}"); + let rects = office["matches"][0]["rects"].as_array().unwrap(); + assert_eq!(rects.len(), 1); + assert!((rects[0][0].as_f64().unwrap() - 90.0).abs() < 0.5, "from the left edge of the o: {rects:?}"); + + let example = find(&mut a, "example"); + assert_eq!(example["count"], 1, "{example}"); + assert_eq!(example["matches"][0]["text"], "exam-\nple"); + let rects = example["matches"][0]["rects"].as_array().unwrap(); + assert_eq!(rects.len(), 2, "one highlight on each line: {rects:?}"); + let top = |k: usize| rects[k][1].as_f64().unwrap(); + assert!(top(1) > top(0) + 10.0, "the second on the next line down: {rects:?}"); + + for q in ["exam-ple", "Exam- ple", "exam-"] { + assert_eq!(find(&mut a, q)["count"], 1, "{q}"); + } + assert_eq!(find(&mut a, "examples")["count"], 0); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/automation/tests/it/list_selection.rs b/crates/automation/tests/it/list_selection.rs new file mode 100644 index 0000000000..2c2dc041ef --- /dev/null +++ b/crates/automation/tests/it/list_selection.rs @@ -0,0 +1,81 @@ +//! Which options a list box has selected, through the tool table (ISO 32000-2 Table 234, `/I`). + +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +fn tool(a: &mut Automation, name: &str, args: Value) -> Value { + match a.call(name, &args).unwrap().remove(0) { + Content::Json(value) => value, + other => panic!("expected JSON: {other:?}"), + } +} + +/// A PDF of the given objects (numbered from 1). +fn pdf_of(objs: &[&str]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn open(test: &str) -> (Automation, u64, std::path::PathBuf) { + let dir = std::env::temp_dir().join(format!("pdfcraft-list-selection-{test}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let bytes = pdf_of(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R] /NeedAppearances true /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (D_dup) /Opt [[(1) (One-A)] [(1) (One-B)] [(2) (Two)]] /V (1) /I [1] /Rect [50 250 200 320] /P 3 0 R /F 4 >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (H_twovals) /Opt [[(TW) (Taiwan)] [(JP) (Japan)] [(US) (United States)]] /V [(TW) (JP)] /Rect [50 150 200 220] /P 3 0 R /F 4 >>", + ]); + std::fs::write(dir.join("choice.pdf"), bytes).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let id = tool(&mut a, "doc_open", json!({ "path": "choice.pdf" }))["doc"].as_u64().unwrap(); + (a, id, dir) +} + +fn field(a: &mut Automation, doc: u64, name: &str) -> Value { + let all = tool(a, "form_fields", json!({ "doc": doc })); + all["fields"].as_array().unwrap().iter().find(|f| f["name"] == name).unwrap().clone() +} + +/// Two options share the export value `1`: `selected` says which one `/I` picks, and filling the +/// field with an option's label picks that option. +#[test] +fn form_fields_reports_which_of_two_options_with_one_export_value_is_selected() { + let (mut a, doc, dir) = open("dup"); + let d = field(&mut a, doc, "D_dup"); + assert_eq!(d["value"], "1"); + assert_eq!(d["selected"], json!([2]), "One-B, the second option: {d}"); + tool(&mut a, "form_fill", json!({ "doc": doc, "values": { "D_dup": "One-A" } })); + let d = field(&mut a, doc, "D_dup"); + assert_eq!((d["value"].clone(), d["selected"].clone()), (json!("1"), json!([1])), "{d}"); + tool(&mut a, "form_fill", json!({ "doc": doc, "values": { "D_dup": "One-B" } })); + let d = field(&mut a, doc, "D_dup"); + assert_eq!(d["selected"], json!([2])); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A list box without MultiSelect given `/V [(TW) (JP)]` reports Taiwan alone (ISO 32000-2 +/// Table 233: "at most one item shall be selected"), and filling it writes one value. +#[test] +fn form_fields_reports_one_value_for_a_single_select_list_given_two() { + let (mut a, doc, dir) = open("twovals"); + let h = field(&mut a, doc, "H_twovals"); + assert_eq!((h["value"].clone(), h["selected"].clone()), (json!("TW"), json!([1])), "{h}"); + tool(&mut a, "form_fill", json!({ "doc": doc, "values": { "H_twovals": "United States" } })); + let h = field(&mut a, doc, "H_twovals"); + assert_eq!((h["value"].clone(), h["selected"].clone()), (json!("US"), json!([3])), "{h}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index f8e3f15f6d..77c3d3eff3 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -9,13 +9,19 @@ mod batch; mod batch_failures; mod batch_secrets; mod button_caption; +mod document_actions; +mod find_text; mod form_bulk; mod hostile_args; +mod list_selection; mod model_3d; mod model_render; +mod office; mod optimize_content_boundaries; +mod portfolio_info; mod redact_after_stray_q_form; mod redact_text_after_q; mod render_warnings; mod text_copy; +mod timesheet_compat; mod unreadable_objects; diff --git a/crates/automation/tests/it/office.rs b/crates/automation/tests/it/office.rs new file mode 100644 index 0000000000..e406b19ad8 --- /dev/null +++ b/crates/automation/tests/it/office.rs @@ -0,0 +1,277 @@ +//! Office documents converted by a real LibreOffice through the tools that take them +//! (`doc_create_multiple`, `doc_combine`), with the converted text checked. Every test here +//! skips when LibreOffice isn't installed, which is the case on CI; run them on a machine that +//! has it. The Word, Excel and PowerPoint files are written here, as the smallest packages +//! LibreOffice reads, so no binary fixtures are committed. + +use std::path::Path; + +use pdfcraft_automation::{Automation, Content, ToolError}; +use serde_json::{Value, json}; + +fn auto(dir: &Path) -> Automation { + Automation::new().with_root(dir).unwrap().with_clock(|| 1_700_000_000) +} + +fn ok(a: &mut Automation, tool: &str, args: Value) -> Value { + match a.call(tool, &args) { + Ok(mut c) => match c.remove(0) { + Content::Json(v) => v, + other => panic!("{tool}: expected JSON, got {other:?}"), + }, + Err(e) => panic!("{tool} {args}: {e}"), + } +} + +/// Each page's text, with runs of whitespace made single spaces. +fn page_text(a: &mut Automation, doc: u64) -> Vec { + ok(a, "text_extract", json!({ "doc": doc }))["pages"] + .as_array() + .unwrap() + .iter() + .map(|p| p["text"].as_str().unwrap().split_whitespace().collect::>().join(" ")) + .collect() +} + +/// A ZIP of `entries`, stored without compression (Office packages may be). +fn zip(entries: &[(&str, &str)]) -> Vec { + fn crc32(data: &[u8]) -> u32 { + let mut crc = !0u32; + for &b in data { + crc ^= u32::from(b); + for _ in 0..8 { + crc = if crc & 1 == 1 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 }; + } + } + !crc + } + let (mut out, mut central) = (Vec::new(), Vec::new()); + for (name, data) in entries { + let (crc, size, offset) = (crc32(data.as_bytes()), data.len() as u32, out.len() as u32); + // version 2.0, no flags, stored, 1980-01-01 00:00 + let common = [&20u16.to_le_bytes()[..], &0u16.to_le_bytes(), &0u16.to_le_bytes(), &0u16.to_le_bytes(), &0x21u16.to_le_bytes()].concat(); + let sizes = [crc.to_le_bytes(), size.to_le_bytes(), size.to_le_bytes()].concat(); + out.extend_from_slice(&0x0403_4b50u32.to_le_bytes()); + out.extend_from_slice(&common); + out.extend_from_slice(&sizes); + out.extend_from_slice(&(name.len() as u16).to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(name.as_bytes()); + out.extend_from_slice(data.as_bytes()); + central.extend_from_slice(&0x0201_4b50u32.to_le_bytes()); + central.extend_from_slice(&20u16.to_le_bytes()); + central.extend_from_slice(&common); + central.extend_from_slice(&sizes); + central.extend_from_slice(&(name.len() as u16).to_le_bytes()); + // extra, comment, disk, internal and external attributes + central.extend_from_slice(&[0u8; 12]); + central.extend_from_slice(&offset.to_le_bytes()); + central.extend_from_slice(name.as_bytes()); + } + let (at, count) = (out.len() as u32, entries.len() as u16); + out.extend_from_slice(¢ral); + out.extend_from_slice(&0x0605_4b50u32.to_le_bytes()); + out.extend_from_slice(&[0u8; 4]); + out.extend_from_slice(&count.to_le_bytes()); + out.extend_from_slice(&count.to_le_bytes()); + out.extend_from_slice(&(central.len() as u32).to_le_bytes()); + out.extend_from_slice(&at.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out +} + +const XML: &str = r#""#; +const PACKAGE_RELS: &str = "http://schemas.openxmlformats.org/package/2006/relationships"; +const DOC_RELS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + +/// `[Content_Types].xml` with one override per (part, type). +fn content_types(parts: &[(&str, &str)]) -> String { + let overrides: String = parts.iter().map(|(part, kind)| format!(r#""#)).collect(); + format!( + r#"{XML}{overrides}"# + ) +} + +/// A relationships part with one relationship per (id, type under officeDocument, target). +fn rels(list: &[(&str, &str, &str)]) -> String { + let items: String = + list.iter().map(|(id, kind, target)| format!(r#""#)).collect(); + format!(r#"{XML}{items}"#) +} + +/// A Word document of one paragraph. +fn docx(text: &str) -> Vec { + let document = format!( + r#"{XML}{text}"# + ); + zip(&[ + ( + "[Content_Types].xml", + &content_types(&[("word/document.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml")]), + ), + ("_rels/.rels", &rels(&[("rId1", "officeDocument", "word/document.xml")])), + ("word/document.xml", &document), + ]) +} + +/// An Excel workbook whose one sheet holds `text` in A1 and a formula, `=6*7`, in A2. +fn xlsx(text: &str) -> Vec { + let workbook = format!( + r#"{XML}"# + ); + let sheet = format!( + r#"{XML}{text}6*7"# + ); + zip(&[ + ( + "[Content_Types].xml", + &content_types(&[ + ("xl/workbook.xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"), + ("xl/worksheets/sheet1.xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"), + ]), + ), + ("_rels/.rels", &rels(&[("rId1", "officeDocument", "xl/workbook.xml")])), + ("xl/workbook.xml", &workbook), + ("xl/_rels/workbook.xml.rels", &rels(&[("rId1", "worksheet", "worksheets/sheet1.xml")])), + ("xl/worksheets/sheet1.xml", &sheet), + ]) +} + +/// A PowerPoint presentation of one slide with one text box. +fn pptx(text: &str) -> Vec { + const P: &str = + r#"xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main""#; + let presentation = format!( + r#"{XML}"# + ); + let tree = r#""#; + let master = format!( + r#"{XML}{tree}"# + ); + let layout = format!(r#"{XML}{tree}"#); + let slide = format!( + r#"{XML}{tree}{text}"# + ); + let theme = format!( + r#"{XML}"# + ); + const PML: &str = "application/vnd.openxmlformats-officedocument.presentationml"; + let types = content_types(&[ + ("ppt/presentation.xml", &format!("{PML}.presentation.main+xml")), + ("ppt/slideMasters/slideMaster1.xml", &format!("{PML}.slideMaster+xml")), + ("ppt/slideLayouts/slideLayout1.xml", &format!("{PML}.slideLayout+xml")), + ("ppt/slides/slide1.xml", &format!("{PML}.slide+xml")), + ("ppt/theme/theme1.xml", "application/vnd.openxmlformats-officedocument.theme+xml"), + ]); + zip(&[ + ("[Content_Types].xml", &types), + ("_rels/.rels", &rels(&[("rId1", "officeDocument", "ppt/presentation.xml")])), + ("ppt/presentation.xml", &presentation), + ( + "ppt/_rels/presentation.xml.rels", + &rels(&[ + ("rId1", "slideMaster", "slideMasters/slideMaster1.xml"), + ("rId2", "slide", "slides/slide1.xml"), + ("rId3", "theme", "theme/theme1.xml"), + ]), + ), + ("ppt/slideMasters/slideMaster1.xml", &master), + ( + "ppt/slideMasters/_rels/slideMaster1.xml.rels", + &rels(&[("rId1", "slideLayout", "../slideLayouts/slideLayout1.xml"), ("rId2", "theme", "../theme/theme1.xml")]), + ), + ("ppt/slideLayouts/slideLayout1.xml", &layout), + ("ppt/slideLayouts/_rels/slideLayout1.xml.rels", &rels(&[("rId1", "slideMaster", "../slideMasters/slideMaster1.xml")])), + ("ppt/slides/slide1.xml", &slide), + ("ppt/slides/_rels/slide1.xml.rels", &rels(&[("rId1", "slideLayout", "../slideLayouts/slideLayout1.xml")])), + ("ppt/theme/theme1.xml", &theme), + ]) +} + +/// An RTF document of one paragraph. +fn rtf(text: &str) -> Vec { + format!("{{\\rtf1\\ansi{{\\fonttbl\\f0\\fswiss Helvetica;}}\\f0\\pard {text}\\par}}").into_bytes() +} + +/// A folder with one Word, Excel, PowerPoint and RTF file, each carrying its own word, and the +/// tools rooted there; `None` when LibreOffice isn't installed. +fn office_files() -> Option<(tempfile::TempDir, Automation)> { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("letter.docx"), docx("Quokka letter")).unwrap(); + std::fs::write(dir.path().join("budget.xlsx"), xlsx("Narwhal budget")).unwrap(); + std::fs::write(dir.path().join("deck.pptx"), pptx("Axolotl deck")).unwrap(); + std::fs::write(dir.path().join("notes.rtf"), rtf("Pangolin notes")).unwrap(); + let mut a = auto(dir.path()); + match a.call("doc_create_multiple", &json!({ "paths": ["notes.rtf"] })) { + Err(ToolError::Failed(e)) if e.contains("needs LibreOffice") => { + eprintln!("skipped: LibreOffice isn't installed"); + None + } + _ => Some((dir, a)), + } +} + +/// Each Office format converts to a PDF of its own that holds the document's text (and, for +/// the spreadsheet, the formula's value). +#[test] +fn office_documents_convert_separately_with_their_text() { + let Some((dir, mut a)) = office_files() else { return }; + let paths = ["letter.docx", "budget.xlsx", "deck.pptx", "notes.rtf"]; + let made = ok(&mut a, "doc_create_multiple", json!({ "paths": paths, "mode": "separate", "out_dir": "out" })); + let files = made["files"].as_array().unwrap(); + assert_eq!(files.len(), 4, "{made}"); + let expected = [ + ("letter.pdf", &["Quokka letter"][..]), + ("budget.pdf", &["Narwhal budget", "42"]), + ("deck.pdf", &["Axolotl deck"]), + ("notes.pdf", &["Pangolin notes"]), + ]; + for (file, (name, words)) in files.iter().zip(expected) { + let output = file["output"].as_str().unwrap_or_else(|| panic!("{file}")); + // The tool reports the folder in its canonical form (`\\?\C:\…` on Windows). + assert_eq!(Path::new(output).file_name(), Some(std::ffi::OsStr::new(name)), "{output}"); + assert!(dir.path().join("out").join(name).is_file(), "{name}"); + let doc = ok(&mut a, "doc_open", json!({ "path": output }))["doc"].as_u64().unwrap(); + let text = page_text(&mut a, doc).join("\n"); + for word in words { + assert!(text.contains(word), "{output}: {word:?} not in {text:?}"); + } + } +} + +/// Office documents combine, in the order given, with a PDF made here, one bookmark each. +#[test] +fn office_documents_combine_in_order_with_a_bookmark_each() { + let Some((dir, mut a)) = office_files() else { return }; + let made = ok( + &mut a, + "doc_create_multiple", + json!({ "paths": ["deck.pptx", "letter.docx", "budget.xlsx", "notes.rtf"], "out": "all.pdf", "open": true }), + ); + let doc = made["document"]["doc"].as_u64().unwrap(); + let pages = page_text(&mut a, doc); + assert_eq!(pages.len(), 4, "{pages:?}"); + for (page, word) in pages.iter().zip(["Axolotl deck", "Quokka letter", "Narwhal budget", "Pangolin notes"]) { + assert!(page.contains(word), "{word:?} not in {page:?}"); + } + let titles: Vec = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"] + .as_array() + .unwrap() + .iter() + .map(|b| b["title"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(titles, ["deck", "letter", "budget", "notes"]); + assert!(dir.path().join("all.pdf").is_file()); + + // doc_combine takes them beside a PDF, with a page range for one of them. + std::fs::write(dir.path().join("cover.txt"), "Cover page").unwrap(); + ok(&mut a, "doc_create_multiple", json!({ "paths": ["cover.txt"], "out": "cover.pdf" })); + assert!(dir.path().join("cover.pdf").is_file()); + let combined = ok(&mut a, "doc_combine", json!({ "paths": ["cover.pdf", "notes.rtf", "letter.docx"], "pages": [null, "1", null], "open": true })); + let doc = combined["document"]["doc"].as_u64().unwrap(); + let pages = page_text(&mut a, doc); + assert_eq!(pages.len(), 3, "{pages:?}"); + for (page, word) in pages.iter().zip(["Cover page", "Pangolin notes", "Quokka letter"]) { + assert!(page.contains(word), "{word:?} not in {page:?}"); + } +} diff --git a/crates/automation/tests/it/portfolio_info.rs b/crates/automation/tests/it/portfolio_info.rs new file mode 100644 index 0000000000..750a3e8840 --- /dev/null +++ b/crates/automation/tests/it/portfolio_info.rs @@ -0,0 +1,54 @@ +//! `doc_info` reports a PDF portfolio (`/Collection`, ISO 32000-2 §7.11.6): the attachment it +//! presents first and how its file list first shows. + +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +/// Call tool `name` and return its JSON result. +fn tool(a: &mut Automation, name: &str, args: Value) -> Value { + match a.call(name, &args).unwrap().remove(0) { + Content::Json(value) => value, + other => panic!("expected JSON: {other:?}"), + } +} + +/// A one-page portfolio of `cover.txt` and `form.pdf` with `collection` as its `/Collection` +/// entries, or none at all. +fn portfolio(collection: Option<&str>) -> Vec { + let collection = collection.map(|c| format!("/Collection << /Type /Collection {c} >>")).unwrap_or_default(); + format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R {collection} /Names << /EmbeddedFiles << /Names [(cover.txt) 5 0 R (form.pdf) 7 0 R] >> >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj +4 0 obj << /Type /EmbeddedFile /Length 5 >> stream +hello +endstream endobj +5 0 obj << /Type /Filespec /F (cover.txt) /UF (cover.txt) /EF << /F 4 0 R >> >> endobj +6 0 obj << /Type /EmbeddedFile /Length 8 >> stream +%PDF-1.7 +endstream endobj +7 0 obj << /Type /Filespec /F (form.pdf) /UF (form.pdf) /EF << /F 6 0 R >> >> endobj +trailer << /Root 1 0 R >> +%%EOF" + ) + .into_bytes() +} + +/// `doc_info` names a portfolio's initial document and view, and reports none for a plain file. +#[test] +fn doc_info_names_the_document_a_portfolio_presents_first() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("tile.pdf"), portfolio(Some("/D (form.pdf) /View /T"))).unwrap(); + std::fs::write(dir.path().join("cover.pdf"), portfolio(Some(""))).unwrap(); + std::fs::write(dir.path().join("plain.pdf"), portfolio(None)).unwrap(); + let mut auto = Automation::new().with_root(dir.path()).unwrap(); + let mut info = |path: &str| { + let doc = tool(&mut auto, "doc_open", json!({ "path": path }))["doc"].as_u64().unwrap(); + tool(&mut auto, "doc_info", json!({ "doc": doc }))["portfolio"].clone() + }; + assert_eq!(info("tile.pdf"), json!({ "initial": "form.pdf", "view": "tile" })); + // Without `/D` the cover sheet is the initial document. + assert_eq!(info("cover.pdf"), json!({ "initial": null, "view": "details" })); + assert_eq!(info("plain.pdf"), Value::Null); +} diff --git a/crates/automation/tests/it/render_warnings.rs b/crates/automation/tests/it/render_warnings.rs index ff50c11e16..aba200d3d8 100644 --- a/crates/automation/tests/it/render_warnings.rs +++ b/crates/automation/tests/it/render_warnings.rs @@ -96,6 +96,72 @@ fn a_clean_page_renders_to_the_png_alone() { let _ = std::fs::remove_dir_all(dir); } +/// An image whose Indexed lookup table is too short (ISO 32000-2 §8.6.6.3) is drawn recovered, and +/// `page_render` says so in the words `pdfcraft-cli render` prints. +#[test] +fn page_render_reports_a_recovered_malformed_indexed_image() { + let dir = std::env::temp_dir().join(format!("pdfcraft-render-warnings-indexed-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let content = "q 200 0 0 200 0 0 cm /Im Do Q"; + // hival 7 over DeviceRGB needs 24 bytes; the table holds 6. + let pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /XObject << /Im 5 0 R >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 1 /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceRGB 7 ] /Filter /ASCIIHexDecode /Length 5 >> stream\n0001>\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len() + ); + std::fs::write(dir.join("indexed.pdf"), pdf).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = open(&mut a, "indexed.pdf"); + let out = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap(); + let Some(Content::Json(report)) = out.get(1) else { panic!("expected the warnings as JSON: {out:?}") }; + assert_eq!(report["warnings"], json!(["an image with a malformed Indexed colour table may be drawn wrongly or not at all"])); + let _ = std::fs::remove_dir_all(dir); +} + +/// A picture whose JBIG2 soft mask is past one of the decoder's limits isn't drawn (#6722), and +/// `page_render` says why the page is incomplete, in the words `pdfcraft-cli render` prints. +#[test] +fn page_render_says_a_picture_was_left_out_for_its_jbig2_mask() { + let dir = std::env::temp_dir().join(format!("pdfcraft-render-warnings-jbig2-mask-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + // One JBIG2 segment (ISO 14492 §7.4.8): the information of a page 65,536 columns wide, a + // column past the decoder's bound on a side, as the mask's dictionary says too. + let mut mask = vec![0, 0, 0, 0, 48, 0, 1, 0, 0, 0, 19]; + mask.extend_from_slice(&65536u32.to_be_bytes()); + mask.extend_from_slice(&2u32.to_be_bytes()); + mask.extend_from_slice(&[0; 11]); // resolution, flags, striping + let content = "q 200 0 0 200 0 0 cm /Im Do Q"; + let mut pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /XObject << /Im 5 0 R >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceGray /SMask 6 0 R /Length 1 >> stream\n\0\nendstream endobj\n\ + 6 0 obj << /Type /XObject /Subtype /Image /Width 65536 /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /Length {} >> stream\n", + content.len(), + mask.len() + ) + .into_bytes(); + pdf.extend_from_slice(&mask); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF"); + std::fs::write(dir.join("mask.pdf"), pdf).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = open(&mut a, "mask.pdf"); + let out = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap(); + let Some(Content::Json(report)) = out.get(1) else { panic!("expected the warnings as JSON: {out:?}") }; + assert_eq!( + report["warnings"], + json!(["content past the page's safety budget, or a picture whose JBIG2 mask is past a decoder limit, was skipped"]) + ); + let _ = std::fs::remove_dir_all(dir); +} + /// Over MCP the warnings are a text block after the image and the call's `structuredContent`. #[cfg(feature = "mcp")] #[test] diff --git a/crates/automation/tests/it/timesheet_compat.rs b/crates/automation/tests/it/timesheet_compat.rs new file mode 100644 index 0000000000..f6910543b6 --- /dev/null +++ b/crates/automation/tests/it/timesheet_compat.rs @@ -0,0 +1,243 @@ +//! Original synthetic workflow: no private timesheet, employee, PIN or signature assets. +use pdfcraft_automation::{Automation, Content}; +use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_full}; +use pdfcraft_engine::{Edit, NewField, Session}; +use serde_json::{Value, json}; +use std::sync::Arc; + +/// Dispatch a synthetic automation step and fail with the returned tool error on refusal. +fn call(a: &mut Automation, name: &str, args: Value) -> Value { + match a.call(name, &args).unwrap().remove(0) { + Content::Json(v) => v, + _ => panic!("expected JSON"), + } +} +/// Construct a synthetic form and transparent Form XObject without private files or font assets. +fn fixture() -> Vec { + let mut session = Session::new(); + let bytes = session.create_blank(600.0, 800.0, 1).unwrap(); + let id = session.open("synthetic.pdf", None, bytes, None).unwrap(); + for (i, name) in ["input", "double", "seen", "choice", "go", "source", "target"].into_iter().enumerate() { + session + .apply( + id, + Edit::AddField { + page: 0, + rect: [40.0, 700.0 - i as f64 * 45.0, 200.0, 730.0 - i as f64 * 45.0], + kind: if i >= 4 { NewField::Button { caption: name.into() } } else { NewField::Text { multiline: false } }, + name: Some(name.into()), + }, + ) + .unwrap(); + } + let mut doc = Document::open(session.save_bytes(id).unwrap()).unwrap(); + let source = session.get(id).unwrap().form.iter().find(|f| f.name == "source").unwrap().obj; + let mut gs = Dict::new(); + gs.set(b"ca".to_vec(), Object::Real(0.5)); + let mut states = Dict::new(); + states.set(b"Half".to_vec(), Object::Dict(gs)); + let mut resources = Dict::new(); + resources.set(b"ExtGState".to_vec(), Object::Dict(states)); + let mut dict = Dict::new(); + dict.set(b"Type".to_vec(), Object::name("XObject")); + dict.set(b"Subtype".to_vec(), Object::name("Form")); + dict.set(b"BBox".to_vec(), Object::Array([0, 0, 60, 30].map(Object::Int).to_vec())); + dict.set(b"Resources".to_vec(), Object::Dict(resources)); + let icon = doc.add(Object::Stream(Stream::from_raw(dict, b"q /Half gs 1 0 0 rg 0 0 60 30 re f Q".to_vec()))); + let mut mk = Dict::new(); + mk.set(b"I".to_vec(), Object::Ref(icon)); + mk.set(b"TP".to_vec(), Object::Int(1)); + doc.update_dict(source, |d| d.set(b"MK".to_vec(), Object::Dict(mk))).unwrap(); + write_full(&doc, &SaveOptions::default()).unwrap() +} +const HELPERS: &str = r#" +var initialized=1, buffer='', savedIcon=null; +function openPad(){var b=getPageBox('Crop',0);var f=getField('go');f.rect=[b[0]+40,b[1]-400,b[0]+200,b[1]-450];f.textSize=16;f.fillColor=['RGB',0.9,0.8,0.7];f.strokeColor=['G',0.4];f.lineWidth=2;f.borderStyle='dashed';f.textColor=color.blue;f.userName='Synthetic keypad';f.display=display.noPrint;} +function digit(d){buffer+=d;getField('go').buttonSetCaption(buffer);if(buffer==='1234'){getField('target').buttonSetIcon(savedIcon);getField('go').display=display.hidden;}} +function selectTime(){var v=app.popUpMenuEx({cName:'Unavailable',cReturn:'bad',bEnabled:false},{cName:'Times',oSubMenu:[{cName:'Seven',cReturn:'07:00',bMarked:true},{cName:'Manual',cReturn:'manual'}]});if(v==='manual')v=app.response({cQuestion:'Time',cDefault:'07:00'});if(v!==null)getField('choice').value=v;} +"#; + +#[test] +/// Exercise retained keypad state, geometry, synchronous calculation and transparent icons through save/reopen and Undo/Redo. +fn timesheet_keypad_geometry_icons_calculation_and_persistence() { + let dir = std::env::temp_dir().join(format!("pdfcraft-timesheet-compat-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("synthetic.pdf"), fixture()).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let id = call(&mut a, "doc_open", json!({"path":"synthetic.pdf"}))["doc"].as_u64().unwrap(); + call(&mut a, "form_set_script", json!({"doc":id,"field":"double","event":"calculate","script":"event.value=Number(getField('input').value)*2;"})); + call(&mut a, "js_set_document_script", json!({"doc":id,"name":"helpers","script":HELPERS})); + let out = call( + &mut a, + "js_run", + json!({"doc":id,"script":"getField('input').value=3;calculateNow();getField('seen').value=getField('double').value;getField('seen').value;"}), + ); + assert_eq!(out["error"], Value::Null); + assert_eq!(out["result"], "6"); + let out = call(&mut a, "js_run", json!({"doc":id,"script":"openPad();savedIcon=getField('source').buttonGetIcon();initialized;"})); + assert_eq!(out["error"], Value::Null); + assert_eq!(out["result"], "1"); + let fields = call(&mut a, "form_fields", json!({"doc":id})); + let go = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "go").unwrap(); + assert_eq!(go["tooltip"], "Synthetic keypad"); + let model = a.session().get(pdfcraft_engine::DocId(id)).unwrap().form.iter().find(|f| f.name == "go").unwrap(); + assert_eq!(model.widgets[0].rect, [40.0, 350.0, 200.0, 400.0]); + assert!(model.da.contains("16 Tf"), "{}", model.da); + for (key, expected) in [("1", "1"), ("2", "12"), ("3", "123")] { + let out = call(&mut a, "js_run", json!({"doc":id,"field":"go","script":format!("digit('{key}');buffer;")})); + assert_eq!(out["error"], Value::Null); + assert_eq!(out["result"], expected); + } + let (_, _, before) = a.render_pixels(id, 1, 1.0).unwrap(); + let out = call(&mut a, "js_run", json!({"doc":id,"field":"go","script":"digit('4');buffer;"})); + assert_eq!(out["error"], Value::Null); + assert_eq!(out["result"], "1234"); + let (_, _, after) = a.render_pixels(id, 1, 1.0).unwrap(); + assert_ne!(before, after); + let blended = after + .as_chunks::<4>() + .0 + .iter() + .filter(|p| p[0] > p[1].saturating_add(50) && (50..180).contains(&p[1]) && (p[1] as i16 - p[2] as i16).abs() <= 2) + .count(); + assert!(blended > 500, "half-opacity red icon must blend across its area with the button's grey background, got {blended} pixels"); + call(&mut a, "edit_undo", json!({"doc":id})); + assert_eq!(a.render_pixels(id, 1, 1.0).unwrap().2, before); + call(&mut a, "edit_redo", json!({"doc":id})); + assert_eq!(a.render_pixels(id, 1, 1.0).unwrap().2, after); + let rejected = call(&mut a, "js_run", json!({"doc":id,"script":"getField('target').buttonSetIcon({});"})); + assert!(rejected["error"].as_str().unwrap().contains("icon")); + call(&mut a, "doc_save", json!({"doc":id,"path":"saved.pdf"})); + let saved = Document::open(Arc::new(std::fs::read(dir.join("saved.pdf")).unwrap())).unwrap(); + let root = saved.get(saved.root().unwrap()); + let af = saved.resolve(root.as_dict().unwrap().get(b"AcroForm").unwrap()); + let fields = saved.resolve(af.as_dict().unwrap().get(b"Fields").unwrap()); + let target = fields + .as_array() + .unwrap() + .iter() + .map(|f| saved.resolve(f)) + .find(|f| f.as_dict().unwrap().get(b"T").and_then(Object::as_string).is_some_and(|s| s.to_text() == "target")) + .unwrap(); + let mk = saved.resolve(target.as_dict().unwrap().get(b"MK").unwrap()); + let icon = saved.resolve(mk.as_dict().unwrap().get(b"I").unwrap()); + let Object::Stream(icon) = &*icon else { panic!("icon stream") }; + assert!(String::from_utf8_lossy(&icon.decoded().unwrap()).contains("/Half gs")); + let reopened = call(&mut a, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(a.render_pixels(reopened, 1, 1.0).unwrap().2, after); + let reset = call(&mut a, "js_run", json!({"doc":reopened,"script":"buffer;"})); + assert_eq!(reset["result"], ""); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +/// Resume one invocation across popup/text prompts and reject disabled or invented choices. +fn popup_and_manual_response_resume_the_same_script_and_validate_choices() { + let dir = std::env::temp_dir().join(format!("pdfcraft-timesheet-menu-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("synthetic.pdf"), fixture()).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let id = call(&mut a, "doc_open", json!({"path":"synthetic.pdf"}))["doc"].as_u64().unwrap(); + call(&mut a, "js_set_document_script", json!({"doc":id,"name":"helpers","script":HELPERS})); + let out = call(&mut a, "js_run", json!({"doc":id,"script":"selectTime();getField('choice').value;","answers":["manual","07:45"]})); + assert_eq!(out["error"], Value::Null); + assert_eq!(out["result"], "07:45"); + let cancel = call(&mut a, "js_run", json!({"doc":id,"script":"selectTime();getField('choice').value;","answers":[null]})); + assert_eq!(cancel["result"], "07:45"); + let invalid = call(&mut a, "js_run", json!({"doc":id,"script":"selectTime();","answers":["bad"]})); + assert!(invalid["error"].as_str().unwrap().contains("invalid popup")); + let alert = call(&mut a, "js_run", json!({"doc":id,"script":"app.alert({cMsg:'Continue?',nType:2});","answers":["4"]})); + assert_eq!(alert["result"], "4"); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// The advertised schema must bound prompt strings before clients dispatch them. +#[test] +fn prompt_answer_schema_advertises_the_length_budget() { + let tool = pdfcraft_automation::tools().into_iter().find(|t| t.name == "js_run").unwrap(); + assert_eq!(tool.input_schema["properties"]["answers"]["items"]["maxLength"], 4096); +} + +#[test] +fn js_run_exact_field_ids_are_discoverable_and_strictly_validated() { + let dir = std::env::temp_dir().join(format!("pdfcraft-field-identity-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("synthetic.pdf"), fixture()).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let id = call(&mut a, "doc_open", json!({"path":"synthetic.pdf"}))["doc"].as_u64().unwrap(); + let fields = call(&mut a, "form_fields", json!({"doc":id})); + let entries = fields["fields"].as_array().unwrap(); + let go = entries.iter().find(|f| f["name"] == "go").unwrap()["field_id"].as_u64().unwrap(); + let other = entries.iter().find(|f| f["name"] == "input").unwrap()["field_id"].as_u64().unwrap(); + let out = call( + &mut a, + "js_run", + json!({"doc":id,"field":"go","field_id":go,"script":"event.target.buttonSetCaption(app.response({cQuestion:'Caption'}));event.target.name;","answers":["exact"]}), + ); + assert_eq!(out["error"], Value::Null); + assert_eq!(out["result"], "go"); + for invalid in [json!(-1), json!(1.5), json!("bad"), json!(281474976710656u64), json!(other)] { + assert!(a.call("js_run", &json!({"doc":id,"field":"go","field_id":invalid,"script":"getField('input').value='bad';"})).is_err()); + } + assert!(a.call("js_run", &json!({"doc":id,"field_id":go,"script":"true;"})).is_err()); + let schema = pdfcraft_automation::tools().into_iter().find(|t| t.name == "js_run").unwrap(); + assert_eq!(schema.input_schema["properties"]["field_id"]["maximum"], 281474976710655u64); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// The headless image picker counterpart accepts exact duplicate identities and keeps legacy lookup. +#[test] +fn exact_image_field_tool_targets_duplicates_and_rejects_invalid_ids() { + let dir = std::env::temp_dir().join(format!("pdfcraft-exact-image-tool-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut session = Session::new(); + let bytes = session.create_blank(300.0, 300.0, 1).unwrap(); + let id = session.open("synthetic.pdf", None, bytes, None).unwrap(); + for (name, x) in [("first", 20.0), ("second", 160.0)] { + session.apply(id, Edit::AddField { page: 0, rect: [x, 20.0, x + 100.0, 80.0], kind: NewField::Image, name: Some(name.into()) }).unwrap(); + } + let mut cos = Document::open(session.save_bytes(id).unwrap()).unwrap(); + for field in session.get(id).unwrap().form.iter() { + cos.update_dict(field.obj, |d| d.set(b"T".to_vec(), pdfcraft_cos::PdfString::text("same"))).unwrap(); + } + std::fs::write(dir.join("synthetic.pdf"), write_full(&cos, &Default::default()).unwrap()).unwrap(); + std::fs::write(dir.join("picture.gif"), pdfcraft_xfa::fixtures::TINY_GIF).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let id = call(&mut a, "doc_open", json!({"path":"synthetic.pdf"}))["doc"].as_u64().unwrap(); + let fields = call(&mut a, "form_fields", json!({"doc":id})); + let second = fields["fields"][1]["field_id"].as_u64().unwrap(); + call(&mut a, "form_set_image", json!({"doc":id,"field":"same","field_id":second,"path":"picture.gif"})); + let icons = |a: &mut Automation| { + call(a, "doc_save", json!({"doc":id,"path":"saved.pdf"})); + let cos = Document::open(Arc::new(std::fs::read(dir.join("saved.pdf")).unwrap())).unwrap(); + let root = cos.get(cos.root().unwrap()); + let form = cos.resolve(root.as_dict().unwrap().get(b"AcroForm").unwrap()); + form.as_dict() + .unwrap() + .get(b"Fields") + .unwrap() + .as_array() + .unwrap() + .iter() + .map(|r| { + cos.resolve(r) + .as_dict() + .and_then(|d| d.get(b"MK").map(|v| cos.resolve(v))) + .is_some_and(|m| m.as_dict().is_some_and(|d| d.contains(b"I"))) + }) + .collect::>() + }; + assert_eq!(icons(&mut a), [false, true]); + for invalid in [json!(-1), json!(1.5), json!("bad"), json!(281474976710656u64)] { + assert!(a.call("form_set_image", &json!({"doc":id,"field":"same","field_id":invalid,"path":"picture.gif"})).is_err()); + } + assert!(a.call("form_set_image", &json!({"doc":id,"field":"missing","field_id":second,"path":"picture.gif"})).is_err()); + assert_eq!(icons(&mut a), [false, true]); + call(&mut a, "edit_undo", json!({"doc":id})); + assert_eq!(icons(&mut a), [false, false]); + call(&mut a, "form_set_image", json!({"doc":id,"field":"same","path":"picture.gif"})); + assert_eq!(icons(&mut a), [true, false]); + let tool = pdfcraft_automation::tools().into_iter().find(|t| t.name == "form_set_image").unwrap(); + assert_eq!(tool.input_schema["properties"]["field_id"]["maximum"], 281474976710655u64); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index fb17a903d1..f77c54e1dc 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -58,6 +58,23 @@ impl Op { pub fn last_name(&self) -> Option<&[u8]> { self.operands.last().and_then(Object::as_name) } + + /// Operand `k` counted from the end (0 is the last), as the renderer reads an operator's + /// operands: `(x) /OC /MC0 BDC` marks with `/OC /MC0`, and `(x) /F1 10 Tf` selects `/F1`. + pub fn back(&self, k: usize) -> Option<&Object> { + let i = self.operands.len().checked_sub(k)?.checked_sub(1)?; + self.operands.get(i) + } + + /// [`Op::back`] as a name. + pub fn name_back(&self, k: usize) -> Option<&[u8]> { + self.back(k).and_then(Object::as_name) + } + + /// [`Op::back`] as a number. + pub fn num_back(&self, k: usize) -> Option { + self.back(k).and_then(Object::as_f64) + } } /// A parsed stream. diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs index 9ca12c4536..89f258853d 100644 --- a/crates/content/src/tests.rs +++ b/crates/content/src/tests.rs @@ -10,6 +10,17 @@ thread_local! { pub(crate) static PIECE_VISITS: std::cell::Cell = const { std::cell::Cell::new(0) }; } +#[test] +fn operands_are_counted_from_the_end() { + let op = parse(b"(x) /OC /MC0 BDC").ops.remove(0); + assert_eq!((op.name_back(1), op.name_back(0)), (Some(&b"OC"[..]), Some(&b"MC0"[..]))); + assert_eq!(op.back(2).and_then(Object::as_string).map(|s| s.bytes.clone()), Some(b"x".to_vec())); + assert_eq!((op.back(3), op.back(usize::MAX)), (None, None)); + let op = parse(b"/F1 10 Tf").ops.remove(0); + assert_eq!((op.name_back(1), op.num_back(0), op.num_back(1)), (Some(&b"F1"[..]), Some(10.0), None)); + assert_eq!(Op::new("Q", vec![]).back(0), None); +} + #[test] fn splicing_visits_each_piece_a_few_times() { let n = 5_000; diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index 56286ce4a0..0067304f66 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -246,12 +246,14 @@ pub struct DecodeBudget { bytes: usize, forms: usize, seen: HashSet, + /// Decodes refused for lack of allowance ([`DecodeBudget::refused`]). + refusals: usize, } impl DecodeBudget { /// An allowance of `bytes` decoded bytes and `forms` form entries. pub fn new(bytes: usize, forms: usize) -> Self { - Self { limit: bytes, bytes, forms, seen: HashSet::new() } + Self { limit: bytes, bytes, forms, seen: HashSet::new(), refusals: 0 } } /// The allowance for one page: [`MAX_PAGE_DECODED`] bytes and [`MAX_PAGE_FORMS`] forms. @@ -264,6 +266,22 @@ impl DecodeBudget { self.bytes } + /// How many decodes were refused for lack of allowance: one that came once it was spent, or + /// ran past what was left. A decode that spent exactly what was left wasn't refused, so an + /// allowance used up to the byte has none. + pub fn refused(&self) -> usize { + self.refusals + } + + /// Whether the allowance is spent and `stream`, which has data, would be refused, noting the + /// refusal ([`DecodeBudget::refused`]): a caller asks this before resolving the stream's + /// parameters, work the allowance doesn't count. A stream without data still reads. + pub fn refuses(&mut self, stream: &Stream) -> bool { + let refuses = self.bytes == 0 && !stream.raw.is_empty(); + self.refusals += usize::from(refuses); + refuses + } + /// Whether a decode was refused for lack of allowance or a form entry for lack of visits. pub fn exhausted(&self) -> bool { self.bytes == 0 || self.forms == 0 @@ -283,6 +301,51 @@ impl DecodeBudget { self.charge(stream, max, Stream::decoded_within_counted).map(|(data, _)| data) } + /// Like [`DecodeBudget::decode_within`], with every filter stage held to `max`, as + /// [`Stream::decoded_within`] holds it, and the stages together to `max` for each of them, the + /// stream's `/DecodeParms` given by reference read in `doc` through a view of it that shares + /// its data and copies no more of its dictionary than its filters and parameters + /// ([`DecodeBudget::decode_with_damage`] reads it so). A + /// stream so reads here when it reads without an allowance and the allowance can pay for + /// what its stages decode, the output of one stage on top of another's. A stage past `max` + /// is an error that charges what the stages before it decoded and `max`, the work it took to + /// find out; past the stages' total it charges that total, and past what is left it spends + /// all of it, as [`DecodeBudget::decode_within`] does. + pub fn decode_stages_within(&mut self, doc: &Document, stream: &Stream, max: usize) -> Result, CosError> { + // Before the parameters are resolved, work the allowance doesn't count. + if self.refuses(stream) { + return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())); + } + let stream = doc.decoding_view(stream); + let total = max.saturating_mul(stream.filters().len().max(1)); + let room = total.min(self.bytes); + match stream.decoded_within_each_counted(max, room) { + Ok((data, _, spent)) => { + self.bytes = self.bytes.saturating_sub(spent); + Ok(data) + } + // A stage stopped at `max`, below what was left of the room. + Err((FilterError::LimitExceeded(_), spent)) if room.saturating_sub(spent) > max => { + self.bytes = self.bytes.saturating_sub(spent.saturating_add(max)); + Err(CosError::Filter(FilterError::LimitExceeded(max).to_string())) + } + // At the stages' total, with more left. + Err((FilterError::LimitExceeded(_), _)) if total < self.bytes => { + self.bytes = self.bytes.saturating_sub(total); + Err(CosError::Filter(FilterError::LimitExceeded(total).to_string())) + } + Err((FilterError::LimitExceeded(_), _)) => { + self.bytes = 0; + self.refusals += 1; + Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())) + } + Err((e, spent)) => { + self.bytes = self.bytes.saturating_sub(spent); + Err(CosError::Filter(e.to_string())) + } + } + } + /// [`DecodeBudget::decode_within`] (`usize::MAX` for no bound of its own), with the stream's /// `/DecodeParms` given by reference read in `doc`, also saying whether the stream is damaged: /// `true` when a filter stopped at corrupt data, so the bytes are only what decoded before it. @@ -304,7 +367,7 @@ impl DecodeBudget { /// pays for the bytes decoded before the error, so referencing a stream again and again /// spends the allowance by the work each decode does. fn charge(&mut self, stream: &Stream, max: usize, decode: Decoder) -> Result<(Vec, bool), CosError> { - if self.bytes == 0 && !stream.raw.is_empty() { + if self.refuses(stream) { return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())); } if max < self.bytes { @@ -324,6 +387,7 @@ impl DecodeBudget { } Err((FilterError::LimitExceeded(_), _)) => { self.bytes = 0; + self.refusals += 1; Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())) } Err((e, spent)) => { @@ -1025,4 +1089,99 @@ mod tests { assert!(budget.decode_strict(&Document::new_empty(), &chained).is_ok()); assert_eq!(budget.remaining(), (10 << 20) - 3 * charged); } + + /// `decode_stages_within` holds every stage to the limit, as a decode without an allowance + /// does, and the stages together to the limit for each of them. A stream behind ASCIIHex and + /// Flate whose stages each stay within it reads, the hex stage's output charged on top; one + /// whose Flate stage would make more stops there, charged what the hex stage made and the + /// limit, the rest of the allowance left; a Flate stage making hex text past the limit leaves + /// the stream unread though the total has room, as `Stream::decoded_within` leaves it; and a + /// stream past what is left spends all of it. + #[test] + fn each_stage_is_held_to_the_limit() { + let chained = |data: &[u8], filters: [&str; 2]| { + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(filters.into_iter().map(Object::name).collect())); + Stream::from_raw(dict, data.to_vec()) + }; + let hexed = |data: &[u8]| { + let flate = Stream::flate(Dict::new(), data); + let hex: Vec = flate.raw.iter().flat_map(|b| format!("{b:02X}").into_bytes()).chain(*b">").collect(); + (chained(&hex, ["ASCIIHexDecode", "FlateDecode"]), flate.raw.len()) + }; + let max = 1 << 16; + let (fits, inner) = hexed(&vec![0u8; max]); + let mut budget = DecodeBudget::new(1 << 20, 0); + assert_eq!(budget.decode_stages_within(&Document::new_empty(), &fits, max).map(|d| d.len()).ok(), Some(max)); + assert_eq!(budget.remaining(), (1 << 20) - inner - max); + let (bomb, inner) = hexed(&vec![0u8; 8 * max]); + let mut budget = DecodeBudget::new(1 << 20, 0); + assert!(budget.decode_stages_within(&Document::new_empty(), &bomb, max).is_err()); + assert_eq!(budget.remaining(), (1 << 20) - inner - max, "the Flate stage stopped at the limit"); + // Flate making 1.2 times the limit of hex text, which ASCIIHex halves: the stages together + // make 1.8 times the limit, within twice it, but the Flate stage alone is past it. + let text = "00".repeat(max * 6 / 10).into_bytes(); + let wide = chained(&Stream::flate(Dict::new(), &text).raw, ["FlateDecode", "ASCIIHexDecode"]); + assert!(wide.decoded_within(max).is_err()); + assert!(DecodeBudget::new(1 << 20, 0).decode_stages_within(&Document::new_empty(), &wide, max).is_err()); + let mut budget = DecodeBudget::new(max, 0); + assert!(budget.decode_stages_within(&Document::new_empty(), &fits, max).is_err()); + assert_eq!(budget.remaining(), 0); + } + + /// What `decode_stages_within` charges, and which limit it reports, on each way a read stops: + /// a stage at the stream's limit with more of the room left charges what came before and the + /// limit; at the stages' total, with more of the allowance left, it charges that total; at + /// what is left of the allowance it spends all of it and reports the allowance's limit, as + /// `decode_within` does. Each boundary between them is read on both sides. + #[test] + fn a_stage_past_its_limit_charges_what_came_before_and_the_limit() { + let max = 1 << 12; + let hex = |data: &[u8]| data.iter().flat_map(|b| format!("{b:02X}").into_bytes()).chain(*b">").collect::>(); + let two = |raw: &[u8]| { + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")])); + Stream::from_raw(dict, hex(raw)) + }; + let says = |read: Result, CosError>, limit: usize| { + read.err().map(|e| e.to_string()) == Some(CosError::Filter(FilterError::LimitExceeded(limit).to_string()).to_string()) + }; + // Flate data making eight times the limit: the hex stage makes `inner` bytes of it, and + // the Flate stage stops at the limit. + let bomb = Stream::flate(Dict::new(), &vec![0u8; 8 * max]).raw.to_vec(); + let inner = bomb.len(); + // A byte more of the room than the hex stage and the limit: the Flate stage stops at the + // limit, so that is what it charges. + let mut budget = DecodeBudget::new(inner + max + 1, 0); + assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&bomb), max), max)); + assert_eq!(budget.remaining(), 1); + // Exactly the hex stage and the limit: what was left stopped it as much as the limit did, + // so it is past the allowance, spends it and reports the allowance's limit. + let mut budget = DecodeBudget::new(inner + max, 0); + assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&bomb), max), inner + max)); + assert_eq!(budget.remaining(), 0); + // The same data padded to exactly the limit: the hex stage makes all of it, and the Flate + // stage, at the limit, reaches the stages' total. With more of the allowance left, that + // total is what it charges and reports. + let mut padded = bomb.clone(); + padded.resize(max, 0); + let mut budget = DecodeBudget::new(10 * max, 0); + assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&padded), max), 2 * max)); + assert_eq!(budget.remaining(), 8 * max); + // With exactly the total left (of an allowance of three times the limit), it is past the + // allowance: all of it is spent, and the allowance's limit reported. + let mut budget = DecodeBudget::new(3 * max, 0); + budget.decode(&Stream::from_raw(Dict::new(), vec![0; max])).unwrap(); + assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&padded), max), 3 * max)); + assert_eq!(budget.remaining(), 0); + // With less than the total left, the same. + let mut budget = DecodeBudget::new(max, 0); + assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&padded), max), max)); + assert_eq!(budget.remaining(), 0); + // With nothing left, a stream with data is refused before it is decoded, even one that + // decodes to nothing; an empty one reads. + let mut budget = DecodeBudget::new(0, 0); + assert!(says(budget.decode_stages_within(&Document::new_empty(), &Stream::flate(Dict::new(), b""), max), 0)); + assert_eq!(budget.decode_stages_within(&Document::new_empty(), &Stream::from_raw(Dict::new(), Vec::new()), max).ok(), Some(Vec::new())); + } } diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 2bf4bce42b..f3ea35d8c8 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -19,6 +19,7 @@ mod reader; mod state; mod xref; +pub use objects::DECODE_PARMS_KEYS; pub(crate) use objects::Held; #[cfg(test)] pub(crate) use reader::find; diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index 8f9d3a8a5b..42aef4af0f 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -2,8 +2,8 @@ use std::collections::HashSet; use super::reader::find; use super::state::{ - LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, ScanLengths, Slot, SourceIdentity, loaded_bytes, loading, note_overlay_read, objstm_member, - overlay_reads, + LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadFrame, ReadState, ScanLengths, Slot, SourceIdentity, loaded_bytes, loading, note_overlay_read, + note_read, objstm_member, overlay_reads, }; use super::xref::{Revision, XrefEntry, XrefIndex}; use super::{ @@ -244,6 +244,12 @@ impl Document { pub(super) fn try_get_reference(&self, r: ObjRef) -> Result, CosError> { if !self.reference_matches(r) { + // An edit of `r.num` can make it match, so a load that read null here read it too; + // with `r.num` edited (freed, or set at another generation) the null is the edit's. + if self.overlay.contains_key(&r.num) { + note_overlay_read(); + } + note_read(r.num, None); return Ok(Arc::new(Object::Null)); } self.try_get(r.num) @@ -507,17 +513,6 @@ impl Document { pdfcraft_filters::decode_ccitt_within(&data, &p, rows, work).map_err(filter) } - /// The `/JBIG2Globals` stream a JBIG2 image `s` names by reference in its last filter's - /// `/DecodeParms`: `None` for a stream whose last filter isn't `JBIG2Decode`, or one without - /// globals. Redaction finds with it the other images that share a redacted image's globals. - pub fn jbig2_globals_ref(&self, s: &Stream) -> Option { - let chain = s.readable_filters().ok()?; - if !matches!(chain.last(), Some((pdfcraft_filters::Filter::Jbig2, _))) { - return None; - } - self.last_filter_parms(s).get(b"JBIG2Globals").and_then(Object::as_ref) - } - /// What the `/JBIG2Globals` stream of JBIG2 image `s` (in its last filter's `/DecodeParms`) /// decodes to, within [`MAX_JBIG2_GLOBALS`]; `None` without one (a reference to no object is /// one to null, ISO 32000-2 §7.3.10, which is an absent entry, §7.3.7). The image is then read @@ -565,22 +560,32 @@ impl Document { pub fn try_get(&self, num: u32) -> Result, CosError> { if let Some(slot) = self.overlay.get(&num) { + note_read(num, None); note_overlay_read(); return Ok(match slot { Slot::Set(_, o) | Slot::RepairedSet(_, _, o) => o.clone(), Slot::Freed(_) => Arc::new(Object::Null), }); } - if let Some(loaded) = self.cache.lock().map_err(|_| CosError::Poisoned)?.get(&num) { + // Clones share the cache: what a load read that this document has edited since (the + // member's object stream, a `/Length`) makes the entry another document's. + if let Some((loaded, reads)) = self.cache.lock().map_err(|_| CosError::Poisoned)?.get(&num) + && !reads.touch(&self.overlay) + { + note_read(num, Some(reads)); return loaded.clone(); } + note_read(num, None); // Loading can re-enter (indirect stream `/Length`, object streams); damaged files make // cycles such as `6 0 obj << /Length 6 0 R >>`. Bound the nesting on every path. let _guard = LoadGuard::enter(num).ok_or_else(|| CosError::Syntax { offset: 0, detail: format!("reference cycle while loading object {num}") })?; let _scope = crate::security::FollowScope::enter(); let edits_read = overlay_reads(); + let frame = ReadFrame::enter(); let loaded = self.load(num, 0).map(Arc::new); + let reads = frame.reads(); + drop(frame); if let Err(e) = &loaded && !matches!(e, CosError::Poisoned) { @@ -592,7 +597,7 @@ impl Document { if overlay_reads() == edits_read && !matches!(loaded, Err(CosError::Poisoned)) { // The editing cache is bounded like the scan reader's: re-parsing a file-backed view // is cheap, and returned objects outlive eviction. - self.cache.lock().map_err(|_| CosError::Poisoned)?.insert(num, loaded.clone(), loaded_bytes(&loaded, &self.data)); + self.cache.lock().map_err(|_| CosError::Poisoned)?.insert(num, loaded.clone(), loaded_bytes(&loaded, &self.data), reads); self.evict_editor_cache_if_over_budget(num)?; } loaded @@ -735,9 +740,14 @@ impl Document { pub(super) fn objstm(&self, num: u32) -> Result, CosError> { // An edited or freed stream is this document's alone: clones share the parsed streams. let edited = self.overlay.contains_key(&num); - if !edited && let Some(s) = self.objstms.lock().map_err(|_| CosError::Poisoned)?.get(&num) { + if !edited + && let Some(s) = self.objstms.lock().map_err(|_| CosError::Poisoned)?.get(&num) + && !s.reads.touch(&self.overlay) + { + note_read(num, Some(&s.reads)); return Ok(s.clone()); } + let frame = ReadFrame::enter(); // A file entry that cannot place the stream (one that says it is itself compressed // inside another stream, as in #2367, or points nowhere) is tried by the stream's own // header, as a plain object with a wrong offset is. An edited or freed stream, in the @@ -751,8 +761,6 @@ impl Document { } _ => Arc::new(self.scan_for(num).map(|(id, o)| self.decrypted(id, o)).ok_or(CosError::MissingObject(num))?), }; - // A stream whose load read this document's edits (its `/Length`) is as much its own as an edited one. - let own = edited || overlay_reads() != edits_read; let Object::Stream(s) = &*loaded else { return Err(CosError::MissingObject(num)) }; let n = s.dict.int(b"N").unwrap_or(0).clamp(0, 1_000_000) as usize; let first = usize::try_from(s.dict.int(b"First").unwrap_or(0).max(0)).ok(); @@ -769,8 +777,10 @@ impl Document { let (Some(Object::Int(onum)), Some(Object::Int(off))) = (lx.object().ok(), lx.object().ok()) else { break }; index.push(objstm_member(onum, off, first, data.len())); } - let stm = Arc::new(ObjStm { index, data: Arc::new(data) }); - if own { + let stm = Arc::new(ObjStm { index, data: Arc::new(data), reads: frame.reads() }); + // A stream whose load or decoding read this document's edits (its `/Length`, its + // `/DecodeParms`) is as much its own as an edited one. + if edited || overlay_reads() != edits_read { return Ok(stm); } // Same bound as the object cache: decoded object streams can be megabytes each. @@ -788,17 +798,21 @@ impl Document { note_overlay_read(); } let cached = loading(); - // A length resolved through this document's edits counts as an edit read on every reuse. - if cached && let Some((known, edits)) = LENGTHS.with(|m| m.borrow().get(&r).copied()) { + // Each reuse counts as a read of the length and of what resolving it read, and as an edit + // read when that went through this document's edits. + if cached && let Some((known, edits, reads)) = LENGTHS.with(|m| m.borrow().get(&r).cloned()) { if edits { note_overlay_read(); } + note_read(r.num, Some(&reads)); return known; } + note_read(r.num, None); let edits_read = overlay_reads(); + let frame = ReadFrame::enter(); let length = self.try_get_reference(r).ok().and_then(|o| o.as_int()); if cached { - LENGTHS.with(|m| m.borrow_mut().insert(r, (length, overlay_reads() != edits_read))); + LENGTHS.with(|m| m.borrow_mut().insert(r, (length, overlay_reads() != edits_read, frame.reads()))); } length } @@ -858,7 +872,6 @@ impl Document { pub fn set(&mut self, r: ObjRef, obj: impl Into) { let obj = self.adopt(obj.into()); self.overlay.insert(r.num, Slot::Set(r.generation, Arc::new(obj))); - self.forget_member_failures(r.num); if r.num >= self.next_num { self.next_num = r.num.saturating_add(1); } @@ -918,16 +931,6 @@ impl Document { /// Delete an object (its number becomes free with the next generation). pub fn free(&mut self, r: ObjRef) { self.overlay.insert(r.num, Slot::Freed(r.generation.saturating_add(1))); - self.forget_member_failures(r.num); - } - - /// Forget cached failures of the members `stream` held in the file: they were failures of - /// the file's copy, and an edit of `stream` may read. Clones share the cache, so theirs are - /// searched again too, which costs time only. A poisoned lock leaves the cache as it is. - fn forget_member_failures(&self, stream: u32) { - if let Ok(mut cache) = self.cache.lock() { - cache.forget_failures(|n| matches!(self.entries.lookup(*n, &self.data), Some(XrefEntry::InStream { stream: s, .. }) if s == stream)); - } } /// Free what was added since the file was read and nothing refers to any more: the objects @@ -1079,7 +1082,7 @@ impl Document { /// The `/DecodeParms` entries the decoders read (ISO 32000-2 Tables 8, 11 and 13: the Flate and /// LZW predictors, CCITT, DCT's colour transform): numbers and flags a file may give by /// reference, which [`Dict::int`] doesn't follow. -const DECODE_PARMS_KEYS: [&[u8]; 13] = [ +pub const DECODE_PARMS_KEYS: [&[u8]; 13] = [ b"Predictor", b"Colors", b"BitsPerComponent", diff --git a/crates/cos/src/document/reader.rs b/crates/cos/src/document/reader.rs index 18f5ce848a..3e0b9684c1 100644 --- a/crates/cos/src/document/reader.rs +++ b/crates/cos/src/document/reader.rs @@ -320,10 +320,25 @@ impl Document { Ok(Some((section, stream.dict.clone(), true))) } + /// Read the cross-reference chain from `startxref` into one map of entries, newer sections + /// winning, with the trailer, the revisions oldest first and the set-aside entries. A chain + /// that lists more than [`MAX_XREF_OBJECTS`] rows in all is refused with + /// [`CosError::Syntax`], and the caller rebuilds the file from object headers (#5082). #[allow(clippy::type_complexity)] pub(super) fn read_xref_chain_materialized( &self, log: &mut Vec, + ) -> Result<(BTreeMap, Dict, Vec, Vec<(u64, u32)>, Vec), CosError> { + self.read_xref_chain_materialized_with_limit(log, MAX_XREF_OBJECTS) + } + + /// [`Self::read_xref_chain_materialized`] refusing a chain past `row_limit` rows in all + /// instead of [`MAX_XREF_OBJECTS`], so tests can reach the limit with a few rows. + #[allow(clippy::type_complexity)] + pub(super) fn read_xref_chain_materialized_with_limit( + &self, + log: &mut Vec, + row_limit: usize, ) -> Result<(BTreeMap, Dict, Vec, Vec<(u64, u32)>, Vec), CosError> { let data = &self.data; let tail_start = data.len().saturating_sub(4096); @@ -347,6 +362,9 @@ impl Document { let mut generation_repairs = Vec::new(); let mut recorded = HashSet::new(); let mut diagnose = true; + // Every row the sections (and their hybrid streams) list, counted by `read_section` as it + // reads them, before any are merged or deduplicated. + let mut merged_rows = 0usize; let mut next = Some(first); while let Some(off) = next.take() { if !visited.insert(off) || visited.len() > 10_000 { @@ -357,13 +375,15 @@ impl Document { let mut sect = BTreeMap::new(); // Object numbers whose entry in this section was set aside ([`Self::xref_oddities`]). let mut odd = Vec::new(); - let ((sect_trailer, is_stream), header_relative) = match self.read_section(off as usize, &mut sect, &mut odd, log) { + let rows_before = merged_rows; + let ((sect_trailer, is_stream), header_relative) = match self.read_section(off as usize, &mut sect, &mut odd, &mut merged_rows, log) { Ok(v) => (v, false), Err(e) if self.header_offset > 0 => { log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header")); sect.clear(); odd.clear(); - (self.read_section(off as usize + self.header_offset, &mut sect, &mut odd, log)?, true) + merged_rows = rows_before; + (self.read_section(off as usize + self.header_offset, &mut sect, &mut odd, &mut merged_rows, log)?, true) } Err(e) => { // An unreadable older section loses every object only it locates (the page @@ -398,7 +418,7 @@ impl Document { } } else if let Some(x) = hybrid { let mut stm = BTreeMap::new(); - let read = match self.read_section(x as usize, &mut stm, &mut odd, log) { + let read = match self.read_section(x as usize, &mut stm, &mut odd, &mut merged_rows, log) { Ok(_) => { for (&num, &entry) in &stm { if !diagnose { @@ -429,6 +449,22 @@ impl Document { hybrids.insert(x, read); } } + // Each section's rows are capped, so the chain is too: at most `row_limit` rows + // (MAX_XREF_OBJECTS outside tests) read in all, or the file is rebuilt from object + // headers, as when one section's stream decodes past that. This counts every row + // read, before deduplication, not the map's size: a chain that relists the same + // objects, or a stream whose `/Index` repeats a range (`[0 4 0 4]`), costs as much to + // merge (#5082). Rows set aside are among the rows read, and a row is set aside at + // most twice (in its section and against a hybrid stream), so they stay bounded too. A + // section's rows (its hybrid stream's included) never outnumber the rows the compact + // reader counts for it, and that reader stops at about a quarter of MAX_XREF_OBJECTS: + // no chain within its limit is refused. + if merged_rows > row_limit { + return Err(CosError::Syntax { + offset: usize::try_from(off).unwrap_or(usize::MAX), + detail: format!("cross-reference chain lists more than {row_limit} entries"), + }); + } oddities.extend(odd.into_iter().map(|num| (off, num))); for (num, e) in sect { entries.entry(num).or_insert(e); @@ -447,12 +483,14 @@ impl Document { } /// Read one section at `off`, adding entries not already known (newer sections win). `odd` - /// collects the object numbers whose entry here is set aside ([`Self::xref_oddities`]). + /// collects the object numbers whose entry here is set aside ([`Self::xref_oddities`]), and + /// `counted` grows by every row read, repeated and set-aside rows included (#5082). fn read_section( &self, off: usize, entries: &mut BTreeMap, odd: &mut Vec, + counted: &mut usize, log: &mut Vec, ) -> Result<(Dict, bool), CosError> { #[cfg(test)] @@ -479,6 +517,7 @@ impl Document { } let start = shifted; for i in 0..count { + *counted = counted.saturating_add(1); lx.skip_ws(); let o = lx.token(); lx.skip_ws(); @@ -549,6 +588,7 @@ impl Document { } for i in 0..*count { let Some(r) = rows.next() else { break }; + *counted = counted.saturating_add(1); let t = field(r, 0, w[0], 1); let a = field(r, w[0], w[1], 0); let b = field(r, w[0] + w[1], w[2], 0); diff --git a/crates/cos/src/document/state.rs b/crates/cos/src/document/state.rs index c5bd85c065..7359ce715a 100644 --- a/crates/cos/src/document/state.rs +++ b/crates/cos/src/document/state.rs @@ -8,7 +8,10 @@ thread_local! { /// retries a parse several ways, and each retry would resolve the same lengths again /// (exponential in the chain of streams whose lengths point at further streams). Each is /// kept with whether resolving it read unsaved edits. - pub(super) static LENGTHS: std::cell::RefCell, bool)>> = std::cell::RefCell::new(HashMap::new()); + pub(super) static LENGTHS: std::cell::RefCell> = std::cell::RefCell::new(HashMap::new()); + /// What the loads running on this thread have read ([`ReadFrame`]): how many frames are + /// open, the object numbers read inside them, and how often that list was full. + static READS: std::cell::RefCell<(u32, Vec, u64)> = const { std::cell::RefCell::new((0, Vec::new(), 0)) }; /// Unsaved edits read on this thread (`Document::try_get`, and a `/Length` in `Document::load`). static OVERLAY_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; /// How deep `Document::scan_for` is nested on this thread, and how many indirect `/Length` @@ -59,6 +62,94 @@ impl Drop for ScanLengths { } } +/// A resolved `/Length` in [`LENGTHS`]: its value, whether resolving it read unsaved edits, and +/// what it read. +pub(super) type Length = (Option, bool, Reads); + +/// The other objects a cached load read: an object stream for a member, an indirect `/Length`, +/// and what those read in turn. Clones share the caches, so a document whose edits include one of +/// them reads the object again instead (`Document::try_get`); `None` is a load that read more +/// than [`Reads::MAX`], which such a document always reads again. +#[derive(Clone, Debug, Default)] +pub(super) struct Reads(Option>); + +impl Reads { + /// Objects one entry keeps: a member's stream and a length or two, and what they read. + const MAX: usize = 64; + /// Object numbers the open frames keep between them. + const HELD: usize = 4096; + + /// Whether `overlay` (a document's edits) holds any of these objects. + pub(super) fn touch(&self, overlay: &BTreeMap) -> bool { + !overlay.is_empty() && self.0.as_ref().is_none_or(|nums| nums.iter().any(|n| overlay.contains_key(n))) + } + + fn bytes(&self) -> usize { + self.0.as_ref().map_or(0, |nums| nums.len().saturating_mul(std::mem::size_of::())) + } +} + +/// Counts `num` as read by the loads open on this thread, with what loading it read (`reads`). +pub(super) fn note_read(num: u32, reads: Option<&Reads>) { + READS.with(|r| { + let (depth, held, full) = &mut *r.borrow_mut(); + if *depth == 0 { + return; + } + let more = reads.map_or(Some(&[][..]), |r| r.0.as_deref()); + match more { + Some(more) if held.len().saturating_add(more.len()) < Reads::HELD => { + held.push(num); + held.extend_from_slice(more); + } + _ => *full = full.wrapping_add(1), + } + }); +} + +/// Collects what one load reads, for [`Reads`]; frames nest, and each read counts for every +/// frame open around it. +pub(super) struct ReadFrame { + start: usize, + full: u64, +} + +impl ReadFrame { + pub(super) fn enter() -> Self { + READS.with(|r| { + let (depth, held, full) = &mut *r.borrow_mut(); + *depth = depth.saturating_add(1); + ReadFrame { start: held.len(), full: *full } + }) + } + + /// What was read since the frame opened. + pub(super) fn reads(&self) -> Reads { + READS.with(|r| { + let (_, held, full) = &*r.borrow(); + if *full != self.full { + return Reads(None); + } + let mut nums = held.get(self.start..).unwrap_or_default().to_vec(); + nums.sort_unstable(); + nums.dedup(); + Reads((nums.len() <= Reads::MAX).then(|| Arc::from(nums))) + }) + } +} + +impl Drop for ReadFrame { + fn drop(&mut self) { + READS.with(|r| { + let (depth, held, _) = &mut *r.borrow_mut(); + *depth = depth.saturating_sub(1); + if *depth == 0 { + held.clear(); + } + }); + } +} + /// Unsaved edits read so far on this thread: a load during which this changes depended on one. pub(super) fn overlay_reads() -> u64 { OVERLAY_READS.with(std::cell::Cell::get) @@ -122,6 +213,8 @@ pub(super) struct ObjStm { /// or offset is unusable ([`objstm_member`]). pub(super) index: Vec>, pub(super) data: Arc>, + /// What parsing it read: the stream itself and its `/Length`, `/DecodeParms` and so on. + pub(super) reads: Reads, } /// Where member `num` of an object stream starts: its `offset` past `/First` (`first`), when the @@ -317,13 +410,14 @@ thread_local! { /// (#5873). #[derive(Default)] pub(crate) struct ObjectCache { - entries: HashMap, + entries: HashMap, owned: usize, } impl ObjectCache { - pub(crate) fn get(&self, num: &u32) -> Option<&Loaded> { - self.entries.get(num).map(|(object, _)| object) + /// What loading `num` gave, and what that load read. + pub(super) fn get(&self, num: &u32) -> Option<(&Loaded, &Reads)> { + self.entries.get(num).map(|(object, _, reads)| (object, reads)) } #[cfg(test)] @@ -331,9 +425,10 @@ impl ObjectCache { self.entries.contains_key(num) } - /// Keep what loading `num` gave, which holds `owned` bytes ([`loaded_bytes`]). - pub(crate) fn insert(&mut self, num: u32, object: Loaded, owned: usize) { - if let Some((_, before)) = self.entries.insert(num, (object, owned)) { + /// Keep what loading `num` gave, which holds `owned` bytes ([`loaded_bytes`]), and what it read. + pub(super) fn insert(&mut self, num: u32, object: Loaded, owned: usize, reads: Reads) { + let owned = owned.saturating_add(reads.bytes()); + if let Some((_, before, _)) = self.entries.insert(num, (object, owned, reads)) { self.owned = self.owned.saturating_sub(before); } self.owned = self.owned.saturating_add(owned); @@ -358,19 +453,14 @@ impl ObjectCache { self.owned = 0; } - /// Drop the cached failures `stale` picks. A failure holds no bytes, so the total stands. - pub(crate) fn forget_failures(&mut self, mut stale: impl FnMut(&u32) -> bool) { - self.entries.retain(|num, (loaded, _)| loaded.is_ok() || !stale(num)); - } - pub(crate) fn retain(&mut self, mut keep: impl FnMut(&u32) -> bool) { self.entries.retain(|num, _| keep(num)); - self.owned = self.entries.values().fold(0usize, |n, (_, owned)| n.saturating_add(*owned)); + self.owned = self.entries.values().fold(0usize, |n, (_, owned, _)| n.saturating_add(*owned)); } #[cfg(test)] pub(crate) fn values(&self) -> impl Iterator> { - self.entries.values().filter_map(|(object, _)| object.as_ref().ok()) + self.entries.values().filter_map(|(object, _, _)| object.as_ref().ok()) } } diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs index 274672bc7c..814d4ef8eb 100644 --- a/crates/cos/src/document/tests.rs +++ b/crates/cos/src/document/tests.rs @@ -1,5 +1,26 @@ use super::*; +/// What [`crate::reachable_objects`] gives is what a full save keeps: not an object nothing refers to, nor one +/// a reference only dangles at, and a ring of references ends. +#[test] +fn what_is_reachable_is_what_a_full_save_keeps() { + let mut doc = Document::new_empty(); + let root = doc.root().unwrap(); + let kept = doc.add(Object::Int(7)); + let dangling = ObjRef::new(4_000, 0); + let list = doc.add(Object::Null); + let ring = doc.add(Object::Array(vec![Object::Ref(list)])); + doc.set(list, Object::Array(vec![Object::Ref(kept), Object::Ref(ring), Object::Ref(dangling)])); + let orphan = doc.add(Object::Int(8)); + doc.update_dict(root, |d| d.set(b"Held".to_vec(), Object::Ref(list))).unwrap(); + let found = crate::reachable_objects(&doc).unwrap(); + assert!([root, kept, list, ring].iter().all(|r| found.contains(&r.num))); + assert!(!found.contains(&orphan.num) && !found.contains(&dangling.num)); + let saved = Document::open(Arc::new(crate::write_full(&doc, &crate::SaveOptions::default()).unwrap())).unwrap(); + let holds = |n: i64| saved.scan_objects().any(|(_, o)| matches!(*o, Object::Int(v) if v == n)); + assert!(holds(7) && !holds(8)); +} + std::thread_local! { /// How many times `Document::scan_for` read the file on this thread. pub(super) static FILE_SCANS: std::cell::Cell = const { std::cell::Cell::new(0) }; @@ -862,6 +883,179 @@ fn an_edited_object_stream_leaves_other_clones_members_alone() { assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes"), "the replacement reached another clone"); } +/// A member another clone read from the file's object stream is not what a clone that replaced +/// the stream reads, nor is one the document read itself before replacing it. +#[test] +fn a_replaced_object_stream_hides_the_members_read_before() { + let replacement = || { + let mut dict = Dict::new(); + dict.set(b"Type".to_vec(), Object::name("ObjStm")); + dict.set(b"N".to_vec(), Object::Int(1)); + dict.set(b"First".to_vec(), Object::Int(4)); + crate::Stream::from_raw(dict, b"3 0 << /Hidden (no) >>".to_vec()) + }; + let file = Arc::new(object_stream_file("3 0 ", "<< /Hidden (yes) >>", 0)); + let doc = Document::open(file.clone()).unwrap(); + assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes")); + let mut replaced = doc.clone(); + replaced.set(ObjRef::new(4, 0), replacement()); + assert_eq!(text_of(&replaced, 3, b"Hidden").as_deref(), Some("no"), "another clone's read of the file's stream"); + assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes"), "the replacement reached another clone"); + let mut doc = Document::open(file.clone()).unwrap(); + assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes")); + doc.set(ObjRef::new(4, 0), replacement()); + assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("no"), "the document's own read before the edit"); + let doc = Document::open(file).unwrap(); + assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes")); + let mut freed = doc.clone(); + freed.free(ObjRef::new(4, 0)); + assert_eq!(freed.try_get(3), Err(CosError::MissingObject(4)), "another clone's read before the free"); +} + +/// An object stream another clone framed by the file's indirect `/Length` is not what a clone +/// that edited that length reads. +#[test] +fn an_edited_object_stream_length_hides_the_members_read_before() { + let file = Arc::new(object_stream_file_with("3 0 ", "<< /Hidden (cut endstream here) >>", 0, true)); + let doc = Document::open(file).unwrap(); + assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("cut endstream here")); + let mut edited = doc.clone(); + edited.set(ObjRef::new(6, 0), Object::Int("3 0 << /Hidden (cut".len() as i64)); + assert_ne!(text_of(&edited, 3, b"Hidden").as_deref(), Some("cut endstream here"), "another clone's framing of the stream"); + assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("cut endstream here"), "the edited length reached another clone"); +} + +/// A stream another clone read by the file's indirect `/Length` is not what a clone that edited +/// that length reads. +#[test] +fn an_edited_stream_length_hides_the_stream_read_before() { + let bodies = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [] /Count 0 >>", + "<< /Length 4 0 R >>\nstream\nshort\nendstream long\nendstream", + "20", + ]; + let mut out = b"%PDF-1.4\n".to_vec(); + let mut table = String::from("xref\n0 5\n0000000000 65535 f \n"); + for (n, body) in bodies.iter().enumerate() { + table.push_str(&format!("{:010} 00000 n \n", out.len())); + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", n + 1).as_bytes()); + } + let start = out.len(); + out.extend_from_slice(format!("{table}trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n").as_bytes()); + let data = |doc: &Document| match &*doc.get(ObjRef::new(3, 0)) { + Object::Stream(s) => s.raw.to_vec(), + other => panic!("{other:?}"), + }; + let doc = Document::open(Arc::new(out)).unwrap(); + assert_eq!(data(&doc), b"short\nendstream long"); + let mut edited = doc.clone(); + edited.set(ObjRef::new(4, 0), Object::Int(5)); + assert_eq!(data(&edited), b"short", "another clone's read by the file's length"); + assert_eq!(data(&doc), b"short\nendstream long", "the edited length reached another clone"); +} + +/// A file whose object 3 sits in an object stream PNG-predicted by `/DecodeParms 6 1 R`, a +/// generation the file doesn't have, and the parameters that make it decode. +fn predicted_object_stream() -> (Vec, Dict) { + let members = b"3 0 << /Hidden (yes) >>"; + // PNG Sub, one row. + let mut rows = vec![1u8]; + let mut before = 0u8; + for b in members { + rows.push(b.wrapping_sub(before)); + before = *b; + } + let coded = pdfcraft_filters::encode_flate(&rows); + let mut out = b"%PDF-1.5\n".to_vec(); + let mut offs = [0usize; 7]; + let mut obj = |out: &mut Vec, n: usize, body: &[u8]| { + offs[n] = out.len(); + out.extend_from_slice(format!("{n} 0 obj\n").as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + }; + obj(&mut out, 1, b"<< /Type /Catalog /Pages 2 0 R >>"); + obj(&mut out, 2, b"<< /Type /Pages /Kids [] /Count 0 /Extra 3 0 R >>"); + let mut stm = format!("<< /Type /ObjStm /N 1 /First 4 /Filter /FlateDecode /DecodeParms 6 1 R /Length {} >>\nstream\n", coded.len()).into_bytes(); + stm.extend_from_slice(&coded); + stm.extend_from_slice(b"\nendstream"); + obj(&mut out, 4, &stm); + let data = [2u8, 0, 4, 0]; + let mut xs = format!("<< /Type /XRef /Size 7 /W [1 2 1] /Index [3 1] /Length {} >>\nstream\n", data.len()).into_bytes(); + xs.extend_from_slice(&data); + xs.extend_from_slice(b"\nendstream"); + obj(&mut out, 5, &xs); + obj(&mut out, 6, b"null"); + let table = out.len(); + out.extend_from_slice(b"xref\n0 7\n0000000000 65535 f \n"); + for (n, off) in offs.iter().enumerate().skip(1) { + let line = if n == 3 { "0000000000 65535 f \n".to_string() } else { format!("{off:010} 00000 n \n") }; + out.extend_from_slice(line.as_bytes()); + } + out.extend_from_slice(format!("trailer << /Size 7 /Root 1 0 R /XRefStm {} >>\nstartxref\n{table}\n%%EOF\n", offs[5]).as_bytes()); + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(11)); + parms.set(b"Columns".to_vec(), Object::Int(members.len() as i64)); + (out, parms) +} + +fn hidden(doc: &Document) -> Option> { + doc.get(ObjRef::new(3, 0)).as_dict().and_then(|d| d.get(b"Hidden")).and_then(Object::as_string).map(|s| s.bytes.clone()) +} + +/// An object stream whose `/DecodeParms` names a generation the file doesn't have decodes without +/// them; a clone that sets that generation decodes with them, not by the stream another clone read. +#[test] +fn parameters_an_edit_makes_match_hide_the_object_stream_read_before() { + let (file, parms) = predicted_object_stream(); + let doc = Document::open(Arc::new(file)).unwrap(); + assert_eq!(hidden(&doc), None, "no object 6 1: no predictor undone"); + let mut edited = doc.clone(); + edited.set(ObjRef::new(6, 1), Object::Dict(parms)); + assert_eq!(hidden(&edited), Some(b"yes".to_vec()), "another clone's stream, decoded without them"); +} + +/// The other way round: an object stream a clone decoded by its edited parameters stays that +/// clone's, so the document it was cloned from still decodes by the file's. +#[test] +fn an_object_stream_decoded_by_edited_parameters_is_not_shared() { + let (file, parms) = predicted_object_stream(); + let doc = Document::open(Arc::new(file)).unwrap(); + let mut edited = doc.clone(); + edited.set(ObjRef::new(6, 1), Object::Dict(parms)); + assert_eq!(hidden(&edited), Some(b"yes".to_vec())); + assert_eq!(hidden(&doc), None, "the clone's decoding, read from the shared streams"); +} + +/// What a load read is kept only up to a bound; past it the load counts as having read every +/// object, so a document with any edit reads it again. +#[test] +fn a_load_that_read_too_much_counts_as_reading_everything() { + use super::state::{ReadFrame, note_read}; + let mut overlay = BTreeMap::new(); + overlay.insert(999_999, Slot::Freed(1)); + let frame = ReadFrame::enter(); + for n in 0..8 { + note_read(n, None); + } + assert!(!frame.reads().touch(&overlay), "a few reads, none edited"); + assert!(!frame.reads().touch(&BTreeMap::new()), "nothing is edited"); + for n in 8..100 { + note_read(n, None); + } + assert!(frame.reads().touch(&overlay), "more objects than an entry keeps"); + drop(frame); + let outer = ReadFrame::enter(); + let inner = ReadFrame::enter(); + for n in 0..5_000 { + note_read(n % 4, None); + } + assert!(inner.reads().touch(&overlay), "more reads than the thread keeps"); + drop(inner); + assert!(outer.reads().touch(&overlay), "an outer frame missed them too"); +} + /// A member that failed to load from the file's object stream is kept as a failure, but replacing /// or freeing that stream forgets it: the member then reads from the edit. #[test] @@ -1479,7 +1673,7 @@ fn temporary_reader_keeps_file_views_and_evicts_data_of_its_own() { // cleared after the next read, including a failed one. let owned = Object::Stream(crate::Stream::from_raw(Dict::new(), vec![b'y'; ObjectReader::STREAM_BYTES + 1])); let size = held_bytes(&owned, &reader.document.data, 0); - reader.document.cache.lock().unwrap().insert(3, Ok(Arc::new(owned)), size); + reader.document.cache.lock().unwrap().insert(3, Ok(Arc::new(owned)), size, Default::default()); assert_eq!(*reader.get(ObjRef::new(99, 0)), Object::Null); assert!(reader.document.cache.lock().unwrap().is_empty(), "owned data over budget is evicted"); } @@ -2064,6 +2258,176 @@ fn a_decompression_bomb_in_an_xref_stream_is_refused_and_the_file_reconstructed( assert!(doc.repair_log().iter().any(|l| l.contains("limit")), "{:?}", doc.repair_log()); } +/// A chain of cross-reference streams whose sections each stay well under the per-section +/// limit but together list more than 8,388,607 rows (#5082). Too many rows for the compact +/// reader, the chain was merged whole; it is now refused and the file rebuilt from object +/// headers. The bulk rows are set aside (in use at offset 0), so the test holds a few bytes a row. +#[test] +fn a_cross_reference_chain_past_the_total_cap_is_refused_and_the_file_reconstructed() { + const BULK: usize = 4_200_000; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for body in + ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>"] + { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", offsets.len()).as_bytes()); + } + // Objects 4 and 5: /W [1 0 0] rows of type 1 at offset 0, each a set-aside entry. + let bulk = pdfcraft_filters::encode_flate(&vec![1u8; BULK]); + let size = 16 + 2 * BULK; + let mut prev = None; + for (num, start) in [(4, 16 + BULK), (5, 16)] { + offsets.push(bytes.len()); + let prev_key = prev.map(|p| format!(" /Prev {p}")).unwrap_or_default(); + bytes.extend_from_slice( + format!( + "{num} 0 obj\n<< /Type /XRef /Size {size} /W [1 0 0] /Index [{start} {BULK}]{prev_key} /Filter /FlateDecode /Length {} >>\nstream\n", + bulk.len() + ) + .as_bytes(), + ); + bytes.extend_from_slice(&bulk); + bytes.extend_from_slice(b"\nendstream\nendobj\n"); + prev = offsets.last().copied(); + } + // Object 6, the newest section: objects 0 to 6 at their offsets. + let x = bytes.len(); + offsets.push(x); + let mut rows = vec![0u8, 0, 0, 0, 0, 255]; + for o in &offsets { + rows.push(1); + rows.extend_from_slice(&u32::try_from(*o).unwrap().to_be_bytes()); + rows.push(0); + } + bytes.extend_from_slice( + format!("6 0 obj\n<< /Type /XRef /Size {size} /Root 1 0 R /W [1 4 1] /Index [0 7] /Prev {} /Length {} >>\nstream\n", offsets[4], rows.len()) + .as_bytes(), + ); + bytes.extend_from_slice(&rows); + bytes.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{x}\n%%EOF\n").as_bytes()); + + let doc = Document::open(Arc::new(bytes)).unwrap(); + let log = doc.repair_log(); + assert!( + log.iter().any(|l| l.contains("cross-reference chain lists more than 8388607 entries") && l.contains("rebuilding from object headers")), + "{log:?}" + ); + assert!(log.iter().any(|l| l.starts_with("rebuilt cross-reference index")), "{log:?}"); + assert!(doc.revisions().is_empty()); + assert_eq!(doc.root(), Some(ObjRef::new(1, 0))); + let page = doc.try_get(3).unwrap(); + assert_eq!(page.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Page"[..])); + // The chain read itself is refused at the second bulk section, which each decode within its own limit. + let Err(CosError::Syntax { offset, detail }) = doc.read_xref_chain_materialized(&mut Vec::new()) else { panic!("the chain must be refused") }; + assert_eq!(offset, offsets[3]); + assert!(detail.contains("more than 8388607 entries"), "{detail}"); +} + +/// Three classic tables listing 2, 1 and 1 rows, newest first: objects 1 and 2, then object 3 +/// (or object 1 again when `overlap`), then object 4. +fn three_table_chain(overlap: bool) -> (Vec, usize) { + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (num, body) in + ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Value 3 >>", "<< /Value 4 >>"].iter().enumerate() + { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", num + 1).as_bytes()); + } + let older = bytes.len(); + bytes.extend_from_slice(format!("xref\n4 1\n{:010} 00000 n \ntrailer << /Size 5 /Root 1 0 R >>\n", offsets[3]).as_bytes()); + let middle = bytes.len(); + let middle_num = if overlap { 1 } else { 3 }; + bytes.extend_from_slice( + format!("xref\n{middle_num} 1\n{:010} 00000 n \ntrailer << /Size 5 /Root 1 0 R /Prev {older} >>\n", offsets[middle_num - 1]).as_bytes(), + ); + let newer = bytes.len(); + bytes.extend_from_slice( + format!( + "xref\n1 1\n{:010} 00000 n \n2 1\n{:010} 00000 n \ntrailer << /Size 5 /Root 1 0 R /Prev {middle} >>\nstartxref\n{newer}\n%%EOF\n", + offsets[0], offsets[1], + ) + .as_bytes(), + ); + (bytes, older) +} + +/// The chain's row limit counts every row merged (#5082): four rows over three sections pass a +/// limit of 4 and are refused at the oldest section by a limit of 3, both when the sections +/// list different objects and when one relists an object a newer section has (a map of three +/// entries, but four rows merged). +#[test] +fn the_chain_row_limit_counts_disjoint_and_relisted_rows() { + for overlap in [false, true] { + let (bytes, older) = three_table_chain(overlap); + let doc = Document::open(Arc::new(bytes)).unwrap(); + let (entries, ..) = doc.read_xref_chain_materialized_with_limit(&mut Vec::new(), 4).unwrap(); + assert_eq!(entries.len(), if overlap { 3 } else { 4 }); + let Err(CosError::Syntax { offset, detail }) = doc.read_xref_chain_materialized_with_limit(&mut Vec::new(), 3) else { + panic!("four rows must be refused by a limit of 3 (overlap: {overlap})") + }; + assert_eq!(offset, older); + assert!(detail.contains("more than 3 entries"), "{detail}"); + } +} + +/// Rows set aside count against the chain's row limit (#5082): a hybrid file whose table puts +/// object 3 at an offset while its /XRefStm puts it in an object stream merges six rows and sets +/// one aside, so a limit of 7 passes and a limit of 6 refuses it. +#[test] +fn the_chain_row_limit_counts_hybrid_rows_set_aside() { + let mut bytes = object_stream_file("3 0 ", "<< /Hidden (yes) >>", 0); + // Object 3's row in the table (`xref\n0 6\n`, 20-byte rows): free, now in use at offset 9. + let row = find(&bytes, b"xref\n0 6\n", 0, bytes.len()).unwrap() + 9 + 3 * 20; + assert_eq!(&bytes[row..row + 20], b"0000000000 65535 f \n"); + bytes[row..row + 20].copy_from_slice(b"0000000009 00000 n \n"); + let doc = Document::open(Arc::new(bytes)).unwrap(); + let (entries, _, _, oddities, _) = doc.read_xref_chain_materialized_with_limit(&mut Vec::new(), 7).unwrap(); + assert_eq!(entries.len(), 6); + assert_eq!(oddities.iter().map(|(_, num)| *num).collect::>(), vec![3]); + let Err(CosError::Syntax { detail, .. }) = doc.read_xref_chain_materialized_with_limit(&mut Vec::new(), 6) else { + panic!("six rows and one set aside must be refused by a limit of 6") + }; + assert!(detail.contains("more than 6 entries"), "{detail}"); +} + +/// A cross-reference stream whose `/Index [0 4 0 4]` lists objects 0 to 3 twice, the same rows +/// both times: eight rows read, four entries (#5082). The chain's row limit counts the rows +/// read, before deduplication, so a limit of 8 passes and a limit of 7 refuses it. +#[test] +fn the_chain_row_limit_counts_rows_an_xref_stream_index_repeats() { + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for body in ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"] { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", offsets.len()).as_bytes()); + } + let x = bytes.len(); + offsets.push(x); + let mut once = vec![0u8, 0, 0, 0, 0, 255]; + for o in &offsets { + once.push(1); + once.extend_from_slice(&u32::try_from(*o).unwrap().to_be_bytes()); + once.push(0); + } + let rows = [once.clone(), once].concat(); + bytes.extend_from_slice( + format!("3 0 obj\n<< /Type /XRef /Size 4 /Root 1 0 R /W [1 4 1] /Index [0 4 0 4] /Length {} >>\nstream\n", rows.len()).as_bytes(), + ); + bytes.extend_from_slice(&rows); + bytes.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{x}\n%%EOF\n").as_bytes()); + let doc = Document::open(Arc::new(bytes)).unwrap(); + let (entries, _, _, oddities, _) = doc.read_xref_chain_materialized_with_limit(&mut Vec::new(), 8).unwrap(); + assert_eq!(entries.len(), 4); + assert!(oddities.is_empty(), "{oddities:?}"); + let Err(CosError::Syntax { offset, detail }) = doc.read_xref_chain_materialized_with_limit(&mut Vec::new(), 7) else { + panic!("eight rows listing four objects must be refused by a limit of 7") + }; + assert_eq!(offset, x); + assert!(detail.contains("more than 7 entries"), "{detail}"); +} + #[test] fn a_stream_whose_length_is_itself_and_never_ends_loads_quickly() { // From the nightly fuzz job: `6 0 obj << /Length 6 0 R >> stream` with no `endstream` @@ -2217,10 +2581,10 @@ fn the_object_cache_keeps_its_total_as_objects_come_and_go() { let two = Arc::new(Object::Array(vec![Object::Int(1); 10])); let (a, b) = (held_bytes(&one, &data, 0), held_bytes(&two, &data, 0)); let mut cache = super::state::ObjectCache::default(); - cache.insert(1, Ok(one.clone()), a); - cache.insert(2, Ok(two), b); + cache.insert(1, Ok(one.clone()), a, Default::default()); + cache.insert(2, Ok(two), b, Default::default()); assert_eq!(cache.owned(), a + b); - cache.insert(2, Ok(one), a); + cache.insert(2, Ok(one), a, Default::default()); assert_eq!(cache.owned(), a + a, "a replaced object no longer counts"); cache.retain(|n| *n == 1); assert_eq!((cache.len(), cache.owned()), (1, a)); diff --git a/crates/cos/src/lib.rs b/crates/cos/src/lib.rs index 7f7f32fedc..5de573294d 100644 --- a/crates/cos/src/lib.rs +++ b/crates/cos/src/lib.rs @@ -24,7 +24,7 @@ mod writer; pub use budget::{DecodeBudget, ImageBudget, MAX_PAGE_DECODED, MAX_PAGE_FORMS, MAX_REDACTED_IMAGE_PIXELS}; pub use bytes::Bytes; -pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry}; +pub use document::{DECODE_PARMS_KEYS, Document, ReadState, Revision, SourceIdentity, XrefEntry}; pub use object::{Dict, MAX_DECODED, MAX_JBIG2_GLOBALS, Name, ObjRef, Object, PdfString, Stream, TextChars}; pub use parser::{Lexer, is_whitespace, parse_indirect}; pub use pdfcraft_crypt::{ diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index f40f175d69..5d121dc41e 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -351,10 +351,12 @@ impl Stream { } /// Decoded data, tolerating truncated or corrupt encodings the way viewers do (the data - /// decoded before the damage is returned). Streams with image codecs fail with + /// decoded before the damage is returned). Decoding past + /// [`crate::budget::MAX_PAGE_DECODED`] (256 MiB), the most the renderer reads from one + /// stream, is an error. Streams with image codecs fail with /// `CosError::Filter` (keep them encoded). Use `decoded_strict` to detect damage. pub fn decoded(&self) -> Result, CosError> { - self.decoded_within(MAX_DECODED) + self.decoded_within(crate::budget::MAX_PAGE_DECODED) } /// Like [`Stream::decoded`], for a stream whose decoded size has a tighter bound than @@ -382,6 +384,26 @@ impl Stream { self.decode_chain(max, decode).map(|(v, spent)| (v, damaged, spent)) } + /// [`Stream::decoded_within_counted`] with every filter stage also held to `each`, as + /// [`Stream::decoded_within`] holds it, while `max` bounds the stages together + /// ([`pdfcraft_filters::decode_tolerant_counted_each`]). Without filters the data is held to + /// both. + pub(crate) fn decoded_within_each_counted( + &self, + each: usize, + max: usize, + ) -> Result<(Vec, bool, usize), (pdfcraft_filters::FilterError, usize)> { + let mut damaged = false; + let decode = |chain: &[(Filter, Params)], data: &[u8], max| { + pdfcraft_filters::decode_tolerant_counted_each(chain, data, each.min(MAX_DECODED), max).map(|(v, partial, spent)| { + damaged = partial; + (v, spent) + }) + }; + let max = if self.readable_filters().is_ok_and(|chain| chain.is_empty()) { max.min(each) } else { max }; + self.decode_chain(max, decode).map(|(v, spent)| (v, damaged, spent)) + } + /// [`Stream::decoded_within`] spending from `work`, what an operation may still do across a /// document's images ([`pdfcraft_filters::decode_tolerant_within`]). pub(crate) fn decoded_spending(&self, max: usize, work: &mut u64) -> Result, CosError> { @@ -724,6 +746,13 @@ mod tests { } } + #[test] + fn strict_decoding_is_bounded_by_the_callers_remaining_allowance() { + let stream = Stream::flate(Dict::new(), &vec![0u8; 1 << 20]); + assert!(matches!(stream.decoded_strict_within(1000), Err(CosError::Filter(_)))); + assert_eq!(stream.decoded_strict_within(1 << 20).map(|v| v.len()).ok(), Some(1 << 20)); + } + #[test] fn empty_filter_arrays_refuse_decoding_past_a_caller_limit() { let mut dict = Dict::new(); @@ -780,6 +809,13 @@ mod tests { assert_eq!(stream.decoded_strict().unwrap(), b"A"); } + #[test] + fn decoded_stops_at_renderer_cap_of_256_mib() { + let data = vec![0u8; (256 << 20) + 1]; + let stream = Stream::flate(Dict::new(), &data); + assert!(matches!(stream.decoded(), Err(CosError::Filter(_)))); + } + #[test] fn first_filter_decodes_only_the_outer_wrapper() { let data = "0 0 m 100 100 l S\n".repeat(64).into_bytes(); diff --git a/crates/create/Cargo.toml b/crates/create/Cargo.toml index 51eca668d0..79c7a4f276 100644 --- a/crates/create/Cargo.toml +++ b/crates/create/Cargo.toml @@ -24,6 +24,11 @@ thiserror = { workspace = true } # Already in the graph through lopdf; used only to read plain-text files (decode_text). encoding_rs = "0.8" +[target.'cfg(unix)'.dependencies] +# Already in the graph (pdfcraft-platform, ui-egui); stops a timed-out LibreOffice's whole process +# group, soffice.bin included, without unsafe code. +rustix = { version = "1", features = ["process"] } + [dev-dependencies] # An independent CCITT decoder, to check Create PDF's Group 4 images. hayro-ccitt.workspace = true diff --git a/crates/create/README.md b/crates/create/README.md index 95d47d27a9..4c9d1a8823 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -28,6 +28,30 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2: Every function returns a `pdfcraft_cos::Document`; the caller writes it. +Office documents (`office.rs`) are the exception: PdfCraft doesn't read Word, Excel, PowerPoint, +OpenDocument or RTF itself. `is_office(name, bytes)` recognises them (an `OFFICE_EXTENSIONS` +name and an OLE, ZIP or RTF signature; `source_kind` reports `SourceKind::Office`), and +`office_to_pdf(name, bytes)` returns PDF bytes made by an installed LibreOffice, run headless as +a separate process: a private temporary folder (owner-only on Unix; the document is always +`document.` there, never its own name), a fresh profile with macros disabled, untrusted +links blocked and Writer and Calc set never to update linked content (sections, files, external +data) on load, no console window on Windows, and a stop after `TIMEOUT` (120 s) or as soon as the +output grows past 512 MB (the whole process tree is stopped, not only `soffice`). LibreOffice is +found through `PDFCRAFT_SOFFICE`, the `PATH`, then the usual install folders (`libreoffice()`). +A snap-installed LibreOffice is not among those folders: its private `/tmp` cannot see the work +folder. Where `/snap/bin` is on the `PATH` it can still be found there, so point +`PDFCRAFT_SOFFICE` at a non-snap install if conversions fail. It takes seconds, so callers keep it +off the frame thread. On the web it returns an error. + +Impress has no setting for linked content, and the profile settings are LibreOffice's own: +PdfCraft doesn't sandbox the process's network access. Converting test documents whose linked +sections, pictures and external cell references point at a local listener made no connection, +with or without the Writer and Calc settings (LibreOffice 26.8.1, Windows, 2026-10-26), so the settings are a +second line of defence rather than a measured fix. + +`IMAGE_EXTENSIONS`, `TEXT_EXTENSIONS` and `OFFICE_EXTENSIONS` are what the app's file pickers +offer, so detection and pickers agree. + Image export and Save Image As apply each lossless image's Decode pair before converting its color components. JBIG2 images and masks are decoded through `pdfcraft_cos::ImageBudget` (`pdfcraft_filters::decode_jbig2_within`), behind other filters too, with the `/JBIG2Globals` of their own `/DecodeParms` entry, as `JBIG2Decode` gives them: black as 0. An image diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index 389173d848..d6c2208a83 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -1,4 +1,4 @@ -//! pdfcraft-create — create PDFs from nothing, images or text (L4). See the README. +//! pdfcraft-create — create PDFs from nothing, images, text or (through LibreOffice) Office documents (L4). See the README. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -8,6 +8,10 @@ mod extract; mod text_font; mod upright_image; pub use extract::{ExtractedImage, ImageExport, extract_images, image_file, image_file_within}; +mod office; +#[cfg(not(target_arch = "wasm32"))] +pub use office::libreoffice; +pub use office::{OFFICE_EXTENSIONS, is_office, office_to_pdf}; use pdfcraft_fonts::wrap_with; use text_font::TextFont; @@ -17,6 +21,9 @@ pub enum CreateError { Image(String, String), #[error("{0}")] Invalid(String), + /// An Office document LibreOffice couldn't convert, or LibreOffice isn't installed. + #[error("{0}")] + Office(String), } /// US Letter in points. @@ -757,9 +764,18 @@ pub enum SourceKind { Pdf, Image, Text, + /// A Word, Excel, PowerPoint or OpenDocument file, converted through LibreOffice. + Office, } -/// File extensions Create converts to PDF (images and plain text). +/// File extensions of the images Create converts to PDF. +pub const IMAGE_EXTENSIONS: [&str; 11] = ["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx", "pcx"]; + +/// File extensions of the plain-text files Create converts to PDF. +pub const TEXT_EXTENSIONS: [&str; 2] = ["txt", "text"]; + +/// File extensions Create converts to PDF itself: [`IMAGE_EXTENSIONS`] and [`TEXT_EXTENSIONS`] +/// (Office documents go through LibreOffice, [`OFFICE_EXTENSIONS`]). pub const CONVERTIBLE: [&str; 13] = ["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx", "pcx", "txt", "text"]; /// A PCX file: its manufacturer byte, a version (1 was never used), run-length coding and 1 or 8 @@ -792,8 +808,8 @@ fn is_image(bytes: &[u8]) -> bool { && bytes.get(14..18).and_then(|h| <[u8; 4]>::try_from(h).ok()).is_some_and(|h| matches!(u32::from_le_bytes(h), 12 | 40 | 52 | 56 | 108 | 124))) } -/// Whether `bytes` named `name` is a PDF, an image Create can embed, or plain text (a `.txt` or -/// `.text` file). `None` for anything else. +/// Whether `bytes` named `name` is a PDF, an image Create can embed, plain text (a `.txt` or +/// `.text` file) or an Office document ([`is_office`]). `None` for anything else. pub fn source_kind(name: &str, bytes: &[u8]) -> Option { let head = bytes.get(..bytes.len().min(1024)).unwrap_or_default(); if head.windows(5).any(|w| w == b"%PDF-") { @@ -802,6 +818,9 @@ pub fn source_kind(name: &str, bytes: &[u8]) -> Option { if is_image(bytes) { return Some(SourceKind::Image); } + if is_office(name, bytes) { + return Some(SourceKind::Office); + } let lower = name.to_ascii_lowercase(); (lower.ends_with(".txt") || lower.ends_with(".text")).then_some(SourceKind::Text) } diff --git a/crates/create/src/office.rs b/crates/create/src/office.rs new file mode 100644 index 0000000000..3d8332c149 --- /dev/null +++ b/crates/create/src/office.rs @@ -0,0 +1,599 @@ +//! Office documents (Word, Excel, PowerPoint, OpenDocument, RTF) to PDF through an installed +//! LibreOffice, run as a separate process: the "LibreOffice sidecar" of +//! `docs/file-format-parity.md`. PdfCraft never parses these formats itself. +//! +//! Detection ([`is_office`]) is pure and works everywhere. Conversion ([`office_to_pdf`]) runs +//! only on desktop builds: each run gets its own temporary folder and a fresh LibreOffice +//! profile with macros and untrusted links turned off, and is stopped after [`TIMEOUT`]. + +use crate::CreateError; + +/// File extensions of the Office documents converted through LibreOffice. +pub const OFFICE_EXTENSIONS: &[&str] = &[ + // Word and Writer + "doc", "docx", "docm", "dot", "dotx", "dotm", "odt", "ott", "rtf", // + // Excel and Calc + "xls", "xlsx", "xlsm", "xlt", "xltx", "xltm", "ods", "ots", // + // PowerPoint and Impress + "ppt", "pptx", "pptm", "pot", "potx", "potm", "pps", "ppsx", "ppsm", "odp", "otp", +]; + +/// The OLE compound-file signature of Office 97–2003 files (`.doc`, `.xls`, `.ppt`). +const OLE: &[u8] = &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; + +/// The longest a conversion may run before LibreOffice is stopped. A fresh profile costs a +/// few seconds on its own; large spreadsheets take longer. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120); + +/// The largest PDF read back from LibreOffice. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +const MAX_PDF: u64 = 512 * 1024 * 1024; + +/// `name`'s extension as it appears in [`OFFICE_EXTENSIONS`], if it is one. +fn office_extension(name: &str) -> Option<&'static str> { + let (_, ext) = name.rsplit_once('.')?; + OFFICE_EXTENSIONS.iter().copied().find(|e| ext.eq_ignore_ascii_case(e)) +} + +/// Whether `bytes` named `name` is an Office document: an Office extension, and the bytes start +/// like one (an OLE compound file, a ZIP package as OOXML and OpenDocument are, or RTF). +pub fn is_office(name: &str, bytes: &[u8]) -> bool { + office_extension(name).is_some() && (bytes.starts_with(OLE) || bytes.starts_with(b"PK\x03\x04") || bytes.starts_with(b"{\\rtf")) +} + +/// Convert the Office document `bytes` named `name` to PDF bytes with LibreOffice. +#[cfg(target_arch = "wasm32")] +pub fn office_to_pdf(name: &str, _bytes: &[u8]) -> Result, CreateError> { + Err(CreateError::Office(format!("{name}: Office documents open only in the desktop app, which converts them with LibreOffice"))) +} + +/// Convert the Office document `bytes` named `name` to PDF bytes with LibreOffice. +#[cfg(not(target_arch = "wasm32"))] +pub fn office_to_pdf(name: &str, bytes: &[u8]) -> Result, CreateError> { + let program = native::libreoffice().ok_or_else(|| { + CreateError::Office(format!( + "{name}: opening Word, Excel and PowerPoint files needs LibreOffice, which isn't installed (get it free from libreoffice.org)" + )) + })?; + native::convert_with(&program, name, bytes, TIMEOUT) +} + +/// Where LibreOffice is installed, if it is: `PDFCRAFT_SOFFICE`, then the `PATH`, then the +/// usual install folders. +#[cfg(not(target_arch = "wasm32"))] +pub fn libreoffice() -> Option { + native::libreoffice() +} + +#[cfg(not(target_arch = "wasm32"))] +mod native { + use super::{CreateError, MAX_PDF, office_extension}; + use std::ffi::OsString; + use std::path::{Path, PathBuf}; + use std::process::{Child, Command, ExitStatus, Stdio}; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::{Duration, Instant}; + + /// The program names LibreOffice installs on the `PATH`. + #[cfg(windows)] + const PATH_NAMES: &[&str] = &["soffice.exe"]; + #[cfg(not(windows))] + const PATH_NAMES: &[&str] = &["soffice", "libreoffice"]; + + pub(super) fn libreoffice() -> Option { + let defaults = default_locations(&|key| std::env::var_os(key)); + candidates(std::env::var_os("PDFCRAFT_SOFFICE"), std::env::var_os("PATH"), defaults).into_iter().find(|p| p.is_file()) + } + + /// Where to look, in order: the override, each `PATH` folder, then `defaults`. + pub(super) fn candidates(override_path: Option, path: Option, defaults: Vec) -> Vec { + let mut out: Vec = override_path.filter(|p| !p.is_empty()).map(PathBuf::from).into_iter().collect(); + if let Some(path) = path { + for dir in std::env::split_paths(&path) { + out.extend(PATH_NAMES.iter().map(|name| dir.join(name))); + } + } + out.extend(defaults); + out + } + + /// The usual install locations, given a way to read environment variables. + #[cfg(windows)] + pub(super) fn default_locations(env: &dyn Fn(&str) -> Option) -> Vec { + ["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"] + .iter() + .filter_map(|key| env(key)) + .map(|dir| PathBuf::from(dir).join("LibreOffice").join("program").join("soffice.exe")) + .collect() + } + + /// The usual install locations, given a way to read environment variables. + #[cfg(target_os = "macos")] + pub(super) fn default_locations(env: &dyn Fn(&str) -> Option) -> Vec { + let app = Path::new("LibreOffice.app/Contents/MacOS/soffice"); + let mut out = vec![Path::new("/Applications").join(app)]; + out.extend(env("HOME").map(|home| PathBuf::from(home).join("Applications").join(app))); + out + } + + /// The usual install locations, given a way to read environment variables. + #[cfg(not(any(windows, target_os = "macos")))] + pub(super) fn default_locations(_env: &dyn Fn(&str) -> Option) -> Vec { + [ + "/usr/bin/soffice", + "/usr/bin/libreoffice", + "/usr/local/bin/soffice", + "/usr/local/bin/libreoffice", + "/usr/lib/libreoffice/program/soffice", + "/opt/libreoffice/program/soffice", + ] + .iter() + .map(PathBuf::from) + .collect() + } + + /// The fresh profile's settings: no macros at all, no links to untrusted locations, and + /// linked content in Writer and Calc documents (sections, files, external data) never + /// updated on load, so a document can't make LibreOffice fetch from the network or a share. + /// The two `Link` settings number "Never" differently (Writer.xcs: 0 Always, 1 On request, + /// 2 Never; Calc.xcs: 0 Always, 1 Never, 2 On request). Impress has no such setting. + pub(super) const PROFILE_SETTINGS: &str = r#" + +true +3 +true +2 +1 + +"#; + + /// A private temporary folder, removed (with everything in it) when dropped. + pub(super) struct WorkDir(pub(super) PathBuf); + + impl WorkDir { + pub(super) fn new() -> std::io::Result { + static NEXT: AtomicU64 = AtomicU64::new(0); + let n = NEXT.fetch_add(1, Ordering::Relaxed); + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.subsec_nanos()); + let dir = std::env::temp_dir().join(format!("pdfcraft-office-{}-{n}-{nanos}", std::process::id())); + // Not recursive: a folder that already exists is not ours. Owner-only on Unix, where the + // temporary folder is shared: the document and its PDF are not for other users. + #[cfg_attr(not(unix), allow(unused_mut))] + let mut builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder.create(&dir)?; + Ok(Self(dir)) + } + } + + impl Drop for WorkDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + /// `path` as a `file://` URL, which `-env:UserInstallation` takes. + pub(super) fn file_url(path: &Path) -> String { + let path = path.to_string_lossy().replace('\\', "/"); + let mut url = String::from("file://"); + // A Windows path (`C:/…`) needs the third slash a Unix path already starts with. + if !path.starts_with('/') { + url.push('/'); + } + for b in path.bytes() { + if b.is_ascii_alphanumeric() || b"-._~/:".contains(&b) { + url.push(char::from(b)); + } else { + url.push_str(&format!("%{b:02X}")); + } + } + url + } + + pub(super) enum Run { + Exited(ExitStatus), + TimedOut, + /// The watched folder grew past its limit while the command ran. + TooLarge, + } + + /// The bytes in the files directly inside `dir` (LibreOffice writes its one output there). + /// Anything unreadable counts as nothing, and the sum saturates. Each size is read from the + /// file itself: on Windows a directory listing keeps the old size while a process still + /// has the file open for writing. + pub(super) fn folder_size(dir: &Path) -> u64 { + let Ok(entries) = std::fs::read_dir(dir) else { return 0 }; + entries + .flatten() + .filter_map(|entry| std::fs::symlink_metadata(entry.path()).ok()) + .filter(|meta| meta.is_file()) + .fold(0u64, |sum, meta| sum.saturating_add(meta.len())) + } + + /// Run `command` to completion, killing it and every process it started once `timeout` + /// passes, or once the files in `watch`'s folder add up to more than its limit (so a + /// document that converts to an enormous PDF can't fill the disk before it is checked). + /// `soffice` hands the conversion to its own child, `soffice.bin`, which would + /// otherwise outlive the kill, holding the profile folder and its memory. + pub(super) fn run_limited(mut command: Command, timeout: Duration, watch: Option<(&Path, u64)>) -> std::io::Result { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // Its own process group, so the whole group can be stopped together. + command.process_group(0); + } + let mut child = command.spawn()?; + let start = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(status)) => return Ok(Run::Exited(status)), + Ok(None) => {} + Err(error) => { + kill_tree(&mut child); + return Err(error); + } + } + if watch.is_some_and(|(dir, limit)| folder_size(dir) > limit) { + kill_tree(&mut child); + return Ok(Run::TooLarge); + } + if start.elapsed() >= timeout { + kill_tree(&mut child); + return Ok(Run::TimedOut); + } + std::thread::sleep(Duration::from_millis(50)); + } + } + + /// Kill `child` and every process it started, then reap it. + fn kill_tree(child: &mut Child) { + #[cfg(unix)] + { + use rustix::process::{Pid, Signal, kill_process_group}; + // The group `process_group(0)` made has the child's id. + if let Some(group) = i32::try_from(child.id()).ok().and_then(Pid::from_raw) { + let _ = kill_process_group(group, Signal::KILL); + } + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + // `/T` walks the tree from the child's id, so it runs while the child is still alive. + // Windows' own copy, not whatever `taskkill` the PATH finds first. + let taskkill = std::env::var_os("SystemRoot") + .map_or_else(|| PathBuf::from("taskkill.exe"), |root| PathBuf::from(root).join("System32").join("taskkill.exe")); + let _ = Command::new(taskkill) + .args(["/T", "/F", "/PID", &child.id().to_string()]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + // CREATE_NO_WINDOW, as for LibreOffice itself. + .creation_flags(0x0800_0000) + .status(); + } + // The child itself, if the tree kill couldn't reach it. + let _ = child.kill(); + let _ = child.wait(); + } + + /// Convert with the LibreOffice at `program`, giving up after `timeout`. + pub(super) fn convert_with(program: &Path, name: &str, bytes: &[u8], timeout: Duration) -> Result, CreateError> { + convert_capped(program, name, bytes, timeout, MAX_PDF) + } + + /// [`convert_with`] with the converted PDF's size limit given: the output folder is watched + /// while LibreOffice runs, and the finished file is checked again. + pub(super) fn convert_capped(program: &Path, name: &str, bytes: &[u8], timeout: Duration, max_pdf: u64) -> Result, CreateError> { + let too_large = || format!("the converted PDF is larger than {} MB", max_pdf / (1024 * 1024)); + let failed = |why: String| CreateError::Office(format!("{name}: {why}")); + let ext = office_extension(name).ok_or_else(|| failed("this isn't a Word, Excel or PowerPoint file".into()))?; + let work = WorkDir::new().map_err(|e| failed(format!("couldn't make a temporary folder: {e}")))?; + // A fixed input name: the document's own name never reaches LibreOffice's command line. + let input = work.0.join(format!("document.{ext}")); + let profile = work.0.join("profile"); + let out = work.0.join("out"); + let prepared = std::fs::write(&input, bytes) + .and_then(|()| std::fs::create_dir_all(profile.join("user"))) + .and_then(|()| std::fs::write(profile.join("user").join("registrymodifications.xcu"), PROFILE_SETTINGS)) + .and_then(|()| std::fs::create_dir(&out)); + prepared.map_err(|e| failed(format!("couldn't prepare the conversion: {e}")))?; + + let mut command = Command::new(program); + command + .arg(format!("-env:UserInstallation={}", file_url(&profile))) + .args(["--headless", "--invisible", "--nologo", "--norestore", "--nodefault", "--nolockcheck", "--convert-to", "pdf", "--outdir"]) + .arg(&out) + .arg(&input) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + // CREATE_NO_WINDOW: no console window flashes up during the conversion. + command.creation_flags(0x0800_0000); + } + let status = match run_limited(command, timeout, Some((&out, max_pdf))) { + Ok(Run::Exited(status)) => status, + Ok(Run::TooLarge) => return Err(failed(format!("{}; LibreOffice was stopped", too_large()))), + Ok(Run::TimedOut) => return Err(failed(format!("LibreOffice took longer than {} seconds and was stopped", timeout.as_secs()))), + Err(e) => return Err(failed(format!("couldn't start LibreOffice ({}): {e}", program.display()))), + }; + let pdf = out.join("document.pdf"); + let size = match std::fs::metadata(&pdf) { + Ok(meta) => meta.len(), + Err(_) if !status.success() => return Err(failed(format!("LibreOffice couldn't convert it ({status})"))), + Err(_) => return Err(failed("LibreOffice didn't produce a PDF; the file may be damaged or password-protected".into())), + }; + if size > max_pdf { + return Err(failed(too_large())); + } + let bytes = std::fs::read(&pdf).map_err(|e| failed(format!("couldn't read the converted PDF: {e}")))?; + if !bytes.starts_with(b"%PDF-") { + return Err(failed("LibreOffice's output isn't a PDF".into())); + } + Ok(bytes) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const DOCX_HEAD: &[u8] = b"PK\x03\x04\x14\x00\x06\x00"; + + #[test] + fn office_files_are_recognised_by_extension_and_signature() { + assert!(is_office("Report.docx", DOCX_HEAD)); + assert!(is_office("SHEET.XLSX", DOCX_HEAD)); + assert!(is_office("deck.odp", DOCX_HEAD)); + assert!(is_office("old.doc", OLE)); + assert!(is_office("old.ppt", &[OLE, b"rest"].concat())); + assert!(is_office("letter.rtf", b"{\\rtf1\\ansi")); + // Word saves RTF under .doc too. + assert!(is_office("letter.doc", b"{\\rtf1\\ansi")); + } + + #[test] + fn other_files_are_not_office_documents() { + // The right signature with another extension, or an Office name with other bytes. + assert!(!is_office("archive.zip", DOCX_HEAD)); + assert!(!is_office("Report.docx", b"%PDF-1.7")); + assert!(!is_office("Report.docx", b"plain text")); + assert!(!is_office("Report.docx", b"")); + assert!(!is_office("docx", DOCX_HEAD)); + assert!(!is_office("", DOCX_HEAD)); + // A multi-byte name never splits a character. + assert!(!is_office("résumé.dócx", DOCX_HEAD)); + assert!(is_office("résumé.docx", DOCX_HEAD)); + } + + #[cfg(not(target_arch = "wasm32"))] + mod native { + use super::super::native::*; + use super::DOCX_HEAD; + use std::ffi::OsString; + use std::path::{Path, PathBuf}; + use std::time::Duration; + + #[test] + fn file_urls_escape_spaces_and_keep_drive_letters() { + assert_eq!(file_url(Path::new("/tmp/a b/profile")), "file:///tmp/a%20b/profile"); + assert_eq!(file_url(Path::new(r"C:\Users\Jo Ann\profile")), "file:///C:/Users/Jo%20Ann/profile"); + assert_eq!(file_url(Path::new("/tmp/é")), "file:///tmp/%C3%A9"); + } + + #[test] + fn the_override_comes_first_then_the_path_then_the_defaults() { + let path = std::env::join_paths([Path::new("first"), Path::new("second")]).unwrap(); + let found = candidates(Some(OsString::from("custom/soffice")), Some(path), vec![PathBuf::from("default/soffice")]); + assert_eq!(found.first(), Some(&PathBuf::from("custom/soffice"))); + assert!(found.get(1).is_some_and(|p| p.starts_with("first"))); + assert_eq!(found.last(), Some(&PathBuf::from("default/soffice"))); + // An empty override is no override. + assert_eq!(candidates(Some(OsString::new()), None, Vec::new()), Vec::::new()); + } + + #[test] + fn default_locations_read_only_the_given_environment() { + let found = default_locations(&|_| None); + assert!(found.iter().all(|p| p.is_absolute()), "{found:?}"); + } + + /// Each setting the fresh profile makes, as (path, property, value). + fn profile_settings() -> Vec<(&'static str, &'static str, &'static str)> { + let between = |s: &'static str, start: &str, end: &str| s.split_once(start).and_then(|(_, rest)| rest.split_once(end)).map(|(v, _)| v); + PROFILE_SETTINGS + .split("", "")?)) + }) + .collect() + } + + #[test] + fn the_profile_turns_off_macros_and_never_updates_links() { + let settings = profile_settings(); + let scripting = "/org.openoffice.Office.Common/Security/Scripting"; + assert!(settings.contains(&(scripting, "DisableMacrosExecution", "true")), "{settings:?}"); + assert!(settings.contains(&(scripting, "MacroSecurityLevel", "3")), "{settings:?}"); + assert!(settings.contains(&(scripting, "BlockUntrustedRefererLinks", "true")), "{settings:?}"); + // "Never" is 2 for Writer but 1 for Calc (Writer.xcs and Calc.xcs); Writer's 1 is + // its default, "On request". + assert!(settings.contains(&("/org.openoffice.Office.Writer/Content/Update", "Link", "2")), "{settings:?}"); + assert!(settings.contains(&("/org.openoffice.Office.Calc/Content/Update", "Link", "1")), "{settings:?}"); + assert_eq!(settings.len(), 5, "{settings:?}"); + } + + #[test] + fn a_missing_converter_is_an_error_not_a_crash() { + let missing = std::env::temp_dir().join("pdfcraft-no-such-soffice").join("soffice"); + let error = convert_with(&missing, "Report.docx", DOCX_HEAD, Duration::from_secs(5)).unwrap_err(); + assert!(error.to_string().contains("couldn't start LibreOffice"), "{error}"); + } + + #[test] + fn a_converter_that_makes_no_pdf_is_reported() { + // This test binary stands in for LibreOffice: it rejects the arguments and exits + // without writing anything. + let program = std::env::current_exe().unwrap(); + let error = convert_with(&program, "Report.docx", DOCX_HEAD, Duration::from_secs(60)).unwrap_err(); + assert!(error.to_string().contains("LibreOffice"), "{error}"); + } + + #[test] + fn a_converter_that_hangs_is_stopped() { + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + command.args(["--ignored", "--exact", "office::tests::native::sleeps_for_a_minute", "--nocapture"]); + command.stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()); + let started = std::time::Instant::now(); + assert!(matches!(run_limited(command, Duration::from_millis(500), None), Ok(Run::TimedOut))); + assert!(started.elapsed() < Duration::from_secs(30)); + } + + /// The stand-in for a LibreOffice that never finishes (run only by the test above). + #[test] + #[ignore] + fn sleeps_for_a_minute() { + std::thread::sleep(Duration::from_secs(60)); + } + + /// Where the stand-ins below write their heartbeat. + const HEARTBEAT: &str = "PDFCRAFT_TEST_OFFICE_HEARTBEAT"; + + /// This test binary run as one of its own ignored tests, with the heartbeat file set. + fn stand_in(test: &str, heartbeat: &Path) -> std::process::Command { + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + command.args(["--ignored", "--exact", test, "--nocapture"]).env(HEARTBEAT, heartbeat); + command.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()); + command + } + + /// `soffice` starts `soffice.bin` as its own child: a timeout must stop both, or the + /// child keeps running, holding the profile folder. + #[test] + fn a_timeout_stops_the_processes_the_converter_started() { + let work = WorkDir::new().unwrap(); + let heartbeat = work.0.join("heartbeat"); + let parent = stand_in("office::tests::native::starts_a_child_that_keeps_beating", &heartbeat); + assert!(matches!(run_limited(parent, Duration::from_secs(5), None), Ok(Run::TimedOut))); + let beats = || std::fs::metadata(&heartbeat).map_or(0, |m| m.len()); + // A killed process may take a moment to go. + std::thread::sleep(Duration::from_millis(300)); + let after_kill = beats(); + assert!(after_kill > 0, "the grandchild started and was beating before the timeout"); + std::thread::sleep(Duration::from_secs(1)); + assert_eq!(beats(), after_kill, "the grandchild was stopped with its parent"); + } + + /// The stand-in for `soffice`: starts [`beats_until_stopped`] and waits (run only by the + /// test above). + #[test] + #[ignore] + fn starts_a_child_that_keeps_beating() { + let Some(heartbeat) = std::env::var_os(HEARTBEAT) else { return }; + let mut child = stand_in("office::tests::native::beats_until_stopped", Path::new(&heartbeat)).spawn().unwrap(); + std::thread::sleep(Duration::from_secs(60)); + let _ = child.kill(); + let _ = child.wait(); + } + + /// The stand-in for `soffice.bin`: appends to the heartbeat file every 50 ms for a minute. + #[test] + #[ignore] + fn beats_until_stopped() { + use std::io::Write; + let Some(heartbeat) = std::env::var_os(HEARTBEAT) else { return }; + let mut file = std::fs::OpenOptions::new().create(true).append(true).open(heartbeat).unwrap(); + for _ in 0..1200 { + file.write_all(b".").unwrap(); + std::thread::sleep(Duration::from_millis(50)); + } + } + + /// Where the stand-in below writes. + const FILL_DIR: &str = "PDFCRAFT_TEST_OFFICE_FILL_DIR"; + + /// A converter that writes without end is stopped as soon as its output passes the + /// limit, not after it has run out its time. + #[test] + fn a_converter_whose_output_grows_past_the_limit_is_stopped() { + let work = WorkDir::new().unwrap(); + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + command.args(["--ignored", "--exact", "office::tests::native::writes_without_end", "--nocapture"]).env(FILL_DIR, &work.0); + command.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()); + let limit = 256 * 1024; + let started = std::time::Instant::now(); + let run = run_limited(command, Duration::from_secs(60), Some((&work.0, limit))); + assert!(matches!(run, Ok(Run::TooLarge)), "stopped for size, not for time"); + assert!(started.elapsed() < Duration::from_secs(30), "stopped long before the timeout"); + // A killed process may take a moment to go; after that nothing is written. + std::thread::sleep(Duration::from_millis(300)); + let after_kill = folder_size(&work.0); + assert!(after_kill > limit, "the stand-in had written past the limit"); + std::thread::sleep(Duration::from_secs(1)); + assert_eq!(folder_size(&work.0), after_kill, "the converter was stopped"); + } + + /// The stand-in for a LibreOffice that fills the disk: 64 KiB every 10 ms for a minute + /// (run only by the test above). + #[test] + #[ignore] + fn writes_without_end() { + use std::io::Write; + let Some(dir) = std::env::var_os(FILL_DIR) else { return }; + let mut file = std::fs::File::create(Path::new(&dir).join("document.pdf")).unwrap(); + let chunk = vec![0u8; 64 * 1024]; + for _ in 0..6000 { + file.write_all(&chunk).unwrap(); + std::thread::sleep(Duration::from_millis(10)); + } + } + + #[test] + fn a_folder_that_cannot_be_read_counts_as_empty() { + assert_eq!(folder_size(&std::env::temp_dir().join("pdfcraft-no-such-folder")), 0); + } + + /// The temporary folder holds a confidential document and its PDF: on Unix only the owner + /// may enter it. + #[cfg(unix)] + #[test] + fn the_temporary_folder_is_private_to_its_owner() { + use std::os::unix::fs::PermissionsExt; + let work = WorkDir::new().unwrap(); + let mode = std::fs::metadata(&work.0).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o700, "{mode:o}"); + } + + #[test] + fn temporary_folders_are_removed_with_their_contents() { + let work = WorkDir::new().unwrap(); + let other = WorkDir::new().unwrap(); + assert_ne!(work.0, other.0); + let dir = work.0.clone(); + std::fs::create_dir_all(dir.join("profile").join("user")).unwrap(); + std::fs::write(dir.join("document.docx"), DOCX_HEAD).unwrap(); + drop(work); + assert!(!dir.exists()); + } + + /// A real conversion, when LibreOffice is installed (skipped otherwise). + #[test] + fn libreoffice_converts_a_document_when_installed() { + let Some(program) = super::super::libreoffice() else { + eprintln!("skipped: LibreOffice isn't installed"); + return; + }; + let rtf = b"{\\rtf1\\ansi{\\fonttbl\\f0\\fswiss Helvetica;}\\f0\\pard Hello from PdfCraft.\\par}"; + let pdf = convert_with(&program, "hello.rtf", rtf, super::super::TIMEOUT).unwrap(); + assert!(pdf.starts_with(b"%PDF-")); + } + } +} diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index d2a8c143e7..f8b870832b 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -559,7 +559,7 @@ fn source_kind_tells_pdfs_images_and_text_apart() { // Text that merely starts like a BMP stays text; a truncated BMP header is not an image. assert_eq!(source_kind("b.txt", b"BMW drivers"), Some(SourceKind::Text)); assert_eq!(source_kind("b.bmp", b"BM"), None); - assert_eq!(source_kind("a.docx", b"PK\x03\x04"), None); + assert_eq!(source_kind("a.docx", b"PK\x03\x04"), Some(SourceKind::Office), "Office documents convert through LibreOffice"); assert_eq!(source_kind("", b""), None); // Only the GIF versions Create decodes count as images, so detection never promises a // conversion that then fails. @@ -2952,3 +2952,19 @@ fn stripping_extended_xmp_leaves_the_rest_of_the_jpeg() { // The helper puts a stray byte before the chunks, which the decoder skips: it stays. assert_eq!(crate::upright_image::without_extended_xmp(&flooded), [&plain[..2], b"\0".as_slice(), &plain[2..]].concat()); } + +#[test] +fn convertible_is_the_image_and_text_extensions() { + let both: Vec<&str> = IMAGE_EXTENSIONS.iter().chain(TEXT_EXTENSIONS.iter()).copied().collect(); + assert_eq!(both, CONVERTIBLE.to_vec()); + assert!(OFFICE_EXTENSIONS.iter().all(|e| !CONVERTIBLE.contains(e) && *e != "pdf")); +} + +#[test] +fn office_documents_are_their_own_kind() { + assert_eq!(source_kind("Report.docx", b"PK\x03\x04\x14\x00"), Some(SourceKind::Office)); + assert_eq!(source_kind("Budget.xls", &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]), Some(SourceKind::Office)); + // A PDF saved with a Word name is still a PDF; a ZIP that isn't named like Office is nothing. + assert_eq!(source_kind("Report.docx", b"%PDF-1.7\n"), Some(SourceKind::Pdf)); + assert_eq!(source_kind("photos.zip", b"PK\x03\x04\x14\x00"), None); +} diff --git a/crates/crypt/Cargo.toml b/crates/crypt/Cargo.toml index 9edb008f2c..dcc7d2b935 100644 --- a/crates/crypt/Cargo.toml +++ b/crates/crypt/Cargo.toml @@ -16,6 +16,7 @@ md-5 = "0.11" sha2 = "0.11" stringprep = "0.1" thiserror.workspace = true +zeroize.workspace = true [dev-dependencies] proptest.workspace = true diff --git a/crates/crypt/src/lib.rs b/crates/crypt/src/lib.rs index 9478e74130..31c4963ae5 100644 --- a/crates/crypt/src/lib.rs +++ b/crates/crypt/src/lib.rs @@ -19,6 +19,7 @@ mod rc4; use md5::{Digest as _, Md5}; use sha2::{Sha256, Sha384, Sha512}; +use zeroize::Zeroizing; pub use aes_cbc::{aes_cbc_decrypt, aes_cbc_encrypt}; pub use rc4::rc4; @@ -126,6 +127,13 @@ impl Permissions { pub fn assemble(&self) -> bool { if self.is_revision_3_or_later() { self.bit(11) } else { self.bit(4) } } + /// Take pages out into another document (Extract, Split, a source of Combine or Insert + /// Pages): [`Self::assemble`] and copying (bit 5), since the pages carry their text and + /// graphics into a new file without the document's protection (#5081). "Any except + /// extracting pages" sets bit 4 but not bit 11, so at revision 3 and later it grants neither. + pub fn extract_pages(&self) -> bool { + self.assemble() && self.copy() + } /// Revision 3 and later need bit 3 as well as bit 12. Table 22 defines bit 12 for those /// revisions only: at revision 2 the print flag (bit 3) is all there is. pub fn print_high_quality(&self) -> bool { @@ -147,9 +155,9 @@ pub enum StreamKind<'a> { #[derive(Clone, Debug)] pub struct SecurityHandler { dict: EncryptDict, - key: Vec, + key: Zeroizing>, auth: Auth, - recovered_user: Option>, + recovered_user: Option>>, methods: Methods, } @@ -190,7 +198,7 @@ impl Methods { } impl SecurityHandler { - fn new(dict: EncryptDict, key: Vec, auth: Auth, recovered_user: Option>) -> Self { + fn new(dict: EncryptDict, key: Zeroizing>, auth: Auth, recovered_user: Option>>) -> Self { let methods = Methods::new(&dict); Self { dict, key, auth, recovered_user, methods } } @@ -248,7 +256,7 @@ impl SecurityHandler { if methods.string != Method::Identity || methods.stream != Method::Identity { return None; } - Some(Self { dict, key: Vec::new(), auth: Auth::User, recovered_user: None, methods }) + Some(Self { dict, key: Zeroizing::new(Vec::new()), auth: Auth::User, recovered_user: None, methods }) } /// Whether the handler has the file key: false for [`SecurityHandler::without_key`]. @@ -289,7 +297,7 @@ impl SecurityHandler { /// For R2–R4 documents opened with the owner password: the user password, which owner /// authentication recovers (Algorithm 7). Lets components that only accept user passwords /// open the document too. - pub fn recovered_user_password(&self) -> Option> { + pub fn recovered_user_password(&self) -> Option>> { self.recovered_user.clone() } @@ -306,7 +314,7 @@ impl SecurityHandler { if !matches!(self.dict.r, 5 | 6) || !self.has_key() { return None; } - let plain = aes_cbc_decrypt(&self.key, &[0; 16], self.dict.perms.first_chunk::<16>()?, false); + let plain = Zeroizing::new(aes_cbc_decrypt(&self.key, &[0; 16], self.dict.perms.first_chunk::<16>()?, false)); let p = i32::from_le_bytes(*plain.first_chunk::<4>()?); (plain.get(9..12) == Some(&b"adb"[..])).then_some(p) } @@ -348,7 +356,7 @@ impl SecurityHandler { /// the same way, so a page it shows is one this crate can read and save. AES-128 keys are /// left as they are: `AESV2` takes a 128-bit key (Table 25), and Acrobat's extension is only /// seen with RC4. Every key PdfCraft writes under `/V 4` is 16 bytes already. - fn object_key(&self, method: Method, num: u32, generation: u16) -> Vec { + fn object_key(&self, method: Method, num: u32, generation: u16) -> Zeroizing> { if method == Method::Aes256 { return self.key.clone(); } @@ -363,8 +371,9 @@ impl SecurityHandler { if method == Method::Aes128 { h.update(b"sAlT"); } - let digest = h.finalize(); - digest[..(key.len() + 5).min(16)].to_vec() + let mut digest = Zeroizing::new(h.finalize().to_vec()); + digest.truncate((key.len() + 5).min(16)); + digest } fn apply(&self, method: Method, num: u32, generation: u16, data: &[u8], encrypt: bool) -> Vec { @@ -456,31 +465,31 @@ pub fn pdfdoc_byte(c: char) -> Option { /// Revisions 2–4: the password in PDFDocEncoding (§7.6.4.3.2, Algorithm 2), at most 32 bytes. /// Characters PDFDocEncoding lacks are dropped. -fn legacy_password(pw: &str) -> Vec { - pw.chars().filter_map(pdfdoc_byte).take(32).collect() +fn legacy_password(pw: &str) -> Zeroizing> { + Zeroizing::new(pw.chars().filter_map(pdfdoc_byte).take(32).collect()) } /// The bytes an R2–R4 password may have been written as: PDFDocEncoding, as the specification /// asks; and for a password with characters PDFDocEncoding lacks (ğ, ş, İ, …), which it gives /// no bytes, also its UTF-8 bytes, which qpdf and other tools write for such passwords. -fn legacy_candidates(pw: &str) -> Vec> { +fn legacy_candidates(pw: &str) -> Vec>> { let mut out = vec![legacy_password(pw)]; if pw.chars().any(|c| pdfdoc_byte(c).is_none()) { - out.push(pw.bytes().take(32).collect()); + out.push(Zeroizing::new(pw.bytes().take(32).collect())); } out } /// Revisions 5–6: SASLprep (RFC 4013), UTF-8, at most 127 bytes. -fn sasl_password(pw: &str) -> Vec { - let prepped = stringprep::saslprep(pw).map(|c| c.into_owned()).unwrap_or_else(|_| pw.to_string()); - let mut bytes = prepped.into_bytes(); +fn sasl_password(pw: &str) -> Zeroizing> { + let prepped = Zeroizing::new(stringprep::saslprep(pw).map(|c| c.into_owned()).unwrap_or_else(|_| pw.to_string())); + let mut bytes = Zeroizing::new(prepped.as_bytes().to_vec()); bytes.truncate(127); bytes } -fn pad(pw: &[u8]) -> [u8; 32] { - let mut out = PADDING; +fn pad(pw: &[u8]) -> Zeroizing<[u8; 32]> { + let mut out = Zeroizing::new(PADDING); let n = pw.len().min(32); out[..n].copy_from_slice(&pw[..n]); out[n..].copy_from_slice(&PADDING[..32 - n]); @@ -494,20 +503,20 @@ fn key_len(d: &EncryptDict) -> usize { // ── Revisions 2–4 ────────────────────────────────────────────────────────────────────────────── /// Algorithm 2: file key from a (user) password. -fn file_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Vec { +fn file_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Zeroizing> { let n = key_len(d); let mut h = Md5::new(); - h.update(pad(pw)); + h.update(*pad(pw)); h.update(&d.o[..d.o.len().min(32)]); h.update(d.p.to_le_bytes()); h.update(id0); if d.r >= 4 && !d.encrypt_metadata { h.update([0xFF; 4]); } - let mut digest = h.finalize().to_vec(); + let mut digest = Zeroizing::new(h.finalize().to_vec()); if d.r >= 3 { for _ in 0..50 { - digest = Md5::digest(&digest[..n]).to_vec(); + digest = Zeroizing::new(Md5::digest(&digest[..n]).to_vec()); } } digest.truncate(n); @@ -524,7 +533,7 @@ fn u_value_r4(d: &EncryptDict, id0: &[u8], key: &[u8]) -> Vec { h.update(id0); let mut x = rc4(key, &h.finalize()); for i in 1..=19u8 { - let k: Vec = key.iter().map(|b| b ^ i).collect(); + let k = Zeroizing::new(key.iter().map(|b| b ^ i).collect::>()); x = rc4(&k, &x); } x.extend_from_slice(&[0u8; 16]); @@ -536,7 +545,7 @@ fn u_value_r4(d: &EncryptDict, id0: &[u8], key: &[u8]) -> Vec { /// Some producers ignore `/EncryptMetadata false` when deriving the key (they omit the /// `FF FF FF FF` of Algorithm 2, step f). Viewers open those files, so if the strict derivation /// fails we retry the other way. -fn user_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option> { +fn user_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option>> { let check = |d: &EncryptDict| { let key = file_key_r4(d, id0, pw); let u = u_value_r4(d, id0, &key); @@ -547,54 +556,57 @@ fn user_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option> { } /// The RC4 key derived from an owner password (Algorithm 3, steps a–d). -fn owner_rc4_key(d: &EncryptDict, owner_pw: &[u8]) -> Vec { +fn owner_rc4_key(d: &EncryptDict, owner_pw: &[u8]) -> Zeroizing> { let n = key_len(d); - let mut digest = Md5::digest(pad(owner_pw)).to_vec(); + let mut digest = Zeroizing::new(Md5::digest(*pad(owner_pw)).to_vec()); if d.r >= 3 { for _ in 0..50 { - digest = Md5::digest(&digest).to_vec(); + digest = Zeroizing::new(Md5::digest(&*digest).to_vec()); } } digest.truncate(n); digest } +type SecretBytes = Zeroizing>; + /// Algorithm 7: authenticate an owner password (recover the user password from `/O`). /// Returns the file key and the recovered user password (padding removed). -fn owner_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option<(Vec, Vec)> { +fn owner_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option<(SecretBytes, SecretBytes)> { let rk = owner_rc4_key(d, pw); - let mut user = d.o[..d.o.len().min(32)].to_vec(); + let mut user = Zeroizing::new(d.o[..d.o.len().min(32)].to_vec()); if d.r == 2 { - user = rc4(&rk, &user); + user = Zeroizing::new(rc4(&rk, &user)); } else { for i in (0..=19u8).rev() { - let k: Vec = rk.iter().map(|b| b ^ i).collect(); - user = rc4(&k, &user); + let k = Zeroizing::new(rk.iter().map(|b| b ^ i).collect::>()); + user = Zeroizing::new(rc4(&k, &user)); } } let key = user_key_r4(d, id0, &user)?; // The recovered value is the padded password: strip the padding suffix. let len = (0..=32).find(|&i| user.len() >= 32 && user[i..32] == PADDING[..32 - i]).unwrap_or(user.len().min(32)); - Some((key, user[..len].to_vec())) + user.truncate(len); + Some((key, user)) } // ── Revisions 5–6 ────────────────────────────────────────────────────────────────────────────── /// The hash of a password for R5 (SHA-256) or R6 (Algorithm 2.B). -fn hash_r6(r: i64, pw: &[u8], salt: &[u8], udata: &[u8]) -> Vec { - let mut k = { +fn hash_r6(r: i64, pw: &[u8], salt: &[u8], udata: &[u8]) -> Zeroizing> { + let mut k = Zeroizing::new({ let mut h = Sha256::new(); h.update(pw); h.update(salt); h.update(udata); h.finalize().to_vec() - }; + }); if r == 5 { return k; } let mut round = 0u32; loop { - let mut k1 = Vec::with_capacity(64 * (pw.len() + k.len() + udata.len())); + let mut k1 = Zeroizing::new(Vec::with_capacity(64 * (pw.len() + k.len() + udata.len()))); for _ in 0..64 { k1.extend_from_slice(pw); k1.extend_from_slice(&k); @@ -603,13 +615,13 @@ fn hash_r6(r: i64, pw: &[u8], salt: &[u8], udata: &[u8]) -> Vec { // `k` is always a SHA-2 digest (32, 48 or 64 bytes): key = its first 16 bytes, IV = the next 16. let Some((key, rest)) = k.split_first_chunk::<16>() else { break }; let Some(iv) = rest.first_chunk::<16>() else { break }; - let e = aes_cbc_encrypt(key, iv, &k1, false); + let e = Zeroizing::new(aes_cbc_encrypt(key, iv, &k1, false)); let m = e.iter().take(16).map(|b| u32::from(*b)).sum::() % 3; - k = match m { - 0 => Sha256::digest(&e).to_vec(), - 1 => Sha384::digest(&e).to_vec(), - _ => Sha512::digest(&e).to_vec(), - }; + k = Zeroizing::new(match m { + 0 => Sha256::digest(&*e).to_vec(), + 1 => Sha384::digest(&*e).to_vec(), + _ => Sha512::digest(&*e).to_vec(), + }); round += 1; if round >= 64 && e.last().is_none_or(|b| u32::from(*b) <= round - 32) { break; @@ -620,19 +632,19 @@ fn hash_r6(r: i64, pw: &[u8], salt: &[u8], udata: &[u8]) -> Vec { } /// Algorithm 2.A: authenticate as owner (`owner = true`) or user; returns the file key. -fn key_r6(d: &EncryptDict, pw: &[u8], owner: bool) -> Result>, CryptError> { +fn key_r6(d: &EncryptDict, pw: &[u8], owner: bool) -> Result>>, CryptError> { if d.u.len() < 48 || d.o.len() < 48 { return Err(CryptError::Malformed("/O and /U must be 48 bytes for R5/R6".into())); } let (hashed, encrypted, udata) = if owner { (&d.o[..48], &d.oe, &d.u[..48]) } else { (&d.u[..48], &d.ue, &[][..]) }; - if hash_r6(d.r, pw, &hashed[32..40], udata) != hashed[..32] { + if *hash_r6(d.r, pw, &hashed[32..40], udata) != hashed[..32] { return Ok(None); } if encrypted.len() < 32 { return Err(CryptError::Malformed("/OE or /UE must be 32 bytes".into())); } let k = hash_r6(d.r, pw, &hashed[40..48], udata); - Ok(Some(aes_cbc_decrypt(&k, &[0; 16], &encrypted[..32], false))) + Ok(Some(Zeroizing::new(aes_cbc_decrypt(&k, &[0; 16], &encrypted[..32], false)))) } // ── Creating encryption ──────────────────────────────────────────────────────────────────────── @@ -654,7 +666,7 @@ pub enum Algorithm { /// dropped), at most 32 bytes; for R6 its SASLprep (RFC 4013: soft hyphens and the like removed, /// other spaces made plain, then NFKC; the password as given if SASLprep refuses it), in UTF-8, /// at most 127 bytes. -fn prepared_password(algorithm: Algorithm, password: &str) -> Vec { +fn prepared_password(algorithm: Algorithm, password: &str) -> Zeroizing> { match algorithm { Algorithm::Rc4_40 | Algorithm::Rc4_128 | Algorithm::Aes128 => legacy_password(password), Algorithm::Aes256 => sasl_password(password), @@ -672,7 +684,7 @@ pub fn effective_password(algorithm: Algorithm, password: &str) -> Vec { let prepared = prepared_password(algorithm, password); match algorithm { Algorithm::Rc4_40 | Algorithm::Rc4_128 | Algorithm::Aes128 => pad(&prepared).to_vec(), - Algorithm::Aes256 => prepared, + Algorithm::Aes256 => prepared.to_vec(), } } @@ -737,10 +749,10 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result= 3 { for i in 1..=19u8 { - let k: Vec = rk.iter().map(|b| b ^ i).collect(); + let k = Zeroizing::new(rk.iter().map(|b| b ^ i).collect::>()); o = rc4(&k, &o); } } @@ -754,15 +766,15 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result> = &h.key; + let _: &Option>> = &h.recovered_user; + assert_eq!(h.recovered_user_password().as_deref().map(Vec::as_slice), Some(&b"user pw"[..]), "{alg:?}"); assert_eq!(SecurityHandler::open(d, ID, Some("user pw")).unwrap().recovered_user_password(), None); } let d = new(Algorithm::Rc4_128, "", "owner", -1).dict().clone(); - assert_eq!(SecurityHandler::open(d, ID, Some("owner")).unwrap().recovered_user_password().as_deref(), Some(&b""[..])); + assert_eq!(SecurityHandler::open(d, ID, Some("owner")).unwrap().recovered_user_password().as_deref().map(Vec::as_slice), Some(&b""[..])); } /// /O, /U and the first /ID of files qpdf 12.4.2 wrote (`--static-id`), with the file keys @@ -393,7 +412,7 @@ fn legacy_passwords_are_pdfdoc_encoded_with_a_utf8_fallback() { assert_eq!((user.auth(), user.file_key().to_vec()), (Auth::User, hex("3564ffcea186122595ad55ee157da71f"))); let owner = SecurityHandler::open(r3.clone(), &id, Some("Öğretmen")).unwrap(); assert_eq!((owner.auth(), owner.file_key().to_vec()), (Auth::Owner, hex("3564ffcea186122595ad55ee157da71f"))); - assert_eq!(owner.recovered_user_password(), Some(vec![0x9A, b'l', 0x9A, b'k', 0xA0])); + assert_eq!(owner.recovered_user_password(), Some(Zeroizing::new(vec![0x9A, b'l', 0x9A, b'k', 0xA0]))); // Dropping ı and € (the old Latin-1 conversion) or a near miss doesn't open it. for wrong in ["lk", "ilik€", "ılık", "Ogretmen"] { assert_eq!(SecurityHandler::open(r3.clone(), &id, Some(wrong)).unwrap_err(), CryptError::WrongPassword, "{wrong}"); @@ -423,6 +442,6 @@ fn pdfdoc_encoding_round_trips() { for c in ['ğ', 'ş', 'İ', '\u{A0}', '\u{85}', 'Ж', '日'] { assert_eq!(pdfdoc_byte(c), None, "{c}"); } - assert_eq!(legacy_candidates("Çok"), [vec![0xC7, b'o', b'k']]); - assert_eq!(legacy_candidates("şifre"), [b"ifre".to_vec(), "şifre".as_bytes().to_vec()]); + assert_eq!(legacy_candidates("Çok"), [Zeroizing::new(vec![0xC7, b'o', b'k'])]); + assert_eq!(legacy_candidates("şifre"), [Zeroizing::new(b"ifre".to_vec()), Zeroizing::new("şifre".as_bytes().to_vec())]); } diff --git a/crates/edit/src/added/store.rs b/crates/edit/src/added/store.rs index a5b53581fc..1ee4c9c4cd 100644 --- a/crates/edit/src/added/store.rs +++ b/crates/edit/src/added/store.rs @@ -119,6 +119,8 @@ pub fn scan_markers(doc: &Document) -> MarkerScan { Some(MarkKind::Watermark) } else if tag == Some(&b"Background"[..]) { Some(MarkKind::Background) + } else if tag == Some(&b"PrinterMarks"[..]) { + Some(MarkKind::PrinterMarks) } else { None }; diff --git a/crates/edit/src/colour.rs b/crates/edit/src/colour.rs new file mode 100644 index 0000000000..7eaab55384 --- /dev/null +++ b/crates/edit/src/colour.rs @@ -0,0 +1,230 @@ +//! Colours as sRGB through the colour space in force, for Convert colours: `g`/`rg`/`k`, and +//! `cs` + `sc`/`scn` through the space named. Device spaces, ICCBased by its component count (or +//! its Alternate), CalRGB and CalGray as their device kin, Lab through CIE XYZ with the +//! dictionary's white point, Indexed through its table, Separation and DeviceN through a Type 2 +//! (exponential) tint transform into the alternate space; a sampled or PostScript tint function, +//! or a pattern, falls back to a plain darkness so nothing reads as the wrong hue. + +use pdfcraft_cos::{Dict, Document, Object}; + +/// A colour space as far as a fill colour needs it. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Space { + Gray, + Rgb, + Cmyk, + Lab { + white: [f64; 3], + range: [f64; 4], + }, + Indexed { + base: Box, + hival: usize, + table: Vec, + }, + /// Separation and DeviceN: the number of inputs, the alternate space, and the tint + /// transform when it is a Type 2 function (C0, C1, N). + Tint { + inputs: usize, + alt: Box, + exp: Option<(Vec, Vec, f64)>, + }, + Pattern, +} + +impl Space { + /// How many numbers set a colour in this space. + fn components(&self) -> usize { + match self { + Space::Gray => 1, + Space::Rgb | Space::Lab { .. } => 3, + Space::Cmyk => 4, + Space::Indexed { .. } => 1, + Space::Tint { inputs, .. } => *inputs, + Space::Pattern => 0, + } + } +} + +fn nums(doc: &Document, o: &Object) -> Vec { + doc.resolve(o).as_array().map(|a| a.iter().filter_map(|v| doc.resolve(v).as_f64()).collect()).unwrap_or_default() +} + +/// The colour space an operand of `cs` names: a device space, or a name in the resources' +/// `/ColorSpace` dictionary, or (inside arrays) a family. +pub(crate) fn space_of(doc: &Document, operand: &Object, cs_res: &Dict, depth: u8) -> Space { + if depth > 4 { + return Space::Gray; + } + let resolved = doc.resolve(operand); + match &*resolved { + Object::Name(n) => match n.as_slice() { + b"DeviceGray" | b"G" | b"CalGray" => Space::Gray, + b"DeviceRGB" | b"RGB" | b"CalRGB" => Space::Rgb, + b"DeviceCMYK" | b"CMYK" => Space::Cmyk, + b"Pattern" => Space::Pattern, + other => match cs_res.get(other) { + Some(o) => space_of(doc, o, &Dict::default(), depth + 1), + None => Space::Gray, + }, + }, + Object::Array(a) => { + let family = a.first().map(|f| doc.resolve(f)).and_then(|f| f.as_name().map(|n| n.to_vec())).unwrap_or_default(); + match family.as_slice() { + b"ICCBased" => { + let stream = a.get(1).map(|s| doc.resolve(s)); + let dict = stream.as_ref().and_then(|s| match &**s { + Object::Stream(st) => Some(st.dict.clone()), + _ => None, + }); + if let Some(alt) = dict.as_ref().and_then(|d| d.get(b"Alternate").cloned()) { + return space_of(doc, &alt, cs_res, depth + 1); + } + match dict.and_then(|d| d.int(b"N")) { + Some(1) => Space::Gray, + Some(4) => Space::Cmyk, + _ => Space::Rgb, + } + } + b"CalRGB" => Space::Rgb, + b"CalGray" => Space::Gray, + b"Lab" => { + let d = a.get(1).map(|d| doc.resolve(d)).and_then(|d| d.as_dict().cloned()).unwrap_or_default(); + let wp = d.get(b"WhitePoint").map(|w| nums(doc, w)).unwrap_or_default(); + let white = if wp.len() == 3 { [wp[0], wp[1], wp[2]] } else { [0.9505, 1.0, 1.089] }; + let r = d.get(b"Range").map(|w| nums(doc, w)).unwrap_or_default(); + let range = if r.len() == 4 { [r[0], r[1], r[2], r[3]] } else { [-100.0, 100.0, -100.0, 100.0] }; + Space::Lab { white, range } + } + b"Indexed" | b"I" => { + let base = a.get(1).map(|b| space_of(doc, b, cs_res, depth + 1)).unwrap_or(Space::Rgb); + let hival = a.get(2).and_then(|h| doc.resolve(h).as_f64()).unwrap_or(0.0).clamp(0.0, 255.0) as usize; + let table = match a.get(3).map(|t| doc.resolve(t)) { + Some(t) => match &*t { + Object::String(s) => s.bytes.clone(), + Object::Stream(st) => st.decoded().unwrap_or_default(), + _ => Vec::new(), + }, + None => Vec::new(), + }; + Space::Indexed { base: Box::new(base), hival, table } + } + b"Separation" | b"DeviceN" => { + // The `/None` colorant paints nothing (ISO 32000-2, 8.6.6.4): given a colour + // of its own it would appear, so it stays what it is, as a pattern does. + let is_none = |o: &Object| doc.resolve(o).as_name() == Some(b"None"); + let none = a.get(1).is_some_and(|names| { + is_none(names) || doc.resolve(names).as_array().is_some_and(|names| !names.is_empty() && names.iter().all(is_none)) + }); + if none { + return Space::Pattern; + } + let inputs = if family == b"Separation" { + 1 + } else { + a.get(1).map(|n| doc.resolve(n)).and_then(|n| n.as_array().map(Vec::len)).unwrap_or(1).max(1) + }; + let alt = a.get(2).map(|b| space_of(doc, b, cs_res, depth + 1)).unwrap_or(Space::Gray); + let exp = a.get(3).map(|f| doc.resolve(f)).and_then(|f| { + let d = match &*f { + Object::Dict(d) => d.clone(), + Object::Stream(s) => s.dict.clone(), + _ => return None, + }; + if d.int(b"FunctionType") != Some(2) { + return None; + } + let c0 = d.get(b"C0").map(|v| nums(doc, v)).unwrap_or_else(|| vec![0.0]); + let c1 = d.get(b"C1").map(|v| nums(doc, v)).unwrap_or_else(|| vec![1.0]); + let n = d.get(b"N").and_then(|v| doc.resolve(v).as_f64()).unwrap_or(1.0); + Some((c0, c1, n)) + }); + Space::Tint { inputs, alt: Box::new(alt), exp } + } + b"Pattern" => Space::Pattern, + b"DeviceGray" | b"G" => Space::Gray, + b"DeviceRGB" | b"RGB" => Space::Rgb, + b"DeviceCMYK" | b"CMYK" => Space::Cmyk, + _ => Space::Gray, + } + } + _ => Space::Gray, + } +} + +fn clamp01(v: f64) -> f64 { + if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 } +} + +/// CMYK to sRGB, the usual multiplicative approximation (no ICC profile). +pub(crate) fn cmyk(c: f64, m: f64, y: f64, k: f64) -> [f64; 3] { + let (c, m, y, k) = (clamp01(c), clamp01(m), clamp01(y), clamp01(k)); + [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)] +} + +/// CIE L*a*b* (with the dictionary's white point) to sRGB, through XYZ and the D65 sRGB matrix. +fn lab(white: [f64; 3], l: f64, a: f64, b: f64) -> [f64; 3] { + let m = (l + 16.0) / 116.0; + let (ll, mm, nn) = (m + a / 500.0, m, m - b / 200.0); + let g = |x: f64| if x >= 6.0 / 29.0 { x * x * x } else { 108.0 / 841.0 * (x - 4.0 / 29.0) }; + let (xw, yw, zw) = (white[0].max(1e-6), white[1].max(1e-6), white[2].max(1e-6)); + let (x, y, z) = (xw * g(ll), yw * g(mm), zw * g(nn)); + // Scale the white point to D65 before the sRGB matrix (a plain von Kries over XYZ). + let (x, y, z) = (x * 0.9505 / xw, y / yw, z * 1.089 / zw); + let lin = [3.2406 * x - 1.5372 * y - 0.4986 * z, -0.9689 * x + 1.8758 * y + 0.0415 * z, 0.0557 * x - 0.2040 * y + 1.0570 * z]; + lin.map(|c| { + let c = clamp01(c); + if c <= 0.0031308 { 12.92 * c } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 } + }) +} + +/// The sRGB of `values` in `space` (missing values read as 0; a pattern reads as black). +pub(crate) fn rgb(space: &Space, values: &[f64]) -> [f64; 3] { + let v = |i: usize| values.get(i).copied().unwrap_or(0.0); + match space { + Space::Gray => { + let g = clamp01(v(0)); + [g, g, g] + } + Space::Rgb => [clamp01(v(0)), clamp01(v(1)), clamp01(v(2))], + Space::Cmyk => cmyk(v(0), v(1), v(2), v(3)), + Space::Lab { white, range } => { + let l = v(0).clamp(0.0, 100.0); + let a = v(1).clamp(range[0].min(range[1]), range[1].max(range[0])); + let b = v(2).clamp(range[2].min(range[3]), range[3].max(range[2])); + lab(*white, l, a, b) + } + Space::Indexed { base, hival, table } => { + let n = base.components().max(1); + let i = (v(0).max(0.0).round() as usize).min(*hival); + let vals: Vec = (0..n).map(|k| table.get(i * n + k).map(|b| *b as f64 / 255.0).unwrap_or(0.0)).collect(); + // Lab tables hold the L*, a*, b* range in bytes; the others are 0..1 already. + match &**base { + Space::Lab { range, .. } => { + let scaled = [ + vals[0] * 100.0, + range[0] + vals.get(1).copied().unwrap_or(0.0) * (range[1] - range[0]), + range[2] + vals.get(2).copied().unwrap_or(0.0) * (range[3] - range[2]), + ]; + rgb(base, &scaled) + } + _ => rgb(base, &vals), + } + } + Space::Tint { inputs, alt, exp } => match exp { + Some((c0, c1, n)) => { + let t = clamp01(v(0)); + let k = if *n == 1.0 { t } else { t.powf(*n) }; + let out: Vec = c0.iter().zip(c1.iter()).map(|(a, b)| a + k * (b - a)).collect(); + rgb(alt, &out) + } + // No usable transform: the darkness of the strongest tint, in the alternate space's ink. + None => { + let t = (0..*inputs).map(v).fold(0.0, f64::max); + let g = 1.0 - clamp01(t); + [g, g, g] + } + }, + Space::Pattern => [0.0, 0.0, 0.0], + } +} diff --git a/crates/edit/src/convert_colours.rs b/crates/edit/src/convert_colours.rs new file mode 100644 index 0000000000..7134e537d8 --- /dev/null +++ b/crates/edit/src/convert_colours.rs @@ -0,0 +1,492 @@ +//! Convert colours (Acrobat's Print production ▸ Convert Colors): every colour +//! operator in the page content and the artwork it draws is rewritten in a target space - +//! grayscale, RGB or CMYK - by the standard device formulas (no ICC profile is applied), black +//! preserved on request; pictures are re-encoded through a helper the caller supplies. +//! +//! This is not a prepress separation. No ICC profile, rendering intent or output intent is +//! used, so the numbers are not calibrated for any press, and the catalog's `/OutputIntents` +//! stay as they were. A PDF/A or PDF/X file may no longer conform afterwards, and still says it +//! does: nothing here changes what it declares (the `doc_convert_colours` tool lists what it +//! declared; `standards_remove_id` takes that off). +//! +//! Spot colours are converted, not kept: Separation and DeviceN through a Type 2 tint transform +//! into their alternate space, any other tint transform as a gray of the strongest tint's +//! darkness (the hue is lost). `/All` converts like any other; `/None` paints nothing and stays. +//! Patterns, shadings, inline images and transparency group spaces stay as they are. +//! +//! Soft masks are not touched: a picture keeps the one it has. The helper leaves a picture alone +//! when it cannot re-encode it (indexed, CMYK, not 8-bit, an explicit mask, a matted soft mask, +//! JPEG 2000 ...); those that are not in the target already are counted in +//! [`Converted::images_left`]. + +use pdfcraft_content::{Op, parse, serialize_ops}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; +use std::collections::{HashMap, HashSet}; + +use crate::colour::{Space, rgb, space_of}; +use crate::page_streams::PageStreams; +use crate::{EditError, check, page_list}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ColourTarget { + Gray, + Rgb, + Cmyk, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ConvertColours { + pub target: ColourTarget, + /// Pure black stays 100% K (or gray 0) rather than a mix. + pub preserve_black: bool, + /// Re-encode pictures too (through the caller's helper). + pub images: bool, + /// The appearance streams of the pages' comments and form fields too. + pub annotations: bool, +} + +impl Default for ConvertColours { + fn default() -> Self { + Self { target: ColourTarget::Gray, preserve_black: true, images: true, annotations: false } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Converted { + /// Colour operators rewritten. + pub colours: usize, + /// Pictures re-encoded. + pub images: usize, + /// Pictures not in the target space that could not be re-encoded and are left as they were. + pub images_left: usize, + /// Pages on which something changed. + pub pages: usize, +} + +/// Re-encode one image stream in the target space, if it can be: the caller's (the optimizer +/// knows how to decode and encode pictures; the edit crate does not). +pub type ImageRecolour<'a> = dyn FnMut(&Document, &Stream, ColourTarget) -> Option + 'a; + +fn luma(c: [f64; 3]) -> f64 { + (0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]).clamp(0.0, 1.0) +} + +/// sRGB → the target's components. +pub fn components(target: ColourTarget, c: [f64; 3], preserve_black: bool) -> Vec { + let black = c.iter().all(|v| *v < 0.02); + match target { + ColourTarget::Gray => vec![if black && preserve_black { 0.0 } else { luma(c) }], + ColourTarget::Rgb => c.to_vec(), + ColourTarget::Cmyk => { + if black && preserve_black { + return vec![0.0, 0.0, 0.0, 1.0]; + } + let k = 1.0 - c.iter().cloned().fold(0.0, f64::max); + if k >= 0.999 { + return vec![0.0, 0.0, 0.0, 1.0]; + } + let f = |v: f64| ((1.0 - v - k) / (1.0 - k)).clamp(0.0, 1.0); + vec![f(c[0]), f(c[1]), f(c[2]), k] + } + } +} + +fn op_for(target: ColourTarget, stroke: bool) -> &'static str { + match (target, stroke) { + (ColourTarget::Gray, false) => "g", + (ColourTarget::Gray, true) => "G", + (ColourTarget::Rgb, false) => "rg", + (ColourTarget::Rgb, true) => "RG", + (ColourTarget::Cmyk, false) => "k", + (ColourTarget::Cmyk, true) => "K", + } +} + +/// Whether a `cs` operand names DeviceCMYK itself (directly or through a `/ColorSpace` +/// resource), whose initial colour is black, unlike an ICC CMYK space's all-zero white. +fn device_cmyk(doc: &Document, operand: &Object, cs_res: &Dict) -> bool { + let is_device = |o: &Object| match &*doc.resolve(o) { + Object::Name(n) => matches!(n.as_slice(), b"DeviceCMYK" | b"CMYK"), + Object::Array(a) => a.len() == 1 && a.first().and_then(Object::as_name).is_some_and(|n| matches!(n, b"DeviceCMYK" | b"CMYK")), + _ => false, + }; + match operand.as_name() { + Some(n) => is_device(operand) || cs_res.get(n).is_some_and(is_device), + None => is_device(operand), + } +} + +fn r3(v: f64) -> Object { + Object::Real((v * 1000.0).round() / 1000.0) +} + +/// The current fill and stroke spaces (None = a device space set by g/rg/k, which the +/// converted operator sets again), with the q/Q stack: what one content stream leaves for the +/// next on the same page. +#[derive(Clone, Debug, Default)] +struct State { + fill: Option, + stroke: Option, + stack: Vec<(Option, Option)>, +} + +/// Rewrite one content stream's colour operators, starting from `state` and leaving in it the +/// state at the stream's end. `cs_res` is its `/ColorSpace` resource dictionary. Returns the +/// new bytes when anything changed, and how many operators. +fn rewrite(data: &[u8], state: &mut State, doc: &Document, cs_res: &Dict, settings: &ConvertColours) -> (Option>, usize) { + let mut ops: Vec = parse(data).ops; + let mut count = 0usize; + let mut out: Vec = Vec::with_capacity(ops.len()); + let State { fill, stroke, stack } = state; + let numbers = |op: &Op| -> Vec { op.operands.iter().filter_map(Object::as_f64).collect() }; + let convert = |c: [f64; 3], is_stroke: bool| -> Op { + Op::new(op_for(settings.target, is_stroke), components(settings.target, c, settings.preserve_black).into_iter().map(r3).collect()) + }; + for op in ops.drain(..) { + match op.op.as_slice() { + b"q" => { + stack.push((fill.clone(), stroke.clone())); + if stack.len() > 512 { + stack.remove(0); + } + out.push(op); + } + b"Q" => { + if let Some((f, s)) = stack.pop() { + *fill = f; + *stroke = s; + } + out.push(op); + } + b"cs" | b"CS" => { + let is_stroke = op.op.as_slice() == b"CS"; + let space = op.operands.first().map(|o| space_of(doc, o, cs_res, 0)).unwrap_or(Space::Gray); + let pattern = space == Space::Pattern; + if is_stroke { + *stroke = Some(space); + } else { + *fill = Some(space); + } + if pattern { + // Patterns stay what they are (their own content is not converted here). + out.push(op); + } else { + // The space's initial colour, now in the target: black (a tint at full, + // DeviceCMYK's 0 0 0 1, ISO 32000-1 8.6.5.5). + let space = if is_stroke { stroke.clone() } else { fill.clone() }.unwrap_or(Space::Gray); + let initial = match space { + Space::Tint { .. } => rgb(&space, &[1.0; 8]), + Space::Cmyk if op.operands.first().is_some_and(|o| device_cmyk(doc, o, cs_res)) => rgb(&space, &[0.0, 0.0, 0.0, 1.0]), + _ => rgb(&space, &[]), + }; + out.push(convert(initial, is_stroke)); + count += 1; + } + } + b"g" | b"rg" | b"k" | b"G" | b"RG" | b"K" => { + let is_stroke = op.op[0].is_ascii_uppercase(); + let space = match op.op.to_ascii_lowercase().as_slice() { + b"g" => Space::Gray, + b"rg" => Space::Rgb, + _ => Space::Cmyk, + }; + let already = matches!( + (settings.target, &space), + (ColourTarget::Gray, Space::Gray) | (ColourTarget::Rgb, Space::Rgb) | (ColourTarget::Cmyk, Space::Cmyk) + ); + if is_stroke { + *stroke = None; + } else { + *fill = None; + } + if already { + out.push(op); + } else { + let c = rgb(&space, &numbers(&op)); + out.push(convert(c, is_stroke)); + count += 1; + } + } + b"sc" | b"scn" | b"SC" | b"SCN" => { + let is_stroke = op.op[0].is_ascii_uppercase(); + let space = if is_stroke { stroke.clone() } else { fill.clone() }; + match space { + Some(Space::Pattern) | None if op.operands.iter().any(|o| matches!(o, Object::Name(_))) => out.push(op), + None => { + // sc after g/rg/k: the device space those set; read the numbers by count. + let n = numbers(&op); + let space = match n.len() { + 1 => Space::Gray, + 4 => Space::Cmyk, + _ => Space::Rgb, + }; + out.push(convert(rgb(&space, &n), is_stroke)); + count += 1; + } + Some(Space::Pattern) => out.push(op), + Some(space) => { + out.push(convert(rgb(&space, &numbers(&op)), is_stroke)); + count += 1; + } + } + } + _ => out.push(op), + } + } + ((count > 0).then(|| serialize_ops(&out)), count) +} + +/// Whether `space`'s colours are `target`'s (an indexed space's are its base's). +fn in_target(space: &Space, target: ColourTarget) -> bool { + match (space, target) { + (Space::Gray, ColourTarget::Gray) | (Space::Rgb, ColourTarget::Rgb) | (Space::Cmyk, ColourTarget::Cmyk) => true, + (Space::Indexed { base, .. }, _) => in_target(base, target), + _ => false, + } +} + +/// Whether the picture `s` is outside `target`: a stencil mask has no colours of its own (it +/// paints with the fill colour, which is converted). +fn needs_converting(doc: &Document, s: &Stream, target: ColourTarget) -> bool { + let stencil = s.dict.get(b"ImageMask").is_some_and(|m| matches!(&*doc.resolve(m), Object::Bool(true))); + !stencil && !s.dict.get(b"ColorSpace").is_some_and(|cs| in_target(&space_of(doc, cs, &Dict::default(), 0), target)) +} + +fn stream_of(doc: &Document, r: ObjRef) -> Option { + match &*doc.get(r) { + Object::Stream(s) => Some(s.clone()), + _ => None, + } +} + +fn cs_resources(doc: &Document, resources: Option<&Dict>) -> Dict { + resources.and_then(|r| r.get(b"ColorSpace")).map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned()).unwrap_or_default() +} + +/// What a run keeps across pages: each XObject already handled, and the object that now +/// stands for it (itself, or the converted copy made because a page not being converted draws +/// it too); and the XObjects those other pages draw. +struct Run { + done: HashMap, + shared: HashSet, +} + +/// The XObjects (forms and images, and those the forms draw), and appearance streams, that +/// the pages not in `pages` draw: converting one of those in place would change those pages. +fn drawn_elsewhere(doc: &Document, pages: &[usize]) -> HashSet { + let mut seen: HashSet = HashSet::new(); + let mut todo: Vec = Vec::new(); + let xobjects_of = |res: Option<&Object>, todo: &mut Vec| { + if let Some(xo) = res + .map(|r| doc.resolve(r)) + .and_then(|r| r.as_dict().and_then(|d| d.get(b"XObject")).map(|x| doc.resolve(x))) + .and_then(|x| x.as_dict().cloned()) + { + todo.extend(xo.iter().map(|(_, o)| o.clone())); + } + }; + for (i, page) in page_list(doc).into_iter().enumerate() { + if pages.contains(&i) { + continue; + } + xobjects_of(page.dict.get(b"Resources"), &mut todo); + let annots = page.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); + for a in annots { + let Some(ap) = doc.resolve(&a).as_dict().and_then(|d| d.get(b"AP").cloned()) else { continue }; + let Some(ap) = doc.resolve(&ap).as_dict().cloned() else { continue }; + for (_, v) in ap.iter() { + match &*doc.resolve(v) { + Object::Dict(states) => todo.extend(states.iter().map(|(_, s)| s.clone())), + _ => todo.push(v.clone()), + } + } + } + } + while let Some(o) = todo.pop() { + let Some(r) = o.as_ref() else { continue }; + if !seen.insert(r) { + continue; + } + let res = match &*doc.get(r) { + Object::Stream(s) => s.dict.get(b"Resources").cloned(), + _ => None, + }; + xobjects_of(res.as_ref(), &mut todo); + } + seen +} + +/// Store a converted XObject: in place, or, when another page draws `r` too, as a new object +/// the caller points to instead. Returns the object that now stands for `r`. +fn put(doc: &mut Document, run: &mut Run, r: ObjRef, obj: Object) -> ObjRef { + let to = if run.shared.contains(&r) { + doc.add(obj) + } else { + doc.set(r, obj); + r + }; + run.done.insert(r, to); + to +} + +/// Convert the XObjects of `resources`: forms rewritten (and their own XObjects after them), +/// images through `recolour`. Each object is converted once; one that a page not being +/// converted draws is converted as a copy, and the returned `/XObject` dictionary, when there +/// is one, points at the copies (the caller puts it in `resources`' place). When `may_copy` is +/// false the caller cannot do that, so such objects are left alone. +fn convert_xobjects( + doc: &mut Document, + resources: Option<&Dict>, + settings: &ConvertColours, + recolour: &mut ImageRecolour<'_>, + run: &mut Run, + result: &mut Converted, + may_copy: bool, +) -> Option { + let xo = resources.and_then(|r| r.get(b"XObject")).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned())?; + let mut repointed = xo.clone(); + let mut changed = false; + for (name, o) in xo.iter() { + let Some(r) = o.as_ref() else { continue }; + if let Some(&to) = run.done.get(&r) { + if to != r { + repointed.set(name.clone(), Object::Ref(to)); + changed = true; + } + continue; + } + if !may_copy && run.shared.contains(&r) { + continue; + } + // Marked before converting, so a form that draws itself ends. + run.done.insert(r, r); + let Some(s) = stream_of(doc, r) else { continue }; + let stored = match s.dict.name(b"Subtype") { + Some(b"Form") => { + let res = s.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()); + let cs = cs_resources(doc, res.as_ref()); + let (new, n) = match s.decoded() { + Ok(data) => rewrite(&data, &mut State::default(), doc, &cs, settings), + Err(_) => (None, 0), + }; + let inner = convert_xobjects(doc, res.as_ref(), settings, recolour, run, result, true); + if new.is_none() && inner.is_none() { + None + } else { + let mut form = s.clone(); + if let Some(xo) = inner { + let mut res = res.unwrap_or_default(); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + form.dict.set(b"Resources".to_vec(), Object::Dict(res)); + } + if let Some(bytes) = new { + let mut dict = form.dict; + for k in [&b"Length"[..], b"Filter", b"DecodeParms"] { + dict.remove(k); + } + form = Stream::flate(dict, &bytes); + result.colours += n; + } + Some(put(doc, run, r, Object::Stream(form))) + } + } + Some(b"Image") if settings.images => match recolour(doc, &s, settings.target) { + Some(new) => { + result.images += 1; + Some(put(doc, run, r, Object::Stream(new))) + } + None => { + result.images_left += usize::from(needs_converting(doc, &s, settings.target)); + None + } + }, + _ => None, + }; + if let Some(to) = stored + && to != r + { + repointed.set(name.clone(), Object::Ref(to)); + changed = true; + } + } + changed.then_some(repointed) +} + +/// Convert the colours on `pages` (0-based). An object that pages not in `pages` also draw is +/// converted as a copy for these pages, so the others keep their colours. +pub fn convert_colours( + doc: &mut Document, + pages: &[usize], + settings: &ConvertColours, + recolour: &mut ImageRecolour<'_>, +) -> Result { + let all = page_list(doc); + check(pages, all.len())?; + let mut result = Converted::default(); + let mut run = Run { done: HashMap::new(), shared: drawn_elsewhere(doc, pages) }; + for &i in pages { + let Some(page) = page_list(doc).get(i).cloned() else { continue }; + let resources = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()); + let cs = cs_resources(doc, resources.as_ref()); + let before = (result.colours, result.images); + // Each content stream on its own (state carries across them); a page with a stream + // that cannot be read keeps its content as it is. + if let Some(streams) = PageStreams::read(doc, &page.dict) { + let mut state = State::default(); + let mut new: Vec>> = Vec::with_capacity(streams.data.len()); + for data in &streams.data { + let (bytes, n) = rewrite(data, &mut state, doc, &cs, settings); + result.colours += n; + new.push(bytes); + } + streams.write(doc, page.obj, new)?; + } + if let Some(xo) = convert_xobjects(doc, resources.as_ref(), settings, recolour, &mut run, &mut result, true) { + // This page's own resources point at the copies; a resource dictionary other pages + // share is not changed. + let mut res = resources.clone().unwrap_or_default(); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; + } + if settings.annotations { + let annots: Vec = page + .dict + .get(b"Annots") + .map(|a| doc.resolve(a)) + .and_then(|a| a.as_array().cloned()) + .unwrap_or_default() + .iter() + .filter_map(|a| a.as_ref()) + .collect(); + for a in annots { + let Some(ap) = + doc.get(a).as_dict().and_then(|d| d.get(b"AP").cloned()).map(|ap| doc.resolve(&ap)).and_then(|ap| ap.as_dict().cloned()) + else { + continue; + }; + // Every appearance stream (and the states of a /N dictionary) as a form. + let mut streams: Vec = Vec::new(); + for (_, v) in ap.iter() { + match &*doc.resolve(v) { + Object::Stream(_) => streams.extend(v.as_ref()), + Object::Dict(states) => streams.extend(states.iter().filter_map(|(_, s)| s.as_ref())), + _ => {} + } + } + let mut fake = Dict::new(); + let mut xo = Dict::new(); + for (k, r) in streams.iter().enumerate() { + xo.set(format!("A{k}").into_bytes(), Object::Ref(*r)); + } + fake.set(b"XObject".to_vec(), Object::Dict(xo)); + // An appearance stream other pages draw is left alone (there is no resource + // dictionary here to point at a copy). + convert_xobjects(doc, Some(&fake), settings, recolour, &mut run, &mut result, false); + } + } + if (result.colours, result.images) != before { + result.pages += 1; + } + } + Ok(result) +} diff --git a/crates/edit/src/find_replace_rows_tests.rs b/crates/edit/src/find_replace_rows_tests.rs new file mode 100644 index 0000000000..a268c2d271 --- /dev/null +++ b/crates/edit/src/find_replace_rows_tests.rs @@ -0,0 +1,75 @@ +//! A replacement through the run sources must leave the rows it does not reach as they were. +use std::sync::Arc; + +use pdfcraft_cos::{Document, SaveOptions, write_incremental}; + +use super::*; + +fn page(content: &str) -> Document { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +fn content(doc: &Document) -> String { + let bytes = write_incremental(doc, &SaveOptions::default()).unwrap(); + let doc = Document::open(Arc::new(bytes)).unwrap(); + let p = pdfcraft_model::pages(&doc).swap_remove(0); + let list = match doc.resolve(p.dict.get(b"Contents").unwrap()).as_ref() { + Object::Array(a) => a.clone(), + _ => vec![p.dict.get(b"Contents").unwrap().clone()], + }; + let mut out = Vec::new(); + for o in &list { + if let Object::Stream(s) = &*doc.resolve(o) { + out.extend(s.decoded().unwrap_or_default()); + } + } + String::from_utf8_lossy(&out).into_owned() +} + +#[test] +fn replace_keeps_untouched_rows() { + let mut doc = page("BT /F1 10 Tf 12 TL 100 700 Td [(Hello) -300 (World)] TJ T* (second row here) Tj ET"); + let block = text_blocks(&doc, 0).unwrap().swap_remove(0); + let start = block.editor_text.find("here").expect("the second row's word is in the paragraph"); + let (text, sources) = block.replaced(std::slice::from_ref(&(start..start + 4)), "there").expect("the range is on boundaries"); + let style = BlockStyle { sources, ..Default::default() }; + rewrite_block(&mut doc, 0, 0, Some(&text), &style).unwrap(); + let after = content(&doc); + assert!(after.contains("-300"), "the first row keeps its TJ kerning: {after}"); +} + +/// The rewrite keeps every space typed, so a deleted word must not leave its two neighbouring +/// spaces behind; a run of spaces the paragraph had is left as it was. +#[test] +fn a_deleted_word_leaves_one_space() { + let doc = page("BT /F1 10 Tf 100 700 Td (one two three) Tj ET"); + let block = text_blocks(&doc, 0).unwrap().swap_remove(0); + assert_eq!(block.editor_text, "one two three"); + let two = block.editor_text.find("two").unwrap(); + let (text, _) = block.replaced(std::slice::from_ref(&(two..two + 3)), "").unwrap(); + assert_eq!(text, "one three"); + let one = block.editor_text.find("one").unwrap(); + let (text, _) = block.replaced(std::slice::from_ref(&(one..one + 3)), "").unwrap(); + assert_eq!(text, " two three"); + let (text, _) = block.replaced(std::slice::from_ref(&(two..two + 3)), "2").unwrap(); + assert_eq!(text, "one 2 three"); +} diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index c4a39bf689..e269fda375 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -61,6 +61,15 @@ impl PageImage { self.clips.iter().any(|clip| clip.app_crop) } + /// The editing frame, respecting explicit PDF/editor crops but not the page boundary. + /// Selected handles remain reachable outside the page; body hits still use `contains`. + pub fn frame_rect(&self) -> Option<[f64; 4]> { + self.clips.iter().try_fold(self.rect, |rect, clip| { + let b = clip.path.bounding_box(); + intersection(rect, [b.x0, b.y0, b.x1, b.y1]) + }) + } + /// Whether a user-space point belongs to the placed artwork and every active clip. pub fn contains(&self, point: [f64; 2]) -> bool { if !point.iter().all(|v| v.is_finite()) { diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 2d508f6878..f02930a8e5 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -61,11 +61,17 @@ mod linebreak; pub mod paragraph_align; pub mod text; pub use text::{ - BlockKey, BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, TextSession, block_overlapping, - clear_block, glyph_text_lines, reading_blocks, reading_blocks_checked, replace_block, replace_line, rewrite_block, text_blocks, text_lines, + BlockKey, BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, RunSources, TextBlock, TextLine, TextSession, + block_overlapping, clear_block, glyph_text_lines, reading_blocks, reading_blocks_checked, replace_block, replace_line, rewrite_block, + text_blocks, text_lines, }; +pub mod printer_marks; +pub use printer_marks::{PrinterMarks, add_printer_marks}; +mod colour; +pub mod convert_colours; pub mod hairlines; mod page_streams; +pub use convert_colours::{ColourTarget, ConvertColours, Converted as ColoursConverted, ImageRecolour, convert_colours}; pub use hairlines::{Fixed as HairlinesFixed, Hairlines, fix_hairlines}; pub mod added; pub use added::{ @@ -88,6 +94,8 @@ use place::{ISOLATION_MAX_DEPTH, OLD_WRAP_CLOSE, WRAP_CLOSE, WRAP_OPEN, is_wrap_ use place::{Reach, check, check_on_top, contents, draw_on_top, lift_on_top, page_list, place_tagged, revealed_under_paint}; pub use place::{add_type3_fonts, stamp, stamp_with_fonts, undrawable_message}; +#[cfg(test)] +mod find_replace_rows_tests; #[cfg(test)] mod marked_isolation_tests; #[cfg(test)] diff --git a/crates/edit/src/mark_transaction_tests.rs b/crates/edit/src/mark_transaction_tests.rs index 2623da88ba..22530c8836 100644 --- a/crates/edit/src/mark_transaction_tests.rs +++ b/crates/edit/src/mark_transaction_tests.rs @@ -121,6 +121,7 @@ fn apply_replacement(doc: &mut Document, kind: MarkKind, source: MarkSource, rep MarkKind::Background => { add_background(doc, SELECTED, &Background { color: [0.1, 0.3, 0.7], replace_backdrop: true, ..Default::default() }, replace) } + MarkKind::PrinterMarks => Ok(()), } } @@ -172,6 +173,7 @@ fn replacements_commit_all_selected_pages_and_preserve_other_marks_and_shared_re MarkKind::HeaderFooter => "(New header)".to_string(), MarkKind::Watermark => format!("/PCPic{} Do", source.xobject.num), MarkKind::Background => "0.1 0.3 0.7 rg".to_string(), + MarkKind::PrinterMarks => String::new(), }; apply_replacement(&mut doc, kind, source, true).unwrap(); let doc = reopen(&doc); diff --git a/crates/edit/src/marks.rs b/crates/edit/src/marks.rs index 6c88fb2bf0..d772c48f4b 100644 --- a/crates/edit/src/marks.rs +++ b/crates/edit/src/marks.rs @@ -77,6 +77,8 @@ pub enum MarkKind { HeaderFooter, Watermark, Background, + /// Printer marks: trim, bleed, registration, colour bars, page information. + PrinterMarks, } impl MarkKind { @@ -85,6 +87,7 @@ impl MarkKind { MarkKind::HeaderFooter => "HeaderFooter", MarkKind::Watermark => "Watermark", MarkKind::Background => "Background", + MarkKind::PrinterMarks => "PrinterMarks", } } } diff --git a/crates/edit/src/printer_marks.rs b/crates/edit/src/printer_marks.rs new file mode 100644 index 0000000000..a247f65d30 --- /dev/null +++ b/crates/edit/src/printer_marks.rs @@ -0,0 +1,340 @@ +//! Printer marks (Acrobat's Print production ▸ Add Printer Marks): trim and +//! bleed marks, registration targets, colour bars and page information drawn outside the trim +//! box, as a page mark like a header, so they can be removed or replaced. The page is widened +//! when asked and the marks would otherwise fall outside it; the trim box stays where it is. +//! +//! Lines, targets and the information line are in registration colour, the `/All` Separation +//! (ISO 32000-2, 8.6.6.4), so they print on every plate, spot plates too; viewers show its +//! four-ink alternate. The colour bars are process inks and stay DeviceCMYK. + +use pdfcraft_cos::{Dict, Document, Object}; +use pdfcraft_fonts::{literal, win_ansi}; + +use crate::marks::{MarkKind, n}; +use crate::place::{END, add_resources, begin, check, page_list, place}; +use crate::{EditError, remove_marks}; + +/// Which marks, and how heavy. +#[derive(Clone, Debug, PartialEq)] +pub struct PrinterMarks { + pub trim: bool, + pub bleed: bool, + pub registration: bool, + pub color_bars: bool, + pub page_info: bool, + /// Line weight in points (Acrobat: 0.25, 0.5, 1). + pub line_weight: f64, + /// Widen the page by this many points on each side (at least what the marks need) when + /// they would fall outside it; `None` leaves the page and clips the marks. + pub make_room: Option, + /// What the page information line says before the page number (the file's name). + pub label: String, + /// The date on the page information line. + pub date: String, +} + +impl Default for PrinterMarks { + fn default() -> Self { + Self { + trim: true, + bleed: true, + registration: true, + color_bars: true, + page_info: true, + line_weight: 0.25, + make_room: Some(36.0), + label: String::new(), + date: String::new(), + } + } +} + +/// How far a mark reaches beyond the trim box: a gap, then the mark itself. +const GAP: f64 = 6.0; +const LENGTH: f64 = 18.0; +/// Registration targets sit this far beyond the trim box, centred on each side. +const TARGET_OFFSET: f64 = 16.0; +const TARGET_RADIUS: f64 = 4.0; +const BAR: f64 = 10.0; +const INFO_SIZE: f64 = 7.0; +/// The colour space resource holding registration colour. +const REGISTRATION: &str = "PCReg"; +/// The largest page side PDF viewers take (Annex C of ISO 32000-1 lists it as Acrobat's limit). +const MAX_PAGE_SIDE: f64 = 14_400.0; + +/// `[/Separation /All /DeviceCMYK ]`: full tint is all four process inks. +fn registration() -> Object { + let reals = |v: &[f64]| Object::Array(v.iter().map(|x| Object::Real(*x)).collect()); + let mut tint = Dict::new(); + tint.set(b"FunctionType".to_vec(), Object::Int(2)); + tint.set(b"Domain".to_vec(), reals(&[0.0, 1.0])); + tint.set(b"C0".to_vec(), reals(&[0.0; 4])); + tint.set(b"C1".to_vec(), reals(&[1.0; 4])); + tint.set(b"N".to_vec(), Object::Int(1)); + Object::Array(vec![Object::name("Separation"), Object::name("All"), Object::name("DeviceCMYK"), Object::Dict(tint)]) +} + +impl PrinterMarks { + /// The line weights a run accepts, in points. + pub const WEIGHTS: std::ops::RangeInclusive = 0.1..=4.0; + /// The most a run widens a page by on each side at the caller's asking, in points. + pub const MAX_ROOM: f64 = 288.0; + + fn any(&self) -> bool { + self.trim || self.bleed || self.registration || self.color_bars || self.page_info + } + + /// How far beyond the trim box the chosen marks reach. + pub fn reach(&self) -> f64 { + let mut r: f64 = 0.0; + if self.trim || self.bleed { + r = r.max(GAP + LENGTH); + } + if self.registration { + r = r.max(TARGET_OFFSET + TARGET_RADIUS + 2.0); + } + if self.color_bars || self.page_info { + r = r.max(GAP + BAR + 6.0); + } + r + } + + /// The marks' content for a page whose trim box is `trim` and bleed box `bleed` (user + /// space), `page` of `count` (1-based). + pub fn content(&self, trim: [f64; 4], bleed: [f64; 4], page: usize, count: usize) -> String { + let w = self.line_weight.clamp(*Self::WEIGHTS.start(), *Self::WEIGHTS.end()); + // Registration colour: every plate. + let mut c = format!("/{REGISTRATION} CS 1 SCN /{REGISTRATION} cs 1 scn {} w 0 J\n", n(w)); + let [x0, y0, x1, y1] = trim; + let line = |c: &mut String, ax: f64, ay: f64, bx: f64, by: f64| { + c.push_str(&format!("{} {} m {} {} l S\n", n(ax), n(ay), n(bx), n(by))); + }; + // Crop (trim) marks: at each corner, outside the box, along both edges. + let corner_marks = |c: &mut String, b: [f64; 4]| { + let [bx0, by0, bx1, by1] = b; + for (x, dir) in [(bx0, -1.0), (bx1, 1.0)] { + for y in [by0, by1] { + // Vertical mark beyond the top or bottom edge. + let (ya, yb) = if y == by0 { (y - GAP, y - GAP - LENGTH) } else { (y + GAP, y + GAP + LENGTH) }; + line(c, x, ya, x, yb); + // Horizontal mark beyond the left or right edge. + line(c, x + dir * GAP, y, x + dir * (GAP + LENGTH), y); + } + } + }; + if self.trim { + corner_marks(&mut c, trim); + } + if self.bleed && bleed != trim { + c.push_str(&format!("[{} {}] 0 d\n", n(w * 4.0), n(w * 4.0))); + corner_marks(&mut c, bleed); + c.push_str("[] 0 d\n"); + } + if self.registration { + let r = TARGET_RADIUS; + let k = 0.5523 * r; + let target = |c: &mut String, cx: f64, cy: f64| { + c.push_str(&format!( + "{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c S\n", + n(cx + r), + n(cy), + n(cx + r), + n(cy + k), + n(cx + k), + n(cy + r), + n(cx), + n(cy + r), + n(cx - k), + n(cy + r), + n(cx - r), + n(cy + k), + n(cx - r), + n(cy), + n(cx - r), + n(cy - k), + n(cx - k), + n(cy - r), + n(cx), + n(cy - r), + n(cx + k), + n(cy - r), + n(cx + r), + n(cy - k), + n(cx + r), + n(cy) + )); + line(c, cx - r * 1.6, cy, cx + r * 1.6, cy); + line(c, cx, cy - r * 1.6, cx, cy + r * 1.6); + }; + let (mx, my) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); + target(&mut c, mx, y1 + TARGET_OFFSET); + target(&mut c, mx, y0 - TARGET_OFFSET); + target(&mut c, x0 - TARGET_OFFSET, my); + target(&mut c, x1 + TARGET_OFFSET, my); + } + if self.color_bars { + // Above the top edge, from the left: C, M, Y, K at 100%, then 75, 50, 25% K. + let y = y1 + GAP + 2.0; + let x = x0 + LENGTH + GAP + 12.0; + let swatches: [(f64, f64, f64, f64); 7] = [ + (1.0, 0.0, 0.0, 0.0), + (0.0, 1.0, 0.0, 0.0), + (0.0, 0.0, 1.0, 0.0), + (0.0, 0.0, 0.0, 1.0), + (0.0, 0.0, 0.0, 0.75), + (0.0, 0.0, 0.0, 0.5), + (0.0, 0.0, 0.0, 0.25), + ]; + for (i, (cy, m, yl, k)) in swatches.iter().enumerate() { + c.push_str(&format!("{} {} {} {} k {} {} {} {} re f\n", n(*cy), n(*m), n(*yl), n(*k), n(x + i as f64 * BAR), n(y), n(BAR), n(BAR))); + } + c.push_str(&format!("/{REGISTRATION} cs 1 scn\n")); + } + if self.page_info { + let mut info = String::new(); + if !self.label.trim().is_empty() { + info.push_str(self.label.trim()); + info.push_str(" "); + } + info.push_str(&format!("{page} of {count}")); + if !self.date.trim().is_empty() { + info.push_str(" "); + info.push_str(self.date.trim()); + } + let info: String = info.chars().filter(|ch| pdfcraft_fonts::win_ansi_encodable(*ch)).collect(); + c.push_str(&format!("BT /PCHelv {} Tf 1 0 0 1 {} {} Tm ", n(INFO_SIZE), n(x0 + LENGTH + GAP + 12.0), n(y0 - GAP - BAR))); + c.push_str(&ascii_literal(&literal(&win_ansi(&info)))); + c.push_str(" Tj ET\n"); + } + c + } +} + +/// A PDF literal string made of WinAnsi bytes, as ASCII text: bytes from 0x80 up are written as +/// octal escapes, so they reach the content stream unchanged instead of being replaced when the +/// bytes are turned into a `String`. +fn ascii_literal(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len()); + for &b in bytes { + if b < 0x80 { + out.push(char::from(b)); + } else { + out.push_str(&format!("\\{b:03o}")); + } + } + out +} + +/// How far the chosen marks reach beyond the trim box on each side (left, bottom, right, top): +/// `reach`, or more where the bleed marks, which start at the bleed box, go further out. +fn side_reach(marks: &PrinterMarks, trim: [f64; 4], bleed: [f64; 4]) -> [f64; 4] { + let base = marks.reach(); + if !(marks.bleed && bleed != trim) { + return [base; 4]; + } + let beyond = |bleed_edge: f64, trim_edge: f64| base.max((trim_edge - bleed_edge).abs() + GAP + LENGTH); + [beyond(bleed[0], trim[0]), beyond(bleed[1], trim[1]), beyond(bleed[2], trim[2]), beyond(bleed[3], trim[3])] +} + +/// Add printer marks to `pages` (0-based). With `replace`, marks already there are removed +/// first. Returns the pages that were widened (media or crop box) to make room. +pub fn add_printer_marks(doc: &mut Document, pages: &[usize], marks: &PrinterMarks, replace: bool) -> Result, EditError> { + let all = page_list(doc); + check(pages, all.len())?; + if !marks.any() { + return Err(EditError::Invalid("choose at least one kind of mark".into())); + } + if !PrinterMarks::WEIGHTS.contains(&marks.line_weight) { + let (low, high) = (PrinterMarks::WEIGHTS.start(), PrinterMarks::WEIGHTS.end()); + return Err(EditError::Invalid(format!("the line weight must be from {low} to {high} points"))); + } + if marks.make_room.is_some_and(|room| !(0.0..=PrinterMarks::MAX_ROOM).contains(&room)) { + return Err(EditError::Invalid(format!("the room to make must be from 0 to {} points", PrinterMarks::MAX_ROOM))); + } + if replace { + remove_marks(doc, pages, MarkKind::PrinterMarks)?; + } + let count = all.len(); + let mut widened = Vec::new(); + for &i in pages { + let [media, crop, bleed, trim, art] = pdfcraft_organize_boxes(doc, i)?; + let side = side_reach(marks, trim, bleed); + let needed = [trim[0] - side[0], trim[1] - side[1], trim[2] + side[2], trim[3] + side[3]]; + let within = |b: [f64; 4]| b[0] <= needed[0] && b[1] <= needed[1] && b[2] >= needed[2] && b[3] >= needed[3]; + // The crop box is what shows, so the marks need room inside it, not only inside the media box. + if !within(crop) + && let Some(room) = marks.make_room + { + let new_media = if within(media) { + None + } else { + let room = |s: f64| room.max(s); + Some([ + media[0].min(trim[0] - room(side[0])), + media[1].min(trim[1] - room(side[1])), + media[2].max(trim[2] + room(side[2])), + media[3].max(trim[3] + room(side[3])), + ]) + }; + if new_media.is_some_and(|m| m[2] - m[0] > MAX_PAGE_SIDE || m[3] - m[1] > MAX_PAGE_SIDE) { + return Err(EditError::Invalid(format!("page {} would be wider than the {MAX_PAGE_SIDE} points a PDF page can be", i + 1))); + } + // Without a wider media box, only the crop box grows, to the marks and no further than the media box. + let new_crop = match new_media { + Some(m) => m, + None => [ + crop[0].min(needed[0]).max(media[0]), + crop[1].min(needed[1]).max(media[1]), + crop[2].max(needed[2]).min(media[2]), + crop[3].max(needed[3]).min(media[3]), + ], + }; + let rect = |r: [f64; 4]| Object::Array(r.iter().map(|v| Object::Real(*v)).collect()); + let page = page_list(doc)[i].clone(); + doc.update_dict(page.obj, |d| { + if let Some(m) = new_media { + d.set(b"MediaBox".to_vec(), rect(m)); + } + // The crop box shows the marks too. The trim, bleed and art boxes default to the + // crop box and are clipped to the media box, so each is written out as it was in + // effect, or it would move with the wider boxes and a second run would widen the + // page again. + d.set(b"CropBox".to_vec(), rect(new_crop)); + for (key, value) in [(&b"BleedBox"[..], bleed), (&b"TrimBox"[..], trim), (&b"ArtBox"[..], art)] { + d.set(key.to_vec(), rect(value)); + } + })?; + widened.push(i); + } + let page = page_list(doc)[i].clone(); + let content = marks.content(trim, bleed, i + 1, count); + let body = format!("{}{}{}", begin(MarkKind::PrinterMarks, "PrinterMarks", [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]), content, END); + add_resources(doc, &page, None, Some(body.as_bytes()))?; + let page = page_list(doc)[i].clone(); + // add_resources gave the page resources of its own. + let mut res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let mut spaces = res.get(b"ColorSpace").map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned()).unwrap_or_default(); + spaces.set(REGISTRATION.as_bytes().to_vec(), registration()); + res.set(b"ColorSpace".to_vec(), Object::Dict(spaces)); + doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; + let page = page_list(doc)[i].clone(); + place(doc, &page, MarkKind::PrinterMarks, body.into_bytes(), false)?; + } + Ok(widened) +} + +/// A page's boxes: media, crop, bleed, trim, art (each inherited or falling back as the +/// standard says), without depending on the organize crate. +fn pdfcraft_organize_boxes(doc: &Document, page: usize) -> Result<[[f64; 4]; 5], EditError> { + let p = page_list(doc).get(page).cloned().ok_or_else(|| EditError::Invalid(format!("page {} does not exist", page + 1)))?; + let rect = |k: &[u8]| -> Option<[f64; 4]> { + let a = p.dict.get(k).map(|o| doc.resolve(o))?; + let v: Vec = a.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); + (v.len() == 4 && v.iter().all(|x| x.is_finite())).then(|| [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]) + }; + let media = rect(b"MediaBox").unwrap_or([0.0, 0.0, 612.0, 792.0]); + let clip = |r: [f64; 4]| [r[0].max(media[0]), r[1].max(media[1]), r[2].min(media[2]), r[3].min(media[3])]; + let crop = rect(b"CropBox").map(clip).unwrap_or(media); + let (bleed, trim, art) = (rect(b"BleedBox").map(clip), rect(b"TrimBox").map(clip), rect(b"ArtBox").map(clip)); + Ok([media, crop, bleed.unwrap_or(crop), trim.unwrap_or(crop), art.unwrap_or(crop)]) +} diff --git a/crates/edit/src/rules.rs b/crates/edit/src/rules.rs index 52600ab4dd..2457e2d7ff 100644 --- a/crates/edit/src/rules.rs +++ b/crates/edit/src/rules.rs @@ -26,6 +26,10 @@ pub fn page_rules(doc: &Document, page: usize) -> Result, EditErro let pages = pdfcraft_model::pages(doc); let p = pages.get(page).ok_or(EditError::NoSuchPage(page))?; let streams = crate::text::content_streams(doc, &p.dict)?; + // Unread content can change the graphics state used by every later stream. + if streams.iter().any(|stream| stream.read != crate::text::Read::Whole) { + return Err(EditError::Invalid("page content could not be completely decoded; cannot extract its ruled lines safely".into())); + } let pieces = crate::text::joined(&streams); let ops = pieces.parse().ops; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 3c1100d312..1f2b925b46 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -2109,6 +2109,229 @@ fn japanese_fallback_reports_the_face_after_save_and_reopen() { } } +#[test] +#[ignore = "manual mixed-script rewrite timing probe; run with --ignored --nocapture"] +fn mixed_script_paragraph_rewrite_timing_probe() { + assert!(pdfcraft_fonts::document_japanese_font().is_some(), "the timing probe requires the release-pinned craft-fonts input"); + for (repeats, width) in [(32, 480.0), (512, 480.0), (2048, 480.0), (512, 14_400.0)] { + let draft = "The quick brown fox шалом ".repeat(repeats); + let mut samples = Vec::new(); + for _ in 0..3 { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (The quick brown fox) Tj ET"); + let style = text::BlockStyle { + width: Some(width), + align: Some(added::Align::Center), + underline: Some(true), + char_spacing: Some(1.25), + scale: Some(80.0), + ..Default::default() + }; + let start = std::time::Instant::now(); + text::rewrite_block(&mut doc, 0, 0, Some(&draft), &style).unwrap(); + samples.push(start.elapsed().as_micros()); + assert!(text::text_lines(&doc, 0).unwrap().iter().any(|line| line.base_font == "Helvetica")); + std::hint::black_box(doc); + } + samples.sort_unstable(); + eprintln!("mixed-script-rewrite bytes={} width={width} median_us={} samples_us={samples:?}", draft.len(), samples[1]); + } +} + +#[test] +fn mixed_script_source_measurements_are_bounded_by_distinct_characters() { + if without_craft_fonts("mixed_script_source_measurements_are_bounded_by_distinct_characters") { + return; + } + for resource in ["F1", "F2"] { + for repeats in [1, 256] { + for width in [80.0, 14_400.0] { + let draft = "ab шалом ab ".repeat(repeats); + let distinct = draft.chars().collect::>().len(); + let mut doc = text_page(&format!("BT /{resource} 12 Tf 72 700 Td (ab) Tj ET")); + let style = text::BlockStyle { width: Some(width), align: Some(added::Align::Justify), underline: Some(true), ..Default::default() }; + text::take_source_character_measurements(); + text::rewrite_block(&mut doc, 0, 0, Some(&draft), &style).unwrap(); + let measured = text::take_source_character_measurements(); + assert_eq!(measured, distinct, "{resource}, {repeats} repeats, width {width}: measure each distinct source candidate once"); + } + } + } +} + +#[test] +fn mixed_script_cached_widths_follow_each_rewrite_state() { + if without_craft_fonts("mixed_script_cached_widths_follow_each_rewrite_state") { + return; + } + let draft = "ab шалом ab шалом ab шалом ab"; + for (resource, size, spacing, scale) in [("F2", 12.0, 0.0, 100.0), ("F1", 18.0, 1.25, 80.0), ("F2", 18.0, 1.25, 80.0), ("F1", 12.0, 0.0, 100.0)] { + let mut doc = text_page(&format!("BT /{resource} 12 Tf 5 Tw 72 700 Td (ab) Tj ET")); + let original_font = doc.get(pdfcraft_cos::ObjRef::new(if resource == "F1" { 5 } else { 6 }, 0)).as_ref().clone(); + let style = text::BlockStyle { + size: Some(size), + width: Some(120.0), + align: Some(added::Align::Center), + underline: Some(true), + char_spacing: Some(spacing), + scale: Some(scale), + ..Default::default() + }; + text::rewrite_block(&mut doc, 0, 0, Some(draft), &style).unwrap(); + let doc = reopen(&doc); + assert_eq!(doc.get(pdfcraft_cos::ObjRef::new(if resource == "F1" { 5 } else { 6 }, 0)).as_ref(), &original_font); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text.split_whitespace().collect::>(), draft.split_whitespace().collect::>()); + let page = pdfcraft_model::pages(&doc).remove(0); + let fonts = doc.dict(doc.dict(page.dict.get(b"Resources").unwrap()).unwrap().get(b"Font").unwrap()).unwrap(); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let underlines: Vec<_> = ops.iter().filter(|op| op.is("m") || op.is("l")).collect(); + let rows: Vec<_> = lines.chunk_by(|a, b| (a.origin_baseline() - b.origin_baseline()).abs() < 0.001).collect(); + assert!(rows.len() > 1, "the cache must also be used while wrapping"); + assert_eq!(underlines.len(), rows.len() * 2); + for (row, underline) in rows.into_iter().zip(underlines.as_chunks::<2>().0) { + // Recompute from the saved fonts, independently of the rewrite's cache. + let widths: Vec<_> = row + .iter() + .map(|line| { + if line.text.chars().any(|ch| ch.is_ascii_alphabetic()) { + assert_eq!(line.font, resource); + } + let m = pdfcraft_fonts::pdf::Metrics::from_dict(&doc, &doc.dict(fonts.get(line.font.as_bytes()).unwrap()).unwrap()); + let bytes = m.encode(&line.text).unwrap_or_else(|| panic!("saved font {} cannot encode {line:?}", m.base_font)); + m.codes(&bytes).iter().map(|(code, _)| m.width(*code) * size + spacing).sum::() * scale / 100.0 + }) + .collect(); + let total: f64 = widths.iter().sum(); + let left = (120.0 - total).max(0.0) / 2.0; + let mut x = 72.0 + left; + for (line, advance) in row.iter().zip(widths) { + assert!((line.baseline()[0] - x).abs() < 0.02, "{resource}, size {size}: {line:?}, expected {x}"); + x += advance; + } + assert!((underline[0].operands[0].as_f64().unwrap() - left).abs() < 0.02); + assert!((underline[1].operands[0].as_f64().unwrap() - left - total).abs() < 0.02); + } + } +} + +#[test] +fn mixed_script_paragraph_keeps_the_source_font_without_an_explicit_line_break() { + if without_craft_fonts("mixed_script_paragraph_keeps_the_source_font_without_an_explicit_line_break") { + return; + } + let original = "The quick brown fox jumps over the lazy dog"; + let ending = "and keeps running through the long field."; + let draft = format!("{original} шалом {ending}"); + let mut doc = text_page(&format!("BT /F1 12 Tf 14.4 TL 72 700 Td ({original}) Tj T* ({ending}) Tj ET")); + let font = doc.get(pdfcraft_cos::ObjRef::new(5, 0)).as_ref().clone(); + text::replace_block(&mut doc, 0, 0, &draft).unwrap(); + let doc = reopen(&doc); + assert_eq!(doc.get(pdfcraft_cos::ObjRef::new(5, 0)).as_ref(), &font); + let lines = text::text_lines(&doc, 0).unwrap(); + assert!(lines.iter().any(|l| l.base_font == "Helvetica" && l.text.contains("The quick brown fox")), "{lines:#?}"); + for line in &lines { + if line.text.chars().any(|ch| ch.is_ascii_alphabetic()) { + assert_eq!(line.base_font, "Helvetica", "Latin must retain its source font: {line:?}"); + } + } + assert!(lines.iter().any(|l| l.text.contains("шалом") && l.base_font != "Helvetica")); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text.split_whitespace().collect::>(), draft.split_whitespace().collect::>()); + let fallback = page_font_programs(&doc).into_iter().filter(|(subtype, _)| subtype == b"Type3").count(); + assert_eq!(fallback, 1, "the new letters share one fallback font"); +} + +#[test] +fn mixed_script_subset_spaces_remain_extractable_after_saving() { + if without_craft_fonts("mixed_script_subset_spaces_remain_extractable_after_saving") { + return; + } + for (resource, draft) in [("F2", "ab шалом ab"), ("F2", "шалом ab"), ("F2", "ab шалом"), ("F1", "ab шалом ab")] { + for align in [added::Align::Left, added::Align::Center, added::Align::Right] { + let mut doc = text_page(&format!("BT /{resource} 12 Tf 72 700 Td (ab) Tj ET")); + let font_id = pdfcraft_cos::ObjRef::new(if resource == "F2" { 6 } else { 5 }, 0); + let original_font = doc.get(font_id).as_ref().clone(); + let style = + text::BlockStyle { width: Some(300.0), align: Some(align), char_spacing: Some(1.25), scale: Some(80.0), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(draft), &style).unwrap(); + for bytes in [write_full(&doc, &Default::default()).unwrap(), pdfcraft_cos::write_incremental(&doc, &Default::default()).unwrap()] { + let saved = Document::open(Arc::new(bytes)).unwrap(); + let lines = text::text_lines(&saved, 0).unwrap(); + let decoded: String = lines.iter().map(|line| line.text.as_str()).collect(); + assert_eq!(decoded, draft, "{resource}: edge spaces of source runs must be real extractable glyphs"); + assert_eq!(text::text_blocks(&saved, 0).unwrap()[0].text, draft); + assert_eq!(saved.get(font_id).as_ref(), &original_font); + for line in lines.iter().filter(|line| line.text.chars().any(|ch| ch.is_ascii_alphabetic())) { + assert_eq!(line.font, resource, "the Latin letters retain their source font: {line:?}"); + } + } + } + } +} + +/// Mixed-font fallback must preserve the refusal of unsupported per-row alignment. +#[test] +fn mixed_script_row_alignment_is_refused_without_changes() { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Latin text) Tj ET"); + let before = write_full(&doc, &SaveOptions::default()).unwrap(); + let style = text::BlockStyle { width: Some(300.0), aligns: vec![Some(added::Align::Center)], ..Default::default() }; + let err = text::rewrite_block(&mut doc, 0, 0, Some("abcшаломdef"), &style).unwrap_err(); + assert!(err.to_string().contains("selected-row alignment"), "{err}"); + assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), before); +} + +#[test] +fn mixed_script_runs_use_source_and_fallback_advances_for_alignment() { + if without_craft_fonts("mixed_script_runs_use_source_and_fallback_advances_for_alignment") { + return; + } + for (resource, prefix, suffix) in [("F1", "abc", "def"), ("F2", "ab", "ab"), ("F1", "abc ", "def")] { + // Rewriting normalizes Tw; measuring a source space must use that same state. + let mut doc = text_page(&format!("BT /{resource} 12 Tf 5 Tw 72 700 Td ({prefix}) Tj ET")); + let draft = format!("{prefix}шалом{suffix}"); + let style = text::BlockStyle { + width: Some(300.0), + align: Some(added::Align::Center), + char_spacing: Some(1.25), + scale: Some(80.0), + ..Default::default() + }; + text::rewrite_block(&mut doc, 0, 0, Some(&draft), &style).unwrap(); + let doc = reopen(&doc); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, draft); + assert_eq!((lines[0].font.as_str(), lines[2].font.as_str()), (resource, resource)); + let page = pdfcraft_model::pages(&doc).remove(0); + let resources = doc.dict(page.dict.get(b"Resources").unwrap()).unwrap(); + let fonts = doc.dict(resources.get(b"Font").unwrap()).unwrap(); + let advances: Vec = lines + .iter() + .zip([prefix, "шалом", suffix]) + .map(|(line, part)| { + let font = doc.dict(fonts.get(line.font.as_bytes()).unwrap()).unwrap(); + let m = pdfcraft_fonts::pdf::Metrics::from_dict(&doc, &font); + let bytes = m.encode(part).unwrap(); + (m.codes(&bytes).iter().map(|(c, _)| m.width(*c) * 12.0).sum::() + part.chars().count() as f64 * 1.25) * 0.8 + }) + .collect(); + let mut x = 72.0 + (300.0 - advances.iter().sum::()) / 2.0; + for (line, width) in lines.iter().zip(advances) { + assert!((line.baseline()[0] - x).abs() < 0.01, "{resource}: {line:?}, expected {x}"); + x += width; + } + } +} + +#[test] +fn mixed_script_paragraph_batches_only_missing_glyphs_and_refuses_atomically() { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Latin text) Tj ET"); + // Cyrillic also uses bundled Inter in builds without craft-fonts. + text::rewrite_block_with_limit(&mut doc, 0, 0, Some("Latin шалом text"), &Default::default(), 2).unwrap(); + assert_eq!(page_font_programs(&doc).iter().filter(|(subtype, _)| subtype == b"Type3").count(), 3); + let before = write_full(&doc, &Default::default()).unwrap(); + assert!(text::replace_block(&mut doc, 0, 0, "Latin \u{FDFD} text").is_err()); + assert_eq!(write_full(&doc, &Default::default()).unwrap(), before); +} + #[test] fn cyrillic_in_a_serif_line_falls_back_to_a_face_that_has_it() { if without_craft_fonts("cyrillic_in_a_serif_line_falls_back_to_a_face_that_has_it") { @@ -2127,7 +2350,33 @@ fn cyrillic_in_a_serif_line_falls_back_to_a_face_that_has_it() { } else { text::replace_line(&mut doc, 0, 0, replacement).unwrap(); } - assert_eq!(text::text_lines(&reopen(&doc), 0).unwrap()[0].text, replacement); + let doc = reopen(&doc); + if paragraph { + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, replacement); + assert!(text::text_lines(&doc, 0).unwrap().iter().any(|l| l.text.contains("world") && l.base_font == "Times-Roman")); + } else { + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, replacement); + } + } +} + +/// A subset missing spaces must still split source and fallback runs while aligning text. +#[test] +fn mixed_source_subset_without_space_keeps_fallback_runs_after_reopen() { + let draft = "a қ b a қ b"; + for align in [added::Align::Left, added::Align::Center, added::Align::Justify] { + let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET"); + let original = doc.get(pdfcraft_cos::ObjRef::new(6, 0)).as_ref().clone(); + let style = text::BlockStyle { width: Some(36.0), align: Some(align), char_spacing: Some(1.25), scale: Some(80.0), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(draft), &style).unwrap(); + let doc = reopen(&doc); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, draft); + assert_eq!(doc.get(pdfcraft_cos::ObjRef::new(6, 0)).as_ref(), &original); + let lines = text::text_lines(&doc, 0).unwrap(); + assert!(lines.iter().any(|line| line.text.contains('a') && line.font == "F2")); + assert!(lines.iter().any(|line| line.text.contains('b') && line.font == "F2")); + assert!(lines.iter().any(|line| line.text.contains('қ') && line.base_font == "Inter-Regular")); + assert!(lines.iter().any(|line| line.text.contains(' ') && line.base_font == "Inter-Regular")); } } @@ -2144,9 +2393,22 @@ fn uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face() { assert_eq!(edit.substituted.as_deref(), Some("Inter Type3"), "{replacement}: {edit:?}"); let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); assert!(content.contains("/PCLat"), "{replacement}: {content}"); - let lines = text::text_lines(&reopen(&doc), 0).unwrap(); - assert_eq!(lines[0].text, replacement); - assert_eq!(lines[0].base_font, "Inter-Regular", "the generated font reports the face it drew"); + let reopened = reopen(&doc); + let lines = text::text_lines(&reopened, 0).unwrap(); + if paragraph { + assert_eq!(text::text_blocks(&reopened, 0).unwrap()[0].text, replacement); + for ch in ['a', 'b'].into_iter().filter(|ch| replacement.contains(*ch)) { + assert!(lines.iter().any(|line| line.text.contains(ch) && line.base_font == "ABCDEF+Arial"), "{lines:?}"); + } + } else { + assert_eq!(lines[0].text, replacement); + } + for ch in replacement.chars().filter(|ch| !ch.is_ascii()) { + assert!( + lines.iter().any(|line| line.text.contains(ch) && line.base_font == "Inter-Regular"), + "the generated Inter face draws {ch}: {lines:?}" + ); + } } } } @@ -6844,6 +7106,142 @@ fn reading_follows_forms_however_their_resources_are_stored() { } } +/// #3503: a form XObject's `/BBox` clips its content, so text drawn outside it is not shown and +/// should not be read either -- matching the renderer, which already honours the clip. +#[test] +fn form_bbox_clips_text_from_reading() { + let page = stream("", "/Fm Do"); + let fm = stream( + "/Type /XObject /Subtype /Form /BBox [0 0 50 50] /Resources << /Font << /F1 6 0 R >> >>", + "BT /F1 12 Tf 5 5 Td (INSIDE) Tj ET BT /F1 12 Tf 100 100 Td (OUTSIDE) Tj ET", + ); + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /XObject << /Fm 5 0 R >> >> /Contents 4 0 R >>", + &page, + &fm, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + ]); + let texts: Vec = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["INSIDE"]); +} + +/// #3503: a page's own `re`/`W`/`n` clip (not a form's `/BBox`) hides text outside it the same +/// way, and `Q` lifts it again -- the clip is scoped to its `q`/`Q` like the rest of the graphics +/// state, not a one-way switch. +#[test] +fn page_re_w_n_clip_hides_text_from_reading_until_q() { + let page = stream( + "", + "q 0 0 50 50 re W n BT /F1 12 Tf 5 5 Td (INSIDE) Tj ET BT /F1 12 Tf 100 100 Td (HIDDEN) Tj ET Q BT /F1 12 Tf 100 100 Td (AFTER) Tj ET", + ); + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>", + &page, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + ]); + let texts: Vec = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["INSIDE", "AFTER"]); +} + +/// The text `reading_blocks` reads on a 400 × 400 pt page drawing `content` with Helvetica as +/// `/F1`, and forms `/Fa` and `/Fb` (objects 6 and 7) with the given dictionary entries and content. +fn read_clipped(content: &str, forms: [(&str, &str); 2]) -> Vec { + let font = "/Font << /F1 5 0 R >>"; + let resources = format!("<< {font} /XObject << /Fa 6 0 R /Fb 7 0 R >> >>"); + let form = |(dict, body): (&str, &str)| stream(&format!("/Type /XObject /Subtype /Form {dict} /Resources {resources}"), body); + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + &format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 400] /Resources {resources} /Contents 4 0 R >>"), + &stream("", content), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + &form(forms[0]), + &form(forms[1]), + ]); + text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect() +} + +/// #5075 review: a `/BBox` with anything but four numbers is no bound. Reading the numbers it +/// happens to hold (`[0 /Bad 0 50 50]` as `[0 0 50 50]`) invented a clip and hid real text. +#[test] +fn a_malformed_form_bbox_clips_nothing() { + let body = "BT /F1 12 Tf 5 5 Td (INSIDE) Tj ET BT /F1 12 Tf 100 100 Td (OUTSIDE) Tj ET"; + for bbox in ["[0 /Bad 0 50 50]", "[0 0 50]", "[0 0 50 50 60]", "[0 (x) 50 50]"] { + let texts = read_clipped("/Fa Do", [(&format!("/BBox {bbox}"), body), ("", "")]); + assert_eq!(texts, ["INSIDE", "OUTSIDE"], "/BBox {bbox}"); + } +} + +/// #5075 review: a clipping path of several rectangles clips to all of them, not to the last one +/// drawn (text seen through the first rectangle was hidden), and not to the box around them +/// either (text in the gap between them was read, though nothing of it shows). +#[test] +fn a_clip_of_several_rectangles_keeps_text_in_each() { + let content = "q 0 0 50 50 re 100 100 50 50 re W n BT /F1 12 Tf 5 5 Td (FIRST) Tj ET BT /F1 12 Tf 105 105 Td (SECOND) Tj ET \ + BT /F1 12 Tf 60 60 Td (GAP) Tj ET BT /F1 12 Tf 300 300 Td (OUTSIDE) Tj ET Q"; + assert_eq!(read_clipped(content, [("", ""), ("", "")]), ["FIRST", "SECOND"]); +} + +/// #5075 review: a form's `/BBox` clips however thin it is and wherever its matrix puts it. A box +/// one point by 1e-10 was taken for no box (its area fell under a fixed threshold), and one moved +/// 1e12 points away lost its area to rounding; either way the text outside it was read. +#[test] +fn a_thin_or_far_off_form_bbox_still_clips() { + let after = "BT /F1 12 Tf 5 300 Td (AFTER) Tj ET"; + let body = "BT /F1 12 Tf 5 5 Td (INSIDE) Tj ET BT /F1 12 Tf 300 300 Td (OUTSIDE) Tj ET"; + let sorted = |mut v: Vec| { + v.sort(); + v + }; + let thin = read_clipped(&format!("/Fa Do {after}"), [("/BBox [0 0 1 1e-10]", body), ("", "")]); + assert_eq!(thin, ["AFTER"], "a thin /BBox"); + let far = read_clipped(&format!("/Fa Do {after}"), [("/Matrix [1 0 0 1 1e12 1e12] /BBox [0 0 100 100]", body), ("", "")]); + assert_eq!(sorted(far), ["AFTER", "INSIDE"], "a /BBox 1e12 points away"); +} + +/// #5075 review: a clip of no area has no inside, so nothing drawn under it shows (ISO 32000-2 +/// §8.5.4). It was taken for no clip at all, and everything under it was read. +#[test] +fn a_clip_of_no_area_shows_nothing() { + let content = "q 0 0 100 0 re W n BT /F1 12 Tf 5 5 Td (HIDDEN) Tj ET Q BT /F1 12 Tf 5 300 Td (AFTER) Tj ET"; + assert_eq!(read_clipped(content, [("", ""), ("", "")]), ["AFTER"]); +} + +/// #5075 review: what shows is the intersection of every clip in force. A long run that crosses a +/// page clip and, apart from it, a form's `/BBox` overlaps each of them but shows in neither, as it +/// does not when one form's `/BBox` lies apart from the one of the form it is drawn in. +#[test] +fn disjoint_clips_show_nothing_between_them() { + let run = "BT /F1 12 Tf 5 10 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET"; + let after = "BT /F1 12 Tf 5 300 Td (AFTER) Tj ET"; + // A page clip and a form's `/BBox` side by side. + let page_clip = format!("q 0 0 50 50 re W n /Fa Do Q {after}"); + assert_eq!(read_clipped(&page_clip, [("/BBox [100 0 150 50]", run), ("", "")]), ["AFTER"]); + // A form's `/BBox` and the `/BBox` of the form inside it, side by side. + let nested = format!("/Fa Do {after}"); + assert_eq!(read_clipped(&nested, [("/BBox [0 0 50 50]", "/Fb Do"), ("/BBox [100 0 150 50]", run)]), ["AFTER"]); +} + +/// #5075 review: a clip turned with its matrix keeps its turned shape. Its upright bounding box +/// takes in corners it does not cover, and text there was read although it is clipped away. +#[test] +fn a_turned_clip_hides_text_in_the_corners_of_its_bounding_box() { + // A 100 pt square turned 45° about (200, 0): a diamond with its lower corner at (200, 0). + // Undone straight after the clip, so the text is drawn upright on the page. + let (turn, undo) = + ("0.70710678 0.70710678 -0.70710678 0.70710678 200 0 cm", "0.70710678 -0.70710678 0.70710678 0.70710678 -141.421356 141.421356 cm"); + let texts = "BT /F1 6 Tf 132 3 Td (CORNER) Tj ET BT /F1 6 Tf 195 65 Td (CENTRE) Tj ET"; + let page_clip = format!("q {turn} 0 0 100 100 re W n {undo} {texts} Q"); + assert_eq!(read_clipped(&page_clip, [("", ""), ("", "")]), ["CENTRE"], "a turned page clip"); + // The same diamond as a form's `/BBox`, under its `/Matrix`. + let matrix = "/Matrix [0.70710678 0.70710678 -0.70710678 0.70710678 200 0] /BBox [0 0 100 100]"; + assert_eq!(read_clipped("/Fa Do", [(matrix, &format!("{undo} {texts}")), ("", "")]), ["CENTRE"], "a turned form /BBox"); +} + /// A chain of forms, each drawing the next twenty times, twelve deep: within the depth cap but /// 20^11 visits without a budget. Reading stops at the page's visit budget instead of hanging. #[test] @@ -6958,24 +7356,28 @@ fn forms_that_fan_out_a_hundred_ways_stop_quickly_and_say_so() { /// Forms that draw themselves or each other, have no resources, a malformed matrix, or are /// missing are read once (or skipped) without panicking or looping. +/// +/// `/BBox [0 0 20 420]` is wide enough to hold every form's own text below (#3503 clips a form's +/// content to its `/BBox`; this test is about the loop and resource handling, not clipping). #[test] fn hostile_form_xobjects_are_read_once() { let page = stream("", "/Loop Do /A Do /NoRes Do /Bad Do /Missing Do /F1 Do"); let font = "/Font << /F1 5 0 R >>"; let lp = stream( - &format!("/Subtype /Form /BBox [0 0 9 9] /Resources << {font} /XObject << /Loop 6 0 R >> >>"), + &format!("/Subtype /Form /BBox [0 0 20 420] /Resources << {font} /XObject << /Loop 6 0 R >> >>"), "/Loop Do BT /F1 10 Tf 10 10 Td (once) Tj ET", ); let a = stream( - &format!("/Subtype /Form /BBox [0 0 9 9] /Resources << {font} /XObject << /B 8 0 R >> >>"), + &format!("/Subtype /Form /BBox [0 0 20 420] /Resources << {font} /XObject << /B 8 0 R >> >>"), "/B Do BT /F1 10 Tf 10 100 Td (from a) Tj ET", ); let b = stream( - &format!("/Subtype /Form /BBox [0 0 9 9] /Resources << {font} /XObject << /A 7 0 R >> >>"), + &format!("/Subtype /Form /BBox [0 0 20 420] /Resources << {font} /XObject << /A 7 0 R >> >>"), "/A Do BT /F1 10 Tf 10 200 Td (from b) Tj ET", ); - let nores = stream("/Subtype /Form /BBox [0 0 9 9]", "BT /F1 10 Tf 10 300 Td (inherited) Tj ET"); - let bad = stream(&format!("/Subtype /Form /BBox [0 0 9 9] /Matrix [1 0 0] /Resources << {font} >>"), "BT /F1 10 Tf 10 400 Td (bad matrix) Tj ET"); + let nores = stream("/Subtype /Form /BBox [0 0 20 420]", "BT /F1 10 Tf 10 300 Td (inherited) Tj ET"); + let bad = + stream(&format!("/Subtype /Form /BBox [0 0 20 420] /Matrix [1 0 0] /Resources << {font} >>"), "BT /F1 10 Tf 10 400 Td (bad matrix) Tj ET"); let doc = build(&[ "<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", @@ -7956,6 +8358,72 @@ fn unchanged_runs_between_two_edits_keep_their_styles() { assert_eq!(style(&after, "Italic footnote"), style(&before, "Italic footer")); } +/// Find and replace: two words replaced in one paragraph of several runs. Each replacement takes +/// the run of the first character it replaces, and every run between them keeps its own style. +#[test] +fn replacing_words_apart_in_a_paragraph_keeps_every_run_between_them() { + let original = mixed_region_page(); + let block = text::text_blocks(&original, 0).unwrap()[1].clone(); + assert_eq!(block.editor_text, "About\nPlain bold text.\nItalic footer"); + let about = block.editor_text.find("About").unwrap(); + let text_word = block.editor_text.find("text").unwrap(); + let (draft, sources) = block.replaced(&[about..about + 5, text_word..text_word + 4], "Info").unwrap(); + assert_eq!(draft, "Info\nPlain bold Info.\nItalic footer"); + let pieces: Vec<&str> = sources.iter().map(|(r, _)| &draft[r.clone()]).collect(); + assert_eq!(pieces, ["Info", "\nPlain ", "bold", " Info.", "\nItalic footer"]); + let before = text::text_lines(&original, 0).unwrap(); + let source_of = |word: &str| before.iter().position(|l| l.text.contains(word)).unwrap(); + let runs: Vec = sources.iter().map(|(_, s)| *s).collect(); + assert_eq!(runs, [source_of("About"), source_of("Plain"), source_of("bold"), source_of(" text."), source_of("Italic")]); + let mut doc = original.clone(); + let style = text::BlockStyle { sources, ..Default::default() }; + assert_eq!(text::rewrite_block(&mut doc, 0, 1, Some(&draft), &style).unwrap().substituted, None); + let reopened = reopen(&doc); + assert_eq!(text::text_blocks(&reopened, 0).unwrap()[1].editor_text, draft); + let lines = text::text_lines(&reopened, 0).unwrap(); + let look = |l: &text::TextLine| (l.base_font.clone(), l.color); + let run = |word: &str| lines.iter().find(|l| l.text.contains(word)).unwrap_or_else(|| panic!("{word}: {lines:?}")); + assert_eq!(look(run("Info")), look(&before[source_of("About")]), "the first replacement takes About's run"); + assert_eq!(look(run("Plain")), look(&before[source_of("Plain")])); + assert_eq!(look(run("bold")), look(&before[source_of("bold")]), "the run between the two replacements keeps its style"); + assert_eq!(look(run("Info.")), look(&before[source_of(" text.")])); + assert_eq!(look(run("Italic")), look(&before[source_of("Italic")])); + // A replaced word that is a run of its own keeps that run's style, where aligning the old and + // new text (the inline editor's way) gives a changed word the run before it. + let bold = block.editor_text.find("bold").unwrap(); + let (draft, sources) = block.replaced(std::slice::from_ref(&(bold..bold + 4)), "heavy").unwrap(); + let mut doc = original.clone(); + text::rewrite_block(&mut doc, 0, 1, Some(&draft), &text::BlockStyle { sources, ..Default::default() }).unwrap(); + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + let heavy = lines.iter().find(|l| l.text.contains("heavy")).unwrap_or_else(|| panic!("{lines:?}")); + assert_eq!(look(heavy), look(&before[source_of("bold")]), "the replacement keeps the bold run's style"); + // Out of order, overlapping or inside a character: no draft. + assert!(block.replaced(&[text_word..text_word + 4, about..about + 5], "x").is_none()); + assert!(block.replaced(&[0..3, 2..5], "x").is_none()); + assert!(block.replaced(&[0..0, 3..4], "x").is_none(), "an empty range"); + assert_eq!(block.replaced(&[], "x").map(|(d, _)| d), Some(block.editor_text.clone())); +} + +/// Sources that don't cover the text in order, or name a run of another paragraph, are refused +/// and change nothing. +#[test] +fn style_sources_must_cover_the_text_with_the_paragraphs_runs() { + let original = mixed_region_page(); + let block = text::text_blocks(&original, 0).unwrap()[1].clone(); + let draft = block.editor_text.replace("About", "Info"); + let own = block.lines[0]; + let other = text::text_blocks(&original, 0).unwrap()[0].lines[0]; + let len = draft.len(); + for sources in + [vec![(0..4, own)], vec![(0..4, own), (5..len, own)], vec![(0..len, other)], vec![(0..len + 1, own)], vec![(4..len, own), (0..4, own)]] + { + let mut doc = original.clone(); + let style = text::BlockStyle { sources: sources.clone(), ..Default::default() }; + assert!(text::rewrite_block(&mut doc, 0, 1, Some(&draft), &style).is_err(), "{sources:?}"); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[1].editor_text, block.editor_text); + } +} + #[test] fn disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically() { let mut doc = mixed_region_page(); @@ -9259,6 +9727,113 @@ fn removing_marks_strips_wrappers_that_closed_open_state() { assert_eq!(streams(&doc, 0), ["q 2 0 0 2 0 0 cm"]); } +/// Printer marks: drawn outside the trim box as a removable page mark; the page +/// is widened to make room when asked and the trim box stays; running again replaces them. +#[test] +fn printer_marks_are_drawn_outside_the_trim_box_and_removable() { + use crate::printer_marks::PrinterMarks; + let mut doc = fixture(); + let page_content_text = |doc: &Document, page: usize| String::from_utf8_lossy(&page_content_bytes(doc, page)).into_owned(); + let page = page_list(&doc)[0].clone(); + // A trim box inside a letter-size page, 36 pt in: room for the marks, bleed marks included + // (the bleed box is 9 pt out, so they reach 33 pt beyond the trim box), without widening. + doc.update_dict(page.obj, |d| { + d.set(b"MediaBox".to_vec(), Object::Array([0.0, 0.0, 612.0, 792.0].iter().map(|v| Object::Real(*v)).collect())); + d.set(b"TrimBox".to_vec(), Object::Array([36.0, 36.0, 576.0, 756.0].iter().map(|v| Object::Real(*v)).collect())); + d.set(b"BleedBox".to_vec(), Object::Array([27.0, 27.0, 585.0, 765.0].iter().map(|v| Object::Real(*v)).collect())); + }) + .unwrap(); + let marks = PrinterMarks { label: "Quote 1042.pdf".into(), date: "2026-10-13".into(), ..PrinterMarks::default() }; + let widened = add_printer_marks(&mut doc, &[0, 1], &marks, false).unwrap(); + assert_eq!(widened, vec![1], "page 2 has no trim box inside its media box, so it was widened"); + assert_eq!(marks_present(&doc), vec![MarkKind::PrinterMarks]); + let text = page_content_text(&doc, 0); + assert!(text.contains("/PCMark /PrinterMarks"), "{text}"); + // A trim mark at the lower-left corner: vertical below the box, horizontal to its left. + assert!(text.contains("36 30 m 36 12 l S") && text.contains("30 36 m 12 36 l S"), "{text}"); + assert!(text.contains("/PCReg CS 1 SCN /PCReg cs 1 scn"), "registration colour: {text}"); + assert!(!text.contains("1 1 1 1 K"), "not four process inks alone: {text}"); + assert!(text.contains("[1 1] 0 d") && text.contains("27 21 m 27 3 l S"), "dashed bleed marks: {text}"); + assert!(text.contains("1 0 0 0 k") && text.contains("0 0 0 0.25 k"), "colour bars: {text}"); + assert!(text.contains("(Quote 1042.pdf 1 of 3 2026-10-13) Tj"), "page information: {text}"); + // Page 2: the media box grew by 36 pt a side around its (whole-page) trim box. + let p2 = page_list(&doc)[1].clone(); + let media: Vec = p2.dict.get(b"MediaBox").and_then(|m| m.as_array()).unwrap().iter().filter_map(|v| v.as_f64()).collect(); + assert_eq!(media, vec![-36.0, -36.0, 636.0, 836.0]); + // Again with replace: still one set of marks per page; without replace, two. + add_printer_marks(&mut doc, &[0], &marks, true).unwrap(); + assert_eq!(page_content_text(&doc, 0).matches("/PCMark /PrinterMarks").count(), 1); + add_printer_marks(&mut doc, &[0], &marks, false).unwrap(); + assert_eq!(page_content_text(&doc, 0).matches("/PCMark /PrinterMarks").count(), 2); + assert_eq!(remove_marks(&mut doc, &[0, 1], MarkKind::PrinterMarks).unwrap(), 3); + assert!(marks_present(&doc).is_empty()); + // Nothing chosen, or a bad weight, is refused. + let none = PrinterMarks { trim: false, bleed: false, registration: false, color_bars: false, page_info: false, ..PrinterMarks::default() }; + assert!(add_printer_marks(&mut doc, &[0], &none, false).is_err()); + assert!(add_printer_marks(&mut doc, &[0], &PrinterMarks { line_weight: 0.0, ..PrinterMarks::default() }, false).is_err()); +} + +/// Convert colours: device, ICC-based and separation colours in page content +/// and forms become the target's operators; black is preserved; patterns stay; a second run +/// finds nothing. +#[test] +fn colours_are_converted_to_gray_rgb_and_cmyk() { + use crate::convert_colours::{ColourTarget, ConvertColours, convert_colours}; + let mut doc = fixture(); + let page = page_list(&doc)[0].clone(); + // An ICCBased RGB space and a spot colour (Separation to CMYK, Type 2 tint). + let mut icc = Dict::new(); + icc.set(b"N".to_vec(), Object::Int(3)); + let icc_ref = doc.add(Object::Stream(Stream::flate(icc, b"not a real profile"))); + let mut fun = Dict::new(); + fun.set(b"FunctionType".to_vec(), Object::Int(2)); + fun.set(b"Domain".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(1)])); + fun.set(b"C0".to_vec(), Object::Array(vec![Object::Int(0); 4])); + fun.set(b"C1".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(1), Object::Int(1), Object::Int(0)])); + fun.set(b"N".to_vec(), Object::Int(1)); + let mut cs = Dict::new(); + cs.set(b"CS0".to_vec(), Object::Array(vec![Object::name("ICCBased"), Object::Ref(icc_ref)])); + cs.set(b"Spot".to_vec(), Object::Array(vec![Object::name("Separation"), Object::name("Red"), Object::name("DeviceCMYK"), Object::Dict(fun)])); + let mut res = Dict::new(); + res.set(b"ColorSpace".to_vec(), Object::Dict(cs)); + let content = b"1 0 0 rg 10 10 50 50 re f 0 0 1 RG 0 0 m 100 100 l S /CS0 cs 0 1 0 scn 0 0 20 20 re f /Spot cs 1 scn 30 30 20 20 re f 0 g 60 60 10 10 re f 0.5 g 70 70 5 5 re f"; + let c = doc.add(Object::Stream(Stream::flate(Dict::new(), content))); + doc.update_dict(page.obj, |d| { + d.set(b"Contents".to_vec(), Object::Ref(c)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + }) + .unwrap(); + let mut no_images = |_: &Document, _: &Stream, _: ColourTarget| None; + let flat = |b: &[u8]| String::from_utf8_lossy(b).split_whitespace().collect::>().join(" "); + // Grayscale. + let mut gray = doc.clone(); + let done = convert_colours(&mut gray, &[0], &ConvertColours::default(), &mut no_images).unwrap(); + assert_eq!((done.colours, done.pages), (6, 1), "{done:?}"); + let t = flat(&page_content_bytes(&gray, 0)); + assert!(t.contains("0.299 g 10 10 50 50 re f"), "red → its luma: {t}"); + assert!(t.contains("0.114 G 0 0 m 100 100 l S"), "blue stroke: {t}"); + assert!(t.contains("0 g 0 1 0 scn") || t.contains("0.587 g 0 0 20 20 re f"), "ICC green through its space: {t}"); + assert!(t.contains("0.587 g 0 0 20 20 re f"), "{t}"); + assert!(t.contains("0.299 g 30 30 20 20 re f"), "the spot's tint through its CMYK alternate (0 1 1 0 = red): {t}"); + assert!(t.contains("0 g 60 60 10 10 re f") && t.contains("0.5 g 70 70 5 5 re f"), "gray stays gray: {t}"); + assert!(!t.contains("/CS0 cs") && !t.contains("/Spot cs"), "named spaces are gone: {t}"); + assert_eq!(convert_colours(&mut gray, &[0], &ConvertColours::default(), &mut no_images).unwrap().colours, 0, "nothing left"); + // CMYK with black preserved; without, black is still 100% K by the formula. + let mut cmyk = doc.clone(); + let s = ConvertColours { target: ColourTarget::Cmyk, ..ConvertColours::default() }; + convert_colours(&mut cmyk, &[0], &s, &mut no_images).unwrap(); + let t = flat(&page_content_bytes(&cmyk, 0)); + assert!(t.contains("0 1 1 0 k 10 10 50 50 re f"), "red: {t}"); + assert!(t.contains("0 0 0 1 k 60 60 10 10 re f"), "black preserved: {t}"); + assert!(t.contains("0 0 0 0.5 k 70 70 5 5 re f"), "gray 0.5 → 50% K: {t}"); + // RGB: the spot becomes its red. + let mut rgbd = doc.clone(); + convert_colours(&mut rgbd, &[0], &ConvertColours { target: ColourTarget::Rgb, ..ConvertColours::default() }, &mut no_images).unwrap(); + let t = flat(&page_content_bytes(&rgbd, 0)); + assert!(t.contains("1 0 0 rg 30 30 20 20 re f"), "{t}"); + assert!(t.contains("1 0 0 rg 10 10 50 50 re f"), "already RGB, kept: {t}"); +} + /// Fix hairlines: zero-width strokes and strokes thinner than the threshold on /// the page (after the CTM) get the chosen width, in page content and the forms it draws; wider /// lines, and glyphs and patterns unless asked, are left alone. @@ -9437,6 +10012,107 @@ fn a_clipping_copy_on_the_same_baseline_is_refused() { assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2, "refused means untouched"); } +/// `'` moves to the next line before it shows its text, and `"` also sets the word and character +/// spacing (ISO 32000-2 §9.4.3, Table 107). Rewriting a paragraph drawn with them drops the +/// operator but must keep what it did: text after it, positioned from that line or using that +/// spacing, stays exactly where and how it was. Both the single-style paragraph path and the +/// region path (a draft with a line break) are checked. +#[test] +fn rewriting_a_paragraph_drawn_with_quote_operators_keeps_their_line_move_and_spacing() { + let cases = [ + // The next line is placed relative to the line `'` moved to. + "BT /F1 12 Tf 14 TL 72 714 Td (Target line here) ' 0 -100 Td (Footer stays put) Tj ET", + // The same with `"`, whose spacing the footer also inherits. + "BT /F1 12 Tf 14 TL 72 714 Td 3 2 (Target line here) \" 0 -100 Td (Footer stays put) Tj ET", + // Spacing set by `"` carries over into the next text object. + "BT /F1 12 Tf 14 TL 72 714 Td 3 2 (Target line here) \" ET BT 72 600 Td (Footer stays put) Tj ET", + // A split text object: text before the paragraph stays first, then the `'` lines go. + "BT /F1 12 Tf 14 TL 72 760 Td (Heading words) Tj 0 -46 Td (Target line here) Tj (more target) ' 0 -100 Td (Footer stays put) Tj ET", + // Split at a `'`: the rewrite's `Tm` there is already past its line move. + "BT /F1 12 Tf 14 TL 72 760 Td (Heading words) Tj 0 -32 Td (Target line here) ' (more target) ' 0 -100 Td (Footer stays put) Tj ET", + ]; + for content in cases { + let footer = |doc: &Document| { + let lines = text::text_lines(doc, 0).unwrap(); + let f = lines.iter().find(|l| l.text == "Footer stays put").unwrap_or_else(|| panic!("no footer in {content}")); + (f.rect, f.ink.clone()) + }; + let original = text_page(content); + let before = footer(&original); + let target = text::text_blocks(&original, 0).unwrap().iter().position(|b| b.text.starts_with("Target")).unwrap(); + for draft in ["NEW TEXT", "NEW\nTEXT"] { + let mut doc = original.clone(); + text::replace_block(&mut doc, 0, target, draft).unwrap(); + let doc = reopen(&doc); + assert_eq!(footer(&doc), before, "{content} with {draft:?}"); + let texts: Vec = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert!(texts.iter().any(|t| t.contains("NEW")) && !texts.iter().any(|t| t.contains("Target")), "{content} with {draft:?}: {texts:?}"); + } + } +} + +/// [`text::replace_line`] also drops copies of the line drawn on the same baseline (a fake-bold +/// overprint). A copy drawn with `'` or `"` must leave that operator's line move and spacing +/// behind (ISO 32000-2 §9.4.3, Table 107), like the line's own first operator does: text after +/// it is positioned from that line or uses that spacing. Each copy is tried as the target, so the +/// one drawn with the quote operator is both the replaced line and the dropped copy. +#[test] +fn replacing_a_line_keeps_what_a_dropped_quote_copy_did() { + let cases = [ + // The copy's `'` moves to the line the footer is placed from. + (2, "BT /F1 12 Tf 14 TL 72 700 Td (Target line here) Tj ET BT 72.3 714 Td (Target line here) ' 0 -100 Td (Footer stays put) Tj ET"), + // The copy's `"` sets the spacing the footer inherits in the next text object. + (2, "BT /F1 12 Tf 0 TL 72 700 Td (Target line here) Tj ET BT 72.3 700 Td 3 2 (Target line here) \" ET BT 72 600 Td (Footer stays put) Tj ET"), + // Two copies, a `'` one and a `"` one: the footer takes the second's line and spacing. + ( + 3, + "BT /F1 12 Tf 0 TL 72 700 Td (Target line here) Tj ET BT 72.3 700 Td (Target line here) ' ET BT 72.6 700 Td 3 2 (Target line here) \" 0 -100 Td (Footer stays put) Tj ET", + ), + ]; + for (drawn, content) in cases { + let footer = |doc: &Document| { + let lines = text::text_lines(doc, 0).unwrap(); + let f = lines.iter().find(|l| l.text == "Footer stays put").unwrap_or_else(|| panic!("no footer in {content}")); + (f.rect, f.ink.clone()) + }; + let original = text_page(content); + let before = footer(&original); + let targets: Vec = + text::text_lines(&original, 0).unwrap().iter().enumerate().filter(|(_, l)| l.text == "Target line here").map(|(i, _)| i).collect(); + assert_eq!(targets.len(), drawn, "{content}"); + for target in targets { + let mut doc = original.clone(); + text::replace_line(&mut doc, 0, target, "NEW TEXT").unwrap(); + let doc = reopen(&doc); + assert_eq!(footer(&doc), before, "{content} replacing line {target}"); + let texts: Vec = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect(); + assert!(texts.iter().any(|t| t == "NEW TEXT") && !texts.iter().any(|t| t.contains("Target")), "{content}: {texts:?}"); + } + } +} + +/// A copy dropped with the line it overprints moved the pen by its text: a run after it in its +/// text object, placed by that pen, stays where it was (ISO 32000-2 §9.4.4). +#[test] +fn replacing_a_line_keeps_the_advance_of_a_dropped_copy() { + for content in [ + "BT /F1 12 Tf 72 700 Td (Target line here) Tj ET BT 72.3 700 Td (Target line here) Tj [-20000] TJ (Far) Tj ET", + "BT /F1 12 Tf 0 TL 72 700 Td (Target line here) Tj ET BT 72.3 700 Td (Target line here) ' [-20000] TJ (Far) Tj ET", + ] { + let original = text_page(content); + let place = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().find(|l| l.text == "Far").map(|l| l.rect); + let before = place(&original).unwrap_or_else(|| panic!("Far is a line of its own in {content}")); + // The line drawn first, so the one followed by Far is the copy. + let target = text::text_lines(&original, 0).unwrap().iter().position(|l| l.text == "Target line here").unwrap(); + let mut doc = original.clone(); + text::replace_line(&mut doc, 0, target, "NEW TEXT").unwrap(); + let doc = reopen(&doc); + let texts: Vec = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect(); + assert!(!texts.iter().any(|t| t.contains("Target")), "{content}: the copy went too: {texts:?}"); + assert_eq!(place(&doc), Some(before), "{content}"); + } +} + /// #2512: a session's drafts are laid out from the document as it was opened, never from each /// other, and its commit writes what a one-shot rewrite writes, whatever was laid out before. #[test] @@ -11794,16 +12470,19 @@ fn variation_public_line_and_block_edits_preserve_selectors_after_reopen() { #[test] fn variation_public_selector_does_not_shift_centered_text_with_character_spacing() { // қ requires the same bundled fallback for both drafts, without optional font inputs. - for spacing in [0.0, 2.0] { - let mut plain = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET"); - let mut selected = plain.clone(); - let style = text::BlockStyle { width: Some(100.0), align: Some(added::Align::Center), char_spacing: Some(spacing), ..Default::default() }; - text::rewrite_block(&mut plain, 0, 0, Some("1қ"), &style).unwrap(); - text::rewrite_block(&mut selected, 0, 0, Some("1\u{FE0F}қ"), &style).unwrap(); - let a = text::text_lines(&reopen(&plain), 0).unwrap(); - let b = text::text_lines(&reopen(&selected), 0).unwrap(); - assert_eq!(a[0].base_font, b[0].base_font); - assert!(close(a[0].rect, b[0].rect), "Tc={spacing}: plain {:?}, selector {:?}", a[0].rect, b[0].rect); + // Exercise both retained source runs and a wholly substituted subset. + for resource in ["F1", "F2"] { + for spacing in [0.0, 2.0] { + let mut plain = text_page(&format!("BT /{resource} 12 Tf 72 700 Td (ab) Tj ET")); + let mut selected = plain.clone(); + let style = text::BlockStyle { width: Some(100.0), align: Some(added::Align::Center), char_spacing: Some(spacing), ..Default::default() }; + text::rewrite_block(&mut plain, 0, 0, Some("1қ"), &style).unwrap(); + text::rewrite_block(&mut selected, 0, 0, Some("1\u{FE0F}қ"), &style).unwrap(); + let a = text::text_lines(&reopen(&plain), 0).unwrap(); + let b = text::text_lines(&reopen(&selected), 0).unwrap(); + assert_eq!(a[0].base_font, b[0].base_font); + assert!(close(a[0].rect, b[0].rect), "{resource}, Tc={spacing}: plain {:?}, selector {:?}", a[0].rect, b[0].rect); + } } } /// Whole-paragraph alignment retains both soft wraps and typed hard breaks after save/reopen. @@ -13363,6 +14042,124 @@ fn review_inside_scope_group_move_matches_direct_transform_under_outer_scale() { } } +/// Each `Tj`/`TJ` moves the text matrix by the width of its text. A run shown later in the same +/// text object without a `Td` or `Tm` of its own (a raised run in another colour, a column after +/// a `TJ` gap) is placed by those advances. Rewriting the paragraph before it must keep them, as +/// deleting a paragraph does: the run must not jump onto the new text. A kept run before the +/// paragraph splits the text object, and what reopens it keeps that run's advance too. +#[test] +fn rewriting_a_paragraph_keeps_later_runs_on_its_baseline_in_place() { + let cases = [ + ("BT /F1 12 Tf 72 700 Td (Target words) Tj 1 0 0 rg 3 Ts (Red) Tj ET", "Red"), + ("BT /F1 12 Tf 72 700 Td (Target words) Tj [-20000] TJ (Far) Tj ET", "Far"), + // Several dropped runs: each one's advance is kept. + ("BT /F1 12 Tf 72 700 Td (Target ) Tj (wor) Tj (ds) Tj [-20000] TJ (Far) Tj ET", "Far"), + // A kept run before the paragraph on its baseline: the text object is split there, and + // the pen it had reached is kept as well as the line matrix. + ("BT /F1 12 Tf 72 700 Td (Left) Tj [-20000] TJ (Target words) Tj [-20000] TJ (Far) Tj ET", "Far"), + ("BT /F1 12 Tf 72 700 Td (Left) Tj [-20000] TJ (Target ) Tj (words) Tj [-20000] TJ (Far) Tj ET", "Far"), + // The same with horizontal scaling and a later line placed from the line matrix. + ("BT /F1 12 Tf 80 Tz 14 TL 72 700 Td (Left) Tj [-20000] TJ (Target words) Tj [-20000] TJ (Far) Tj T* (Next) Tj ET", "Far"), + ("BT /F1 12 Tf 80 Tz 14 TL 72 700 Td (Left) Tj [-20000] TJ (Target words) Tj [-20000] TJ (Far) Tj T* (Next) Tj ET", "Next"), + // A `Q` puts back the text matrix its `q` saved: the `Tm` between them doesn't place `Far`. + ("BT /F1 12 Tf 72 700 Td (Target words) Tj q 1 0 0 1 300 300 Tm Q [-20000] TJ (Far) Tj ET", "Far"), + // A `Q` with nothing saved restores nothing: the advance is still kept. + ("BT /F1 12 Tf 72 700 Td (Target words) Tj Q [-20000] TJ (Far) Tj ET", "Far"), + ]; + for (content, run) in cases { + let original = text_page(content); + let place = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().find(|l| l.text == run).map(|l| l.rect); + let before = place(&original).unwrap_or_else(|| panic!("{run} is a line of its own in {content}")); + let target = text::text_blocks(&original, 0).unwrap().iter().position(|b| b.text == "Target words").unwrap(); + for draft in ["NEW TEXT", "NEW\nTEXT"] { + let mut doc = original.clone(); + text::replace_block(&mut doc, 0, target, draft).unwrap(); + let after = place(&reopen(&doc)).unwrap_or_else(|| panic!("{run} is gone: {content} with {draft:?}")); + assert!(after.iter().zip(before).all(|(a, b)| (a - b).abs() < 1e-6), "{content} with {draft:?}: {before:?} -> {after:?}"); + } + } +} + +/// Vertical text advances down the column (ISO 32000-2 §9.4.4): runs dropped by a rewrite leave +/// that advance, so a run later in the same text object stays where it was. +#[test] +fn rewriting_vertical_runs_keeps_a_later_run_in_its_column_in_place() { + // The second with a horizontal scaling (`Tz`), which a vertical advance doesn't take. + for content in [ + "BT /V1 20 Tf 500 720 Td <0041> Tj <0042> Tj [-8000] TJ <0043> Tj ET", + "BT /V1 20 Tf 80 Tz 500 720 Td <0041> Tj <0042> Tj [-8000] TJ <0043> Tj ET", + ] { + vertical_runs_keep_a_later_run_in_place(content); + } +} + +fn vertical_runs_keep_a_later_run_in_place(content: &str) { + let original = vertical_page(content); + let place = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().find(|l| l.text == "C").map(|l| l.rect); + let before = place(&original).unwrap_or_else(|| panic!("C is a line of its own: {:?}", text::text_lines(&original, 0))); + let target = text::text_blocks(&original, 0).unwrap().iter().position(|b| b.text == "AB").unwrap(); + for draft in [ + "D", "D +E", + ] { + let mut doc = original.clone(); + text::replace_block(&mut doc, 0, target, draft).unwrap(); + let after = place(&reopen(&doc)).unwrap_or_else(|| panic!("C is gone with {draft:?}")); + assert!(after.iter().zip(before).all(|(a, b)| (a - b).abs() < 1e-6), "{content} with {draft:?}: {before:?} -> {after:?}"); + } +} + +/// The advances read the glyphs without `/ActualText`, which can come to more text than the +/// reading the edit was laid out from. Past the page's text budget that way, no advance is kept +/// and the edit goes ahead, rather than refusing a page the reading allowed. +#[test] +fn dropped_advances_past_the_text_budget_keep_none_rather_than_refuse() { + let doc = text_page("BT /F1 12 Tf 72 700 Td (Drop) Tj [-20000] TJ (Far) Tj ET"); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let drop: std::collections::HashSet = ops.iter().position(|o| o.is("Tj")).into_iter().collect(); + let page = pdfcraft_model::pages(&doc)[0].dict.clone(); + let res = page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + assert_eq!(text::dropped_advances_within(&doc, &ops, &res, &drop, None).len(), 1, "the advance Far relies on"); + assert!(text::dropped_advances_within(&doc, &ops, &res, &drop, Some(2)).is_empty(), "too much text to read: none"); +} + +#[test] +fn page_rules_refuse_unread_graphics_state() { + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("UnsupportedFilter")); + let unread = Stream::from_raw(dict, b"2 0 0 2 0 0 cm".to_vec()); + let damaged = damaged_flate(b"0 g\n", b"2 0 0 2 0 0 cm"); + for stream in [unread, damaged] { + let mut doc = image_page_with("10 10 m 100 10 l S"); + let incomplete = doc.add(Object::Stream(stream)); + doc.update_dict(ObjRef::new(3, 0), |page| { + page.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(incomplete), Object::Ref(ObjRef::new(4, 0))])); + }) + .unwrap(); + assert!(page_rules(&doc, 0).is_err(), "later geometry cannot be trusted after unread graphics state"); + } + assert_eq!(page_rules(&image_page_with("10 10 m 100 10 l S"), 0).unwrap(), [[10.0, 10.0, 100.0, 10.0]]); +} + +#[test] +fn image_editing_frame_keeps_page_edge_outside_explicit_clips() { + for (prefix, expected) in [ + ("", [-30.0, -20.0, 70.0, 80.0]), + ("-10 -5 60 65 re W n ", [-10.0, -5.0, 50.0, 60.0]), + ("/PdfCraftImageCrop MP -10 -5 60 65 re W n ", [-10.0, -5.0, 50.0, 60.0]), + ] { + let doc = image_page_with(&format!("q {prefix}100 0 0 100 -30 -20 cm /Im0 Do Q")); + for doc in [&doc, &reopen(&doc)] { + let im = images::page_images(doc, 0).unwrap().remove(0); + assert_eq!(im.frame_rect(), Some(expected)); + assert_eq!(im.has_editor_crop(), prefix.starts_with("/PdfCraftImageCrop")); + assert_eq!(im.visible_rect.unwrap()[0..2], [0.0, 0.0]); + assert!(!im.contains([-5.0, 10.0]), "out-of-page pixels stay unselectable"); + assert!(im.contains([10.0, 10.0])); + } + } +} + #[test] fn explicit_builtin_thai_replaces_an_unavailable_source_without_installing_a_font() { let mut doc = text_page("BT /F1 16 Tf 50 700 Td (A) Tj ET BT /F2 12 Tf 50 600 Td (ab) Tj ET"); @@ -14030,6 +14827,61 @@ fn legacy_thai_subsets_are_read_within_one_budget_per_edit() { assert_eq!(pdfcraft_cos::write_full(&distinct, &Default::default()).unwrap(), before, "a refused edit changes nothing"); } +/// An edit whose THSarabunPSK subset spends the edit's allowance to the byte goes through: only a +/// read refused for want of it refuses the edit. The subset is read four times over its program +/// (the edit's metrics read, `LegacyThaiFont`'s, the editor's program read and its metrics read) +/// and three times over its ToUnicode, so a program 192 bytes short of 16 MiB and a ToUnicode of +/// 256 bytes cost exactly the 64 MiB; a byte more of ToUnicode is three past it. +#[test] +fn a_thai_edit_that_spends_its_allowance_to_the_byte_goes_through() { + let mut program = legacy_thai_program(); + program.resize((16 << 20) - 192, 0); + let edit = |unicode_len: usize| { + let mut unicode = LEGACY_THAI_UNICODE.to_vec(); + unicode.resize(unicode_len, b' '); + let mut doc = text_page("BT /F1 16 Tf 50 650 Td <00d6> Tj ET"); + let font = legacy_thai_font(&mut doc, "ABCDEF+THSarabunPSK", &program, &unicode); + doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(font)); + let style = BlockStyle { width: Some(400.0), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some("กขค"), &style).map(|_| ()).map_err(|e| e.to_string()) + }; + assert_eq!(edit(256), Ok(())); + let error = edit(257).unwrap_err(); + assert!(error.contains("THSarabunPSK") && error.contains("MiB of font data"), "{error}"); +} + +/// Only a read the allowance refuses leaves a font of the face unread for want of it. With the +/// allowance spent to the byte as above, another font of the face, whose ToUnicode is an empty +/// stream behind a filter nothing decodes (unread with any allowance), still has its program read, +/// which the allowance refuses: the edit is refused, rather than going through without that font. +#[test] +fn a_font_of_the_face_unread_on_its_own_once_the_allowance_is_spent_still_refuses_the_edit() { + let mut program = legacy_thai_program(); + program.resize((16 << 20) - 192, 0); + let mut unicode = LEGACY_THAI_UNICODE.to_vec(); + unicode.resize(256, b' '); + let mut doc = text_page("BT /F1 16 Tf 50 650 Td <00d6> Tj ET"); + let font = legacy_thai_font(&mut doc, "ABCDEF+THSarabunPSK", &program, &unicode); + doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(font)); + // Not a subset, so its metrics read no program, only the ToUnicode. + let mut other = legacy_thai_font(&mut doc, "THSarabunPSK", &legacy_thai_program(), LEGACY_THAI_UNICODE); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter")); + other.set(b"ToUnicode".to_vec(), Object::Ref(doc.add(Object::Stream(Stream::from_raw(dict, Vec::new()))))); + let other = doc.add(Object::Dict(other)); + doc.update_dict(pdfcraft_cos::ObjRef::new(3, 0), |page| { + let Some(Object::Dict(mut resources)) = page.get(b"Resources").cloned() else { panic!("fixture") }; + let Some(Object::Dict(mut fonts)) = resources.get(b"Font").cloned() else { panic!("fixture") }; + fonts.set(b"F2".to_vec(), Object::Ref(other)); + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + }) + .unwrap(); + let style = BlockStyle { width: Some(400.0), ..Default::default() }; + let error = text::rewrite_block(&mut doc, 0, 0, Some("กขค"), &style).unwrap_err().to_string(); + assert!(error.contains("THSarabunPSK") && error.contains("MiB of font data"), "{error}"); +} + /// A THSarabunPSK-named subset that has OpenType Thai mark positioning is a modern face: it is /// shaped with its own layout like any source font, not by the legacy shaper (which would ask /// for Windows PUA alternates such a face doesn't need). diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 2dbf302685..b58c41303f 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -10,7 +10,7 @@ //! otherwise it is set in Helvetica (WinAnsi), and the result says the font was substituted. //! Reflowing a paragraph to a new width is not done here (see `edit.text-reflow`). -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::rc::Rc; use pdfcraft_content::{Matrix, Op, Pieces, parse, serialize_ops}; @@ -39,25 +39,27 @@ mod rewrite; #[cfg(test)] use blocks::wrap; pub use blocks::{ - BlockKey, BlockStyle, RangeStyle, ReadingBlocks, TextBlock, block_overlapping, clear_block, reading_blocks, reading_blocks_checked, + BlockKey, BlockStyle, RangeStyle, ReadingBlocks, RunSources, TextBlock, block_overlapping, clear_block, reading_blocks, reading_blocks_checked, replace_block, text_blocks, }; use blocks::{kept_words, paragraph_separator, row_break}; pub use editor::{TextEditLayout, TextEditorFont, register_text_font, text_edit_layout, text_editor_fonts}; use editor::{builtin_thai_choice, layout_draft}; -#[cfg(test)] -pub(crate) use fallback::SOURCE_SPACE_CALLS; use fallback::{ MAX_TYPE3_GLYPHS, Type3Glyph, fallback_face, needs_type3, no_fallback_font, no_glyph_for, same_base_font, same_font, source_encodes, source_family, source_program, source_show, source_space, source_width, type3_faces, type3_glyph, write_type3_glyphs, }; #[cfg(test)] +pub(crate) use fallback::{SOURCE_SPACE_CALLS, take_source_character_measurements}; +#[cfg(test)] use fallback::{add_type3_font, is_type3_fallback}; pub(crate) use fallback::{glyph_path, type3_font_dict, unique_font_name}; use interp::{Origin, Paint, union}; +#[cfg(test)] +pub(crate) use lines::dropped_advances_within; pub(crate) use lines::{ - ContentStream, FormBudget, Read, content_streams, content_streams_within, dict_in_place, drawn_before_damage, first_op_after_damage, form_call, - joined, resource_dict, rewritten_contents, splice, + ContentStream, FormBudget, Read, content_streams, content_streams_within, dict_in_place, drawn_before_damage, dropped_advances, + dropped_side_effects, first_op_after_damage, form_call, joined, reopen_text_object, resource_dict, rewritten_contents, splice, }; pub use lines::{MAX_FORM_DEPTH, MAX_FORM_VISITS, glyph_text_lines, text_lines}; use lines::{page_dict, word_lines}; @@ -155,6 +157,66 @@ impl TextLine { mod tests { use super::*; + /// The clip regions reading uses (#3503): intersected regions keep only what both cover, + /// regions apart leave nothing, turned ones keep their shape, and touching is not showing. + #[test] + fn clip_regions_intersect_and_show_what_they_cover() { + use interp::{intersect, region, shows}; + let square = |x: f64, y: f64| region(&Matrix::IDENTITY, [x, y, x + 10.0, y + 10.0]).unwrap(); + let area = |p: &[[f64; 2]]| p.iter().zip(p.iter().cycle().skip(1)).map(|(a, b)| a[0] * b[1] - b[0] * a[1]).sum::().abs() / 2.0; + // Squares overlapping by half each way share a quarter. + let both = intersect(&square(0.0, 0.0), &square(5.0, 5.0)); + assert!((area(&both) - 25.0).abs() < 1e-9, "{both:?}"); + assert!(shows([6.0, 6.0, 7.0, 7.0], &both) && !shows([1.0, 1.0, 2.0, 2.0], &both)); + // Squares apart share nothing, so nothing shows, not even a box across both. + let none = intersect(&square(0.0, 0.0), &square(20.0, 0.0)); + assert!(!shows([-100.0, -100.0, 100.0, 100.0], &none), "{none:?}"); + // Squares that meet only along an edge share no area either: a box across that edge + // does not show (#5075 review). + let edge = intersect(&square(0.0, 0.0), &square(10.0, 0.0)); + assert!(!shows([8.0, 2.0, 12.0, 4.0], &edge), "{edge:?}"); + // Nothing intersected with anything is nothing, whichever side it is on. + assert!(!shows([1.0, 1.0, 2.0, 2.0], &intersect(&square(0.0, 0.0), &Vec::new()))); + assert!(!shows([1.0, 1.0, 2.0, 2.0], &intersect(&Vec::new(), &square(0.0, 0.0)))); + // A box touching an edge shares no area; one a hair inside does. + assert!(!shows([10.0, 0.0, 12.0, 5.0], &square(0.0, 0.0))); + assert!(shows([9.99, 0.0, 12.0, 5.0], &square(0.0, 0.0))); + // A square turned 45° keeps its diamond shape: a box in the corner of its bounding box is + // outside it, the centre is inside. + let c = std::f64::consts::FRAC_1_SQRT_2; + let diamond = region(&Matrix([c, c, -c, c, 0.0, 0.0]), [0.0, 0.0, 10.0, 10.0]).unwrap(); + assert!(!shows([-7.0, 0.0, -6.5, 0.5], &diamond) && shows([-0.5, 6.5, 0.5, 7.5], &diamond)); + // A corner repeated (an edge of no length) does not hide what the region covers. + let doubled = vec![[0.0, 0.0], [10.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]]; + assert!(shows([4.0, 4.0, 6.0, 6.0], &doubled)); + // A rectangle of no area is a region that covers nothing; a corner that isn't finite + // gives no region to clip with at all. + let flat = region(&Matrix::IDENTITY, [0.0, 0.0, 10.0, 0.0]).unwrap(); + assert!(!shows([-100.0, -100.0, 100.0, 100.0], &flat), "{flat:?}"); + assert!(region(&Matrix::IDENTITY, [0.0, 0.0, f64::INFINITY, 10.0]).is_none()); + // However thin, a rectangle with area keeps it, and so does one far from the origin. + let thin = region(&Matrix::IDENTITY, [0.0, 0.0, 1.0, 1e-10]).unwrap(); + assert!(shows([0.25, 0.0, 0.75, 1.0], &thin) && !shows([0.25, 1.0, 0.75, 2.0], &thin)); + let far = region(&Matrix([1.0, 0.0, 0.0, 1.0, 1e12, 1e12]), [0.0, 0.0, 100.0, 100.0]).unwrap(); + assert!(shows([1e12 + 10.0, 1e12 + 10.0, 1e12 + 20.0, 1e12 + 20.0], &far) && !shows([1e12 + 200.0, 1e12, 1e12 + 300.0, 1e12 + 10.0], &far)); + } + + /// A stream that sets clip after clip, each turned a little more, keeps its region bounded: + /// past the corner cap a region becomes its bounding box, which still holds all it held. + #[test] + fn clip_after_clip_keeps_the_region_bounded() { + use interp::{intersect, region, shows}; + let mut clip = region(&Matrix::IDENTITY, [-100.0, -100.0, 100.0, 100.0]).unwrap(); + for i in 1..=10_000 { + let (s, c) = (f64::from(i) * 0.001).sin_cos(); + let turned = region(&Matrix([c, s, -s, c, 0.0, 0.0]), [-60.0, -60.0, 60.0, 60.0]).unwrap(); + clip = intersect(&clip, &turned); + assert!(clip.len() <= 32 && clip.iter().flatten().all(|v| v.is_finite()), "clip {i}: {} corners", clip.len()); + } + // Every square is centred on the origin, so the middle still shows. + assert!(shows([-1.0, -1.0, 1.0, 1.0], &clip)); + } + fn block(text: &str, rect: [f64; 4]) -> TextBlock { TextBlock { text: text.into(), diff --git a/crates/edit/src/text/blocks.rs b/crates/edit/src/text/blocks.rs index b714bcd592..db4fa4f2db 100644 --- a/crates/edit/src/text/blocks.rs +++ b/crates/edit/src/text/blocks.rs @@ -1,5 +1,5 @@ use super::interp::{Carry, interpret}; -use super::lines::{content_streams, joined, page_dict, reading_lines, rewritten_contents, splice, text_lines}; +use super::lines::{content_streams, joined, page_dict, reading_lines, rewritten_contents, show_side_effects, splice, text_lines}; use super::rewrite::{LineEdit, coincident_ops, rewrite_block}; use super::{Document, EditError, HashMap, Object, Op, TextLine, group_blocks, regions}; @@ -61,14 +61,7 @@ impl TextBlock { let old: Vec<_> = self.editor_text.chars().collect(); let new: Vec<_> = draft.char_indices().collect(); let chars: Vec<_> = new.iter().map(|(_, c)| *c).collect(); - let mut original = Vec::with_capacity(old.len()); - let mut span = 0; - for (byte, _) in self.editor_text.char_indices() { - while self.spans.get(span).is_some_and(|(r, _)| byte >= r.end) { - span += 1; - } - original.push(self.spans.get(span).or_else(|| self.spans.last()).map_or(0, |(_, i)| *i)); - } + let original = self.char_sources(); let mut sources = Vec::with_capacity(new.len()); let slices = |text: &[char]| words(text).into_iter().map(|w| text.get(w).unwrap_or_default().to_vec()).collect::>(); let (old_words, new_words) = (slices(&old), slices(&chars)); @@ -229,6 +222,79 @@ fn replaced(original: &[usize], at: usize, old: &[char], new: &[char], out: &mut } } +/// Byte ranges of a paragraph's text and the source run (an index into [`text_lines`]) each one +/// keeps the style of, in order ([`TextBlock::spans`], [`BlockStyle::sources`]). +pub type RunSources = Vec<(std::ops::Range, usize)>; + +impl TextBlock { + /// The source run (an index into [`text_lines`]) of each character of `editor_text`. A + /// character no span covers (the line break between two rows, a space put between two runs) + /// belongs to the run that follows it. + fn char_sources(&self) -> Vec { + let mut original = Vec::with_capacity(self.editor_text.len()); + let mut span = 0; + for (byte, _) in self.editor_text.char_indices() { + while self.spans.get(span).is_some_and(|(r, _)| byte >= r.end) { + span += 1; + } + original.push(self.spans.get(span).or_else(|| self.spans.last()).map_or(0, |(_, i)| *i)); + } + original + } + + /// `editor_text` with each of `ranges` (byte ranges in it, in order and not overlapping) put + /// in `with`, and the source run each byte range of the result keeps the style of + /// ([`BlockStyle::sources`]): every character outside the ranges its own, a replacement the + /// run of the first character it replaces. Spaces a replacement puts side by side become + /// one (deleting a word leaves one space between its neighbours), while a run of spaces the + /// paragraph already had stays, since the rewrite keeps every space typed. `None` when a + /// range is out of order, overlaps another or does not fall on character boundaries. + pub fn replaced(&self, ranges: &[std::ops::Range], with: &str) -> Option<(String, RunSources)> { + let mut end = 0; + for r in ranges { + if r.start < end || r.start >= r.end || self.editor_text.get(r.clone()).is_none() { + return None; + } + end = r.end; + } + // One run per stretch of the result: a piece from the same run right after it joins it. + fn extend(spans: &mut Vec<(std::ops::Range, usize)>, start: usize, len: usize, source: usize) { + match spans.last_mut() { + _ if len == 0 => {} + Some((range, last)) if *last == source && range.end == start => range.end = start.saturating_add(len), + _ => spans.push((start..start.saturating_add(len), source)), + } + } + let mut draft = String::with_capacity(self.editor_text.len()); + let mut spans = Vec::new(); + let mut next = ranges.iter().peekable(); + // Right after a replacement: a space here would join one the draft ends with. + let mut joined = false; + let spacing = |c: char| c != '\n' && c != '\r' && paragraph_separator(c); + for ((byte, ch), source) in self.editor_text.char_indices().zip(self.char_sources()) { + if let Some(r) = next.peek() + && byte >= r.start + { + if byte == r.start { + extend(&mut spans, draft.len(), with.len(), source); + draft.push_str(with); + } + if byte.saturating_add(ch.len_utf8()) >= r.end { + next.next(); + joined = true; + } + continue; + } + if std::mem::take(&mut joined) && spacing(ch) && draft.chars().next_back().is_some_and(spacing) { + continue; + } + extend(&mut spans, draft.len(), ch.len_utf8(), source); + draft.push(ch); + } + Some((draft, spans)) + } +} + /// How a listing of [`text_blocks`] records a paragraph so that it can be found again. /// /// The index of a paragraph is its position in a list regrouped from geometry on every read, so @@ -315,13 +381,7 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(), } let mut replacement = Vec::new(); if let Some(op) = ops.get(s.op) { - if op.is("\"") { - replacement.push(Op::new("Tw", vec![op.operands.first().cloned().unwrap_or(Object::Int(0))])); - replacement.push(Op::new("Tc", vec![op.operands.get(1).cloned().unwrap_or(Object::Int(0))])); - } - if op.is("'") || op.is("\"") { - replacement.push(Op::new("T*", vec![])); - } + replacement.extend(show_side_effects(op)); } replacement.push(Op::new("TJ", vec![Object::Array(vec![pdfcraft_content::num(adjustment)])])); patches.insert(s.op, replacement); @@ -467,6 +527,12 @@ pub struct BlockStyle { /// For an invisible paragraph ([`TextBlock::invisible`]): fill its old area with this colour /// (RGB 0–1) before the new text, hiding the scanned word beneath it. pub cover: Option<[f64; 3]>, + /// The source run each byte range of the new text keeps the style of (indexes into + /// [`text_lines`], as in [`TextBlock::spans`]), covering the text in order. Empty: + /// [`TextBlock::edited_spans`] works it out by aligning the old and new text, where a changed + /// word takes the run before it. Find and replace sets it ([`TextBlock::replaced`]): there a + /// replacement takes the run of the text it replaces, however many matches the paragraph has. + pub sources: RunSources, /// For an invisible paragraph whose formatting nobody asked to change: cover and redraw only /// the words the draft changes, and leave the rest as the scan shows them (#3313). The session /// sets this; a caller that sets it should also give `cover`. diff --git a/crates/edit/src/text/fallback.rs b/crates/edit/src/text/fallback.rs index f7c795ca65..d186548fcb 100644 --- a/crates/edit/src/text/fallback.rs +++ b/crates/edit/src/text/fallback.rs @@ -1,3 +1,4 @@ +use super::HashSet; use super::interp::Ts; use super::lines::page_dict; use super::{ @@ -630,6 +631,21 @@ pub(super) fn type3_encode(fallback: &Type3Fallback, text: &str) -> Option(mut text: &'a str, source_chars: Option<&'a HashSet>) -> impl Iterator + 'a { + std::iter::from_fn(move || { + let first = text.chars().next()?; + let source = source_chars.is_some_and(|chars| chars.contains(&first)); + let end = source_chars + .and_then(|chars| text.char_indices().find(|(_, ch)| chars.contains(ch) != source).map(|(offset, _)| offset)) + .unwrap_or(text.len()); + // char_indices supplies UTF-8 boundaries; get also handles an invalid range safely. + let run = text.get(..end)?; + text = text.get(end..)?; + Some((source, run)) + }) +} + /// The source font's own TrueType/OpenType program, when the document embeds a complete one whose /// licence allows editable embedding. A subset (an `ABCDEF+` name) is left out: a character can keep /// its mapping there after the subsetter dropped its outline. @@ -702,6 +718,25 @@ pub(crate) fn source_width(metrics: &Metrics, text: &str, size: f64, space: f64, words + text.matches(' ').count() as f64 * (space * size + char_spacing + word_spacing) } +#[cfg(test)] +thread_local! { + static SOURCE_CHARACTER_MEASUREMENTS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[cfg(test)] +pub(crate) fn take_source_character_measurements() -> usize { + SOURCE_CHARACTER_MEASUREMENTS.with(|count| count.replace(0)) +} + +/// An actually encodable source character in the normalized `Tw = 0` drawing state. +pub(super) fn source_character_advance(metrics: &Metrics, ch: char, size: f64, char_spacing: f64) -> Option { + #[cfg(test)] + SOURCE_CHARACTER_MEASUREMENTS.with(|count| count.set(count.get().saturating_add(1))); + let mut utf8 = [0; 4]; + let bytes = metrics.encode(ch.encode_utf8(&mut utf8))?; + Some(metrics.codes(&bytes).iter().map(|(code, _)| metrics.width(*code) * size + char_spacing).sum()) +} + pub(crate) fn source_show(metrics: &Metrics, text: &str, size: f64, space: f64, char_spacing: f64, word_spacing: f64) -> Result, EditError> { if let Some(bytes) = metrics.encode(text) { return Ok(vec![Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])]); diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs index 6fb50138ab..0bc9373899 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -218,9 +218,139 @@ pub(crate) fn union(a: [f64; 4], b: [f64; 4]) -> [f64; 4] { [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])] } +/// A convex part of the page, in page space: the corners of a convex polygon, in order. With fewer +/// than three corners it covers nothing. +pub(super) type Region = Vec<[f64; 2]>; + +/// A clip, as the union of the convex regions it leaves visible: one for each rectangle of its +/// path. With no parts it leaves nothing visible. +pub(super) type Clip = Vec; + +/// The most corners a clip region keeps. Each intersection with a four-cornered clip can add +/// corners, so past this a region becomes its bounding box: still all it held, and a stream that +/// sets clip after clip stays bounded. +const MAX_REGION_CORNERS: usize = 32; + +/// The most parts a clip keeps. Clips intersect part by part, so past this its parts become one, +/// the box around them: still all they held, and the work stays bounded. +const MAX_CLIP_PARTS: usize = 16; + +/// `r` (`[x0, y0, x1, y1]`, either way round) under `m`, keeping its shape when `m` turns or +/// skews it. A rectangle of no area is a region that covers nothing, however thin a rectangle +/// with area is; `None` when a corner, or its measure, is not finite. +pub(super) fn region(m: &Matrix, r: [f64; 4]) -> Option { + let corners: Region = [(r[0], r[1]), (r[2], r[1]), (r[2], r[3]), (r[0], r[3])] + .into_iter() + .map(|(x, y)| { + let (x, y) = m.apply(x, y); + [x, y] + }) + .collect(); + let finite = corners.iter().flatten().all(|v| v.is_finite()) && twice_area(&corners).is_finite() && perimeter(&corners).is_finite(); + finite.then(|| if encloses_area(&corners) { corners } else { Vec::new() }) +} + +/// The polygon's signed area, doubled: positive when its corners run anticlockwise (y up). +/// Measured from its first corner, so that far from the origin it keeps its precision. +fn twice_area(p: &[[f64; 2]]) -> f64 { + let Some(o) = p.first() else { return 0.0 }; + p.iter().zip(p.iter().cycle().skip(1)).map(|(a, b)| (a[0] - o[0]) * (b[1] - o[1]) - (b[0] - o[0]) * (a[1] - o[1])).sum() +} + +fn perimeter(p: &[[f64; 2]]) -> f64 { + p.iter().zip(p.iter().cycle().skip(1)).map(|(a, b)| (b[0] - a[0]).hypot(b[1] - a[1])).sum() +} + +/// Whether the polygon encloses any area: its area against the square of its perimeter, so the +/// answer is the same at any scale and a thin box still has one. +fn encloses_area(p: &[[f64; 2]]) -> bool { + let around = perimeter(p); + p.len() >= 3 && twice_area(p).abs() > 1e-12 * around * around +} + +/// The four corners of the box around `points`. +fn bounding<'a>(points: impl Iterator) -> Region { + let b = points.fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])]); + vec![[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]] +} + +/// The part of `a` inside `b`, both convex (Sutherland-Hodgman: `a` cut by each edge of `b`). Clips +/// in force intersect, so text shows only where all of them let it through. Regions that share no +/// area (apart, or meeting only along an edge) leave an empty one, as does an empty `a` or `b`. +pub(super) fn intersect(a: &Region, b: &Region) -> Region { + if a.len() < 3 || b.len() < 3 { + return Vec::new(); + } + let turn = twice_area(b).signum(); + let mut out = a.clone(); + for (p, q) in b.iter().zip(b.iter().cycle().skip(1)) { + // Positive on the side of the edge `p` → `q` that `b` lies on. + let inside = |v: &[f64; 2]| turn * ((q[0] - p[0]) * (v[1] - p[1]) - (q[1] - p[1]) * (v[0] - p[0])); + let cut = std::mem::take(&mut out); + for (s, e) in cut.iter().zip(cut.iter().cycle().skip(1)) { + let (ds, de) = (inside(s), inside(e)); + if ds >= 0.0 { + out.push(*s); + } + // The edge crosses the line: `ds - de` is not zero, as one is negative and one is not. + if (ds >= 0.0) != (de >= 0.0) { + let t = ds / (ds - de); + out.push([s[0] + t * (e[0] - s[0]), s[1] + t * (e[1] - s[1])]); + } + } + } + if !encloses_area(&out) { + return Vec::new(); + } + if out.len() > MAX_REGION_CORNERS { + out = bounding(out.iter()); + } + out +} + +/// The clip that leaves visible only what both `a` and `b` do: each part of one intersected with +/// each part of the other. +pub(super) fn clip_within(a: &Clip, b: &Clip) -> Clip { + let mut out: Clip = a.iter().flat_map(|p| b.iter().map(move |q| intersect(p, q))).filter(|r| !r.is_empty()).collect(); + if out.len() > MAX_CLIP_PARTS { + out = vec![bounding(out.iter().flatten())]; + } + out +} + +/// Whether the box `r` shows through `clip`: through any of its parts. +pub(super) fn clip_shows(r: [f64; 4], clip: &Clip) -> bool { + clip.iter().any(|part| shows(r, part)) +} + +/// Whether the box `r` (`[x0, y0, x1, y1]`) and `region` share some area, by the separating axis +/// test: two convex shapes are apart exactly when their shadows on some axis, one of the box's or +/// a normal of one of the region's edges, do not overlap. Touching is not sharing. +pub(super) fn shows(r: [f64; 4], region: &Region) -> bool { + if region.len() < 3 { + return false; + } + let corners = [[r[0], r[1]], [r[2], r[1]], [r[2], r[3]], [r[0], r[3]]]; + let apart = |axis: [f64; 2]| { + let shadow = |points: &[[f64; 2]]| { + points.iter().map(|p| p[0] * axis[0] + p[1] * axis[1]).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(v), hi.max(v))) + }; + let ((a0, a1), (b0, b1)) = (shadow(&corners), shadow(region)); + a1 <= b0 || b1 <= a0 + }; + // An edge of no length (two corners in one place) has no normal to test. + let normals = region.iter().zip(region.iter().cycle().skip(1)).map(|(p, q)| [p[1] - q[1], q[0] - p[0]]).filter(|n| n[0] != 0.0 || n[1] != 0.0); + !([[1.0, 0.0], [0.0, 1.0]].into_iter().chain(normals).any(apart)) +} + #[derive(Clone)] pub(super) struct Ts { pub(super) ctm: Matrix, + /// The clip in force, if any, as what it leaves visible: the intersection of every + /// rectangular content-stream clip (`re` `W`/`W*` `n`, ISO 32000-2 §8.5.4) and every enclosing + /// form's `/BBox` ([`super::lines`]), each in the shape its matrix gives it. `q`/`Q` save and + /// restore it along with the rest of `Ts`. + pub(super) clip: Option, /// The operators that set the current fill colour (`g`, `rg`, `k`, or `cs` + `sc`/`scn`). pub(super) fill: Vec, pub(super) font: Option<(Vec, Rc)>, @@ -506,6 +636,7 @@ impl Carry { pub(super) fn new() -> Self { let ts = Ts { ctm: Matrix::IDENTITY, + clip: None, fill: Vec::new(), font: None, face: None, @@ -860,11 +991,57 @@ pub(super) fn interpret( // with no properties dict (a file saved before this was tracked, or by another writer) reads // as 1, the single space a rejoin always used before #2529. let mut soft_gap = 1usize; + // A pending path of `x y w h re`s, and whether a `W`/`W*` has marked it as the path that `n` + // turns into the new clip (ISO 32000-2 §8.5.4) -- the common "set a rectangular clip" idiom. + // The path is `Some(None)` once one of its rectangles can't be read, and then clips nothing. + // Each rectangle is a part of the clip; a path of more than `MAX_CLIP_PARTS` keeps the box + // around them, which still holds them all. Any other operator between `re` and `n` ends the + // idiom: the clip is not read then. + let mut pending_clip: Option>> = None; + let mut clip_pending = false; for (i, op) in ops.iter().enumerate() { // A gap's space carries only into a `Tj` or `TJ` right after it. if !matches!(op.op.as_slice(), b"Tj" | b"TJ") { carry.gap_space = false; } + match op.op.as_slice() { + b"re" => { + let rect = op.nums::<4>().map(|[x, y, w, h]| [x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)]); + pending_clip = Some(match (pending_clip.take(), rect) { + (None, Some(rect)) => Some(vec![rect]), + (Some(Some(mut path)), Some(rect)) => { + if path.len() >= MAX_CLIP_PARTS { + path = vec![path.iter().fold(rect, |all, r| union(all, *r))]; + } else { + path.push(rect); + } + Some(path) + } + _ => None, + }); + } + b"W" | b"W*" => clip_pending = pending_clip.is_some(), + b"n" => { + if clip_pending && let Some(Some(path)) = pending_clip.take() { + // A rectangle past what a matrix can hold leaves the clip unread. One of no + // area is a part that shows nothing, so a clip of no area hides everything. + let parts: Option = path.iter().map(|r| region(&ts.ctm, *r)).collect(); + if let Some(parts) = parts { + let new: Clip = parts.into_iter().filter(|part| !part.is_empty()).collect(); + ts.clip = Some(match &ts.clip { + Some(old) => clip_within(old, &new), + None => new, + }); + } + } + pending_clip = None; + clip_pending = false; + } + _ => { + pending_clip = None; + clip_pending = false; + } + } match op.op.as_slice() { // No depth cap: every `EMC` pops, so a capped push would pair it with the enclosing // sequence (#3573). Both stacks stay bounded by the number of operators. diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs index 1e899bc352..e1c4b2d312 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use super::fallback::{same_font, same_paint}; -use super::interp::{Carry, Origin, Pen, Shown, TextState, Ts, direction, interpret, to_frame, union}; +use super::interp::{Carry, Clip, Origin, Pen, Shown, TextState, Ts, clip_shows, clip_within, direction, interpret, region, to_frame, union}; use super::{DecodeBudget, Dict, Document, EditError, HashMap, Matrix, Object, Op, Pieces, Stream, TextLine, parse, serialize_ops}; impl TextLine { @@ -115,6 +115,146 @@ pub(crate) fn content_streams_within(doc: &Document, page: &Dict, budget: &mut D Ok(out) } +/// What a text-showing operator does besides showing text (ISO 32000-2 §9.4.3, Table 107): `'` +/// moves to the next line (`T*`), and `"` first sets the word and character spacing (`aw Tw`, +/// `ac Tc`). Text after it relies on both, so a dropped `'` or `"` leaves these in its place. +pub(crate) fn show_side_effects(op: &Op) -> Vec { + let mut out = Vec::new(); + if op.is("\"") { + out.extend(op.num(0).map(|aw| Op::new("Tw", vec![pdfcraft_content::num(aw)]))); + out.extend(op.num(1).map(|ac| Op::new("Tc", vec![pdfcraft_content::num(ac)]))); + } + if op.is("'") || op.is("\"") { + out.push(Op::new("T*", vec![])); + } + out +} + +/// For a paragraph or region rewrite: what dropped operator `i` must leave behind +/// ([`show_side_effects`]), so the text after it keeps its line and spacing. `split_at` is the +/// operator the text object was split at: the rewrite's `Tm` and state there already include +/// that operator's effects. +pub(crate) fn dropped_side_effects(ops: &[Op], i: usize, keep: bool, split_at: Option) -> Vec { + if keep || split_at == Some(i) { + return Vec::new(); + } + ops.get(i).map(show_side_effects).unwrap_or_default() +} + +/// For a paragraph or region rewrite that splits a text object: what reopens it after the +/// rewritten paragraph, so the text after it starts where it did. `tlm` and `tm` are the line and +/// text matrices where the paragraph began, and `size` and `scale` the font size (`Tf`) and +/// horizontal scaling (`Tz` / 100) in force there. A `Tm` restores the line matrix, which a later +/// `Td` or `T*` moves from; a kept run before the paragraph on the same baseline had already +/// advanced the pen beyond it (ISO 32000-2 §9.4.4), so a numeric `TJ` then moves the text matrix +/// alone by that advance. Only an advance along a horizontal baseline is expressible that way; +/// one that isn't (a vertical run's, or one at a zero size) leaves the pen at the line matrix. +pub(crate) fn reopen_text_object(tlm: [f64; 6], tm: [f64; 6], size: Option, scale: f64) -> Vec { + let n = pdfcraft_content::num; + let mut out = vec![Op::new("BT", vec![]), Op::new("Tm", tlm.iter().map(|v| n(*v)).collect())]; + // tm = [1 0 0 1 tx ty] x tlm: solve for the advance (tx, ty) in text space. + let (dx, dy) = (tm[4] - tlm[4], tm[5] - tlm[5]); + let det = tlm[0] * tlm[3] - tlm[1] * tlm[2]; + let tx = (dx * tlm[3] - dy * tlm[2]) / det; + let ty = (dy * tlm[0] - dx * tlm[1]) / det; + let denominator = size.unwrap_or(0.0) * scale; + let adjustment = -1000.0 * tx / denominator; + if tx.is_finite() && ty.is_finite() && tx.abs() > 1e-9 && ty.abs() <= 1e-9 && denominator != 0.0 && adjustment.is_finite() { + out.push(Op::new("TJ", vec![Object::Array(vec![n(adjustment)])])); + } + out +} + +/// For a paragraph or region rewrite: a numeric `TJ` per dropped show operator in `drop`, moving +/// the text matrix as far as that operator's text did without drawing anything (as +/// [`super::clear_block`] does). Text shown later in the same text object without its own `Td` +/// or `Tm`, such as a run in another style or column on the same baseline, then stays where it +/// was. Only an advance such a run relies on is kept: none when the text object ends, or its next +/// kept run starts a line of its own (`Td`, `TD`, `Tm`, `T*`, `'`, `"`), first. An advance a `TJ` +/// can't express (spacing at a zero font size) is left out. `resources` is the page's resource +/// dictionary. This reads the glyphs without `/ActualText`, which can come to more text than the +/// reading the edit was laid out from; a page whose text that way exceeds the page's text budget +/// keeps no advances rather than refusing an edit the reading allowed. +pub(crate) fn dropped_advances(doc: &Document, ops: &[Op], resources: &Dict, drop: &std::collections::HashSet) -> HashMap { + dropped_advances_within(doc, ops, resources, drop, None) +} + +/// [`dropped_advances`] with `text_left` in place of the page's text budget (tests make it small). +pub(crate) fn dropped_advances_within( + doc: &Document, + ops: &[Op], + resources: &Dict, + drop: &std::collections::HashSet, + text_left: Option, +) -> HashMap { + let mut out = HashMap::new(); + let mut carry = Carry::new(); + if let Some(left) = text_left { + carry.text_left = left; + } + let follows = kept_run_follows(ops, drop); + let Ok(shown) = interpret(doc, ops, resources, &mut HashMap::new(), &mut carry, None, None) else { + return out; + }; + for s in shown { + if !drop.contains(&s.op) || s.advance == 0.0 || !follows.get(s.op).copied().unwrap_or(false) { + continue; + } + let denominator = s.state.size * s.state.scale; + let adjustment = -1000.0 * s.advance / denominator; + if denominator != 0.0 && adjustment.is_finite() { + out.entry(s.op).or_insert_with(|| Op::new("TJ", vec![Object::Array(vec![pdfcraft_content::num(adjustment)])])); + } + } + out +} + +/// For each operator: whether a run shown after it in its text object, and not in `drop`, is +/// placed by the pen it left rather than by a line move of its own (see [`dropped_advances`]). +/// A `Q` puts back the text matrices its `q` saved (as [`interpret`] reads it), so a line move +/// between them doesn't count for the text after the `Q`. Leaving an advance in is harmless where +/// nothing turns out to use it. One pass from the end, so a page of many dropped runs stays linear. +fn kept_run_follows(ops: &[Op], drop: &std::collections::HashSet) -> Vec { + // The `q` and `Q` that pair up: [`interpret`] ignores a `Q` with nothing saved, so only a + // `Q` that restores something is a boundary here. + let mut paired = vec![false; ops.len()]; + let mut open: Vec = Vec::new(); + for (i, op) in ops.iter().enumerate() { + if op.is("q") { + open.push(i); + } else if op.is("Q") + && let Some(q) = open.pop() + { + for at in [q, i] { + if let Some(slot) = paired.get_mut(at) { + *slot = true; + } + } + } + } + let mut out = vec![false; ops.len()]; + let mut follows = false; + // What followed each `Q` still open (read backwards), restored at its `q`. + let mut after_restore: Vec = Vec::new(); + for (i, op) in ops.iter().enumerate().rev() { + if let Some(slot) = out.get_mut(i) { + *slot = follows; + } + let pairs = paired.get(i).copied().unwrap_or(false); + match op.op.as_slice() { + b"Q" if pairs => { + after_restore.push(follows); + follows = false; + } + b"q" if pairs => follows = follows || after_restore.pop().unwrap_or(false), + b"ET" | b"BT" | b"Td" | b"TD" | b"Tm" | b"T*" | b"'" | b"\"" => follows = false, + b"Tj" | b"TJ" if !drop.contains(&i) => follows = true, + _ => {} + } + } + out +} + /// The page's streams joined into the one stream they are: an operator may span two of them. pub(crate) fn joined(streams: &[ContentStream]) -> Pieces { Pieces::join(&streams.iter().map(|s| s.data.as_slice()).collect::>()) @@ -565,8 +705,9 @@ impl FormBudget { } } -/// A form XObject a page draws: its object, content, resources and its matrix composed with the -/// CTM at the `Do`. +/// A form XObject a page draws: its object, content, resources, its matrix composed with the +/// CTM at the `Do`, and the clip its `/BBox` makes (page space, under that same matrix, so a +/// turned form keeps its turned box; a box of no area hides everything) if it has a valid one. pub(crate) struct FormCall { pub(crate) obj: Option, pub(crate) data: Vec, @@ -575,6 +716,7 @@ pub(crate) struct FormCall { form: Arc, own: Option>, pub(crate) ctm: Matrix, + bbox: Option, } impl FormCall { @@ -644,6 +786,12 @@ pub(crate) fn form_call( <[f64; 6]>::try_from(v).ok().filter(|v| v.iter().all(|x| x.is_finite())) }); let own = s.dict.get(b"Resources").filter(|r| matches!(r, Object::Ref(_))).map(|r| doc.resolve(r)); + // Four finite numbers, or no bound at all: a member that isn't one makes the box unknown, and + // reading the numbers around it would invent a clip. + let bbox = s.dict.get(b"BBox").map(|b| doc.resolve(b)).and_then(|b| { + let values: Vec = b.as_array()?.iter().map(|x| doc.resolve(x).as_f64().filter(|v| v.is_finite())).collect::>()?; + <[f64; 4]>::try_from(values).ok() + }); let data = match budget.decode.decode(s) { Ok(data) => data, Err(_) if budget.decode.remaining() == 0 => { @@ -653,7 +801,8 @@ pub(crate) fn form_call( Err(_) => Vec::new(), }; budget.visits = budget.visits.saturating_add(1); - Some(FormCall { obj, data, form, own, ctm: m.map_or(ctm, |m| Matrix(m).then(&ctm)) }) + let ctm = m.map_or(ctm, |m| Matrix(m).then(&ctm)); + Some(FormCall { obj, data, form, own, ctm, bbox: bbox.and_then(|b| region(&ctm, b)).map(|r| if r.is_empty() { Vec::new() } else { vec![r] }) }) } /// The lines a page shows as it reads, including text drawn by form XObjects (which @@ -684,6 +833,12 @@ pub(super) fn reading_lines(doc: &Document, page: usize) -> Result<(Vec Result<(Vec Some(clip_within(outer, own)), + (outer, own) => outer.clone().or_else(|| own.clone()), + }; let mut carry = Carry { - ts: Ts { ctm: form.ctm, ..ts }, + ts: Ts { ctm: form.ctm, clip, ..ts }, stack: Vec::new(), marked: Vec::new(), layers: Vec::new(), diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index c146ffcbc0..398396a53b 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -1,13 +1,17 @@ use super::blocks::{BlockStyle, TextBlock, paragraph_separator, row_break, wrap}; use super::fallback::{ Encoder, MAX_TYPE3_GLYPHS, Type3Fallback, Type3Run, drop_unused_fallback_fonts, is_variation_selector, needs_type3, no_japanese_font, - source_encodes, source_family, source_program_completes, source_show, source_space, source_width, type3_encode, type3_font, + source_character_advance, source_encodes, source_fallback_runs, source_family, source_program_completes, source_show, source_space, source_width, + type3_encode, type3_font, }; use super::interp::{Carry, Origin, TextState, interpret}; -use super::lines::{content_streams, joined, page_dict, rewritten_contents, splice, text_lines}; +use super::lines::{ + content_streams, dropped_advances, dropped_side_effects, joined, page_dict, reopen_text_object, rewritten_contents, show_side_effects, splice, + text_lines, +}; use super::{ - Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, PdfString, TextLine, TextSession, alignment_frame, group_blocks, is_centred, - parse, rewrite_changed_words, rewrite_styled_block, + Dict, Document, EditError, HashMap, HashSet, Matrix, Metrics, Object, Op, PdfString, TextLine, TextSession, alignment_frame, group_blocks, + is_centred, parse, rewrite_changed_words, rewrite_styled_block, }; /// What replacing a line did. @@ -68,16 +72,8 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi let reused = font.as_ref().filter(|m| source_encodes(m, &shown)); let mut substituted = None; let mut replacement: Vec = Vec::new(); - // ' and " also move to the next line; keep that. - match ops[first].op.as_slice() { - b"'" => replacement.push(Op::new("T*", vec![])), - b"\"" => { - replacement.push(Op::new("Tw", vec![ops[first].operands.first().cloned().unwrap_or(Object::Int(0))])); - replacement.push(Op::new("Tc", vec![ops[first].operands.get(1).cloned().unwrap_or(Object::Int(0))])); - replacement.push(Op::new("T*", vec![])); - } - _ => {} - } + // ' and " also move to the next line (and " sets the spacing); keep that. + replacement.extend(ops.get(first).map(show_side_effects).unwrap_or_default()); match reused { Some(metrics) => replacement.extend(source_show( metrics, @@ -134,8 +130,23 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi } } } - // Rebuild: the line's first operator becomes the replacement, its others go. - let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) }); + // Rebuild: the line's first operator becomes the replacement, its others go. Each other + // dropped `'` or `"` (a later run, or a copy on the same baseline) leaves its line move and + // spacing behind, as the first one does inside the replacement, and a dropped copy its advance + // where text after it is placed by that (the line's own runs give way to the replacement's). + let mut advances = dropped_advances(doc, &ops, &res, &drops); + for i in &target.ops { + advances.remove(i); + } + let new_data = splice(&pieces, &ops, |i| { + if i == first { + return (std::mem::take(&mut replacement), false); + } + let keep = !drops.contains(&i); + let mut insert = dropped_side_effects(&ops, i, keep, None); + insert.extend(advances.remove(&i)); + (insert, keep) + }); let contents = rewritten_contents(doc, &streams, new_data)?; let page_ref = p.obj; if substituted.is_some() { @@ -344,6 +355,7 @@ fn turned_line_width(first: &TextLine, crop: [f64; 4], offset: [f64; 2], width: /// The widths of the paragraph's text in the font it will be written in, in user space. struct Advance<'a> { + partial_advances: &'a HashMap>, /// The source font, when its glyphs are reused. source: Option<&'a Metrics>, type3: Option<&'a Type3Fallback>, @@ -370,11 +382,24 @@ impl Advance<'_> { (None, Some(fallback)) => { // Each character's count in `s`, from one pass over `s`. let mut counts: HashMap = HashMap::new(); + let mut source_advance = 0.0; + let mut fallback_count = 0usize; for c in s.chars() { - let count = counts.entry(c).or_insert(0); - *count = count.saturating_add(1); + // The selector is emitted with zero spacing, so it cannot widen the line. + if is_variation_selector(c) { + continue; + } + if let Some(width) = self.partial_advances.get(&c).copied().flatten() { + source_advance += width; + } else { + let count = counts.entry(c).or_insert(0); + *count = count.saturating_add(1); + fallback_count = fallback_count.saturating_add(1); + } } - fallback.widths.iter().map(|(ch, width)| counts.get(ch).copied().unwrap_or(0) as f64 * width * self.size).sum::() + extra + source_advance + + fallback.widths.iter().map(|(ch, width)| counts.get(ch).copied().unwrap_or(0) as f64 * width * self.size).sum::() + + fallback_count as f64 * self.char_spacing } (None, None) => self.family.std14(self.bold, self.italic).text_width(s, self.size) + extra, }; @@ -541,6 +566,8 @@ impl Alignment { /// Everything the wrapped lines of one paragraph are written with. struct LineWriter<'a> { + source_name: &'a str, + partial_chars: Option<&'a HashSet>, advance: &'a Advance<'a>, alignment: Alignment, encode: &'a Encoder, @@ -649,7 +676,8 @@ impl LineWriter<'_> { } // Each wrapped line in typing order; drawn left to right (right-to-left runs reversed). let visual = crate::bidi::to_visual(shown); - if let Some(metrics) = self.source.filter(|m| m.encode(" ").is_none()) { + // Partial reuse must reach show_piece: its missing spaces and glyphs belong to the fallback. + if let Some(metrics) = self.source.filter(|m| self.partial_chars.is_none() && m.encode(" ").is_none()) { let indic_line = if metrics.visual_order { crate::indic::to_visual(&visual) } else { visual }; ops.extend(source_show(metrics, &indic_line, self.size, self.space, self.char_spacing, tw + stretch)?); } else { @@ -670,24 +698,30 @@ impl LineWriter<'_> { fn show_piece(&self, piece: &str, line: &str, tw: f64, cursor: &mut LineCursor, ops: &mut Vec) -> Result<(), EditError> { let n = pdfcraft_content::num; - if let Some(metrics) = self.source { - let indic_piece = if metrics.visual_order { crate::indic::to_visual(piece) } else { piece.to_owned() }; - ops.extend(source_show(metrics, &indic_piece, self.size, self.space, self.char_spacing, tw)?); - return Ok(()); - } - for run in (self.encode)(piece).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? { - if run.name != cursor.shown_font { - ops.push(Op::new("Tf", vec![Object::name(&run.name), n(self.size)])); - cursor.shown_font = run.name; - } - if let Some(tc) = run.spacing { - // A variation selector's empty glyph: shown at spacing zero, then the paragraph's - // own spacing back, so it adds no advance. - ops.push(Op::new("Tc", vec![n(tc)])); - ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))])); - ops.push(Op::new("Tc", vec![n(self.char_spacing)])); + for (source, run) in source_fallback_runs(piece, self.partial_chars) { + if let Some(metrics) = self.source.filter(|_| self.partial_chars.is_none() || source) { + if cursor.shown_font != self.source_name { + ops.push(Op::new("Tf", vec![Object::name(self.source_name), n(self.size)])); + cursor.shown_font = self.source_name.to_owned(); + } + let indic_piece = if metrics.visual_order { crate::indic::to_visual(run) } else { run.to_owned() }; + ops.extend(source_show(metrics, &indic_piece, self.size, self.space, self.char_spacing, tw)?); } else { - ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))])); + for run in (self.encode)(run).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? { + if run.name != cursor.shown_font { + ops.push(Op::new("Tf", vec![Object::name(&run.name), n(self.size)])); + cursor.shown_font = run.name; + } + if let Some(tc) = run.spacing { + // A variation selector's empty glyph: shown at spacing zero, then the paragraph's + // own spacing back, so it adds no advance. + ops.push(Op::new("Tc", vec![n(tc)])); + ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))])); + ops.push(Op::new("Tc", vec![n(self.char_spacing)])); + } else { + ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))])); + } + } } } Ok(()) @@ -881,6 +915,23 @@ pub(crate) fn rewrite_block_from( let (font_name, ParaStyle { state: o_state, size, old_size, k }) = paragraph_style(o, style, b.phrase_size())?; let source = source_font(doc, &fonts_res, &font_name, first, &flat, style); let reuse = source.reuse; + // Preserve every drawable source glyph; cache missing-glyph lookups as well. + let visual_indic = source.metrics.as_ref().is_some_and(|m| m.visual_order) || first.visual_order; + let hebrew = flat.chars().any(crate::bidi::is_hebrew); + let source_advances: HashMap> = source + .metrics + .as_ref() + .filter(|_| !reuse && upright && !hebrew && !visual_indic && style.family.is_none() && style.bold.is_none() && needs_type3(&flat)) + .map(|m| { + let mut advances = HashMap::new(); + for ch in flat.chars() { + advances.entry(ch).or_insert_with(|| source_character_advance(m, ch, size, o_state.char_spacing)); + } + advances + }) + .unwrap_or_default(); + let source_chars: HashSet = source_advances.iter().filter_map(|(&ch, width)| width.map(|_| ch)).collect(); + let partial_reuse = !source_chars.is_empty(); // The chosen style also determines the real Japanese fallback outlines and advances. let (family, bold, italic) = style.family.unwrap_or((source_family(&b.base_font), b.bold, b.italic)); let bold = style.bold.unwrap_or(bold); @@ -910,9 +961,15 @@ pub(crate) fn rewrite_block_from( let right_edge = if anchored_right { (b.rect[2] - b.rect[0]).max(0.0) } else { width }; let movement = drawn_movement(emit.ctm, dx, dy)?; let tm = emit_text_matrix(o, emit)?; - let type3 = if !reuse && needs_type3(&flat) { Some(type3_font(doc, &mut fonts_res, &flat, family, bold, max_glyphs)?) } else { None }; + let type3 = if !reuse && needs_type3(&flat) { + let missing: String = flat.chars().filter(|ch| !source_chars.contains(ch)).collect(); + Some(type3_font(doc, &mut fonts_res, &missing, family, bold, max_glyphs)?) + } else { + None + }; let source_metrics = source.metrics.as_ref().filter(|_| reuse); let advance = Advance { + partial_advances: &source_advances, source: source_metrics, type3: type3.as_ref(), family, @@ -958,10 +1015,12 @@ pub(crate) fn rewrite_block_from( None => type3.is_none(), }; let writer = LineWriter { + source_name: &font_name, + partial_chars: partial_reuse.then_some(&source_chars), advance: &advance, alignment: Alignment { width, k, anchored_right, right_edge, rtl }, encode: &font.encode, - source: source_metrics, + source: source.metrics.as_ref().filter(|_| reuse || partial_reuse), align, aligns: &style.aligns, word_spacing_reaches_spaces, @@ -995,16 +1054,18 @@ pub(crate) fn rewrite_block_from( }; let mut block_ops = block_ops(&plan, &writer, &wrapped, font.show_font.clone())?; let on_top = crate::lift_on_top(over, &mut block_ops); - let n = pdfcraft_content::num; block_ops.extend(after.ops().into_iter().filter(|op| !op.is("Tf") || after.font.is_some())); if split { block_ops.insert(0, Op::new("ET", vec![])); - block_ops.push(Op::new("BT", vec![])); - block_ops.push(Op::new("Tm", emit.tlm.iter().map(|v| n(*v)).collect())); + block_ops.extend(reopen_text_object(emit.tlm, emit.tm, after.font.as_ref().map(|(_, size)| *size), after.scale)); } + let mut advances = dropped_advances(doc, &ops, &res, &dropped); let new_data = splice(&pieces, &ops, |i| { - let insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }; - (insert, !dropped.contains(&i)) + let mut insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }; + let keep = !dropped.contains(&i); + insert.extend(dropped_side_effects(&ops, i, keep, split.then_some(start))); + insert.extend(advances.remove(&i)); + (insert, keep) }); let contents = rewritten_contents(doc, &streams, new_data)?; let new_font = !reuse; diff --git a/crates/edit/src/text_regions/ocr_words.rs b/crates/edit/src/text_regions/ocr_words.rs index d511ccdf47..518fa4a3c1 100644 --- a/crates/edit/src/text_regions/ocr_words.rs +++ b/crates/edit/src/text_regions/ocr_words.rs @@ -150,8 +150,8 @@ fn key(line: &TextLine) -> (&str, [i64; 4]) { } /// How far right a rewrite of the runs `indices` drew (`None` when it drew nothing), or an error -/// when it moved or lost any other text on the page: text positioned after the end of the text -/// before it, say, depends on the words that went. +/// when it moved or lost any other text on the page: text positioned after the end of the words +/// that went, say, when their advance couldn't be left behind (see `dropped_advances`). fn drawn_right(before: &[TextLine], indices: &[usize], after: &[TextLine]) -> Result, EditError> { let mut unmatched: HashMap<_, usize> = HashMap::new(); for line in before { diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 487bb0e20e..a1beb291f9 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -116,8 +116,11 @@ struct LegacyFace { space: f64, } -/// What one edit may decode, and so keep, of the font programs of its THSarabunPSK subsets -/// (each at most 16 MiB): a page may name many of them. +/// What one edit may decode of its THSarabunPSK subsets' font data: their programs (each at most +/// 16 MiB), which it keeps, and the streams of the metrics read to measure them and to adapt their +/// programs' cmaps: their CMaps and glyph maps, and a subset's program again, for which of its +/// glyphs have outlines. A page may name many of them. The metrics read to find the subsets among +/// the page's fonts aren't charged to it. const MAX_THAI_PROGRAMS: usize = 64 << 20; struct PreparedCharacters { @@ -804,9 +807,8 @@ fn wrap_styled_rows( fn apply_range_styles<'a>( first: &TextLine, - block: &TextBlock, lines: &[TextLine], - draft: &str, + sources: Vec<(std::ops::Range, usize)>, ranges: &[&'a RangeStyle], style: &BlockStyle, ) -> StyledLines<'a> { @@ -814,7 +816,7 @@ fn apply_range_styles<'a>( let mut styled_spans = Vec::new(); let mut underlines = HashMap::new(); let mut overrides = HashMap::new(); - for (span, source) in block.edited_spans(draft) { + for (span, source) in sources { let mut edges = vec![span.start, span.end]; for range in ranges { for edge in [range.range.start, range.range.end] { @@ -900,6 +902,9 @@ fn legacy_thai_font( } let family = pdfcraft_fonts::embedding::face_key(&source.base_font); let mut candidates = vec![source.font.clone()]; + // Every name on the page is read here for its face name alone, aliases of one font and other + // faces' fonts too, with no total, as before: charged to the edit's allowance, each alias would + // pay again and other faces would spend what the face's subsets need. candidates.extend(fonts.iter().filter_map(|(name, font)| { let font = doc.dict(font)?; (pdfcraft_fonts::embedding::face_key(&Metrics::from_dict(doc, &font).base_font) == family).then(|| String::from_utf8_lossy(name).into_owned()) @@ -914,9 +919,15 @@ fn legacy_thai_font( let face = match object.and_then(|o| cache.values().find(|face| face.object == Some(o))) { Some(alias) => LegacyFace { glyphs: alias.glyphs.clone(), metrics: alias.metrics.clone(), object, ..*alias }, None => { - let metrics = Metrics::from_dict(doc, &font); - let space = source_space(doc, fonts, &metrics); - let glyphs = pdfcraft_fonts::pdf::LegacyThaiFont::from_pdf(doc, &font, space, budget).ok().flatten().map(std::rc::Rc::new); + // Read within the edit's allowance; past it the subset goes unread, and the edit is + // refused below. + let metrics = Metrics::from_dict_within(doc, &font, budget); + let (space, glyphs) = if metrics.past_allowance { + (0.0, None) + } else { + let space = source_space(doc, fonts, &metrics); + (space, pdfcraft_fonts::pdf::LegacyThaiFont::from_pdf(doc, &font, space, budget).ok().flatten().map(std::rc::Rc::new)) + }; let style = FontStyle::of(doc, &font, &metrics); LegacyFace { glyphs, metrics, style, object, space } } @@ -924,9 +935,11 @@ fn legacy_thai_font( cache.insert(name, face); } // A subset left unread would count as unusable, silently changing which subset draws the text. - if budget.remaining() == 0 { + // A read refused for want of the allowance is what refuses the edit: reads that spent it to the + // byte refused nothing. + if budget.refused() > 0 { return Err(EditError::Invalid(format!( - "the page's {} subsets need more than {} MiB of font programs for one edit; choose a replacement font for this text", + "the page's {} subsets need more than {} MiB of font data for one edit; choose a replacement font for this text", source.base_font.rsplit('+').next().unwrap_or(&source.base_font), MAX_THAI_PROGRAMS >> 20 ))); @@ -1532,7 +1545,8 @@ fn push_row_break_marker(block_ops: &mut Vec, row_soft: &[bool], row_gap: &[ /// [`splice`] with the rewritten rows spliced in at `at`, and (when `marker` names one) a `/P` /// marker opened at its start and closed at its `next` line or, when nothing follows, after the /// paragraph's own last `ET` (appended past the stream's last operator when that close is at or -/// beyond `ops.len()`, since `splice`'s callback never visits that index). +/// beyond `ops.len()`, since `splice`'s callback never visits that index). A dropped operator +/// `i` leaves `left_behind(i, keep)` in its place: its line move, spacing and advance. fn splice_with_marker( pieces: &Pieces, ops: &[Op], @@ -1540,6 +1554,7 @@ fn splice_with_marker( at: usize, mut block_ops: Vec, drop: &std::collections::HashSet, + mut left_behind: impl FnMut(usize, bool) -> Vec, ) -> Vec> { let data = splice(pieces, ops, |i| { let mut insert = Vec::new(); @@ -1557,7 +1572,9 @@ fn splice_with_marker( if i == at { insert.append(&mut block_ops); } - (insert, !drop.contains(&i)) + let keep = !drop.contains(&i); + insert.extend(left_behind(i, keep)); + (insert, keep) }); if !marker.is_some_and(|(_, next, close)| next.is_none() && close >= ops.len()) { return data; @@ -1573,6 +1590,25 @@ fn splice_with_marker( data } +/// The source run each part of the draft is styled from: the ones given, which must cover the draft +/// in order, on character boundaries, from the block's runs, or else the edit's own. +fn draft_sources(block: &TextBlock, draft: &str, style: &BlockStyle) -> Result, usize)>, EditError> { + if style.sources.is_empty() { + return Ok(block.edited_spans(draft)); + } + let mut at = 0; + for (range, source) in &style.sources { + if range.start != at || range.start >= range.end || draft.get(range.clone()).is_none() || !block.lines.contains(source) { + return Err(EditError::Invalid("the text's style sources must cover it in order with the paragraph's own runs".into())); + } + at = range.end; + } + if at != draft.len() { + return Err(EditError::Invalid("the text's style sources must cover it in order with the paragraph's own runs".into())); + } + Ok(style.sources.clone()) +} + /// Rewrite visual rows while keeping the source style on each surviving character. Resource /// names may differ between Unicode subsets of the same face; encoding searches those subsets. pub(crate) fn rewrite_styled_block( @@ -1591,7 +1627,8 @@ pub(crate) fn rewrite_styled_block( } let ranges = validated_ranges(draft, style)?; let source_count = lines.len(); - let styled = apply_range_styles(first, block, lines, draft, &ranges, style); + let sources = draft_sources(block, draft, style)?; + let styled = apply_range_styles(first, lines, sources, &ranges, style); let lines = styled.lines.as_slice(); let styled_spans = styled.spans; let underlines = styled.underlines; @@ -1693,7 +1730,7 @@ pub(crate) fn rewrite_styled_block( // A row the edit doesn't reach keeps its source operators, so its kerning, TJ word gaps and // glyph widths stay exactly as they were instead of being rebuilt from the text state. A row // aligned on its own is reached; the rows around it are not. - let plain = BlockStyle { ranges: Vec::new(), aligns: Vec::new(), ..style.clone() } == BlockStyle::default(); + let plain = BlockStyle { ranges: Vec::new(), sources: Vec::new(), aligns: Vec::new(), ..style.clone() } == BlockStyle::default(); let mut kept = if plain { untouched_rows(block, draft, &row_ranges) .into_iter() @@ -1882,14 +1919,21 @@ pub(crate) fn rewrite_styled_block( block_ops.extend(after.ops()); if split { block_ops.insert(0, Op::new("ET", vec![])); - block_ops.push(Op::new("BT", vec![])); - block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect())); + block_ops.extend(crate::text::reopen_text_object(o.tlm, o.tm, after.font.as_ref().map(|(_, size)| *size), after.scale)); } // A kept source row sits outside `block_ops`. Bound the whole paragraph when a marker is // safe. The close is not part of `block_ops`: that vector can end inside a text object // when the rewrite splits one, and a kept row's operators come later in the stream. let marker = if marked { None } else { paragraph_marker(block, lines, &ops) }; - let data = splice_with_marker(&pieces, &ops, marker, at, block_ops, &drop); + // A dropped `'` or `"` leaves its line move and spacing, and a dropped show its advance. + let mut advances = crate::text::dropped_advances(doc, &ops, &res, &drop); + let split_at = split.then_some(start); + let left_behind = |i: usize, keep: bool| { + let mut out = crate::text::dropped_side_effects(&ops, i, keep, split_at); + out.extend(advances.remove(&i)); + out + }; + let data = splice_with_marker(&pieces, &ops, marker, at, block_ops, &drop, left_behind); let contents = rewritten_contents(doc, &streams, data)?; res.set(b"Font".to_vec(), Object::Dict(fonts)); doc.update_dict(p.obj, |d| { diff --git a/crates/edit/tests/it/convert_colours_cmyk_initial.rs b/crates/edit/tests/it/convert_colours_cmyk_initial.rs new file mode 100644 index 0000000000..277aaaaf20 --- /dev/null +++ b/crates/edit/tests/it/convert_colours_cmyk_initial.rs @@ -0,0 +1,71 @@ +//! Convert colours: `/DeviceCMYK cs` starts at black (0 0 0 1, ISO 32000-1 8.6.5.5), so the +//! converted operator is black too, not the white that four zeros would be. + +use pdfcraft_cos::{Document, ObjRef, Object, Stream}; +use pdfcraft_edit::{ColourTarget, ConvertColours, convert_colours}; +use std::sync::Arc; + +/// A document of `objs` (object k is `k + 1 0 obj`; object 1 is the catalog). A `{stream:...}` +/// entry is written as a stream holding the text after the colon, with its `/Length`. +fn pdf(objs: &[&str]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + let body = match o.split_once(">>stream:") { + Some((dict, data)) => format!("{dict} /Length {} >>\nstream\n{data}\nendstream", data.len()), + None => (*o).to_string(), + }; + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +fn flat(b: &[u8]) -> String { + String::from_utf8_lossy(b).split_whitespace().collect::>().join(" ") +} + +fn stream_text(doc: &Document, r: ObjRef) -> String { + match &*doc.get(r) { + Object::Stream(s) => flat(&s.decoded().unwrap()), + _ => panic!("not a stream"), + } +} + +/// The page's `/Contents` references. +fn contents(doc: &Document, page: usize) -> Vec { + let p = pdfcraft_model::pages(doc).swap_remove(page); + match &*doc.resolve(p.dict.get(b"Contents").unwrap()) { + Object::Array(a) => a.iter().filter_map(Object::as_ref).collect(), + _ => p.dict.get(b"Contents").and_then(Object::as_ref).into_iter().collect(), + } +} + +fn no_images(_: &Document, _: &Stream, _: ColourTarget) -> Option { + None +} + +fn page_with(content: &str) -> Document { + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>", + &format!("<< >>stream:{content}"), + ]) +} + +#[test] +fn device_cmyk_starts_at_black() { + let mut doc = page_with("/DeviceCMYK cs 0 0 10 10 re f /DeviceCMYK CS 0 0 m 10 10 l S"); + let s = ConvertColours { preserve_black: false, ..ConvertColours::default() }; + let done = convert_colours(&mut doc, &[0], &s, &mut no_images).unwrap(); + assert_eq!(done.colours, 2); + let t = stream_text(&doc, contents(&doc, 0)[0]); + assert!(t.contains("0 g 0 0 10 10 re f") && t.contains("0 G 0 0 m 10 10 l S"), "black, not white: {t}"); +} diff --git a/crates/edit/tests/it/convert_colours_shared.rs b/crates/edit/tests/it/convert_colours_shared.rs new file mode 100644 index 0000000000..bd373011d6 --- /dev/null +++ b/crates/edit/tests/it/convert_colours_shared.rs @@ -0,0 +1,144 @@ +//! Convert colours on some pages leaves the others as they were: a form or picture they share +//! is converted as a copy for the pages being converted, and the resource dictionary they +//! share is not changed. + +use pdfcraft_cos::{Document, ObjRef, Object, Stream}; +use pdfcraft_edit::{ColourTarget, ConvertColours, convert_colours}; +use std::sync::Arc; + +/// A document of `objs` (object k is `k + 1 0 obj`; object 1 is the catalog). A `{stream:...}` +/// entry is written as a stream holding the text after the colon, with its `/Length`. +fn pdf(objs: &[&str]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + let body = match o.split_once(">>stream:") { + Some((dict, data)) => format!("{dict} /Length {} >>\nstream\n{data}\nendstream", data.len()), + None => (*o).to_string(), + }; + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +fn flat(b: &[u8]) -> String { + String::from_utf8_lossy(b).split_whitespace().collect::>().join(" ") +} + +fn stream_text(doc: &Document, r: ObjRef) -> String { + match &*doc.get(r) { + Object::Stream(s) => flat(&s.decoded().unwrap()), + _ => panic!("not a stream"), + } +} + +fn no_images(_: &Document, _: &Stream, _: ColourTarget) -> Option { + None +} + +/// Two pages sharing one resource dictionary (inherited from the page tree) whose form draws +/// red, and a form inside it that draws blue. +fn two_pages() -> Document { + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 600 800] /Resources 5 0 R >>", + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>", + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>", + "<< /XObject << /Fm1 7 0 R >> >>", + "<< >>stream:/Fm1 Do", + "<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Resources << /XObject << /Fm2 8 0 R >> >> >>stream:1 0 0 rg 0 0 10 10 re f /Fm2 Do", + "<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] >>stream:0 0 1 rg 0 0 5 5 re f", + ]) +} + +fn form_of(doc: &Document, page: usize, name: &[u8]) -> ObjRef { + let p = pdfcraft_model::pages(doc).swap_remove(page); + let res = doc.resolve(p.dict.get(b"Resources").unwrap()); + let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap()); + xo.as_dict().unwrap().get(name).and_then(Object::as_ref).unwrap() +} + +fn inner_form(doc: &Document, outer: ObjRef) -> ObjRef { + match &*doc.get(outer) { + Object::Stream(s) => { + let res = doc.resolve(s.dict.get(b"Resources").unwrap()); + let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap()); + xo.as_dict().unwrap().get(b"Fm2").and_then(Object::as_ref).unwrap() + } + _ => panic!("not a stream"), + } +} + +#[test] +fn a_shared_form_is_converted_as_a_copy() { + let mut doc = two_pages(); + let done = convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap(); + assert_eq!(done.colours, 2); + // The other page still draws the original forms, in their colours. + let kept = form_of(&doc, 1, b"Fm1"); + assert!(stream_text(&doc, kept).contains("1 0 0 rg 0 0 10 10 re f"), "{}", stream_text(&doc, kept)); + assert!(stream_text(&doc, inner_form(&doc, kept)).contains("0 0 1 rg 0 0 5 5 re f")); + // The converted page draws copies, the inner one too. + let copy = form_of(&doc, 0, b"Fm1"); + assert_ne!(copy, kept); + assert!(stream_text(&doc, copy).contains("0.299 g 0 0 10 10 re f"), "{}", stream_text(&doc, copy)); + assert!(stream_text(&doc, inner_form(&doc, copy)).contains("0.114 g 0 0 5 5 re f")); +} + +#[test] +fn converting_every_page_changes_the_forms_in_place() { + let mut doc = two_pages(); + let before = form_of(&doc, 0, b"Fm1"); + convert_colours(&mut doc, &[0, 1], &ConvertColours::default(), &mut no_images).unwrap(); + assert_eq!(form_of(&doc, 0, b"Fm1"), before); + assert_eq!(form_of(&doc, 1, b"Fm1"), before); + assert!(stream_text(&doc, before).contains("0.299 g 0 0 10 10 re f")); +} + +/// One page of `content` with `res` as its resources, followed by `more` objects from 5 up. +fn page_of(res: &str, content: &str, more: &[&str]) -> Document { + let page = format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources {res} >>"); + let content = format!("<< >>stream:{content}"); + let mut objs = vec!["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", page.as_str(), content.as_str()]; + objs.extend_from_slice(more); + pdf(&objs) +} + +/// The `/None` colorant paints nothing; a colour of its own would make it appear. +#[test] +fn the_none_colorant_stays() { + let tint = "<< /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 0 0 1] /N 1 >>"; + let res = format!( + "<< /ColorSpace << /S [/Separation /None /DeviceCMYK {tint}] /D [/DeviceN [/None /None] /DeviceCMYK {tint}] /Real [/Separation /Red /DeviceCMYK {tint}] >> >>" + ); + let content = "/S cs 1 scn 0 0 9 9 re f /D cs 1 1 scn 0 0 9 9 re f"; + let mut doc = page_of(&res, content, &[]); + assert_eq!(convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap().colours, 0); + assert_eq!(stream_text(&doc, ObjRef { num: 4, generation: 0 }), content); + let mut doc = page_of(&res, "/Real cs 1 scn 0 0 9 9 re f", &[]); + assert_eq!(convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap().colours, 2, "a real spot is converted"); +} + +/// Pictures the helper cannot re-encode are counted, unless they are in the target already or +/// have no colours of their own. +#[test] +fn pictures_left_alone_are_counted() { + const IMAGE: &str = "/Type /XObject /Subtype /Image /Width 1 /Height 1"; + let indexed = format!("<< {IMAGE} /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceRGB 0 ] >>stream:x"); + let gray_table = format!("<< {IMAGE} /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceGray 0 <80>] >>stream:x"); + let gray = format!("<< {IMAGE} /BitsPerComponent 8 /ColorSpace /DeviceGray >>stream:x"); + let stencil = format!("<< {IMAGE} /BitsPerComponent 1 /ImageMask true >>stream:x"); + let res = "<< /XObject << /A 5 0 R /B 6 0 R /C 7 0 R /D 8 0 R >> >>"; + let mut doc = page_of(res, "/A Do /B Do /C Do /D Do", &[&indexed, &gray_table, &gray, &stencil]); + let mut none = doc.clone(); + assert_eq!(convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap().images_left, 1); + let s = ConvertColours { images: false, ..ConvertColours::default() }; + assert_eq!(convert_colours(&mut none, &[0], &s, &mut no_images).unwrap().images_left, 0, "pictures were not asked for"); +} diff --git a/crates/edit/tests/it/convert_colours_streams.rs b/crates/edit/tests/it/convert_colours_streams.rs new file mode 100644 index 0000000000..edad0a32e8 --- /dev/null +++ b/crates/edit/tests/it/convert_colours_streams.rs @@ -0,0 +1,100 @@ +//! Convert colours rewrites each of a page's content streams on its own: a stream keeps its +//! dictionary (its `/PCMark` among it), unchanged streams stay as they are, the colour space +//! set in one stream holds in the next, and a page with an unreadable stream is left alone. + +use pdfcraft_cos::{Document, ObjRef, Object, Stream}; +use pdfcraft_edit::{ColourTarget, ConvertColours, convert_colours}; +use std::sync::Arc; + +/// A document of `objs` (object k is `k + 1 0 obj`; object 1 is the catalog). A `{stream:...}` +/// entry is written as a stream holding the text after the colon, with its `/Length`. +fn pdf(objs: &[&str]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + let body = match o.split_once(">>stream:") { + Some((dict, data)) => format!("{dict} /Length {} >>\nstream\n{data}\nendstream", data.len()), + None => (*o).to_string(), + }; + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +fn flat(b: &[u8]) -> String { + String::from_utf8_lossy(b).split_whitespace().collect::>().join(" ") +} + +fn stream_text(doc: &Document, r: ObjRef) -> String { + match &*doc.get(r) { + Object::Stream(s) => flat(&s.decoded().unwrap()), + _ => panic!("not a stream"), + } +} + +/// The page's `/Contents` references. +fn contents(doc: &Document, page: usize) -> Vec { + let p = pdfcraft_model::pages(doc).swap_remove(page); + match &*doc.resolve(p.dict.get(b"Contents").unwrap()) { + Object::Array(a) => a.iter().filter_map(Object::as_ref).collect(), + _ => p.dict.get(b"Contents").and_then(Object::as_ref).into_iter().collect(), + } +} + +fn no_images(_: &Document, _: &Stream, _: ColourTarget) -> Option { + None +} + +/// One page drawing `streams`, with an indexed colour space `/CS0` (0 = red, 1 = green). +fn page_with(streams: &[&str]) -> Document { + let refs: Vec = (0..streams.len()).map(|i| format!("{} 0 R", i + 4)).collect(); + let page = format!( + "<< /Type /Page /Parent 2 0 R /Resources << /ColorSpace << /CS0 [/Indexed /DeviceRGB 1 ] >> >> /Contents [{}] >>", + refs.join(" ") + ); + let mut objs: Vec<&str> = vec!["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", &page]; + objs.extend_from_slice(streams); + pdf(&objs) +} + +#[test] +fn each_stream_keeps_its_dictionary_and_only_changed_ones_are_replaced() { + let mut doc = page_with(&["<< >>stream:0.5 g 0 0 10 10 re f", "<< /PCMark /Redaction >>stream:1 0 0 rg 0 0 10 10 re f"]); + let before = contents(&doc, 0); + let done = convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap(); + assert_eq!(done.colours, 1); + let after = contents(&doc, 0); + assert_eq!(after.len(), 2, "still two streams"); + assert_eq!(after[0], before[0], "the unchanged stream is the same object"); + let mark = match &*doc.get(after[1]) { + Object::Stream(s) => s.dict.name(b"PCMark").map(<[u8]>::to_vec), + _ => None, + }; + assert_eq!(mark.as_deref(), Some(&b"Redaction"[..]), "the mark is kept"); + assert!(stream_text(&doc, after[1]).contains("0.299 g 0 0 10 10 re f")); +} + +#[test] +fn the_colour_space_carries_from_one_stream_to_the_next() { + // Index 0 of the space set in the first stream is red; read as a gray, it would be black. + let mut doc = page_with(&["<< >>stream:/CS0 cs", "<< >>stream:0 sc 0 0 10 10 re f"]); + convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap(); + let after = contents(&doc, 0); + assert!(stream_text(&doc, after[1]).contains("0.299 g 0 0 10 10 re f"), "{}", stream_text(&doc, after[1])); +} + +#[test] +fn a_page_with_an_unreadable_stream_is_left_alone() { + let mut doc = page_with(&["<< >>stream:1 0 0 rg 0 0 10 10 re f", "<< /Filter /DCTDecode >>stream:not a picture"]); + let before = contents(&doc, 0); + let done = convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap(); + assert_eq!(done.colours, 0); + assert_eq!(contents(&doc, 0), before, "no stream is dropped"); +} diff --git a/crates/edit/tests/it/main.rs b/crates/edit/tests/it/main.rs new file mode 100644 index 0000000000..b64db49da1 --- /dev/null +++ b/crates/edit/tests/it/main.rs @@ -0,0 +1,5 @@ +//! The crate's integration tests, linked into one test binary. + +mod convert_colours_cmyk_initial; +mod convert_colours_shared; +mod convert_colours_streams; diff --git a/crates/edit/tests/printer_marks_accented_label.rs b/crates/edit/tests/printer_marks_accented_label.rs new file mode 100644 index 0000000000..ccb8eb5861 --- /dev/null +++ b/crates/edit/tests/printer_marks_accented_label.rs @@ -0,0 +1,12 @@ +//! The page information line keeps accented letters: WinAnsi bytes from 0x80 up must reach the +//! content stream as escapes, not be replaced with U+FFFD. + +use pdfcraft_edit::PrinterMarks; + +#[test] +fn accented_label_is_written_as_winansi_escapes() { + let marks = PrinterMarks { label: "Café.pdf".into(), ..PrinterMarks::default() }; + let content = marks.content([0.0, 0.0, 612.0, 792.0], [0.0, 0.0, 612.0, 792.0], 1, 1); + assert!(!content.contains('\u{FFFD}'), "no replacement character in the stream: {content:?}"); + assert!(content.contains("(Caf\\351.pdf 1 of 1)"), "é is the WinAnsi byte 0xE9 written as \\351: {content:?}"); +} diff --git a/crates/edit/tests/printer_marks_bleed_reach.rs b/crates/edit/tests/printer_marks_bleed_reach.rs new file mode 100644 index 0000000000..cb7590768c --- /dev/null +++ b/crates/edit/tests/printer_marks_bleed_reach.rs @@ -0,0 +1,50 @@ +//! Bleed marks start at the bleed box, so the room made for the marks counts the bleed too. + +use pdfcraft_cos::{Dict, Document, Object}; +use pdfcraft_edit::{PrinterMarks, add_printer_marks}; + +fn rect(r: [f64; 4]) -> Object { + Object::Array(r.iter().map(|v| Object::Real(*v)).collect()) +} + +fn page_with(boxes: &[(&[u8], [f64; 4])]) -> Document { + let mut doc = Document::new_empty(); + let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Parent".to_vec(), Object::Ref(pages)); + for (key, r) in boxes { + page.set(key.to_vec(), rect(*r)); + } + let page = doc.add(page); + doc.update_dict(pages, |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + d.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + doc +} + +fn page_box(doc: &Document, key: &[u8]) -> Option> { + let page = pdfcraft_model::pages(doc).first().cloned()?; + let a = page.dict.get(key).map(|o| doc.resolve(o))?; + Some(a.as_array()?.iter().filter_map(|v| doc.resolve(v).as_f64()).collect()) +} + +/// A 9 pt bleed: the bleed marks run out to 9 + 6 + 18 = 33 pt from the trim box, further than the +/// usual 24, so the crop box grows by that much. +#[test] +fn crop_box_grows_to_hold_the_bleed_marks() { + let mut doc = page_with(&[ + (b"MediaBox", [0.0, 0.0, 1000.0, 1000.0]), + (b"CropBox", [200.0, 200.0, 800.0, 800.0]), + (b"TrimBox", [200.0, 200.0, 800.0, 800.0]), + (b"BleedBox", [191.0, 191.0, 809.0, 809.0]), + ]); + let marks = PrinterMarks::default(); + assert_eq!(marks.reach(), 24.0); + let widened = add_printer_marks(&mut doc, &[0], &marks, false).unwrap(); + assert_eq!(widened, vec![0]); + assert_eq!(page_box(&doc, b"CropBox"), Some(vec![167.0, 167.0, 833.0, 833.0])); + assert_eq!(page_box(&doc, b"TrimBox"), Some(vec![200.0, 200.0, 800.0, 800.0])); +} diff --git a/crates/edit/tests/printer_marks_boxes.rs b/crates/edit/tests/printer_marks_boxes.rs new file mode 100644 index 0000000000..d236c48197 --- /dev/null +++ b/crates/edit/tests/printer_marks_boxes.rs @@ -0,0 +1,108 @@ +//! Printer marks and the page boxes: the marks must show inside the crop box, and running +//! again must not move the trim box or grow the page. + +use pdfcraft_cos::{Dict, Document, Object}; +use pdfcraft_edit::{PrinterMarks, add_printer_marks}; + +fn rect(r: [f64; 4]) -> Object { + Object::Array(r.iter().map(|v| Object::Real(*v)).collect()) +} + +/// One empty page with these boxes set on it. +fn page_with(boxes: &[(&[u8], [f64; 4])]) -> Document { + let mut doc = Document::new_empty(); + let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Parent".to_vec(), Object::Ref(pages)); + for (key, r) in boxes { + page.set(key.to_vec(), rect(*r)); + } + let page = doc.add(page); + doc.update_dict(pages, |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + d.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + doc +} + +fn page_box(doc: &Document, key: &[u8]) -> Option> { + let page = pdfcraft_model::pages(doc).first().cloned()?; + let a = page.dict.get(key).map(|o| doc.resolve(o))?; + Some(a.as_array()?.iter().filter_map(|v| doc.resolve(v).as_f64()).collect()) +} + +/// A crop box cut to the trim inside a large media box: the marks fit the media box but +/// not the crop box, so the crop box grows to show them and the trim box stays. +#[test] +fn printer_marks_widen_crop_inside_media() { + let mut doc = page_with(&[(b"MediaBox", [0.0, 0.0, 1000.0, 1000.0]), (b"CropBox", [200.0, 200.0, 800.0, 800.0])]); + let marks = PrinterMarks::default(); + let reach = marks.reach(); + let widened = add_printer_marks(&mut doc, &[0], &marks, false).unwrap(); + assert_eq!(widened, vec![0]); + assert_eq!(page_box(&doc, b"MediaBox"), Some(vec![0.0, 0.0, 1000.0, 1000.0]), "the media box already had room"); + let crop = page_box(&doc, b"CropBox").unwrap(); + assert_eq!(crop, vec![200.0 - reach, 200.0 - reach, 800.0 + reach, 800.0 + reach]); + assert_eq!(page_box(&doc, b"TrimBox"), Some(vec![200.0, 200.0, 800.0, 800.0])); +} + +/// No trim box given: it defaults to the crop box, which widening moves. Running again with +/// replace must keep the trim box and the page size from the first run. +#[test] +fn printer_marks_rerun_keeps_trim() { + let mut doc = page_with(&[(b"MediaBox", [0.0, 0.0, 612.0, 792.0])]); + let marks = PrinterMarks::default(); + add_printer_marks(&mut doc, &[0], &marks, false).unwrap(); + let media = page_box(&doc, b"MediaBox"); + let crop = page_box(&doc, b"CropBox"); + assert_eq!(page_box(&doc, b"TrimBox"), Some(vec![0.0, 0.0, 612.0, 792.0])); + assert_eq!(page_box(&doc, b"BleedBox"), Some(vec![0.0, 0.0, 612.0, 792.0])); + let widened = add_printer_marks(&mut doc, &[0], &marks, true).unwrap(); + assert!(widened.is_empty(), "the page already has room"); + assert_eq!(page_box(&doc, b"MediaBox"), media, "the page did not grow again"); + assert_eq!(page_box(&doc, b"CropBox"), crop); + assert_eq!(page_box(&doc, b"TrimBox"), Some(vec![0.0, 0.0, 612.0, 792.0])); +} + +/// A trim box past the media box is clipped to it. Widening the media box must not let it +/// grow to what was written. +#[test] +fn printer_marks_keep_the_trim_box_in_effect() { + let mut doc = page_with(&[(b"MediaBox", [0.0, 0.0, 100.0, 100.0]), (b"TrimBox", [-20.0, -20.0, 120.0, 120.0])]); + add_printer_marks(&mut doc, &[0], &PrinterMarks::default(), false).unwrap(); + assert_eq!(page_box(&doc, b"TrimBox"), Some(vec![0.0, 0.0, 100.0, 100.0])); +} + +/// Line weights and room out of range are refused, not clamped, and nothing past the largest +/// page a PDF can have is written. +#[test] +fn printer_marks_refuse_numbers_out_of_range() { + let mut doc = page_with(&[(b"MediaBox", [0.0, 0.0, 612.0, 792.0])]); + for line_weight in [-1.0, 0.0, 0.05, 5.0, 10_000.0, f64::NAN, f64::INFINITY] { + assert!(add_printer_marks(&mut doc, &[0], &PrinterMarks { line_weight, ..PrinterMarks::default() }, false).is_err(), "{line_weight}"); + } + for room in [-1.0, 289.0, 1e9, f64::NAN, f64::INFINITY] { + assert!(add_printer_marks(&mut doc, &[0], &PrinterMarks { make_room: Some(room), ..PrinterMarks::default() }, false).is_err(), "{room}"); + } + let mut big = page_with(&[(b"MediaBox", [0.0, 0.0, 14_400.0, 1000.0])]); + assert!(add_printer_marks(&mut big, &[0], &PrinterMarks::default(), false).is_err()); + assert!(add_printer_marks(&mut big, &[0], &PrinterMarks { make_room: None, ..PrinterMarks::default() }, false).is_ok(), "left as it is"); +} + +/// Registration colour is the /All Separation, in the page's resources beside what it had. +#[test] +fn printer_marks_use_the_all_separation() { + let mut doc = page_with(&[(b"MediaBox", [0.0, 0.0, 612.0, 792.0])]); + add_printer_marks(&mut doc, &[0], &PrinterMarks::default(), false).unwrap(); + let page = pdfcraft_model::pages(&doc).swap_remove(0); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()); + let res = res.as_dict().unwrap(); + assert!(res.get(b"Font").is_some()); + let spaces = doc.resolve(res.get(b"ColorSpace").unwrap()); + let cs = doc.resolve(spaces.as_dict().unwrap().get(b"PCReg").unwrap()); + let cs = cs.as_array().unwrap(); + assert_eq!(cs[..3], [Object::name("Separation"), Object::name("All"), Object::name("DeviceCMYK")]); + assert!(cs[3].as_dict().unwrap().int(b"FunctionType") == Some(2)); +} diff --git a/crates/engine/README.md b/crates/engine/README.md index 86492bc043..25216ba46a 100644 --- a/crates/engine/README.md +++ b/crates/engine/README.md @@ -78,9 +78,15 @@ family (`sign`, `sign_with_timestamp`, `timestamp_document`, `embed_ltv`). | `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` diff --git a/crates/engine/src/actions.rs b/crates/engine/src/actions.rs index 249279e09d..6608cb3297 100644 --- a/crates/engine/src/actions.rs +++ b/crates/engine/src/actions.rs @@ -151,6 +151,8 @@ fn images_refused_note(images: usize) -> Option { (images > 0).then(|| format!("Reduce file size: {images} image(s) stayed as they were, past what one run may decode")) } +/// Apply one action step to document `id`, adding what it did to `log`; returns the new +/// document when the step replaced it. fn apply_step(s: &mut Session, id: DocId, step: &Step, log: &mut Vec) -> Result, EditError> { let pages = s.get(id).map_or(0, |d| d.info.pages.len()); let all: Vec = (0..pages).collect(); @@ -209,9 +211,19 @@ fn apply_step(s: &mut Session, id: DocId, step: &Step, log: &mut Vec) -> Ok(None) } -/// Run `action` on one file. `progress(step, steps)` is called before each step. -pub fn run_on(action: &Action, name: &str, bytes: Arc>, mut progress: impl FnMut(usize, usize)) -> Result { +/// Run `action` on one file. `javascript` is the caller's [`Session::javascript`] preference: +/// when off, neither open-time scripts nor "Run a JavaScript" steps run. `progress(step, steps)` +/// is called before each step. +pub fn run_on( + action: &Action, + name: &str, + bytes: Arc>, + javascript: bool, + mut progress: impl FnMut(usize, usize), +) -> Result { let mut s = Session::new(); + // Before the open: open-time (XFA) scripts read it there. + s.set_javascript(javascript); let mut id = s.open(name, None, bytes, None).map_err(|e| e.to_string())?; if let Some(why) = s.get(id).and_then(|d| d.read_only_reason.clone()) { return Err(why); diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index e78786bf8b..1f757f98e4 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -409,9 +409,9 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ items: &[ item("Output preview", "layers", "prepress.output_preview", Planned("M11")), item("Preflight", "file-check", "preflight.run", Planned("M11")), - item("Convert colors", "palette", "prepress.convert_colors", Planned("M11")), + item("Convert colours", "palette", "prepress.convert_colors", Ready), item("Set page boxes", "square-dashed-mouse-pointer", "page.boxes", Ready), - item("Add printer marks", "ruler", "prepress.marks", Planned("M11")), + item("Add printer marks", "ruler", "prepress.marks", Ready), item("Fix hairlines", "minus", "prepress.hairlines", Ready), ], }], diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 01004182cf..55a5d840ba 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -18,8 +18,10 @@ pub enum Needs { Nothing, /// A document is open. Document, - /// The open document's security allows page changes (insert, delete, rotate, extract…). + /// The open document's security allows page changes (insert, delete, rotate…). Assembly, + /// The open document's security allows taking pages out into new files (extract, split). + PageExtraction, /// The open document's security allows content and metadata changes. Modification, /// The open document's security allows adding and changing comments. @@ -198,6 +200,9 @@ pub const COMMANDS: &[CommandSpec] = &[ ct("edit.undo", "Undo", EDIT, Some(Shortcut::cmd("Z")), Undo, "undo-2"), ct("edit.redo", "Redo", EDIT, Some(Shortcut::cmd_shift("Z")), Redo, "redo-2"), c("edit.find", "Find…", EDIT, Some(Shortcut::cmd("F")), Document, "search"), + // Find and replace, in Edit text mode (the Find box's Replace row). Not ⌘H/Ctrl+H: macOS + // takes ⌘H to hide the app, and Ctrl+H is Read mode on Windows and Linux (as in Acrobat). + c("edit.replace", "Replace…", EDIT, Some(Shortcut::cmd_shift("H")), Modification, "replace"), c("edit.advanced_search", "Advanced search…", EDIT, Some(Shortcut::cmd_shift("F")), Document, "search"), c("view.palette", "Find tools and commands…", VIEW, Some(Shortcut::cmd("K")), Nothing, "search"), // Page display: View ▸ Page display and the rail's button list the base layouts as radios and @@ -231,6 +236,16 @@ pub const COMMANDS: &[CommandSpec] = &[ // Split view (Acrobat's Window ▸ Split): the current document in two panes, or back to one; // a tab's right-click menu also splits right or down with any tab. c("view.split", "Split", VIEW, Some(Shortcut::cmd("\\")), Document, "columns-2"), + // A document can hide the toolbars, menu and panels (ISO 32000-2 Table 147); this brings them + // back for its tab, and with nothing hidden hides them. + c( + "view.toggle_interface", + "Show or hide toolbars and panels", + VIEW, + Some(Shortcut { command: false, shift: false, mac_ctrl: false, key: "F8" }), + Document, + "panel-left", + ), // Browsers' tab keys (#1542): Ctrl+Tab on every platform, ⌃Tab rather than ⌘Tab on macOS, // where the system takes ⌘Tab. They follow the tabs' order on screen and wrap around, and // also run from Combine files, which has no document open. @@ -403,9 +418,12 @@ pub const COMMANDS: &[CommandSpec] = &[ c("page.boxes", "Set page boxes…", PAGES, None, Assembly, "square-dashed-mouse-pointer"), c("page.insert", "Insert pages from file…", PAGES, None, Assembly, "file-input"), c("page.replace", "Replace pages…", PAGES, None, Assembly, "replace"), - c("page.extract", "Extract pages…", PAGES, None, Assembly, "file-output"), - c("page.split", "Split document…", PAGES, None, Assembly, "scissors"), + c("page.extract", "Extract pages…", PAGES, None, PageExtraction, "file-output"), + c("page.split", "Split document…", PAGES, None, PageExtraction, "scissors"), c("page.number", "Number pages…", PAGES, None, Assembly, "hash"), + c("prepress.marks", "Add printer marks…", None, None, Modification, "ruler"), + c("prepress.marks.remove", "Remove printer marks", None, None, Marks(crate::MarkKind::PrinterMarks), "ruler"), + c("prepress.convert_colors", "Convert colours…", None, None, Modification, "palette"), c("prepress.hairlines", "Fix hairlines…", None, None, Modification, "minus"), c("page.properties", "Page properties…", PAGES, None, Modification, "info"), c("help.shortcuts", "Keyboard shortcuts", HELP, None, Nothing, "circle-help"), @@ -433,6 +451,7 @@ pub fn is_enabled(spec: &CommandSpec, session: &Session, active: Option) Nothing => true, Document | TwoPageView => doc.is_some(), Assembly => doc.is_some_and(|d| d.allows_assembly()), + PageExtraction => doc.is_some_and(|d| d.allows_page_extraction()), Modification => doc.is_some_and(|d| d.allows_modification()), Annotate => doc.is_some_and(|d| d.allows_annotation()), Copying => doc.is_some_and(|d| d.allows_copying()), diff --git a/crates/engine/src/comments.rs b/crates/engine/src/comments.rs index c8262a7241..a700e38f17 100644 --- a/crates/engine/src/comments.rs +++ b/crates/engine/src/comments.rs @@ -24,9 +24,12 @@ pub(crate) fn comment_list(doc: &pdfcraft_cos::Document) -> Vec &str { } } -/// [`comment_type_name`], telling callouts, clouds and typewriter text apart by `/IT`. +/// [`comment_type_name`], telling replaced text, callouts, clouds and typewriter text apart by `/IT`. pub fn comment_kind(a: &pdfcraft_render::Annotation) -> &str { match a.intent.as_deref() { + Some("StrikeOutTextEdit" | "Replace") => "Replace Text", Some("FreehandHighlight") if a.subtype == "Ink" => "Freehand Highlight", Some("FreeTextCallout") => "Callout", Some("PolygonCloud") => "Cloud", @@ -106,19 +110,26 @@ pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: Su } // Write a comment's replies, indenting each level. A reply to a reply keeps its `/IRT` on the // direct parent, so following one level would leave the deeper replies out of the summary. - fn write_replies(out: &mut String, children: &Children<'_>, parent: &pdfcraft_render::Annotation, depth: usize) { + // A group subordinate (`/RT /Group`, ISO 32000-2 §12.5.6.2) is part of the comment, not a + // reply: it is not written, and replies to it are written at its level, as the comment's. + // `depth` bounds the walk; `level` is the indent. + fn write_replies(out: &mut String, children: &Children<'_>, parent: &pdfcraft_render::Annotation, depth: usize, level: usize) { use std::fmt::Write; if depth > 64 { return; } for r in children.get(&(parent.page, parent.index)).into_iter().flatten() { - let indent = " ".repeat(depth + 1); + if r.is_group_member() { + write_replies(out, children, r, depth + 1, level); + continue; + } + let indent = " ".repeat(level + 1); let _ = writeln!(out, "{indent}Author: {} Subject: Reply Date: {}", r.author.as_deref().unwrap_or(""), r.modified.as_deref().unwrap_or("")); for line in r.contents.as_deref().unwrap_or("").lines() { let _ = writeln!(out, "{indent}{line}"); } - write_replies(out, children, r, depth + 1); + write_replies(out, children, r, depth + 1, level + 1); } } let mut numbered: Vec<(usize, &pdfcraft_render::Annotation)> = Vec::new(); @@ -157,7 +168,7 @@ pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: Su if let Some(c) = a.contents.as_deref().filter(|c| !c.is_empty()) { let _ = writeln!(out, "{c}"); } - write_replies(&mut out, &children, a, 0); + write_replies(&mut out, &children, a, 0, 0); out.push('\n'); } out diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index f8f3706446..602410922f 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -52,6 +52,9 @@ pub struct Document { pub(crate) config: RenderConfig, /// What field scripts printed or asked for (see [`Session::take_js_output`]). pub(crate) js_output: js::JsOutput, + pub(crate) js_runtime: js::Runtime, + pub(crate) js_zoom: f64, + pub(crate) js_page: usize, /// Dynamic XFA forms: what laying the template out produced (pages and fields are /// PdfCraft's; Adobe's viewers draw the form from the XFA packets themselves). pub xfa: Option, @@ -59,6 +62,10 @@ pub struct Document { pub(crate) xfa_template: Option>, /// A script of the XFA form ran away, so its scripts are off (see [`Document::xfa_scripts_off`]). pub(crate) xfa_scripts_off: bool, + /// SOM paths of the XFA fields whose exit scripts ran with a value change ([`xfa::on_changes`]) + /// since they last gained focus: their exit on losing focus has run already + /// ([`Session::field_focus`]). + pub(crate) xfa_exited: std::collections::HashSet, /// A content-only refresh keeps `info` from before the edit; an edit may have added or /// embedded fonts, so the font list is read again on demand ([`Session::refresh_fonts`]). pub(crate) fonts_stale: bool, @@ -66,8 +73,9 @@ pub struct Document { /// (also in `info.warnings`, kept there when the document is re-read). pub xfa_warnings: Vec, /// What the last successful [`Session::apply`](crate::Session::apply) wants said (see - /// [`EditNotice`]): text its fonts can't draw, pages whose own backdrop stays off. The - /// interface shows it; tools return it. + /// [`EditNotice`]): text its fonts can't draw, pages whose own backdrop stays off, places + /// applied redactions left that still hold the removed text. The interface shows it; tools + /// return it. pub edit_notices: Vec, } @@ -82,6 +90,21 @@ pub enum EditNotice { /// turning it on could paint over something (`pdfcraft_edit::backdrops_left_off`). They look /// the same on white paper. BackdropsLeftOff { pages: Vec }, + /// Applying redactions left these places as they were, and they still hold the removed text + /// (bookmark titles, attachments, the comments that remain, XMP on other objects; see + /// [`RedactionElsewhere`](crate::RedactionElsewhere)). Only when one is counted, or the text + /// couldn't be searched for. + RedactionElsewhere(pdfcraft_redact::Elsewhere), + /// Applying redactions removed the document's XMP packet, which held the removed text and + /// said the file conforms to PDF/A or PDF/UA: each claim was written back in a packet of its + /// own, or lost (see [`RedactionIdentification`](crate::RedactionIdentification)). + RedactionIdentification(pdfcraft_redact::Identification), +} + +/// The standards whose identification `id` has as `claim`: "PDF/A", "PDF/UA" or both, joined. +pub(crate) fn standards_with(id: &pdfcraft_redact::Identification, claim: pdfcraft_redact::Claim) -> Option { + let names: Vec<&str> = [("PDF/A", id.pdfa), ("PDF/UA", id.pdfua)].into_iter().filter(|(_, c)| *c == claim).map(|(n, _)| n).collect(); + (!names.is_empty()).then(|| names.join(" and ")) } impl std::fmt::Display for EditNotice { @@ -96,10 +119,50 @@ impl std::fmt::Display for EditNotice { page_runs(pages) ) } + EditNotice::RedactionElsewhere(e) => elsewhere_text(e, f), + EditNotice::RedactionIdentification(id) => { + use pdfcraft_redact::Claim; + write!(f, "the document's XMP metadata held the redacted text and was removed")?; + if let Some(kept) = standards_with(id, Claim::Kept) { + write!(f, "; its {kept} identification was written back on its own")?; + } + match standards_with(id, Claim::Lost) { + Some(lost) => write!(f, "; its {lost} identification couldn't be kept, so the file no longer says it conforms to {lost}"), + None => Ok(()), + } + } } } } +/// [`EditNotice::RedactionElsewhere`] in words (English; the interface has its own wording). +fn elsewhere_text(e: &pdfcraft_redact::Elsewhere, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let count = |n: usize, one: &str, many: &str| match n { + 0 => None, + 1 => Some(format!("1 {one}")), + n => Some(format!("{n} {many}")), + }; + let places: Vec = [ + count(e.bookmarks, "bookmark title", "bookmark titles"), + count(e.attachments, "attachment", "attachments"), + count(e.comments, "comment", "comments"), + count(e.xmp, "other metadata stream", "other metadata streams"), + ] + .into_iter() + .flatten() + .collect(); + let list = match places.split_last() { + None => "no place counted".to_string(), + Some((last, [])) => last.clone(), + Some((last, rest)) => format!("{} and {last}", rest.join(", ")), + }; + if e.unsearched { + write!(f, "the redacted text couldn't be searched for, so {list} may still hold it (Remove hidden information removes them)") + } else { + write!(f, "the redacted text also appears in {list}, which redaction doesn't change (Remove hidden information removes them)") + } +} + /// 0-based pages as 1-based runs: `[0, 1, 2, 5]` is "1-3, 6". pub(crate) fn page_runs(pages: &[usize]) -> String { let mut runs: Vec<(usize, usize)> = Vec::new(); @@ -186,6 +249,26 @@ pub(crate) fn undrawn_notices(prev: &pdfcraft_cos::Document, next: &pdfcraft_cos } impl Document { + /// The places the last edit's applied redactions left that still hold the removed text + /// ([`EditNotice::RedactionElsewhere`]), or `None` when there are none or the last edit + /// applied none. + pub fn redaction_elsewhere(&self) -> Option { + self.edit_notices.iter().find_map(|n| match n { + EditNotice::RedactionElsewhere(e) => Some(*e), + _ => None, + }) + } + + /// What became of the PDF/A and PDF/UA identification when the last edit's applied + /// redactions removed the document's XMP packet ([`EditNotice::RedactionIdentification`]), + /// or `None` when no packet that made such a claim was removed. + pub fn redaction_identification(&self) -> Option { + self.edit_notices.iter().find_map(|n| match n { + EditNotice::RedactionIdentification(id) => Some(*id), + _ => None, + }) + } + /// How the document opens (Document Properties ▸ Initial View). /// Accessibility ▸ Check for accessibility: the full check (`None` if the document can't be /// read for editing). @@ -419,10 +502,16 @@ impl Document { self.undo_label().map(Label::as_str) } + /// What Redo would redo, in English, when there is something to redo. pub fn can_redo(&self) -> Option<&str> { self.redo_label().map(Label::as_str) } + /// How many steps Undo can take back: a step still at this depth is still the last one. + pub fn undo_depth(&self) -> usize { + self.editor.as_ref().map_or(0, |e| e.undo.len()) + } + /// What Undo would undo, in parts, for an interface that says it in another language. pub fn undo_label(&self) -> Option<&Label> { self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, ..)| l) @@ -465,11 +554,17 @@ impl Document { if self.allows_copying() { Ok(()) } else { Err(EditError::NotPermitted("copying text and images")) } } - /// Page changes (insert, delete, rotate, move, extract) are allowed. + /// Page changes (insert, delete, rotate, move) are allowed. pub fn allows_assembly(&self) -> bool { self.editable() && self.permissions().is_none_or(|p| p.assemble()) } + /// Pages may be taken out into new files (Extract, Split): the page changes above and copying, + /// since the new files don't keep the document's protection (#5081). + pub fn allows_page_extraction(&self) -> bool { + self.editable() && self.permissions().is_none_or(|p| p.extract_pages()) + } + /// Redaction marks waiting to be applied. pub fn redaction_marks(&self) -> usize { self.info.annotations.iter().filter(|a| a.subtype == "Redact").count() diff --git a/crates/engine/src/document_actions_tests.rs b/crates/engine/src/document_actions_tests.rs new file mode 100644 index 0000000000..8511163eff --- /dev/null +++ b/crates/engine/src/document_actions_tests.rs @@ -0,0 +1,379 @@ +//! Catalog JavaScript actions (#269, ported from #344): the `/OpenAction` runs once on opening +//! and again on Revert, `/AA /WS` before a save and `/AA /WP` before printing, all in the +//! document's retained realm. + +use std::sync::Arc; + +use crate::js::DocumentScriptEvent; +use crate::{DocId, Session}; + +/// A PDF of `objs`, numbered from 1, with a cross-reference table. +fn pdf_of(objs: &[String]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// A one-field form (`log`, a text field) whose catalog carries `catalog` (PDF syntax, e.g. +/// `/OpenAction 6 0 R`), with `extra` objects numbered from 6. +fn form(catalog: &str, extra: &[&str]) -> Vec { + let mut objs = vec![ + format!("<< /Type /Catalog /Pages 2 0 R {catalog} /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>"), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + ]; + objs.extend(extra.iter().map(|s| s.to_string())); + pdf_of(&objs) +} + +fn log(s: &Session, id: DocId) -> String { + s.get(id).unwrap().form.iter().find(|f| f.name == "log").unwrap().value.join(",") +} + +/// The open action runs once on opening, after the document scripts and in their realm, and +/// sees `event.name` "Open"; its field change is applied. +#[test] +fn the_open_action_runs_once_on_opening_in_the_document_realm() { + let bytes = form( + "/OpenAction 6 0 R /Names << /JavaScript << /Names [(init) 7 0 R] >> >>", + &[ + "<< /S /JavaScript /JS (opened++; getField\\('log'\\).value = event.type + '/' + event.name + '/' + opened;) >>", + "<< /S /JavaScript /JS (var opened = 0;) >>", + ], + ); + let mut s = Session::new(); + let id = s.open("open.pdf", None, Arc::new(bytes), None).unwrap(); + assert_eq!(log(&s, id), "Doc/Open/1"); + assert!(s.take_js_output(id).errors.is_empty()); + // The realm is kept: a later script sees the count the open action left. + assert_eq!(s.run_javascript(id, "opened;", None).unwrap().result.as_deref(), Some("1")); +} + +/// An open action chain runs in order through `/Next` (a dictionary or an array), skipping +/// actions of other types, and a cycle in it runs each action once. +#[test] +fn an_open_action_chain_runs_in_order_and_a_cycle_runs_each_action_once() { + let bytes = form( + "/OpenAction 6 0 R", + &[ + "<< /S /JavaScript /JS (var seen = ['a'];) /Next [7 0 R 8 0 R] >>", + "<< /S /GoTo /D [3 0 R /Fit] /Next 9 0 R >>", + "<< /S /JavaScript /JS (seen.push\\('c'\\); getField\\('log'\\).value = seen.join\\(''\\);) /Next 6 0 R >>", + "<< /S /JavaScript /JS (seen.push\\('b'\\);) /Next 7 0 R >>", + ], + ); + let mut s = Session::new(); + let id = s.open("chain.pdf", None, Arc::new(bytes), None).unwrap(); + assert_eq!(log(&s, id), "abc"); +} + +/// What an open action asks for that needs a click (a URL, printing) is dropped; a page +/// change it asks for is kept. +#[test] +fn an_open_action_may_not_open_a_url_or_print() { + let bytes = form("/OpenAction << /S /JavaScript /JS (app.launchURL\\('https://example.com'\\); this.print\\(\\); this.pageNum = 0;) >>", &[]); + let mut s = Session::new(); + let id = s.open("asks.pdf", None, Arc::new(bytes), None).unwrap(); + let out = s.take_js_output(id); + assert_eq!(out.requests, [crate::js::Request::GoToPage(0)], "{out:?}"); +} + +/// With JavaScript off the open action doesn't run. +#[test] +fn the_open_action_does_not_run_with_javascript_off() { + let bytes = form("/OpenAction << /S /JavaScript /JS (getField\\('log'\\).value = 'ran';) >>", &[]); + let mut s = Session::new(); + s.set_javascript(false); + let id = s.open("off.pdf", None, Arc::new(bytes), None).unwrap(); + assert_eq!(log(&s, id), ""); + assert!(s.run_document_actions(id, DocumentScriptEvent::CatalogOpen).is_empty()); +} + +/// Revert reopens the saved file in a fresh realm, so the open action runs again from scratch. +#[test] +fn revert_runs_the_open_action_again_in_a_fresh_realm() { + let bytes = form( + "/OpenAction << /S /JavaScript /JS (if \\(typeof runs == 'undefined'\\) var runs = 0; runs++; getField\\('log'\\).value = 'run ' + runs;) >>", + &[], + ); + let mut s = Session::new(); + let id = s.open("revert.pdf", None, Arc::new(bytes), None).unwrap(); + assert_eq!(log(&s, id), "run 1"); + s.run_javascript(id, "getField('log').value = 'typed';", None).unwrap(); + s.revert(id).unwrap(); + assert_eq!(log(&s, id), "run 1", "a fresh realm counts from zero again"); +} + +/// Will Save runs in the document realm before the bytes are written, and what it changes is in +/// the saved file; a failing Will Save is reported and the save still has bytes to write. +#[test] +fn will_save_changes_what_is_saved_and_a_failing_one_does_not_stop_the_save() { + let bytes = form( + "/Names << /JavaScript << /Names [(init) 6 0 R] >> >> /AA << /WS << /S /JavaScript /JS (saves++; getField\\('log'\\).value = event.name + ' ' + saves;) >> >>", + &["<< /S /JavaScript /JS (var saves = 0;) >>"], + ); + let mut s = Session::new(); + let id = s.open("save.pdf", None, Arc::new(bytes), None).unwrap(); + let out = s.run_document_actions(id, DocumentScriptEvent::WillSave); + assert!(out.errors.is_empty(), "{out:?}"); + assert_eq!(log(&s, id), "WillSave 1"); + let saved = s.save_bytes(id).unwrap(); + let mut reopened = Session::new(); + reopened.set_javascript(false); + let again = reopened.open("saved.pdf", None, saved, None).unwrap(); + assert_eq!(log(&reopened, again), "WillSave 1"); + + let failing = form("/AA << /WS << /S /JavaScript /JS (throw new Error\\('no'\\);) >> >>", &[]); + let id = s.open("fail.pdf", None, Arc::new(failing), None).unwrap(); + let out = s.run_document_actions(id, DocumentScriptEvent::WillSave); + assert!(out.errors.iter().any(|e| e.contains("no")), "{out:?}"); + assert!(s.save_bytes(id).is_ok()); +} + +/// Will Print runs before printing, in the same realm, and sees `event.name` "WillPrint". +#[test] +fn will_print_runs_in_the_document_realm() { + let bytes = form("/AA << /WP << /S /JavaScript /JS (getField\\('log'\\).value = event.name;) >> >>", &[]); + let mut s = Session::new(); + let id = s.open("print.pdf", None, Arc::new(bytes), None).unwrap(); + assert!(s.run_document_actions(id, DocumentScriptEvent::WillSave).is_empty(), "no Will Save action: nothing runs"); + assert_eq!(log(&s, id), ""); + s.run_document_actions(id, DocumentScriptEvent::WillPrint); + assert_eq!(log(&s, id), "WillPrint"); +} + +/// A form with a text field `log` and a text field `hot` whose widget has JavaScript for every +/// pointer and focus trigger, each appending `event.name` to `log`. +fn triggers_form() -> Vec { + let add = + || "<< /S /JavaScript /JS (var l = getField\\('log'\\); l.value = l.value + event.name + ':' + event.target.name + ';';) >>".to_string(); + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + format!( + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (hot) /V () /Rect [20 200 180 220] /P 3 0 R /F 4 /AA << /E {} /X {} /D {} /U {} /Fo {} /Bl {} >> >>", + add(), + add(), + "<< /S /JavaScript /JS (getField\\('log'\\).value += 'D;'; this.print\\(\\);) >>", + add(), + add(), + "<< /S /JavaScript /JS (getField\\('log'\\).value += 'Bl;'; this.print\\(\\);) >>", + ), + ]; + pdf_of(&objs) +} + +/// Pointer and focus triggers run the widget's JavaScript with the event names Acrobat uses, +/// each as one undoable edit; a field without such a script runs nothing. +#[test] +fn pointer_and_focus_triggers_run_the_widget_scripts() { + use crate::js::FieldFocus; + use pdfcraft_forms::Trigger; + let mut s = Session::new(); + let id = s.open("hot.pdf", None, Arc::new(triggers_form()), None).unwrap(); + assert!(s.field_trigger(id, "hot", 0, Trigger::MouseEnter).unwrap()); + assert!(s.field_focus(id, "hot", 0, FieldFocus::Enter).unwrap()); + assert!(s.field_trigger(id, "hot", 0, Trigger::MouseUp).unwrap()); + assert!(s.field_trigger(id, "hot", 0, Trigger::MouseExit).unwrap()); + assert_eq!(log(&s, id), "Mouse Enter:hot;Focus:hot;Mouse Up:hot;Mouse Exit:hot;"); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript")); + assert!(!s.field_trigger(id, "log", 0, Trigger::MouseEnter).unwrap(), "no script: nothing runs"); + assert!(!s.field_trigger(id, "hot", 5, Trigger::MouseEnter).unwrap(), "no such widget: nothing runs"); + assert!(s.field_trigger(id, "missing", 0, Trigger::MouseEnter).is_err()); + s.set_javascript(false); + assert!(!s.field_trigger(id, "hot", 0, Trigger::MouseEnter).unwrap(), "JavaScript off"); +} + +/// A click's Mouse Down may ask to print; losing the focus, which happens unasked, may not. +#[test] +fn only_a_click_trigger_may_print() { + use crate::js::{FieldFocus, Request}; + use pdfcraft_forms::Trigger; + let mut s = Session::new(); + let id = s.open("hot.pdf", None, Arc::new(triggers_form()), None).unwrap(); + s.field_trigger(id, "hot", 0, Trigger::MouseDown).unwrap(); + assert_eq!(s.take_js_output(id).requests, [Request::Print]); + s.field_focus(id, "hot", 0, FieldFocus::Exit).unwrap(); + let out = s.take_js_output(id); + assert!(out.requests.is_empty(), "{out:?}"); + assert_eq!(log(&s, id), "D;Bl;"); +} + +/// `setItems` and `setAction` are written to the document: the combo box gets its new items +/// (pairs where text and export value differ), the installed scripts run on their triggers, and +/// both survive a save (#269, from #344). +#[test] +fn set_items_and_set_action_are_written_to_the_document() { + use pdfcraft_forms::Trigger; + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Ch /Ff 131072 /T (size) /Opt [(Small) (Large)] /V (Large) /Rect [20 200 180 220] /P 3 0 R /F 4 >>" + .into(), + ]; + let mut s = Session::new(); + let id = s.open("items.pdf", None, Arc::new(pdf_of(&objs)), None).unwrap(); + let out = s + .run_javascript( + id, + "var f = getField('size'); f.setItems([['Medium', 'm'], 'Large']); \ + f.setAction('MouseEnter', \"getField('log').value = 'entered ' + event.target.value;\");", + None, + ) + .unwrap(); + assert_eq!(out.error, None, "{out:?}"); + let size = s.get(id).unwrap().form.iter().find(|f| f.name == "size").unwrap().clone(); + assert_eq!(size.options, [("m".to_string(), "Medium".to_string()), ("Large".into(), "Large".into())]); + assert_eq!(size.value, ["Large"], "the value is still an item: it stays"); + assert!(s.field_trigger(id, "size", 0, Trigger::MouseEnter).unwrap()); + assert_eq!(log(&s, id), "entered Large"); + s.undo(id).unwrap(); + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "size").unwrap().options[0].1, "Small", "undone"); + s.redo(id).unwrap(); + let saved = s.save_bytes(id).unwrap(); + let mut again = Session::new(); + let reopened = again.open("saved.pdf", None, saved, None).unwrap(); + let size = again.get(reopened).unwrap().form.iter().find(|f| f.name == "size").unwrap().clone(); + assert_eq!(size.options[0], ("m".to_string(), "Medium".to_string())); + assert!(again.field_trigger(reopened, "size", 0, Trigger::MouseEnter).unwrap(), "the installed script is in the file"); +} + +/// A check box's Mouse Up, run after the click stored the new value, reads that value and can +/// show or hide another field by it (#247). +#[test] +fn a_check_box_mouse_up_reads_the_value_its_click_stored() { + use crate::{Edit, FieldValue}; + use pdfcraft_forms::Trigger; + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R 7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (strike) /V /Off /AS /Off /Rect [20 150 50 180] /P 3 0 R /F 4 /MK << /CA (4) >> /AP << /N << /Yes 8 0 R /Off 8 0 R >> >> /A << /S /JavaScript /JS (getField\\('note'\\).display = event.target.value == 'Off' ? display.hidden : display.visible; getField\\('log'\\).value += 'up:' + event.target.value + ';';) >> >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (note) /V (struck) /Rect [60 150 180 180] /P 3 0 R /F 6 >>".into(), + "<< /Type /XObject /Subtype /Form /BBox [0 0 30 30] /Length 0 >>\nstream\n\nendstream".into(), + ]; + let mut s = Session::new(); + let id = s.open("strike.pdf", None, Arc::new(pdf_of(&objs)), None).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "strike".into(), value: FieldValue::Text("Yes".into()) }).unwrap(); + let ran = s.field_trigger(id, "strike", 0, Trigger::MouseUp); + let out = s.take_js_output(id); + assert!(ran.unwrap(), "{out:?}"); + assert_eq!(log(&s, id), "up:Yes;"); + let note = |s: &Session| s.get(id).unwrap().form.iter().find(|f| f.name == "note").unwrap().widgets[0].hidden; + assert!(!note(&s), "shown by the script"); + s.apply(id, Edit::SetFieldValue { name: "strike".into(), value: FieldValue::Check(false) }).unwrap(); + assert!(s.field_trigger(id, "strike", 0, Trigger::MouseUp).unwrap()); + assert_eq!(log(&s, id), "up:Yes;up:Off;"); + assert!(note(&s), "hidden again"); +} + +/// A widget action's `/Next` chain runs too, each JavaScript action in order (depth first), past +/// an action of another type at its head. +#[test] +fn a_widget_actions_next_chain_runs_each_script_in_order() { + use pdfcraft_forms::Trigger; + let js = |text: &str, next: &str| format!("<< /S /JavaScript /JS (getField\\('log'\\).value += '{text}';) {next} >>"); + let chain = format!("<< /S /URI /URI (https://example.com/) /Next [{} {}] >>", js("a;", &format!("/Next {}", js("b;", ""))), js("c;", "")); + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (hot) /V () /Rect [20 200 180 220] /P 3 0 R /F 4 /AA << /E {chain} >> >>"), + ]; + let mut s = Session::new(); + let id = s.open("chain.pdf", None, Arc::new(pdf_of(&objs)), None).unwrap(); + assert!(s.field_trigger(id, "hot", 0, Trigger::MouseEnter).unwrap()); + assert_eq!(log(&s, id), "a;b;c;"); +} + +/// `setAction` on a field that shares its name with another installs the script on that field, +/// the one the script's object stands for, not the first of the name. +#[test] +fn set_action_installs_on_the_field_the_object_stands_for() { + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 200 180 220] /P 3 0 R /F 4 >>".into(), + ]; + let mut s = Session::new(); + let id = s.open("dup.pdf", None, Arc::new(pdf_of(&objs)), None).unwrap(); + let second = crate::js::field_object_id(pdfcraft_cos::ObjRef::new(6, 0)); + let out = + s.run_javascript_answers_target(id, "event.target.setAction('Format', \"event.value = 'x';\");", Some("dup"), Some(second), &[]).unwrap(); + assert_eq!(out.error, None, "{out:?}"); + let cos = s.get(id).unwrap().editor.as_ref().unwrap().cos.clone(); + let formats: Vec<(u32, bool)> = pdfcraft_forms::fields(&cos).iter().map(|f| (f.obj.num, f.actions.scripts.format.is_some())).collect(); + assert_eq!(formats, [(4, false), (6, true)]); +} + +/// Of two fields sharing a name, the one the pointer is on runs its own widget script. +#[test] +fn a_trigger_on_a_field_sharing_a_name_runs_that_fields_script() { + use pdfcraft_forms::Trigger; + let enter = |text: &str| format!("/AA << /E << /S /JavaScript /JS (getField\\('log'\\).value += '{text}';) >> >>"); + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R 7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>" + .to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 200 180 220] /P 3 0 R /F 4 {} >>", enter("first;")), + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 150 180 170] /P 3 0 R /F 4 {} >>", enter("second;")), + ]; + let mut s = Session::new(); + let id = s.open("dup.pdf", None, Arc::new(pdf_of(&objs)), None).unwrap(); + let second = crate::js::field_object_id(pdfcraft_cos::ObjRef::new(7, 0)); + assert!(s.field_trigger_target(id, "dup", Some(second), 0, Trigger::MouseEnter).unwrap()); + assert_eq!(log(&s, id), "second;"); + assert!(s.field_trigger(id, "dup", 0, Trigger::MouseEnter).unwrap(), "by name alone, the first"); + assert_eq!(log(&s, id), "second;first;"); +} + +/// Focus too: of two fields sharing a name, the one that gained focus runs its On Focus script. +#[test] +fn focus_on_a_field_sharing_a_name_runs_that_fields_script() { + use crate::js::FieldFocus; + let focus = |text: &str| format!("/AA << /Fo << /S /JavaScript /JS (getField\\('log'\\).value += '{text}';) >> >>"); + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R 7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>" + .to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 200 180 220] /P 3 0 R /F 4 {} >>", focus("first;")), + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 150 180 170] /P 3 0 R /F 4 {} >>", focus("second;")), + ]; + let mut s = Session::new(); + let id = s.open("dup.pdf", None, Arc::new(pdf_of(&objs)), None).unwrap(); + let second = crate::js::field_object_id(pdfcraft_cos::ObjRef::new(7, 0)); + assert!(s.field_focus_target(id, "dup", Some(second), 0, FieldFocus::Enter).unwrap()); + assert_eq!(log(&s, id), "second;"); +} diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index b4b8e56ffd..80c51658e4 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -5,8 +5,8 @@ use std::sync::Arc; use crate::{ AddedContent, AddedText, Background, BorderShape, BoxSpec, EditError, FieldProps, FieldValue, FillMark, Hairlines, HeaderFooter, Hidden, ImageEdit, InitialView, Label, LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget, - PageBox, Protection, ReviewState, Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file, - embed_image, field_table, fit_picture, image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa, + PageBox, PrinterMarks, Protection, ReviewState, Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, + decode_data_file, embed_image, field_table, fit_picture, image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, strip_xfa, unrestricted, xfa, xfa_after_data_change, }; @@ -160,12 +160,14 @@ pub enum Edit { /// `rect` is a point: the stamp is centred there at its natural size (at most 200 pt). /// Any other `rect` is a box: the picture is scaled to fit inside it, keeping its shape, /// and centred in it (#4077). + /// `line` is a text line under the picture, already filled in ([`Session::stamp_line`](crate::Session::stamp_line)). AddCustomStamp { page: usize, rect: [f64; 4], name: String, file: MarkFile, author: String, + line: Option, }, /// Rich Media ▸ Add 3D: a U3D or PRC `model` as a 3D annotation in `rect` on `page`, named /// `name` (its alternate description). Its poster is the picture in `poster` (a PDF page or an @@ -243,6 +245,14 @@ pub enum Edit { index: usize, points: Vec<[f64; 2]>, }, + /// Open, close or move a comment's pop-up window (`rect` in user space; a comment without a + /// pop-up gets one). + SetAnnotationPopup { + page: usize, + index: usize, + rect: Option<[f64; 4]>, + open: Option, + }, /// Resize a rectangle, oval or text box. ResizeAnnotation { page: usize, @@ -328,6 +338,12 @@ pub enum Edit { name: String, image: Arc>, }, + /// Set the picture of one exact push-button field; legacy name-only calls remain available. + SetFieldImageTarget { + name: String, + object: pdfcraft_cos::ObjRef, + image: Arc>, + }, /// Duplicate a field onto other pages (same field, shared value). DuplicateField { name: String, @@ -349,6 +365,14 @@ pub enum Edit { }, /// Document Properties ▸ Initial View (and the language and binding). SetInitialView(Box), + /// Print production ▸ Add printer marks: trim and bleed marks, registration targets, colour + /// bars and page information outside the trim box, as a page mark; the page may be widened + /// to make room (`settings.make_room`), so the page geometry is read again. + AddPrinterMarks { + pages: Vec, + settings: PrinterMarks, + replace: bool, + }, /// Print production ▸ Fix hairlines: strokes narrower than `settings.narrower_than` points on /// the page (after every transformation; zero-width lines always) get `settings.width`. FixHairlines { @@ -367,6 +391,16 @@ pub enum Edit { DeleteAttachment { key: String, }, + /// Print production ▸ Convert colours: every colour on the pages in grayscale, RGB or CMYK. + ConvertColours { + pages: Vec, + settings: pdfcraft_edit::ConvertColours, + }, + /// Change or clear only `/Desc` on a document-level attachment's file specification. + EditAttachmentDescription { + key: String, + description: Option, + }, /// Build a tagged structure tree for an untagged, text-only document. AutoTagText, /// Add alternate text: set (or clear, with `None`) a figure's alternate text. `figure` is @@ -424,8 +458,8 @@ pub enum Edit { RemoveStandardId { standard: Standard, }, - /// What a script (button or console) changed in form fields: values, read-only, required - /// and visibility. + /// What a script (button or console) changed in form fields: values, appearance colours, + /// read-only, required and visibility. ApplyScriptChanges { changes: Vec, }, diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index 1979c86348..93045146ae 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -31,6 +31,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Insert pages from {name} — fields renamed", "Attach {name}", "Delete attachment {key}", + "Edit attachment description", "Add bookmark", "Rename bookmark", "Delete bookmark", @@ -87,6 +88,9 @@ pub const EDIT_LABELS: &[&str] = &[ "Unlock comment", "Move comment", "Reshape comment", + "Open comment pop-up", + "Close comment pop-up", + "Move comment pop-up", "Resize comment", "Change comment properties", "Fill in {name}", @@ -163,8 +167,16 @@ pub const EDIT_LABELS: &[&str] = &[ /// The names of the batches built below L7, which an interface shows in the same places as /// [`EDIT_LABELS`] and so translates the same way. A batch built in the interface itself arrives /// already worded in the interface's language. -pub const BATCH_LABELS: &[&str] = - &["Set document title", "Mark differences", "Detect form fields", "Run JavaScript", "Recognize text", field_table::LABEL, "Style"]; +pub const BATCH_LABELS: &[&str] = &[ + "Set document title", + "Mark differences", + "Detect form fields", + "Run JavaScript", + "Recognize text", + field_table::LABEL, + "Style", + crate::find_replace::REPLACE_ALL, +]; impl Edit { /// Label for the Edit menu and history ("Undo Rotate pages"). @@ -232,6 +244,9 @@ impl Edit { Edit::LockAnnotation { .. } => Label::of("Unlock comment"), Edit::MoveAnnotation { .. } => Label::of("Move comment"), Edit::ReshapeAnnotation { .. } => Label::of("Reshape comment"), + Edit::SetAnnotationPopup { rect: Some(_), .. } => Label::of("Move comment pop-up"), + Edit::SetAnnotationPopup { open: Some(true), .. } => Label::of("Open comment pop-up"), + Edit::SetAnnotationPopup { .. } => Label::of("Close comment pop-up"), Edit::ResizeAnnotation { .. } => Label::of("Resize comment"), Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } @@ -241,7 +256,7 @@ impl Edit { | Edit::BorderShapeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => Label::of("Change comment properties"), Edit::SetFieldValue { name, .. } => Label::with("Fill in {name}", name.as_str()), - Edit::SetFieldImage { name, .. } => Label::with("Set the image of {name}", name.as_str()), + Edit::SetFieldImage { name, .. } | Edit::SetFieldImageTarget { name, .. } => Label::with("Set the image of {name}", name.as_str()), Edit::ResetForm { .. } => Label::of("Clear form"), Edit::AddField { .. } => Label::of("Add field"), Edit::SetFieldProps { .. } => Label::of("Change field properties"), @@ -253,6 +268,8 @@ impl Edit { Edit::FixHairlines { .. } => Label::of("Fix hairlines"), Edit::AddAttachment { name, .. } => Label::with("Attach {name}", name.as_str()), Edit::DeleteAttachment { key } => Label::with("Delete attachment {key}", key.as_str()), + Edit::ConvertColours { .. } => Label::of("Convert colours"), + Edit::EditAttachmentDescription { .. } => Label::of("Edit attachment description"), Edit::AutoTagText => Label::of("Automatically tag text"), Edit::SetAltText { .. } => Label::of("Set alternate text"), Edit::AutoTagFormFields => Label::of("Tag form fields"), @@ -301,6 +318,8 @@ impl Edit { Edit::RemoveMarks { kind: MarkKind::HeaderFooter } => Label::of("Remove header & footer"), Edit::RemoveMarks { kind: MarkKind::Watermark } => Label::of("Remove watermark"), Edit::RemoveMarks { kind: MarkKind::Background } => Label::of("Remove background"), + Edit::RemoveMarks { kind: MarkKind::PrinterMarks } => Label::of("Remove printer marks"), + Edit::AddPrinterMarks { .. } => Label::of("Add printer marks"), Edit::AddText { .. } => Label::of("Add text"), Edit::AddImage { .. } => Label::of("Add image"), Edit::UpdateContent { .. } => Label::of("Edit content"), diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index e584ac2413..57e5729fc8 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -1,6 +1,6 @@ use super::{ AddedContent, Arc, Edit, EditError, FieldValue, ImageEdit, NewAnnotation, Shape, a11y, copy_image, decode_data_file, embed_image, fit_picture, - image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, + image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, strip_xfa, unrestricted, xfa, xfa_after_data_change, }; use crate::SourceProblem; use crate::sources::{comment_source, import_pdf_comments}; @@ -62,6 +62,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re | Edit::LockAnnotation { .. } | Edit::MoveAnnotation { .. } | Edit::ReshapeAnnotation { .. } + | Edit::SetAnnotationPopup { .. } | Edit::ResizeAnnotation { .. } | Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } @@ -77,7 +78,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re Err(EditError::NotPermitted("comments")) } } - Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } | Edit::XfaEvent { .. } => { + Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::SetFieldImageTarget { .. } | Edit::ApplyScriptChanges { .. } | Edit::XfaEvent { .. } => { if p.fill_forms() { Ok(()) } else { @@ -91,6 +92,11 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re Err(EditError::NotPermitted("changing security")) } } + // Converting the appearances of comments and form fields changes them too, which + // content changes alone do not allow. + Edit::ConvertColours { settings, .. } if settings.annotations && !(p.modify() && p.annotate()) => { + Err(EditError::NotPermitted(if p.modify() { "comments" } else { "changes to the document" })) + } // Converting to PDF/A takes the encryption off (PDF/A forbids it), so on an encrypted // document it is a change of security (#3921). Edit::ConvertPdfA { .. } => { @@ -102,6 +108,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re } Edit::SetInfo { .. } | Edit::AddHeaderFooter { .. } + | Edit::AddPrinterMarks { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. } @@ -137,6 +144,8 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re | Edit::FixHairlines { .. } | Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } + | Edit::ConvertColours { .. } + | Edit::EditAttachmentDescription { .. } | Edit::AutoTagText | Edit::SetAltText { .. } | Edit::AutoTagFormFields @@ -212,6 +221,7 @@ fn skipped_text(s: &str) -> String { pub(crate) struct EditCtx { /// Field scripts run (`None`: JavaScript is off) and what they produced. pub(crate) js: Option, + defer_reset_callbacks: bool, /// A laid-out XFA form's template (`None`: not an XFA form, or JavaScript is off) and what /// its scripts produced. pub(crate) xfa: Option>, @@ -220,12 +230,16 @@ pub(crate) struct EditCtx { pub(crate) xfa_datasets: Option, /// A form script ran too long and was abandoned: the document's scripts go off. pub(crate) xfa_ran_away: bool, + /// An XFA enter or exit event whose scripts changed nothing: no edit to keep. + pub(crate) xfa_unchanged: bool, /// What the XFA scripts' time limits are multiplied by ([`Session::with_xfa_time_scale`]). pub(crate) xfa_time_scale: u32, /// The edit took the XFA out of the form: what went with it, for the document's warnings. pub(crate) xfa_removed: Vec, /// Pages in the document, for `xfa.layout.pageCount()`. pub(crate) pages: usize, + /// Whether the viewer highlights form fields, for `app.runtimeHighlight`. + pub(crate) highlight: bool, pub(crate) date: Option, /// Today in local time, for date tokens. pub(crate) today: (i64, u32, u32), @@ -239,12 +253,34 @@ pub(crate) struct EditCtx { /// The overlay streams the edit's applied redactions added, which alone the renderer's check /// leaves out. pub(crate) redaction_overlays: Vec, + /// What the edit's applied redactions left as it was that still holds the removed text. + pub(crate) redaction_elsewhere: Option, + /// What became of the PDF/A and PDF/UA identification when the edit's applied redactions + /// removed the catalog's XMP packet. + pub(crate) redaction_identification: pdfcraft_redact::Identification, /// What [`Edit::PrepareText`] prepared, repaired and left grouped, for /// [`Session::prepare_text`](crate::Session::prepare_text). pub(crate) prepared: Option, } impl EditCtx { + /// The field highlight (`app.runtimeHighlight`) the edit's next script reads: the field + /// scripts' runner holds it while there is one (its scripts change it there), else the edit. + pub(crate) fn highlight(&self) -> bool { + self.js.as_ref().map_or(self.highlight, |js| js.doc.runtime_highlight) + } + + /// A script changed the field highlight: both script runtimes of the edit read the new one, + /// so a later edit of a batch sees it whichever kind of script it runs. + pub(crate) fn set_highlight(&mut self, on: bool) { + self.highlight = on; + if let Some(js) = self.js.as_mut() { + js.doc.runtime_highlight = on; + } + } + + /// The context for one edit: `now` (the session's clock) dates it, and with `salt` seeds the + /// ids it makes. pub(crate) fn new(now: Option, salt: u64) -> Self { let mut seed = 0x9E37_79B9_7F4A_7C15u64 ^ salt; if let Some(t) = now { @@ -257,13 +293,16 @@ impl EditCtx { } Self { js: None, + defer_reset_callbacks: false, xfa: None, xfa_out: Default::default(), xfa_datasets: None, xfa_ran_away: false, + xfa_unchanged: false, xfa_time_scale: 1, xfa_removed: Vec::new(), pages: 0, + highlight: false, date: now.map(pdfcraft_cos::pdf_date), today: (1970, 1, 1), seed, @@ -271,6 +310,8 @@ impl EditCtx { rng: getrandom::fill, renamed_fields: Vec::new(), redaction_overlays: Vec::new(), + redaction_elsewhere: None, + redaction_identification: pdfcraft_redact::Identification::default(), prepared: None, } } @@ -381,13 +422,10 @@ fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usi if let Some((added_idx, added_item)) = matched_added && let AddedContent::Image(ai) = added_item.content.clone() { + // Page visibility alone is not a crop: dragging an added image over the page edge + // must not discard its editable added-item identity on the next resize. let has_active_crop = ai.crop.iter().any(|v| v.abs() > 1e-6) - || img.visible_rect.is_some_and(|v| { - (v[0] - img.rect[0]).abs() > 1e-4 - || (v[1] - img.rect[1]).abs() > 1e-4 - || (v[2] - img.rect[2]).abs() > 1e-4 - || (v[3] - img.rect[3]).abs() > 1e-4 - }); + || img.frame_rect().is_none_or(|frame| frame.iter().zip(img.rect).any(|(a, b)| (a - b).abs() > 1e-4)); let non_axis_transform = matches!(change, ImageEdit::Transform([a, b, c, d, _, _]) if !((b.abs() < 1e-5 && c.abs() < 1e-5) || (a.abs() < 1e-5 && d.abs() < 1e-5))); let should_promote = has_active_crop @@ -472,6 +510,14 @@ fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usi Ok(()) } +/// Forget cached calculation order and field metadata after changing the form structure. +fn invalidate_form_script_snapshot(cx: &mut EditCtx) { + if let Some(js) = cx.js.as_mut() { + js.invalidate_calculations(); + js.invalidate_field_snapshots(); + } +} + /// Perform an edit on a working copy (the caller discards it on error). pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Result<(), EditError> { match edit { @@ -485,13 +531,13 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::SetInfo { key, value } => pdfcraft_organize::set_info(doc, key, value)?, Edit::DuplicatePages { pages, at } => pdfcraft_organize::duplicate_pages(doc, pages, *at)?, Edit::ReplacePages { pages, name, bytes, src_pages } => { - let src = open_source(name, bytes)?; + let src = crate::sources::open_source_into(name, bytes, doc)?; pdfcraft_organize::replace_pages(doc, pages, &src, src_pages)?; } Edit::SetPageBox { pages, which, spec } => pdfcraft_organize::set_page_box(doc, pages, *which, *spec)?, Edit::SetTransition { pages, spec } => pdfcraft_organize::set_transition(doc, pages, spec)?, Edit::InsertPagesFrom { name, bytes, pages, at, field_conflicts } => { - let src = open_source(name, bytes)?; + let src = crate::sources::open_source_into(name, bytes, doc)?; let pages = match pages { Some(p) => p.clone(), None => (0..pdfcraft_organize::page_count(&src)?).collect(), @@ -500,6 +546,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E let title = std::path::Path::new(name).file_stem().map_or_else(|| name.clone(), |s| s.to_string_lossy().into_owned()); let outcome = pdfcraft_organize::insert_file_pages(doc, &src, &title, &pages, *at, *field_conflicts)?; cx.renamed_fields = outcome.renamed_fields; + invalidate_form_script_snapshot(cx); } Edit::AddBookmark { parent, index, title, page, left, top } => { pdfcraft_organize::add_bookmark_at(doc, parent, *index, title, *page, *left, *top)?; @@ -559,9 +606,12 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E } note_skipped(doc, &report); } - Edit::AddCustomStamp { page, rect, name, file, author } => { + Edit::AddCustomStamp { page, rect, name, file, author, line } => { let src = mark_source(doc, file)?; - let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0)); + let line = line.as_deref().map(str::trim).filter(|l| !l.is_empty()).map(|l| l.chars().take(crate::MAX_STAMP_LINE).collect::()); + let picture = (src.size.0.max(1.0), src.size.1.max(1.0)); + // A text line adds its band under the picture, and widens the stamp to fit (#1741). + let (sw, sh) = pdfcraft_annot::custom_stamp_size(picture, line.as_deref()); let natural = rect[2] == rect[0] && rect[3] == rect[1]; let rotation = pdfcraft_organize::page_rotation(doc, *page)?; // The picture as displayed: a quarter turn swaps its sides in user space. @@ -573,7 +623,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E } else { fit_picture(*rect, dw, dh) }; - let shape = Shape::CustomStamp { rect, name: name.clone(), picture: src.xobject, image: src.image, size: (sw, sh) }; + let shape = Shape::CustomStamp { rect, name: name.clone(), picture: src.xobject, image: src.image, size: picture, line }; let style = pdfcraft_annot::Style::default_for(&shape); let new = NewAnnotation { page: *page, shape, style, contents: name.clone(), author: author.clone() }; let index = pdfcraft_annot::add_annotation(doc, &new, &cx.meta())?; @@ -610,8 +660,25 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E pdfcraft_organize::add_attachment(doc, name, bytes, description.as_deref(), mime.as_deref(), cx.date.as_deref())?; } Edit::DeleteAttachment { key } => pdfcraft_organize::delete_attachment(doc, key)?, + Edit::ConvertColours { pages, settings } => { + let mut recolour = |doc: &pdfcraft_cos::Document, s: &pdfcraft_cos::Stream, t: pdfcraft_edit::ColourTarget| { + let target = match t { + pdfcraft_edit::ColourTarget::Gray => pdfcraft_optimize::ImageTarget::Gray, + pdfcraft_edit::ColourTarget::Rgb => pdfcraft_optimize::ImageTarget::Rgb, + pdfcraft_edit::ColourTarget::Cmyk => pdfcraft_optimize::ImageTarget::Cmyk, + }; + pdfcraft_optimize::recolour_image(doc, s, target) + }; + let done = pdfcraft_edit::convert_colours(doc, pages, settings, &mut recolour)?; + if done.colours == 0 && done.images == 0 { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("nothing on those pages needed converting".into()))); + } + } + Edit::EditAttachmentDescription { key, description } => { + pdfcraft_organize::set_attachment_description(doc, key, description.as_deref())?; + } Edit::ReplaceText { page, quads, text, author, strike, caret } => { - // One `/NM` each: the strikeout is grouped under the caret, and XFDF names that link by + // One `/NM` each: the strikeout is grouped with the caret, and XFDF names that link by // the caret's `/NM` (#2666). let (strike_meta, caret_meta) = (cx.meta(), cx.meta()); pdfcraft_annot::add_text_replacement( @@ -634,6 +701,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::LockAnnotation { page, index, locked } => pdfcraft_annot::set_locked(doc, *page, *index, *locked)?, Edit::MoveAnnotation { page, index, dx, dy } => pdfcraft_annot::move_annotation(doc, *page, *index, *dx, *dy, &cx.meta())?, Edit::ReshapeAnnotation { page, index, points } => pdfcraft_annot::set_points(doc, *page, *index, points, &cx.meta())?, + Edit::SetAnnotationPopup { page, index, rect, open } => pdfcraft_annot::set_popup(doc, *page, *index, *rect, *open, &cx.meta())?, Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?, Edit::StyleAnnotation { page, index, color, opacity, width, endings } => { pdfcraft_annot::set_style( @@ -665,19 +733,43 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E set(doc, typed.as_ref().unwrap_or(value))?; } Edit::ApplyScriptChanges { changes } => match cx.js.as_mut() { - Some(js) => pdfcraft_forms::apply_script_changes(doc, changes, js)?, + Some(js) => { + js.invalidate_field_snapshots(); + pdfcraft_forms::apply_script_changes(doc, changes, js)?; + } None => pdfcraft_forms::apply_script_changes(doc, changes, &mut pdfcraft_forms::NoScripts)?, }, Edit::XfaEvent { som, activity } => { let tpl = cx.xfa.clone().ok_or_else(|| EditError::Invalid("this is not a laid-out XFA form, or JavaScript is off".into()))?; - let mut run = - xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale }; - let ran = xfa::on_event(doc, &tpl, som, activity, &mut run); + let highlight = cx.highlight(); + let mut run = xfa::XfaRun { + page_count: cx.pages, + out: &mut cx.xfa_out, + datasets: cx.xfa_datasets, + ran_away: false, + highlight, + time_scale: cx.xfa_time_scale, + }; + let ran = if matches!(activity.as_str(), "enter" | "exit") { + xfa::on_focus(doc, &tpl, som, activity, &mut run).map(|changes| changes.is_empty()) + } else { + xfa::on_event(doc, &tpl, som, activity, &mut run).map(|()| false) + }; cx.xfa_datasets = run.datasets; cx.xfa_ran_away |= run.ran_away; - ran.map_err(EditError::Invalid)?; + // The next edit of a batch reads the highlight this one's scripts set. + let on = run.highlight; + cx.set_highlight(on); + cx.xfa_unchanged = ran.map_err(EditError::Invalid)?; } - Edit::SetFieldImage { name, image } => { + Edit::SetFieldImage { name, image } | Edit::SetFieldImageTarget { name, image, .. } => { + let object = match edit { + Edit::SetFieldImageTarget { object, .. } => Some(*object), + _ => None, + }; + if object.is_some_and(|object| !pdfcraft_forms::fields(doc).iter().any(|f| f.name == *name && f.obj == object)) { + return Err(EditError::Invalid("the selected image field is no longer available".into())); + } let (img, _) = embed_image(doc, name, image)?; let px = match &*doc.get(img) { pdfcraft_cos::Object::Stream(s) => (s.dict.int(b"Width").unwrap_or(1) as u32, s.dict.int(b"Height").unwrap_or(1) as u32), @@ -694,7 +786,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E let Some(content_type) = image_content_type(image) else { return Err(EditError::Invalid(format!("{name}: an XFA form keeps a PNG, JPEG, GIF, BMP or TIFF picture; choose one of those"))); }; - pdfcraft_forms::set_button_icon(doc, name, img, px)?; + pdfcraft_forms::set_button_icon_target(doc, name, object, img, px)?; let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find(|(_, n)| n == name).map(|(s, _)| s).unwrap_or_else(|| name.clone()); let path = match cx.xfa.as_deref() { Some(tpl) => pdfcraft_xfa::data_path(tpl, &som), @@ -709,30 +801,46 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E cx.xfa_out.errors.extend(r.warnings); xfa_after_data_change(doc, cx, false)?; } else { - pdfcraft_forms::set_button_icon(doc, name, img, px)?; + pdfcraft_forms::set_button_icon_target(doc, name, object, img, px)?; } } Edit::ResetForm { names } => { - pdfcraft_forms::reset(doc, names.as_deref())?; + if let Some(js) = cx.js.as_mut() { + pdfcraft_forms::reset_with_formatting(doc, names.as_deref(), js, !cx.defer_reset_callbacks)?; + } else { + pdfcraft_forms::reset(doc, names.as_deref())?; + } } Edit::AddField { page, rect, kind, name } => { pdfcraft_forms::add_field(doc, *page, *rect, kind, name.as_deref())?; + invalidate_form_script_snapshot(cx); } Edit::SetFieldProps { name, props } => { pdfcraft_forms::set_props(doc, name, props)?; + invalidate_form_script_snapshot(cx); } Edit::DuplicateField { name, pages } => { if pdfcraft_forms::duplicate_field(doc, name, pages)? == 0 { return Err(EditError::Form(pdfcraft_forms::FormError::Invalid(format!("{name} is already on those pages")))); } + invalidate_form_script_snapshot(cx); + } + Edit::DeleteField { name } => { + pdfcraft_forms::delete_field(doc, name)?; + invalidate_form_script_snapshot(cx); + } + Edit::DeleteWidget { name, widget } => { + pdfcraft_forms::delete_widget(doc, name, *widget)?; + invalidate_form_script_snapshot(cx); } - Edit::DeleteField { name } => pdfcraft_forms::delete_field(doc, name)?, - Edit::DeleteWidget { name, widget } => pdfcraft_forms::delete_widget(doc, name, *widget)?, Edit::SetTabOrder { pages, order } => pdfcraft_forms::set_tab_order(doc, pages, *order)?, Edit::MoveInTabOrder { name, earlier } => pdfcraft_forms::move_in_tab_order(doc, name, *earlier)?, // As after a script edit: the values the new order gives show at once. Edit::SetCalculationOrder { names } => { pdfcraft_forms::set_calculation_order(doc, names)?; + if let Some(js) = cx.js.as_mut() { + js.invalidate_calculations(); + } match cx.js.as_mut() { Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, None => pdfcraft_forms::recalculate(doc)?, @@ -760,6 +868,9 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E } Edit::SetFieldScript { name, event, script } => { pdfcraft_forms::set_field_script(doc, name, event, script.as_deref())?; + if let Some(js) = cx.js.as_mut() { + js.invalidate_calculations(); + } match cx.js.as_mut() { Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, None => pdfcraft_forms::recalculate(doc)?, @@ -803,6 +914,9 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::LayoutTableToBlocks { object } => { a11y::layout_table_to_blocks(doc, *object).map_err(|e| EditError::Accessibility(e.to_string()))?; } + Edit::AddPrinterMarks { pages, settings, replace } => { + pdfcraft_edit::add_printer_marks(doc, pages, settings, *replace)?; + } Edit::AddHeaderFooter { pages, settings, replace } => { let date = cx.today; pdfcraft_edit::add_header_footer(doc, pages, settings, *replace, &pdfcraft_edit::Context { date })?; @@ -877,7 +991,10 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E pdfcraft_edit::add_content(doc, *page, &content)?; } Edit::ApplyRedactions { pages } => { - cx.redaction_overlays = pdfcraft_redact::apply(doc, pages.as_deref())?.overlays; + let report = pdfcraft_redact::apply(doc, pages.as_deref())?; + cx.redaction_overlays = report.overlays; + cx.redaction_elsewhere = Some(report.still_holding); + cx.redaction_identification = report.identification; } Edit::ClearRedactions => { pdfcraft_redact::clear_marks(doc, None)?; @@ -993,10 +1110,40 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E } doc.remove_encryption(); } - Edit::Batch { edits, .. } => { - for e in edits { - run_edit(doc, e, cx)?; + Edit::Batch { edits, .. } => run_batch(doc, edits, cx)?, + } + Ok(()) +} + +/// Apply an edit batch while keeping reset deferral scoped to adjacent script writeback. +fn run_batch(doc: &mut pdfcraft_cos::Document, edits: &[Edit], cx: &mut EditCtx) -> Result<(), EditError> { + // A form-script event runs on its own: in a batch, its scripts and those the batch's + // value edits run afterwards (change, exit) would interleave, an exit running twice. + if edits.iter().any(|e| matches!(e, Edit::XfaEvent { .. })) { + return Err(EditError::Invalid("a form script event can't be part of a batch of edits".into())); + } + // Only contiguous resets immediately preceding script value writeback can + // defer callbacks. Other edits may read calculated values during validation. + let mut script_writeback = vec![false; edits.len()]; + let mut writes_values = false; + for (index, edit) in edits.iter().enumerate().rev() { + match edit { + Edit::ResetForm { .. } => script_writeback[index] = writes_values, + Edit::ApplyScriptChanges { changes } => writes_values = changes.iter().any(|change| change.value.is_some()), + _ => writes_values = false, + } + } + let deferred = cx.defer_reset_callbacks; + for (index, e) in edits.iter().enumerate() { + cx.defer_reset_callbacks = script_writeback[index]; + let result = run_edit(doc, e, cx); + cx.defer_reset_callbacks = deferred; + result?; + if let Some(js) = cx.js.as_mut() { + if js.refused.is_some() { + break; } + js.refresh_after_batch_edit(doc, e); } } Ok(()) diff --git a/crates/engine/src/find_replace.rs b/crates/engine/src/find_replace.rs new file mode 100644 index 0000000000..006a53bb29 --- /dev/null +++ b/crates/engine/src/find_replace.rs @@ -0,0 +1,346 @@ +//! Find and replace in the page text (Edit text ▸ Replace, the `text_replace` tool). +//! +//! Each match is rewritten inside its own paragraph ([`crate::Document::text_blocks`]) through +//! [`Edit::EditTextBlock`], so the paragraph keeps its fonts, sizes and colours and rewraps in its +//! box. The paragraph's `editor_text` is matched with the find bar's own rules +//! ([`pdfcraft_render::text::find_in_text`]) and the replacement takes the style of the run it +//! lands in ([`pdfcraft_edit::TextBlock::replaced`]); every other run keeps its own. +//! +//! A longer replacement changes the paragraph only, measured with its own fonts' widths, and +//! nothing is shrunk or hyphenated to fit. A paragraph of one row grows to the right, up to 36 pt +//! from the crop box (a centred line stays in its own box), then wraps; a paragraph of several rows +//! keeps its width and rewraps at spaces. New rows go below the last one at the paragraph's line +//! spacing and nothing else on the page moves, so they can run over what is below, or off the page, +//! without being reported. A word wider than the width stays whole, past the box. +//! +//! A match is left alone, and counted, when it runs from one paragraph into the next, lies in +//! invisible text over a scan, would need another font (the paragraph's own can't draw the new +//! text, see [`pdfcraft_edit::LineEdit::substituted`]) or is not page text the paragraphs hold +//! (a form field, a comment, text drawn inside a form XObject). Each paragraph is rewritten on a +//! copy first, so the edit returned is exactly what will apply: [`Edit::Batch`] for Replace all, +//! one undo step. + +use std::collections::BTreeMap; +use std::ops::Range; + +use crate::edit::check_permission; +use crate::{Document, Edit, EditError, guard}; + +/// What to look for and what to put in its place, with the find bar's options. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Replace { + pub find: String, + /// May be empty: the match is deleted. + pub with: String, + pub case_sensitive: bool, + pub whole_words: bool, +} + +/// Which matches to replace. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Which { + /// Every match on the pages. + All, + /// The match the find bar highlights on `page`: the centres of its first and last glyphs, in + /// view space (points from the top-left of the displayed page, y down: the text layer's). + At { page: usize, start: [f32; 2], end: [f32; 2] }, +} + +impl Which { + /// The first match the find bar shows (page text order: pages in order, then reading order), + /// as an [`Which::At`] on its first and last glyphs, or `None` when no page has one. `texts` + /// are the pages' text, in page order. Replacing "the first match" is replacing this one: if + /// no paragraph holds it, [`Document::plan_replace`] reports it as not editable and replaces + /// nothing, rather than passing it for a later match that is. + pub fn first_in<'a>(texts: impl IntoIterator, query: &Replace) -> Option { + texts.into_iter().find_map(|(page, text)| { + let found = text.find_opts_limited(&query.find, query.case_sensitive, query.whole_words, 1).into_iter().next()?; + let centre = |g: usize| text.glyphs.get(g).map(|g| [(g.rect[0] + g.rect[2]) / 2.0, (g.rect[1] + g.rect[3]) / 2.0]); + Some(Which::At { page, start: centre(found.start)?, end: centre(found.end.saturating_sub(1))? }) + }) + } +} + +/// Why a match was left as it is. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Skip { + /// It runs from one paragraph into the next. + SpansParagraphs, + /// It is invisible text over a scanned image (recognized text). + ScannedText, + /// The paragraph's font can't draw the new text; another font would have stepped in. + MissingGlyphs, + /// The paragraph couldn't be rewritten (see [`Replacement::refusals`]). + Refused, + /// It is not in the page's editable text: a form field, a comment, text inside a form XObject. + NotEditable, +} + +impl Skip { + /// How the reason is said after a count ("2 skipped: …"); an interface translates it. + pub fn wording(self) -> &'static str { + match self { + Skip::SpansParagraphs => "spans two paragraphs", + Skip::ScannedText => "scanned text", + Skip::MissingGlyphs => "the font can't draw it", + Skip::Refused => "the paragraph can't be rewritten", + Skip::NotEditable => "not editable text", + } + } + + /// A short name for tools. + pub fn key(self) -> &'static str { + match self { + Skip::SpansParagraphs => "spans_paragraphs", + Skip::ScannedText => "scanned_text", + Skip::MissingGlyphs => "missing_glyphs", + Skip::Refused => "refused", + Skip::NotEditable => "not_editable", + } + } +} + +/// Every [`Skip::wording`], for an interface to translate. +pub const SKIP_WORDINGS: &[&str] = + &["spans two paragraphs", "scanned text", "the font can't draw it", "the paragraph can't be rewritten", "not editable text"]; + +/// What a replacement would do: the edit (apply it with [`crate::Session::apply`]) and the counts. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Replacement { + /// `None` when nothing can be replaced. + pub edit: Option, + /// Matches replaced. + pub replaced: usize, + /// The pages (0-based, ascending) where something was replaced. + pub pages: Vec, + /// Matches left alone, by reason. + pub skipped: BTreeMap, + /// What the engine said about the paragraphs it refused (at most [`MAX_REFUSALS`]). + pub refusals: Vec, +} + +/// Refusal messages kept in a [`Replacement`]. +pub const MAX_REFUSALS: usize = 10; + +impl Replacement { + pub fn skipped_total(&self) -> usize { + self.skipped.values().fold(0, |a, b| a.saturating_add(*b)) + } + + /// Say that `page`'s text could not be read for the find bar (`why`): its matches can't be + /// counted, so one is counted as [`Skip::Refused`] and the page is named in the refusals. + pub fn unreadable_page(&mut self, page: usize, why: &str) { + refused(self, 1, page, format!("its text could not be read: {why}")); + } + + fn skip(&mut self, why: Skip, n: usize) { + if n > 0 { + let count = self.skipped.entry(why).or_insert(0); + *count = count.saturating_add(n); + } + } +} + +/// The label of Replace all in the history (one of [`crate::BATCH_LABELS`]). +pub const REPLACE_ALL: &str = "Replace all"; + +impl Document { + /// The edit that replaces `query`'s matches on `pages` (0-based; others are ignored), and + /// what it replaces and leaves. `found` is how many matches the find bar has on each page: + /// those no paragraph holds are counted as [`Skip::NotEditable`] (`None`: not counted). The + /// document is not changed; the same permission check as Edit text applies. + pub fn plan_replace( + &self, + pages: &[usize], + query: &Replace, + which: Which, + found: Option<&BTreeMap>, + ) -> Result { + let editor = self.editor.as_ref().ok_or_else(|| EditError::ReadOnly(self.read_only_reason.clone().unwrap_or_default()))?; + // What Edit text checks: the document's permissions. + let probe = Edit::EditTextBlock { page: 0, block: 0, text: String::new(), style: Default::default() }; + if let Some(p) = editor.cos.permissions() { + check_permission(&probe, &p)?; + } + if query.find.split_whitespace().next().is_none() { + return Err(EditError::Invalid("there is nothing to find".into())); + } + let n = self.info.pages.len(); + let mut pages: Vec = pages.iter().copied().filter(|p| *p < n).collect(); + pages.sort_unstable(); + pages.dedup(); + if let Which::At { page, .. } = which { + pages.retain(|p| *p == page); + } + let mut out = Replacement::default(); + let mut work = editor.cos.clone(); + let mut edits = Vec::new(); + for page in pages { + // A page whose text can't be read is reported, and the others still replaced. + let blocks = match pdfcraft_edit::text_blocks(&work, page) { + Ok(blocks) => blocks, + Err(e) => { + refused(&mut out, found.and_then(|f| f.get(&page)).copied().unwrap_or(0), page, e.to_string()); + continue; + } + }; + let matches: Vec>> = blocks.iter().map(|b| find(&b.editor_text, query, usize::MAX)).collect(); + // Paragraph `k`'s matches to replace now. + let mut chosen: BTreeMap>> = BTreeMap::new(); + match which { + Which::All => { + let held = matches.iter().fold(0usize, |a, m| a.saturating_add(m.len())); + let spanning = spanning(&blocks, query); + out.skip(Skip::SpansParagraphs, spanning); + if let Some(f) = found.and_then(|f| f.get(&page)) { + out.skip(Skip::NotEditable, f.saturating_sub(held.saturating_add(spanning))); + } + chosen.extend(matches.into_iter().enumerate().filter(|(_, m)| !m.is_empty())); + } + Which::At { start, end, .. } => match at(self, &work, page, &blocks, &matches, start, end) { + Ok((k, m)) => { + chosen.insert(k, vec![m]); + } + Err(why) => out.skip(why, 1), + }, + } + // Last paragraph first, which usually leaves those before it where they were. That is + // checked, not assumed: once a paragraph on this page is rewritten, the page is read + // again and a paragraph that is no longer what the match was found in is refused. + let mut rewrote = false; + for (k, ranges) in chosen.into_iter().rev() { + let Some(block) = blocks.get(k) else { continue }; + let count = ranges.len(); + if rewrote { + let same = pdfcraft_edit::text_blocks(&work, page) + .ok() + .is_some_and(|now| now.get(k).is_some_and(|b| b.editor_text == block.editor_text)); + if !same { + refused(&mut out, count, page, "the page's paragraphs moved after an earlier replacement".into()); + continue; + } + } + if block.invisible { + out.skip(Skip::ScannedText, count); + continue; + } + let Some((text, sources)) = block.replaced(&ranges, &query.with) else { + out.skip(Skip::Refused, count); + continue; + }; + if text == block.editor_text { + // The replacement is what was there. + out.replaced = out.replaced.saturating_add(count); + continue; + } + let style = pdfcraft_edit::BlockStyle { sources, ..Default::default() }; + let mut trial = work.clone(); + match guard(|| pdfcraft_edit::rewrite_block(&mut trial, page, k, Some(&text), &style)) { + Ok(Ok(pdfcraft_edit::LineEdit { substituted: None })) => { + work = trial; + rewrote = true; + edits.push(Edit::EditTextBlock { page, block: k, text, style }); + out.replaced = out.replaced.saturating_add(count); + if !out.pages.contains(&page) { + out.pages.push(page); + } + } + Ok(Ok(pdfcraft_edit::LineEdit { substituted: Some(_) })) => out.skip(Skip::MissingGlyphs, count), + Ok(Err(e)) => refused(&mut out, count, page, e.to_string()), + Err(panic) => refused(&mut out, count, page, panic), + } + } + } + out.pages.sort_unstable(); + out.edit = match (which, edits.len()) { + (_, 0) => None, + (Which::All, _) => Some(Edit::Batch { label: REPLACE_ALL.into(), edits }), + (Which::At { .. }, _) => edits.pop(), + }; + Ok(out) + } +} + +fn find(text: &str, query: &Replace, limit: usize) -> Vec> { + pdfcraft_render::text::find_in_text(text, &query.find, query.case_sensitive, query.whole_words, limit) +} + +fn refused(out: &mut Replacement, count: usize, page: usize, why: String) { + out.skip(Skip::Refused, count); + if out.refusals.len() < MAX_REFUSALS { + out.refusals.push(format!("page {}: {why}", page.saturating_add(1))); + } +} + +/// Matches that start in one paragraph and end in a later one: the page's paragraphs read one +/// after another (a line break between two), as the find bar reads the page. +fn spanning(blocks: &[pdfcraft_edit::TextBlock], query: &Replace) -> usize { + let mut joined = String::new(); + let mut ends = Vec::with_capacity(blocks.len()); + for b in blocks { + if !joined.is_empty() { + joined.push('\n'); + } + joined.push_str(&b.editor_text); + ends.push(joined.len()); + } + // Which paragraph a byte of `joined` is in (a separator counts with the paragraph after it). + let paragraph = |byte: usize| ends.partition_point(|end| *end <= byte); + find(&joined, query, usize::MAX).into_iter().filter(|r| paragraph(r.start) != paragraph(r.end.saturating_sub(1))).count() +} + +/// The paragraph match the find bar's match at `start` … `end` (view space) is: the paragraph +/// whose box holds both, and its match nearest `start`. +fn at( + doc: &Document, + cos: &pdfcraft_cos::Document, + page: usize, + blocks: &[pdfcraft_edit::TextBlock], + matches: &[Vec>], + start: [f32; 2], + end: [f32; 2], +) -> Result<(usize, Range), Skip> { + let info = doc.info.pages.get(page).ok_or(Skip::NotEditable)?; + let view = |x: f64, y: f64| info.user_to_view(x as f32, y as f32); + // A paragraph's box in view space, a little larger so a glyph's centre on its edge is inside. + let holds = |b: &pdfcraft_edit::TextBlock, p: [f32; 2]| { + let corners = [view(b.rect[0], b.rect[1]), view(b.rect[2], b.rect[3]), view(b.rect[0], b.rect[3]), view(b.rect[2], b.rect[1])]; + let pad = (b.size as f32 * 0.3).clamp(1.0, 12.0); + let (x0, x1) = corners.iter().fold((f32::MAX, f32::MIN), |(a, b), c| (a.min(c[0]), b.max(c[0]))); + let (y0, y1) = corners.iter().fold((f32::MAX, f32::MIN), |(a, b), c| (a.min(c[1]), b.max(c[1]))); + p[0] >= x0 - pad && p[0] <= x1 + pad && p[1] >= y0 - pad && p[1] <= y1 + pad + }; + let lines = pdfcraft_edit::text_lines(cos, page).unwrap_or_default(); + // Where a match's first character is drawn, estimated along its run. + let place = |b: &pdfcraft_edit::TextBlock, r: &Range| -> Option<[f32; 2]> { + let (span, line) = b.spans.iter().find(|(s, _)| s.contains(&r.start)).or_else(|| b.spans.iter().find(|(s, _)| s.start >= r.start))?; + let line = lines.get(*line)?; + let run = b.editor_text.get(span.clone())?; + let before = b.editor_text.get(span.start..r.start.max(span.start))?.chars().count() as f64; + let total = run.chars().count().max(1) as f64; + let along = ((before + 0.5) / total).clamp(0.0, 1.0); + let x = line.rect[0] + along * (line.rect[2] - line.rect[0]); + Some(view(x, (line.rect[1] + line.rect[3]) / 2.0)) + }; + let distance = |p: [f32; 2]| (p[0] - start[0]).hypot(p[1] - start[1]); + let mut best: Option<(f32, usize, Range)> = None; + for (k, b) in blocks.iter().enumerate().filter(|(_, b)| holds(b, start)) { + for r in matches.get(k).into_iter().flatten() { + let d = place(b, r).map_or(f32::MAX, distance); + if best.as_ref().is_none_or(|(e, ..)| d < *e) { + best = Some((d, k, r.clone())); + } + } + } + match best { + Some((_, k, r)) if blocks.get(k).is_some_and(|b| holds(b, end)) => Ok((k, r)), + Some(_) => Err(Skip::SpansParagraphs), + None if blocks.iter().any(|b| holds(b, start)) && blocks.iter().any(|b| holds(b, end) && !holds(b, start)) => Err(Skip::SpansParagraphs), + None => Err(Skip::NotEditable), + } +} + +#[cfg(test)] +mod rewrite_order_tests; +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/find_replace/rewrite_order_tests.rs b/crates/engine/src/find_replace/rewrite_order_tests.rs new file mode 100644 index 0000000000..d13c8c9cf9 --- /dev/null +++ b/crates/engine/src/find_replace/rewrite_order_tests.rs @@ -0,0 +1,48 @@ +//! Replacing in several paragraphs of one page: each is checked against the page as it is after +//! the earlier rewrites, and a paragraph that still reads as it was found is replaced. +use std::sync::Arc; + +use super::*; +use crate::{DocId, Session}; + +fn page_with(content: &str) -> Vec { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn open(bytes: Vec) -> (Session, DocId) { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("order.pdf", None, Arc::new(bytes), None).expect("opens"); + (s, id) +} + +#[test] +fn replacing_in_every_paragraph_of_a_page_still_replaces_each_one() { + let (mut s, id) = open(page_with("BT /F1 12 Tf 72 700 Td (red apple) Tj 0 -60 Td (red pear) Tj 0 -60 Td (red plum) Tj ET")); + let q = Replace { find: "red".into(), with: "green".into(), ..Default::default() }; + let plan = s.get(id).unwrap().plan_replace(&[0], &q, Which::All, None).unwrap(); + assert_eq!(plan.replaced, 3, "refusals: {:?}", plan.refusals); + assert!(plan.refusals.is_empty(), "{:?}", plan.refusals); + let edit = plan.edit.expect("an edit"); + s.apply(id, edit).unwrap(); + let texts: Vec = s.get(id).unwrap().text_blocks(0).into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["green apple", "green pear", "green plum"]); +} diff --git a/crates/engine/src/find_replace/tests.rs b/crates/engine/src/find_replace/tests.rs new file mode 100644 index 0000000000..72f474f822 --- /dev/null +++ b/crates/engine/src/find_replace/tests.rs @@ -0,0 +1,341 @@ +use std::collections::BTreeMap; +use std::sync::Arc; + +use super::*; +use crate::{DocId, Session}; + +/// A one-page document with `content` and two fonts: `F1` Helvetica, `F2` Helvetica-Bold, and +/// `F3` a subset of a face no computer has, with glyphs for `a` and `b` only. +fn pdf(content: &str) -> Vec { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R /F3 7 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Font /Subtype /TrueType /BaseFont /ABCDEF+NoSuchFace /FirstChar 97 /LastChar 99 /Widths [500 520 0] /Encoding /WinAnsiEncoding >>" + .into(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn open(bytes: Vec) -> (Session, DocId) { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("replace.pdf", None, Arc::new(bytes), None).expect("opens"); + (s, id) +} + +fn query(find: &str, with: &str) -> Replace { + Replace { find: find.into(), with: with.into(), ..Default::default() } +} + +/// Plan and apply; the plan's counts. +fn replace_all(s: &mut Session, id: DocId, q: &Replace) -> Replacement { + let pages: Vec = (0..s.get(id).unwrap().info.pages.len()).collect(); + let plan = s.get(id).unwrap().plan_replace(&pages, q, Which::All, None).unwrap(); + if let Some(edit) = plan.edit.clone() { + s.apply(id, edit).unwrap(); + } + plan +} + +fn texts(s: &Session, id: DocId, page: usize) -> Vec { + s.get(id).unwrap().text_blocks(page).into_iter().map(|b| b.text).collect() +} + +/// The page text as the find bar reads it (the text layer's glyphs). +fn page_text(s: &Session, id: DocId, page: usize) -> pdfcraft_render::PageText { + let doc = s.get(id).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), pdfcraft_render::RenderConfig::default()); + let out = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); + out.text.map(|t| t.as_ref().clone()).unwrap_or_default() +} + +#[test] +fn a_replaced_word_keeps_its_paragraphs_font_and_size() { + let (mut s, id) = open(pdf("BT /F1 14 Tf 72 700 Td (The quick brown fox) Tj ET")); + let plan = replace_all(&mut s, id, &query("quick", "slow")); + assert_eq!((plan.replaced, plan.pages.clone(), plan.skipped_total()), (1, vec![0], 0)); + assert_eq!(texts(&s, id, 0), ["The slow brown fox"]); + let lines = s.get(id).unwrap().text_lines(0); + assert_eq!(lines.len(), 1); + assert_eq!((lines[0].font.as_str(), lines[0].base_font.as_str(), lines[0].size), ("F1", "Helvetica", 14.0)); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Replace all")); +} + +/// A red word and a bold name inside a paragraph keep their own style when words around them +/// are replaced; a replacement inside the red word stays red. +#[test] +fn a_mixed_style_paragraph_keeps_the_other_runs_styles() { + let content = "BT /F1 12 Tf 72 700 Td 0 g (Please pay the invoice ) Tj 1 0 0 rg (now) Tj 0 g (, before the end of the month,) Tj ET +BT /F1 12 Tf 72 686 Td 0 g (as agreed with ) Tj /F2 12 Tf (Ada Lovelace) Tj /F1 12 Tf (.) Tj ET"; + let (mut s, id) = open(pdf(content)); + assert_eq!(texts(&s, id, 0).len(), 1, "one paragraph of five runs"); + let q = Replace { whole_words: true, ..query("the", "THE") }; + let plan = replace_all(&mut s, id, &q); + assert_eq!(plan.replaced, 3); + let plan = replace_all(&mut s, id, &query("now", "today")); + assert_eq!(plan.replaced, 1); + assert_eq!(texts(&s, id, 0), ["Please pay THE invoice today, before THE end of THE month, as agreed with Ada Lovelace."]); + let lines = s.get(id).unwrap().text_lines(0); + let run = |word: &str| lines.iter().find(|l| l.text.contains(word)).unwrap_or_else(|| panic!("no run with {word:?}: {lines:?}")); + assert_eq!(run("today").color, [1.0, 0.0, 0.0], "the replacement takes the red run it lands in"); + assert_eq!(run("today").text.trim(), "today", "and only it is red"); + assert_eq!(run("Ada").base_font, "Helvetica-Bold"); + assert_eq!(run("Ada").text.trim(), "Ada Lovelace"); + assert_eq!(run("invoice").color, [0.0, 0.0, 0.0]); + assert_eq!(run("invoice").base_font, "Helvetica"); +} + +#[test] +fn replace_all_across_pages_is_one_undo_step() { + let (mut s, id) = open(crate::tests::fixture(3)); + let before = s.get(id).unwrap().bytes.clone(); + let plan = replace_all(&mut s, id, &query("page", "Sheet")); + assert_eq!((plan.replaced, plan.pages.clone()), (3, vec![0, 1, 2])); + assert!(matches!(&plan.edit, Some(Edit::Batch { edits, .. }) if edits.len() == 3)); + for p in 0..3 { + assert_eq!(texts(&s, id, p), [format!("Sheet {}", p + 1)]); + } + s.undo(id).unwrap(); + for p in 0..3 { + assert_eq!(texts(&s, id, p), [format!("Page {}", p + 1)]); + } + assert_eq!(s.get(id).unwrap().can_undo(), None, "one step undid all three pages"); + assert_eq!(s.get(id).unwrap().bytes, before, "the original bytes are back"); +} + +#[test] +fn whole_words_and_case_follow_the_find_options() { + let content = "BT /F1 12 Tf 72 700 Td (cat Cats concat CAT) Tj ET"; + let count = |q: Replace| { + let (s, id) = open(pdf(content)); + s.get(id).unwrap().plan_replace(&[0], &q, Which::All, None).unwrap().replaced + }; + assert_eq!(count(query("cat", "dog")), 4); + assert_eq!(count(Replace { whole_words: true, ..query("cat", "dog") }), 2); + assert_eq!(count(Replace { case_sensitive: true, ..query("cat", "dog") }), 2); + assert_eq!(count(Replace { case_sensitive: true, whole_words: true, ..query("CAT", "dog") }), 1); + let (mut s, id) = open(pdf(content)); + replace_all(&mut s, id, &Replace { whole_words: true, ..query("cat", "dog") }); + assert_eq!(texts(&s, id, 0), ["dog Cats concat dog"]); + // An empty replacement deletes the match. + replace_all(&mut s, id, &query(" concat", "")); + assert_eq!(texts(&s, id, 0), ["dog Cats dog"]); +} + +#[test] +fn a_match_spanning_two_paragraphs_is_skipped_and_counted() { + let content = "BT /F1 12 Tf 72 700 Td (The first ends here) Tj ET BT /F1 12 Tf 72 500 Td (second starts there) Tj ET"; + let (mut s, id) = open(pdf(content)); + assert_eq!(texts(&s, id, 0).len(), 2); + let plan = replace_all(&mut s, id, &query("here second", "x")); + assert_eq!(plan.replaced, 0); + assert_eq!(plan.skipped, BTreeMap::from([(Skip::SpansParagraphs, 1)])); + assert!(plan.edit.is_none()); + assert_eq!(texts(&s, id, 0), ["The first ends here", "second starts there"]); +} + +/// The subset in `F3` has glyphs for `a` and `b` only, from a face no computer has: a `c` would +/// be drawn in Helvetica instead. That paragraph is left alone and counted, the other replaced. +#[test] +fn a_character_the_font_cannot_draw_is_skipped_and_counted() { + let content = "BT /F3 12 Tf 72 700 Td (ab ba) Tj ET BT /F1 12 Tf 72 500 Td (ab in Helvetica) Tj ET"; + let (mut s, id) = open(pdf(content)); + let before = texts(&s, id, 0); + assert_eq!(before, ["ab ba", "ab in Helvetica"]); + let plan = replace_all(&mut s, id, &query("ab", "cb")); + assert_eq!(plan.replaced, 1); + assert_eq!(plan.skipped, BTreeMap::from([(Skip::MissingGlyphs, 1)])); + assert_eq!(texts(&s, id, 0), ["ab ba", "cb in Helvetica"]); + let lines = s.get(id).unwrap().text_lines(0); + assert!(lines.iter().filter(|l| l.text.contains("ab ba")).all(|l| l.font == "F3"), "the subset paragraph keeps its font: {lines:?}"); + // What the subset can draw still goes in. + let plan = replace_all(&mut s, id, &query("ab ba", "ba ab")); + assert_eq!((plan.replaced, plan.skipped_total()), (1, 0)); + assert_eq!(texts(&s, id, 0)[0], "ba ab"); +} + +/// The styled rewrite (a paragraph of several runs) reports the missing character as the +/// one-style rewrite above does. +#[test] +fn a_character_the_font_cannot_draw_is_skipped_in_a_paragraph_of_several_runs() { + let (mut s, id) = open(pdf("BT /F3 12 Tf 72 700 Td (ab ) Tj /F1 12 Tf (and Helvetica) Tj ET")); + let plan = replace_all(&mut s, id, &query("ab", "cb")); + assert_eq!(plan.replaced, 0); + assert_eq!(plan.skipped, BTreeMap::from([(Skip::MissingGlyphs, 1)])); + assert!(plan.edit.is_none()); +} + +/// A character Helvetica has no code for is never written as a box or a `?`: whether another font +/// has it or none does, the match is left alone and reported. +#[test] +fn a_replacement_outside_the_fonts_encoding_is_reported_and_changes_nothing() { + for with in ["\u{65E5}\u{672C}", "\u{1F600}"] { + let (mut s, id) = open(pdf("BT /F1 12 Tf 72 700 Td (one cat) Tj ET")); + let plan = replace_all(&mut s, id, &query("cat", with)); + assert_eq!((plan.replaced, plan.skipped_total()), (0, 1), "{with}: {plan:?}"); + assert!(plan.edit.is_none(), "{with}"); + assert_eq!(texts(&s, id, 0), ["one cat"]); + } +} + +/// The rects of the page's text lines holding `word`. +fn line_rects(s: &Session, id: DocId, word: &str) -> Vec<[f64; 4]> { + s.get(id).unwrap().text_lines(0).into_iter().filter(|l| l.text.contains(word)).map(|l| l.rect).collect() +} + +/// A paragraph of several rows keeps its width and gains rows below; what is under it stays where +/// it was, so the new rows run over it. +#[test] +fn a_longer_replacement_adds_rows_and_moves_nothing_else() { + let content = + "BT /F1 12 Tf 14 TL 72 700 Td (alpha beta gamma delta) Tj T* (epsilon zeta eta theta) Tj ET BT /F1 12 Tf 72 650 Td (next paragraph) Tj ET"; + let (mut s, id) = open(pdf(content)); + assert_eq!(texts(&s, id, 0).len(), 2); + let right = s.get(id).unwrap().text_blocks(0)[0].rect[2]; + let next = line_rects(&s, id, "next paragraph"); + let with = "a much longer replacement that takes several more rows than the paragraph has"; + let plan = replace_all(&mut s, id, &query("beta", with)); + assert_eq!((plan.replaced, plan.skipped_total()), (1, 0)); + let lines = s.get(id).unwrap().text_lines(0); + let rows: Vec<_> = lines.iter().filter(|l| !l.text.contains("next paragraph")).collect(); + assert!(rows.len() >= 5, "{rows:?}"); + assert!(rows.iter().all(|l| l.rect[2] <= right + 1.0), "the rows keep the width: {rows:?}"); + assert!(rows.iter().any(|l| l.rect[1] < next[0][1]), "the new rows go past what was below: {rows:?}"); + assert_eq!(line_rects(&s, id, "next paragraph"), next); +} + +/// One row grows to the right up to the margin, then wraps; a word wider than that stays whole. +#[test] +fn a_longer_replacement_grows_one_row_to_the_margin() { + let (mut s, id) = open(pdf("BT /F1 12 Tf 72 700 Td (short) Tj ET")); + replace_all(&mut s, id, &query("short", "a line far wider than the word it took the place of")); + assert_eq!(s.get(id).unwrap().text_lines(0).len(), 1); + let (mut s, id) = open(pdf("BT /F1 12 Tf 72 700 Td (short) Tj ET")); + replace_all(&mut s, id, &query("short", &"many words in a row ".repeat(8))); + let lines = s.get(id).unwrap().text_lines(0); + assert!(lines.len() > 1 && lines.iter().all(|l| l.rect[2] <= 595.0 - 36.0 + 1.0), "{lines:?}"); + let (mut s, id) = open(pdf("BT /F1 12 Tf 72 700 Td (short) Tj ET")); + replace_all(&mut s, id, &query("short", &"w".repeat(80))); + let lines = s.get(id).unwrap().text_lines(0); + assert_eq!(lines.len(), 1); + assert!(lines[0].rect[2] > 595.0, "the word runs past the page: {lines:?}"); +} + +/// A match can run over a row's end hyphen, typed as the find bar reads it (the break is a space); +/// Replace all then folds case, joins the rows and counts what the find bar counts. The word read +/// unhyphenated is also a find-bar match, but no paragraph's text holds it: left alone and counted. +#[test] +fn case_insensitive_replace_all_takes_a_match_across_a_hyphenated_row_end() { + let content = "BT /F1 12 Tf 14 TL 72 700 Td (An EXAM-) Tj T* (PLE and an Exam-) Tj T* (ple to end) Tj ET"; + let (mut s, id) = open(pdf(content)); + assert_eq!(texts(&s, id, 0), ["An EXAMPLE and an Example to end"]); + let text = page_text(&s, id, 0); + let whole = query("example", "sample"); + let found = BTreeMap::from([(0, text.find_opts(&whole.find, false, false).len())]); + assert_eq!(found[&0], 2); + let none = s.get(id).unwrap().plan_replace(&[0], &whole, Which::All, Some(&found)).unwrap(); + assert_eq!((none.replaced, none.skipped_total(), none.edit.is_none()), (0, 2, true)); + let q = query("exam- ple", "sample"); + let found = BTreeMap::from([(0, text.find_opts(&q.find, false, false).len())]); + assert_eq!(found[&0], 2); + let plan = s.get(id).unwrap().plan_replace(&[0], &q, Which::All, Some(&found)).unwrap(); + assert_eq!((plan.replaced, plan.skipped_total()), (2, 0)); + s.apply(id, plan.edit.unwrap()).unwrap(); + assert_eq!(texts(&s, id, 0), ["An sample and an sample to end"]); +} + +#[test] +fn invisible_text_over_a_scan_is_skipped() { + let (mut s, id) = open(pdf("BT 3 Tr /F1 12 Tf 72 700 Td (recognized words) Tj ET")); + let plan = replace_all(&mut s, id, &query("words", "text")); + assert_eq!(plan.skipped, BTreeMap::from([(Skip::ScannedText, 1)])); + assert!(plan.edit.is_none()); +} + +/// What the find bar counts on a page beyond what the paragraphs hold is reported as not +/// editable text (a form field, a comment), so the counts add up to what was highlighted. +#[test] +fn matches_the_paragraphs_dont_hold_are_counted_as_not_editable() { + let (s, id) = open(pdf("BT /F1 12 Tf 72 700 Td (one cat) Tj ET")); + let found = BTreeMap::from([(0, 3)]); + let plan = s.get(id).unwrap().plan_replace(&[0], &query("cat", "dog"), Which::All, Some(&found)).unwrap(); + assert_eq!(plan.replaced, 1); + assert_eq!(plan.skipped, BTreeMap::from([(Skip::NotEditable, 2)])); +} + +/// Replace (one match): the highlighted one, found by where the find bar's glyphs are, even when +/// the paragraph holds the word twice. +#[test] +fn the_highlighted_match_is_the_one_replaced() { + let (mut s, id) = open(pdf("BT /F1 12 Tf 72 700 Td (cat and cat) Tj ET BT /F1 12 Tf 72 500 Td (no match) Tj ET")); + let text = page_text(&s, id, 0); + let found = text.find("cat"); + assert_eq!(found.len(), 2); + let centre = |g: usize| { + let r = text.glyphs[g].rect; + [(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0] + }; + let second = &found[1]; + let which = Which::At { page: 0, start: centre(second.start), end: centre(second.end - 1) }; + let plan = s.get(id).unwrap().plan_replace(&[0], &query("cat", "dog"), which, None).unwrap(); + assert_eq!(plan.replaced, 1); + assert!(matches!(&plan.edit, Some(Edit::EditTextBlock { block: 0, .. })), "one paragraph edit, not a batch: {:?}", plan.edit); + s.apply(id, plan.edit.unwrap()).unwrap(); + assert_eq!(texts(&s, id, 0), ["cat and dog", "no match"]); + // A point in no paragraph is not editable text. + let away = Which::At { page: 0, start: [500.0, 20.0], end: [510.0, 20.0] }; + let plan = s.get(id).unwrap().plan_replace(&[0], &query("cat", "dog"), away, None).unwrap(); + assert_eq!(plan.skipped, BTreeMap::from([(Skip::NotEditable, 1)])); +} + +#[test] +fn first_replaces_only_the_first_match_in_document_order() { + let (mut s, id) = open(crate::tests::fixture(2)); + let q = query("page", "Sheet"); + let texts_of: Vec<_> = (0..2).map(|p| page_text(&s, id, p)).collect(); + let which = Which::first_in(texts_of.iter().enumerate(), &q).expect("the find bar has a match"); + assert!(matches!(which, Which::At { page: 0, .. })); + let plan = s.get(id).unwrap().plan_replace(&[0, 1], &q, which, None).unwrap(); + assert_eq!((plan.replaced, plan.pages.clone()), (1, vec![0])); + s.apply(id, plan.edit.unwrap()).unwrap(); + assert_eq!((texts(&s, id, 0), texts(&s, id, 1)), (vec!["Sheet 1".to_string()], vec!["Page 2".to_string()])); + assert!(Which::first_in(texts_of.iter().enumerate(), &query("absent", "x")).is_none()); +} + +/// The first match is the find bar's, not the first one a paragraph holds: when it is a match no +/// paragraph holds (a word split by a line-end hyphen), it is reported and nothing is replaced, +/// not the later paragraph that has the word whole. +#[test] +fn first_does_not_skip_a_match_no_paragraph_holds_for_a_later_one() { + let content = "BT /F1 12 Tf 14 TL 72 700 Td (An EXAM-) Tj T* (PLE to end) Tj ET BT /F1 12 Tf 72 400 Td (a second example) Tj ET"; + let (s, id) = open(pdf(content)); + let q = query("example", "sample"); + let text = page_text(&s, id, 0); + assert_eq!(text.find_opts("example", false, false).len(), 2, "the find bar has both"); + let which = Which::first_in([(0, &text)], &q).unwrap(); + let plan = s.get(id).unwrap().plan_replace(&[0], &q, which, None).unwrap(); + assert_eq!((plan.replaced, plan.edit.is_none()), (0, true), "{plan:?}"); + assert_eq!(plan.skipped, BTreeMap::from([(Skip::NotEditable, 1)])); + assert_eq!(texts(&s, id, 0).last().unwrap(), "a second example"); +} + +#[test] +fn nothing_to_find_is_refused() { + let (s, id) = open(crate::tests::fixture(1)); + assert!(matches!(s.get(id).unwrap().plan_replace(&[0], &query(" ", "x"), Which::All, None), Err(EditError::Invalid(_)))); +} diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 441fb7d1b5..393c233297 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -38,7 +38,11 @@ pub(crate) enum Scope { /// Edits during which field scripts run. pub(crate) fn uses_scripts(edit: &Edit) -> bool { match edit { - Edit::SetFieldValue { .. } | Edit::ApplyScriptChanges { .. } | Edit::SetFieldScript { .. } | Edit::SetCalculationOrder { .. } => true, + Edit::SetFieldValue { .. } + | Edit::ResetForm { .. } + | Edit::ApplyScriptChanges { .. } + | Edit::SetFieldScript { .. } + | Edit::SetCalculationOrder { .. } => true, Edit::Batch { edits, .. } => edits.iter().any(uses_scripts), _ => false, } @@ -47,8 +51,9 @@ pub(crate) fn uses_scripts(edit: &Edit) -> bool { pub(crate) fn scope_of(edit: &Edit) -> Scope { match edit { Edit::SetInfo { .. } => Scope::Metadata, + Edit::AddPrinterMarks { .. } => Scope::Full, // The Attachments list is read from the whole document. - Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } => Scope::Full, + Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } | Edit::EditAttachmentDescription { .. } => Scope::Full, // A file attachment also changes the Attachments list. Edit::AddAnnotation(a) if matches!(a.shape, Shape::Attachment { .. }) => Scope::Full, Edit::AddMeasurement(_) @@ -66,6 +71,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { | Edit::EraseInk { .. } | Edit::MoveAnnotation { .. } | Edit::ReshapeAnnotation { .. } + | Edit::SetAnnotationPopup { .. } | Edit::ResizeAnnotation { .. } | Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } @@ -75,7 +81,11 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { | Edit::BorderShapeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => Scope::Comments, Edit::AddLink { .. } | Edit::SetLink { .. } | Edit::DeleteLink { .. } | Edit::RemoveLinks { .. } | Edit::AddLinks { .. } => Scope::Links, - Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => Scope::Form, + Edit::SetFieldValue { .. } + | Edit::ResetForm { .. } + | Edit::SetFieldImage { .. } + | Edit::SetFieldImageTarget { .. } + | Edit::ApplyScriptChanges { .. } => Scope::Form, // Page content, resources and added content/marks only: the page tree, annotations, // fields, links and document metadata are untouched (see `refresh_content`). Edit::MoveObjects { .. } @@ -96,6 +106,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. } + | Edit::ConvertColours { .. } | Edit::FixHairlines { .. } // Recognized text is an invisible text layer stamped on its page, with its font in the // page's resources (#6310): a full re-inspection made applying one word cost seconds on diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index 0dd14a42b4..bde0d5a02c 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -11,6 +11,86 @@ pub use pdfcraft_js::{Limits, Message, MessageButtons, MessageIcon, Outcome, Pan use crate::{DocId, Edit, EditError, Session}; +/// Reset writeback does two full passes (preview and commit); reject amplification before either. +const MAX_SCRIPT_RESETS: usize = pdfcraft_js::MAX_RESET_CALLS; +const MAX_SCRIPT_RESET_SELECTORS: usize = 4096; +const MAX_SCRIPT_RESET_WORK: usize = 1 << 24; + +/// Bound request count, selector storage/scans and estimated form/calculation visits before cloning a PDF. +fn validate_reset_work(fields: &[Field], states: &[pdfcraft_js::FieldState], requests: &[Request]) -> Result<(), EditError> { + let invalid = || EditError::Invalid("script reset work limit exceeded; use fewer reset calls or combine field selections".into()); + let (mut resets, mut selectors, mut bytes) = (0usize, 0usize, 0usize); + for request in requests { + if let Request::Reset(names) = request { + resets = resets.saturating_add(1); + selectors = selectors.saturating_add(names.len()); + if resets > MAX_SCRIPT_RESETS || selectors > MAX_SCRIPT_RESET_SELECTORS { + return Err(invalid()); + } + bytes = names.iter().fold(bytes, |n, name| n.saturating_add(name.len())); + } + } + if resets == 0 { + return Ok(()); + } + let count = fields.len().max(states.len()); + let (mut calculations, mut references, mut source_bytes) = (0usize, 0usize, 0usize); + for field in fields { + use pdfcraft_forms::af::Calculate; + if field.actions.calculate != Calculate::None || field.actions.scripts.calculate.is_some() { + calculations = calculations.saturating_add(1); + } + references = references.saturating_add(match &field.actions.calculate { + Calculate::Simple { fields, .. } => fields.len(), + Calculate::Notation(source) | Calculate::Custom(source) => source.len(), + Calculate::None => 0, + }); + source_bytes = source_bytes.saturating_add(field.actions.scripts.calculate.as_ref().map_or(0, String::len)); + } + let name_bytes = + fields.iter().fold(0usize, |n, f| n.saturating_add(f.name.len())).max(states.iter().fold(0usize, |n, f| n.saturating_add(f.name.len()))); + let widgets = fields.iter().fold(0usize, |n, f| n.saturating_add(f.widgets.len())); + let per_pass = count + .saturating_mul(1usize.saturating_add(calculations).saturating_add(references)) + .saturating_add(source_bytes) + .saturating_add(name_bytes) + .saturating_add(widgets); + // Bound the expansion scan before doing it, then charge only the actual selections + // for the forms layer's membership and validation scans in both reset passes. + let comparisons = count.saturating_mul(selectors).saturating_add(count.saturating_mul(bytes)); + let mut work = resets.saturating_mul(per_pass).saturating_add(comparisons).saturating_add(bytes); + if work > MAX_SCRIPT_RESET_WORK / 2 { + return Err(invalid()); + } + for request in requests { + let Request::Reset(names) = request else { continue }; + if names.is_empty() { + continue; + } + for field in states.iter().filter(|f| reset_name_matches(&f.name, names)) { + work = work.saturating_add(count.saturating_mul(field.name.len().saturating_add(1)).saturating_mul(2)); + if work > MAX_SCRIPT_RESET_WORK / 2 { + return Err(invalid()); + } + } + } + Ok(()) +} + +/// Match an exact reset selector or one of its dotted descendants. +fn reset_name_matches(name: &str, selectors: &[String]) -> bool { + selectors.iter().any(|selector| name == selector || name.strip_prefix(selector).is_some_and(|rest| rest.starts_with('.'))) +} + +/// A field gaining or losing focus ([`Session::field_focus`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FieldFocus { + /// The field gained focus: its `enter` scripts run. + Enter, + /// The field lost focus: its `exit` scripts run. + Exit, +} + /// What scripts produced for the viewer since it last looked. #[derive(Clone, Debug, Default, PartialEq)] pub struct JsOutput { @@ -72,20 +152,26 @@ impl JsOutput { } /// Adds what a field's own script produced: its alerts, console lines, errors and requests, - /// except a request to show a side panel or open Save As, which is noted on the console - /// and dropped. + /// except a request to print, show a side panel, open Save As, or open or save an attached + /// file, which is noted on the console and dropped. fn absorb(&mut self, o: &Outcome) { self.alert(o.alerts.iter().cloned()); self.say(o.console.iter().cloned()); // A field's own scripts (keystroke, format, validate, calculate) run unasked: only a - // button's may show or hide a side panel, or open Save As. + // button's or the console's may print, show or hide a side panel, open Save As, or open + // or save an attached file (#4088). // The warnings go through `say` like any other console line, so they count against the // line and byte caps instead of growing the console past them. let mut notes = Vec::new(); for r in &o.requests { match r { + Request::Print => notes + .push("Print requested by an automatic field script was ignored; use a button or the JavaScript console to print".to_string()), Request::ShowPanel(_) => notes.push("A field script asked to show a side panel; only a button may show a side panel".to_string()), Request::SaveAs => notes.push("A field script asked to save the document as a new file; only a button may open Save As".to_string()), + Request::OpenAttachment { name, .. } => { + notes.push(format!("A field script asked to open the attached file {name}; only a button may open an attached file")) + } r => self.requests.push(r.clone()), } } @@ -191,28 +277,99 @@ fn display(doc: &pdfcraft_cos::Document, f: &Field) -> i32 { } } -/// A form field as scripts see it. -pub fn field_state(doc: &pdfcraft_cos::Document, f: &Field) -> pdfcraft_js::FieldState { - field_state_with_display(f, display(doc, f)) +/// Document-local PDF field identity used by snapshots, tools and native hit tests. +pub fn field_object_id(object: pdfcraft_cos::ObjRef) -> u64 { + (u64::from(object.num) << 16) | u64::from(object.generation) } -fn field_state_with_display(f: &Field, display: i32) -> pdfcraft_js::FieldState { +/// Check that a supplied field identity belongs to the requested field name. +fn validate_script_target(target: Option<&str>, identity: Option, fields: &[Field]) -> Result<(), EditError> { + if let Some(id) = identity + && !fields.iter().any(|f| Some(f.name.as_str()) == target && field_object_id(f.obj) == id) + { + return Err(EditError::Invalid("script field identity does not match the named field in this document".into())); + } + Ok(()) +} + +/// Snapshot a current form field using page geometry already read once for this runner. +/// Values/appearances come from `doc` and `f`, never from the cached calculation plan. +pub fn field_state(doc: &pdfcraft_cos::Document, f: &Field, geometry: &pdfcraft_js::DocInfo) -> pdfcraft_js::FieldState { let mut s = pdfcraft_js::FieldState::new(f.name.clone(), kind(f.kind), f.value.clone()); + s.object_id = Some(field_object_id(f.obj)); s.default = f.default.clone(); + s.default_known = pdfcraft_forms::default_value_known(doc, f); s.readonly = f.read_only(); s.required = f.has(pdfcraft_forms::flags::REQUIRED); - s.display = display; - s.rect = f.widgets.first().map(|w| w.rect); + s.display = display(doc, f); + let look = pdfcraft_forms::look(doc, f); + s.text_color = Some(std::iter::once("RGB".to_string()).chain(look.text.map(|v| v.to_string())).collect()); s.char_limit = f.max_len; s.options = match f.kind { FieldKind::CheckBox | FieldKind::Radio => f.widgets.iter().filter_map(|w| w.on_state.clone()).map(|o| (o.clone(), o)).collect(), _ => f.options.clone(), }; + s.caption = f.widgets.first().and_then(|w| w.caption.clone()); + s.caption_mixed = f.widgets.iter().any(|w| w.caption != s.caption); + s.line_width = look.width; + s.border_style = match look.style { + pdfcraft_forms::BorderStyle::Solid => "solid", + pdfcraft_forms::BorderStyle::Dashed => "dashed", + pdfcraft_forms::BorderStyle::Beveled => "beveled", + pdfcraft_forms::BorderStyle::Inset => "inset", + pdfcraft_forms::BorderStyle::Underline => "underline", + } + .into(); + s.stroke_color = Some( + look.border + .map_or_else(|| std::sync::Arc::from(["T".into()]), |rgb| std::iter::once("RGB".into()).chain(rgb.map(|v| v.to_string())).collect()), + ); + s.fill_color = Some( + look.fill.map_or_else(|| std::sync::Arc::from(["T".into()]), |rgb| std::iter::once("RGB".into()).chain(rgb.map(|v| v.to_string())).collect()), + ); + s.user_name = doc + .get(f.obj) + .as_dict() + .and_then(|d| d.get(b"TU").map(|v| doc.resolve(v))) + .and_then(|v| v.as_string().map(|v| v.to_text())) + .unwrap_or_default(); + s.text_size = + f.da.split_whitespace() + .collect::>() + .windows(2) + .find_map(|p| (p.get(1) == Some(&"Tf")).then(|| p.first()?.parse::().ok()).flatten()) + .unwrap_or(0.0); + if let Some(w) = f.widgets.first() { + let page = w.page.unwrap_or(0); + s.page_rotation = geometry.page_rotations.get(page).copied().unwrap_or(0); + s.page_crop = geometry.page_boxes.get(page).map_or([0.0; 4], |boxes| boxes[1]); + s.rect = Some(pdfcraft_js::rotate_rect(w.rect, s.page_crop, s.page_rotation)); + s.icon = doc + .get(w.obj) + .as_dict() + .and_then(|d| d.get(b"MK").map(|v| doc.resolve(v))) + .and_then(|v| v.as_dict().and_then(|d| d.get(b"I")).and_then(pdfcraft_cos::Object::as_ref)) + .map(|r| ((r.num as u64) << 16) | r.generation as u64); + } + + s.icon_mixed = f.widgets.iter().any(|w| { + let icon = doc + .get(w.obj) + .as_dict() + .and_then(|d| d.get(b"MK").map(|v| doc.resolve(v))) + .and_then(|v| v.as_dict().and_then(|d| d.get(b"I")).and_then(pdfcraft_cos::Object::as_ref)) + .map(field_object_id); + icon != s.icon + }); s } /// The changes a script made, against the fields as they were. -fn changes(before: &[pdfcraft_js::FieldState], after: &[pdfcraft_js::FieldState]) -> Vec { +fn changes( + before: &[pdfcraft_js::FieldState], + after: &[pdfcraft_js::FieldState], + formatted: &std::collections::BTreeSet, +) -> Vec { if after.is_empty() { return Vec::new(); } @@ -222,52 +379,250 @@ fn changes(before: &[pdfcraft_js::FieldState], after: &[pdfcraft_js::FieldState] for field in before { by_name.entry(field.name.as_str()).or_insert(field); } + let by_id: std::collections::HashMap<_, _> = before.iter().filter_map(|f| f.object_id.map(|id| (id, f))).collect(); + let mut names = std::collections::HashSet::new(); + let duplicates: std::collections::HashSet<_> = + before.iter().filter_map(|f| (!names.insert(f.name.as_str())).then_some(f.name.as_str())).collect(); after .iter() .filter_map(|a| { - let b = by_name.get(a.name.as_str())?; + let b = if let Some(id) = a.object_id { by_id.get(&id)? } else { by_name.get(a.name.as_str())? }; let c = FieldChange { name: a.name.clone(), + object: a + .object_id + .filter(|_| duplicates.contains(a.name.as_str())) + .map(|id| pdfcraft_cos::ObjRef::new((id >> 16) as u32, id as u16)), value: (a.value != b.value).then(|| a.value.clone()), + formatted: a.value != b.value && formatted.contains(&a.name), read_only: (a.readonly != b.readonly).then_some(a.readonly), required: (a.required != b.required).then_some(a.required), display: (a.display != b.display).then_some(a.display), - rect: (a.rect != b.rect).then_some(a.rect).flatten(), + rect: (a.rect != b.rect).then(|| a.rect.map(|r| pdfcraft_js::unrotate_rect(r, b.page_crop, b.page_rotation))).flatten(), + stroke_color: a.stroke_color.as_deref().filter(|_| a.stroke_color != b.stroke_color).map(<[_]>::to_vec), + text_size: (a.text_size != b.text_size).then_some(a.text_size), + line_width: (a.line_width != b.line_width).then_some(a.line_width), + border_style: (a.border_style != b.border_style).then(|| a.border_style.clone()), + user_name: (a.user_name != b.user_name).then(|| a.user_name.clone()), + icon: (a.icon != b.icon || (a.icon_written && b.icon_mixed)).then_some(a.icon), + text_color: a.text_color.as_deref().filter(|_| a.text_color != b.text_color).map(<[_]>::to_vec), + fill_color: a.fill_color.as_deref().filter(|_| a.fill_color != b.fill_color).map(<[_]>::to_vec), + caption: (a.caption != b.caption || (a.caption_written && b.caption_mixed)).then(|| a.caption.clone()).flatten(), + options: (a.options != b.options).then(|| a.options.clone()), + actions: a.actions.clone(), }; - (c.value.is_some() || c.read_only.is_some() || c.required.is_some() || c.display.is_some() || c.rect.is_some()).then_some(c) + (c.value.is_some() + || c.read_only.is_some() + || c.required.is_some() + || c.display.is_some() + || c.caption.is_some() + || c.text_color.is_some() + || c.fill_color.is_some() + || c.rect.is_some() + || c.stroke_color.is_some() + || c.text_size.is_some() + || c.line_width.is_some() + || c.border_style.is_some() + || c.user_name.is_some() + || c.icon.is_some() + || c.options.is_some() + || !c.actions.is_empty()) + .then_some(c) }) .collect() } /// Runs field scripts with the document's object model and keeps what they print or ask for. +pub type Runtime = std::sync::Arc>; + +/// Invalidate pending results while retaining the document's native worker reservation. +pub(crate) fn replace_runtime(runtime: &mut Runtime) { + let Ok(mut old) = runtime.lock() else { return }; + let next = old.fresh_realm(); + drop(old); + *runtime = std::sync::Arc::new(std::sync::Mutex::new(next)); +} + pub struct JsRunner { + pub runtime: Runtime, pub doc: pdfcraft_js::DocInfo, pub doc_scripts: Vec, pub output: JsOutput, - /// Immutable document snapshot from the start of the edit (for widget `display`). - cos: pdfcraft_cos::Document, - // `cos` is the same immutable pre-edit snapshot for the runner's lifetime. Cache only - // its scalar display readings, not COS objects: large forms exceed the object cache and - // otherwise reparse every widget for every calculation. Values/flags still come from - // the live `fields` snapshot on each call. A new edit gets a new runner and cache. - displays: std::collections::HashMap, + pub(crate) refused: Option, + pub(crate) callbacks_touched_realm: bool, + #[cfg(not(target_arch = "wasm32"))] + callback_started: Option, + /// Source/order cache for this edit only; each callback still snapshots current field values. + calculations: Option>, + // Cache at most 4,096 field identities in the current edit. Entries only survive value-only + // callbacks. Appearance and structural edits clear them before the next snapshot. + snapshot_fields: std::collections::HashMap, + // The last callback's snapshot. A calculation chain changes one or two values per callback, + // so the next callback patches those entries instead of rebuilding every field. + last_snapshot: Option>>, } impl JsRunner { + pub(crate) fn invalidate_field_snapshots(&mut self) { + self.snapshot_fields.clear(); + self.last_snapshot = None; + } + + fn callback_fields(&mut self, doc: &pdfcraft_cos::Document, fields: &[Field]) -> std::sync::Arc> { + if let Some(snapshot) = self.last_snapshot.as_mut() + && snapshot.len() == fields.len() + && snapshot.iter().zip(fields).all(|(state, field)| state.object_id == Some(field_object_id(field.obj))) + { + let stale = |state: &pdfcraft_js::FieldState, field: &Field| { + state.name != field.name + || state.value != field.value + || state.readonly != field.read_only() + || state.required != field.has(pdfcraft_forms::flags::REQUIRED) + }; + if snapshot.iter().zip(fields).any(|(state, field)| stale(state, field)) { + for (state, field) in std::sync::Arc::make_mut(snapshot).iter_mut().zip(fields) { + if stale(state, field) { + state.name.clone_from(&field.name); + state.value.clone_from(&field.value); + state.readonly = field.read_only(); + state.required = field.has(pdfcraft_forms::flags::REQUIRED); + } + } + } + if let Ok(mut runtime) = self.runtime.lock() { + runtime.stamp_fields(snapshot); + } + return std::sync::Arc::clone(snapshot); + } + let states = fields + .iter() + .map(|field| { + if self.snapshot_fields.len() >= 4096 && !self.snapshot_fields.contains_key(&field.obj) { + return field_state(doc, field, &self.doc); + } + let template = self.snapshot_fields.entry(field.obj).or_insert_with(|| { + let mut state = field_state(doc, field, &self.doc); + state.value.clear(); + state + }); + let mut state = template.clone(); + state.name.clone_from(&field.name); + state.value.clone_from(&field.value); + state.readonly = field.read_only(); + state.required = field.has(pdfcraft_forms::flags::REQUIRED); + state + }) + .collect(); + let mut states = std::sync::Arc::new(states); + if let Ok(mut runtime) = self.runtime.lock() { + runtime.stamp_fields(&mut states); + } + self.last_snapshot = Some(std::sync::Arc::clone(&states)); + states + } + + /// Forget the per-edit calculation plan after a calculation source is changed. + pub(crate) fn invalidate_calculations(&mut self) { + self.calculations = None; + } + + /// Refresh structural/document sources between batch children, retaining the realm, + /// accumulated output and the current viewer snapshot. Ordinary value edits reuse caches. + pub(crate) fn refresh_after_batch_edit(&mut self, cos: &pdfcraft_cos::Document, edit: &Edit) { + if matches!(edit, Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::ApplyScriptChanges { .. } | Edit::Batch { .. }) { + return; + } + let mut current = Self::new(cos, &self.doc.file_name, self.doc.runtime_highlight); + current.doc.zoom = self.doc.zoom; + current.doc.page = self.doc.page; + self.doc = current.doc; + self.doc_scripts = current.doc_scripts; + self.calculations = None; + self.invalidate_field_snapshots(); + } + /// A runner for `cos`: what scripts read about the document (pages, info, whether it is - /// an XFA form) and its document-level scripts, read once. - pub fn new(cos: &pdfcraft_cos::Document, file_name: &str) -> JsRunner { + /// an XFA form) and its document-level scripts, read once. `highlight`: whether the viewer + /// highlights form fields (`app.runtimeHighlight`). + pub fn new(cos: &pdfcraft_cos::Document, file_name: &str, highlight: bool) -> JsRunner { let xfa = pdfcraft_xfa::xfa_shape(cos).is_some(); - let doc = - pdfcraft_js::DocInfo { file_name: file_name.to_string(), num_pages: pdfcraft_model::pages(cos).len(), page: 0, info: info(cos), xfa }; + #[cfg(test)] + SNAPSHOT_WORK.with(|c| { + let (g, p) = c.get(); + c.set((g + 1, p)); + }); + let pages = pdfcraft_model::pages(cos); + let doc = pdfcraft_js::DocInfo { + file_name: file_name.to_string(), + num_pages: pages.len(), + page: 0, + info: info(cos), + page_boxes: pdfcraft_organize::page_boxes(cos).unwrap_or_default(), + page_rotations: pages.iter().map(|page| page.rotation(cos)).collect(), + xfa, + zoom: 100.0, + runtime_highlight: highlight, + icon_epoch: 0, + icon_versions: Default::default(), + }; // Once XFA owns the form, the PDF container's scripts belong to its fallback view. // Keep them in the file and in Document JavaScripts, but do not initialize an // AcroForm context with them: a viewer-compatibility alert would be misleading. // Packets alone are not enough: static XFA and an unlaid-out shell keep their scripts. let xfa_active = xfa && pdfcraft_xfa::existing_layout(cos).is_some(); let doc_scripts = if xfa_active { Vec::new() } else { pdfcraft_forms::document_scripts(cos) }; - JsRunner { doc, doc_scripts, output: JsOutput::default(), cos: cos.clone(), displays: std::collections::HashMap::new() } + JsRunner { + doc, + doc_scripts, + output: JsOutput::default(), + refused: None, + callbacks_touched_realm: false, + #[cfg(not(target_arch = "wasm32"))] + callback_started: None, + runtime: Default::default(), + calculations: None, + snapshot_fields: Default::default(), + last_snapshot: None, + } } + + #[cfg(not(target_arch = "wasm32"))] + fn callback_time_left(&mut self) -> std::time::Duration { + std::time::Duration::from_secs(10).saturating_sub(self.callback_started.get_or_insert_with(std::time::Instant::now).elapsed()) + } +} + +/// Index calculation sources by PDF object identity, retaining their matching snapshot positions. +fn calculation_scripts(cos: &pdfcraft_cos::Document, fields: &[Field]) -> Vec<(usize, String)> { + #[cfg(test)] + SNAPSHOT_WORK.with(|c| { + let (g, p) = c.get(); + c.set((g, p + 1)); + }); + let by_object: std::collections::HashMap<_, _> = fields.iter().enumerate().map(|(i, f)| (f.obj, (i, f))).collect(); + pdfcraft_forms::calculation_order(cos, fields) + .fields + .iter() + .filter_map(|ordered| { + let (index, field) = by_object.get(&ordered.obj)?; + let script = field.actions.scripts.calculate.clone().or_else(|| { + let source = pdfcraft_forms::af::calculate_js(&field.actions.calculate)?; + Some(match &field.actions.calculate { + pdfcraft_forms::af::Calculate::Simple { .. } => format!("event.value = {source}"), + _ => source, + }) + })?; + Some((*index, script)) + }) + .collect() +} + +/// The field highlight the last of `requests` that sets it asks for (`app.runtimeHighlight`). +fn last_highlight(requests: &[Request]) -> Option { + requests.iter().rev().find_map(|r| match r { + Request::Highlight(on) => Some(*on), + _ => None, + }) } /// The Info dictionary as `this.info` keys (lower-case). @@ -282,54 +637,140 @@ fn info(cos: &pdfcraft_cos::Document) -> Vec<(String, String)> { } impl Scripts for JsRunner { + fn stopped(&self) -> bool { + self.refused.is_some() + } + /// Run field `target`'s script for `event` over the form's fields, keeping what it showed and /// asked for; a refusal carries its last message. - fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> ScriptResult { - // As in Acrobat, a failing script leaves the value alone (the error goes to the console). - self.run_logged(event, script, target, value, fields, true).unwrap_or_else(|| ScriptResult { - rc: true, - value: value.to_string(), - value_set: false, - changes: Vec::new(), - message: None, - }) - } - - fn rerun_with_english_dates(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> Option { - pdfcraft_js::with_english_printd(true, || self.run_logged(event, script, target, value, fields, false)) - } -} - -impl JsRunner { - /// [`Scripts::run`], keeping what the script showed and asked for only when `log`; `None` - /// when the script failed. - fn run_logged(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field], log: bool) -> Option { - let states: Vec<_> = fields - .iter() - .map(|f| { - let display = match f.widgets.first() { - Some(w) => *self.displays.entry(w.obj).or_insert_with(|| display(&self.cos, f)), - None => pdfcraft_js::DISPLAY_VISIBLE, - }; - field_state_with_display(f, display) - }) - .collect(); - let states = std::sync::Arc::new(states); - let ev = pdfcraft_js::Event::field(event.name(), &target.name, value); - let o = pdfcraft_js::run_shared(script, &ev, &self.doc, std::sync::Arc::clone(&states), &self.doc_scripts, Limits::default()); - if log { - self.output.absorb(&o); + fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field], doc: &pdfcraft_cos::Document) -> ScriptResult { + let unchanged = || ScriptResult { rc: true, value: value.to_string(), value_set: false, changes: Vec::new(), message: None }; + if self.stopped() { + return unchanged(); } - if o.error.is_some() { - return None; + #[cfg(not(target_arch = "wasm32"))] + if self.callback_time_left().is_zero() { + self.refused = Some("form script callbacks exceeded their shared execution budget".into()); + return unchanged(); } - Some(ScriptResult { + let states = self.callback_fields(doc, fields); + let mut ev = pdfcraft_js::Event::field(event.name(), &target.name, value); + ev.target_id = Some((u64::from(target.obj.num) << 16) | u64::from(target.obj.generation)); + let calculations = self.calculations.get_or_insert_with(|| calculation_scripts(doc, fields)).clone(); + let job = pdfcraft_js::session::Job { + script: script.into(), + event: ev, + doc: self.doc.clone(), + fields: states, + doc_scripts: self.doc_scripts.clone(), + calculations, + }; + #[cfg(not(target_arch = "wasm32"))] + let remaining = self.callback_time_left(); + let o = match self.runtime.lock() { + #[cfg(not(target_arch = "wasm32"))] + Ok(mut runtime) if !remaining.is_zero() => runtime.run_with_limit(job, &[], remaining), + #[cfg(not(target_arch = "wasm32"))] + Ok(_) => Outcome { + execution_refused: true, + realm_untouched: true, + error: Some("form script callbacks exceeded their shared execution budget".into()), + ..Default::default() + }, + #[cfg(target_arch = "wasm32")] + Ok(mut runtime) => runtime.run(job, &[]), + Err(_) => Outcome { execution_refused: true, error: Some("document script runtime is unavailable".into()), ..Default::default() }, + }; + self.callbacks_touched_realm |= !o.realm_untouched; + self.output.absorb(&o); + if o.execution_refused || o.reset_limit_exceeded || o.calculation_limit_exceeded { + self.refused = Some(o.error.clone().unwrap_or_else(|| "form script exceeded its execution budget".into())); + } + if o.error.is_some() || o.reset_limit_exceeded || o.calculation_limit_exceeded { + // As in Acrobat, a failing script leaves the value alone (the error goes to the console). + return unchanged(); + } + // The edit's later scripts read the field highlight this one set. + if let Some(on) = last_highlight(&o.requests) { + self.doc.runtime_highlight = on; + } + // Most field callbacks only set event.value. Hand the unique snapshot to the + // session so stamping field identities does not clone every field. Reconstruct + // the baseline only for a callback that also wrote to Field objects. + let changes = if o.changed.is_empty() { + Vec::new() + } else { + let baseline = self.callback_fields(doc, fields); + changes(&baseline, &o.changed, &o.formatted) + }; + if changes.iter().any(|change| { + *change != FieldChange { name: change.name.clone(), object: change.object, value: change.value.clone(), ..Default::default() } + }) { + self.invalidate_field_snapshots(); + } + ScriptResult { rc: o.rc, value: o.value.clone(), value_set: o.value_set, - changes: changes(&states, &o.changed), + changes, message: (!o.rc).then(|| o.alerts.last().map(|m| m.text.clone())).flatten(), - }) + } + } + + /// Retry appearance formatting in isolation so the font fallback cannot replay persistent side effects. + fn rerun_with_english_dates( + &mut self, + event: FieldEvent, + script: &str, + target: &Field, + value: &str, + fields: &[Field], + doc: &pdfcraft_cos::Document, + ) -> Option { + if self.stopped() { + return None; + } + #[cfg(not(target_arch = "wasm32"))] + if self.callback_time_left().is_zero() { + self.refused = Some("form script callbacks exceeded their shared execution budget".into()); + return None; + } + let states = self.callback_fields(doc, fields); + let mut ev = pdfcraft_js::Event::field(event.name(), &target.name, value); + ev.target_id = Some(field_object_id(target.obj)); + let calculations = self.calculations.get_or_insert_with(|| calculation_scripts(doc, fields)); + #[cfg(target_arch = "wasm32")] + let o = pdfcraft_js::with_english_printd(true, || { + pdfcraft_js::run_shared_calculations(script, &ev, &self.doc, states, &self.doc_scripts, calculations, Limits::default()) + }); + #[cfg(not(target_arch = "wasm32"))] + let o = { + let job = pdfcraft_js::session::Job { + script: script.into(), + event: ev, + doc: self.doc.clone(), + fields: states, + doc_scripts: self.doc_scripts.clone(), + calculations: calculations.clone(), + }; + let remaining = self.callback_time_left(); + let outcome = if remaining.is_zero() { + Outcome { execution_refused: true, ..Default::default() } + } else if let Ok(mut runtime) = self.runtime.lock() { + runtime.run_english_retry(job, remaining) + } else { + Outcome { execution_refused: true, error: Some("document script runtime is unavailable".into()), ..Default::default() } + }; + if outcome.execution_refused { + self.refused = Some(outcome.error.unwrap_or_else(|| "form script callbacks exceeded their shared execution budget".into())); + return None; + } + outcome + }; + if o.error.is_some() || o.reset_limit_exceeded || o.calculation_limit_exceeded { + return None; + } + Some(ScriptResult { rc: o.rc, value: o.value.clone(), value_set: o.value_set, changes: Vec::new(), message: None }) } } @@ -355,34 +796,384 @@ impl crate::Document { } } +/// When a signature field's XFA signing scripts run ([`Session::run_sign_event`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SignEvent { + /// Just before the field is signed (`preSign`): what it changes is signed too. + Before, + /// Once the field is signed (`postSign`). + After, +} + +impl SignEvent { + /// The template's name for the event. + pub fn activity(self) -> &'static str { + match self { + SignEvent::Before => "preSign", + SignEvent::After => "postSign", + } + } +} + +/// A PDF document action dispatched by the host through its existing AcroForm session. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DocumentScriptEvent { + CatalogOpen, + WillSave, + WillPrint, +} + +impl DocumentScriptEvent { + fn name(self) -> &'static str { + match self { + Self::CatalogOpen => "Open", + Self::WillSave => "WillSave", + Self::WillPrint => "WillPrint", + } + } + + /// Where the catalog keeps this event's actions. + fn trigger(self) -> pdfcraft_forms::DocumentTrigger { + match self { + Self::CatalogOpen => pdfcraft_forms::DocumentTrigger::Open, + Self::WillSave => pdfcraft_forms::DocumentTrigger::WillSave, + Self::WillPrint => pdfcraft_forms::DocumentTrigger::WillPrint, + } + } +} + +/// Keep only what a script that ran unasked (on opening, before saving or printing) may ask +/// the viewer for: highlighting, zoom, focus and the page shown. Opening a URL, a file, mail or +/// a dialog needs a click. +fn unasked_requests(requests: &mut Vec) { + requests + .retain(|r| matches!(r, Request::Highlight(_) | Request::Zoom(_) | Request::Focus(_) | Request::FocusField { .. } | Request::GoToPage(_))); +} + impl Session { /// Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript (on by default). pub fn set_javascript(&mut self, on: bool) { self.js_off = !on; + if !on { + for doc in &mut self.docs { + replace_runtime(&mut doc.js_runtime); + } + } } + /// Whether JavaScript is on (Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript). pub fn javascript(&self) -> bool { !self.js_off } + /// Whether the viewer highlights form fields (Highlight fields), which forms' scripts read as + /// `app.runtimeHighlight`. A script that changes it asks for [`Request::Highlight`]; the + /// viewer then calls this with the new setting. + pub fn set_field_highlight(&mut self, on: bool) { + self.field_highlight = on; + } + /// What scripts printed or asked for since the last call (alerts, console, print, …). pub fn take_js_output(&mut self, id: DocId) -> JsOutput { self.docs.iter_mut().find(|d| d.id == id).map(|d| std::mem::take(&mut d.js_output)).unwrap_or_default() } + /// The last field-highlight change (`app.runtimeHighlight`) scripts asked for in document + /// `id`, taken out of its waiting output; the rest stays for [`Session::take_js_output`]. + pub fn take_js_highlight(&mut self, id: DocId) -> Option { + let doc = self.docs.iter_mut().find(|d| d.id == id)?; + let mut last = None; + doc.js_output.requests.retain(|r| match r { + Request::Highlight(on) => { + last = Some(*on); + false + } + _ => true, + }); + last + } + + /// Widget `widget` of field `name` in document `id` gained or lost focus: on a laid-out XFA form, + /// run the field's `enter` or `exit` scripts, as one undoable edit when they change something + /// (and none when they don't). An exit that the field's new value already ran (the value's + /// change runs it) isn't run a second time. Returns whether the document changed; what the + /// scripts showed or asked for is in [`Session::take_js_output`]. + pub fn field_focus(&mut self, id: DocId, name: &str, widget: usize, focus: FieldFocus) -> Result { + self.field_focus_target(id, name, None, widget, focus) + } + + /// [`Session::field_focus`] for the field object `target` ([`field_object_id`]) when given: + /// of fields sharing `name`, the one that gained or lost focus runs its own script. + pub fn field_focus_target(&mut self, id: DocId, name: &str, target: Option, widget: usize, focus: FieldFocus) -> Result { + if self.js_off { + return Ok(false); + } + let doc = self.doc_mut(id)?; + let Some(tpl) = doc.xfa_template.clone().filter(|t| pdfcraft_xfa::has_focus_scripts(t)) else { + // An AcroForm widget's own On Focus / On Blur JavaScript (#269). + let trigger = match focus { + FieldFocus::Enter => pdfcraft_forms::Trigger::OnFocus, + FieldFocus::Exit => pdfcraft_forms::Trigger::OnBlur, + }; + return self.field_trigger_target(id, name, target, widget, trigger); + }; + let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return Ok(false) }; + let Some(som) = doc + .form + .iter() + .find(|f| f.name == name && target.is_none_or(|t| field_object_id(f.obj) == t)) + .and_then(|f| crate::xfa::widget_som(cos, f, widget)) + else { + return Ok(false); + }; + let activity = match focus { + FieldFocus::Enter => "enter", + FieldFocus::Exit => "exit", + }; + if !crate::xfa::has_event(cos, &tpl, &som, activity) { + doc.xfa_exited.remove(&som); + return Ok(false); + } + // Gaining focus starts afresh; losing it runs the exit unless the change just did. + if doc.xfa_exited.remove(&som) && focus == FieldFocus::Exit { + return Ok(false); + } + let before = doc.generation; + self.apply(id, Edit::XfaEvent { som, activity: activity.into() })?; + Ok(self.get(id).is_some_and(|d| d.generation != before)) + } + + /// Widget `widget` (0-based) of AcroForm field `name` in document `id` saw `trigger`: the + /// pointer entered, left, pressed or released it, or it gained or lost the focus. Run that + /// widget's JavaScript action for the trigger, if it has one, in the document's realm, with + /// `event.type` "Field" and `event.name` "Mouse Enter", "Mouse Exit", "Mouse Down", "Mouse + /// Up", "Focus" or "Blur" (#269, ported from #344), then those of the actions chained after + /// it (`/Next`). What each action changes is one undoable edit. + /// Returns whether the document changed; what the script showed or asked for is in + /// [`Session::take_js_output`]. Pointer and focus scripts run unasked, so they may not + /// print, open Save As or show a panel; Mouse Down and Mouse Up come from a click and may. + /// Nothing runs on an XFA form (its own events run instead), with JavaScript off, or for a + /// widget with no JavaScript for the trigger. + pub fn field_trigger(&mut self, id: DocId, name: &str, widget: usize, trigger: pdfcraft_forms::Trigger) -> Result { + self.field_trigger_target(id, name, None, widget, trigger) + } + + /// [`Session::field_trigger`] for the field object `target` ([`field_object_id`]) when given: + /// of fields sharing `name`, the one the pointer is on runs its own script. + pub fn field_trigger_target( + &mut self, + id: DocId, + name: &str, + target: Option, + widget: usize, + trigger: pdfcraft_forms::Trigger, + ) -> Result { + use pdfcraft_forms::Trigger; + if self.js_off { + return Ok(false); + } + let doc = self.get(id).ok_or(EditError::NoDocument)?; + if doc.info.xfa.is_some() { + return Ok(false); + } + let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return Ok(false) }; + let field = doc + .form + .iter() + .find(|f| f.name == name && target.is_none_or(|t| field_object_id(f.obj) == t)) + .ok_or_else(|| EditError::Invalid(format!("there is no field named {name:?}")))?; + let Some(w) = field.widgets.get(widget) else { return Ok(false) }; + let scripts = pdfcraft_forms::widget_scripts(cos, w.obj, trigger); + if scripts.is_empty() { + return Ok(false); + } + let target_id = Some(field_object_id(field.obj)); + let event = match trigger { + Trigger::OnFocus => "Focus", + Trigger::OnBlur => "Blur", + other => other.label(), + }; + let before = doc.generation; + let mut out = JsOutput::default(); + // Each action of a /Next chain runs on its own, as Acrobat runs them: one that fails + // doesn't stop the next. + for script in scripts { + let o = match self.run_javascript_job(id, &script, (Some(name), target_id), &[], Some(event), false) { + Ok(o) => o, + Err(e) => { + out.errors.push(e.to_string()); + continue; + } + }; + if matches!(trigger, Trigger::MouseUp | Trigger::MouseDown) { + out.append(JsOutput { + alerts: o.alerts, + console: o.console, + requests: o.requests, + errors: o.error.into_iter().chain(o.doc_errors.iter().cloned()).collect(), + doc_errors: o.doc_errors, + }); + } else { + out.absorb(&o); + } + } + let doc = self.doc_mut(id)?; + doc.js_output.append(out); + Ok(doc.generation != before) + } + + /// Run signature field `field`'s XFA signing scripts for `event` in document `id`: `preSign` + /// before it is signed, `postSign` after, as one undoable step, as a form's signing events run + /// (DA 31's dates the signature and locks the date). `Ok(false)`, with nothing changed, when + /// there are none: not a laid-out XFA form, JavaScript off, no such field, or no such script. + /// What the scripts said or asked for is in [`Session::take_js_output`]. + pub fn run_sign_event(&mut self, id: DocId, field: &str, event: SignEvent) -> Result { + if self.get(id).is_none() { + return Err(EditError::NoDocument); + } + let Some(som) = self.sign_event_som(id, field, event) else { return Ok(false) }; + self.apply(id, Edit::XfaEvent { som, activity: event.activity().into() })?; + Ok(true) + } + + /// Whether signature field `field` of document `id` has XFA signing scripts for `event` that + /// [`Session::run_sign_event`] would run, without running them. + pub fn sign_event_scripted(&self, id: DocId, field: &str, event: SignEvent) -> bool { + self.sign_event_som(id, field, event).is_some() + } + + /// The SOM path of signature field `field` when it has scripts for `event` to run. + fn sign_event_som(&self, id: DocId, field: &str, event: SignEvent) -> Option { + if self.js_off { + return None; + } + let doc = self.get(id)?; + let (tpl, editor) = (doc.xfa_template.as_ref()?, doc.editor.as_ref()?); + let f = doc.form.iter().find(|f| f.name == field)?; + crate::xfa::scripted_som(&editor.cos, tpl, f, event.activity()) + } + /// Run `script` in document `id`, for a button (`target`: the field, event "Mouse Up") or /// the console (no target). Field changes and `resetForm` are applied as one undoable step; /// everything else the script asked for is returned. pub fn run_javascript(&mut self, id: DocId, script: &str, target: Option<&str>) -> Result { + self.run_javascript_answers(id, script, target, &[]) + } + + /// Run a button/console event with ordered headless prompt answers and commit field changes as one undo step. + pub fn run_javascript_answers( + &mut self, + id: DocId, + script: &str, + target: Option<&str>, + answers: &[Option], + ) -> Result { + self.run_javascript_answers_target(id, script, target, None, answers) + } + /// Run with an optional exact PDF field identity; name-only callers keep first-match behavior. + pub fn run_javascript_target(&mut self, id: DocId, script: &str, target: Option<&str>, target_id: Option) -> Result { + self.run_javascript_answers_target(id, script, target, target_id, &[]) + } + /// Exact-target counterpart of run_javascript_answers, including deterministic prompt replies. + pub fn run_javascript_answers_target( + &mut self, + id: DocId, + script: &str, + target: Option<&str>, + target_id: Option, + answers: &[Option], + ) -> Result { + self.run_javascript_job(id, script, (target, target_id), answers, None, false) + } + + pub(crate) fn run_opening_javascript(&mut self, id: DocId) -> Result { + self.run_javascript_job(id, "", (None, None), &[], None, true) + } + + /// Opening document `id`: its document-level scripts run once, then the catalog + /// `/OpenAction`'s JavaScript, in the document's retained realm (#269). Not on an XFA form + /// (its own opening scripts run instead) or with JavaScript off. What they showed or asked + /// for waits in [`Session::take_js_output`]. + pub(crate) fn run_open_scripts(&mut self, id: DocId) { + if self.js_off { + return; + } + let Some(doc) = self.get(id).filter(|d| d.info.xfa.is_none()) else { return }; + let has_document_scripts = !doc.document_scripts().is_empty(); + let opens = + doc.editor.as_ref().map(|e| pdfcraft_forms::document_action_scripts(&e.cos, pdfcraft_forms::DocumentTrigger::Open)).unwrap_or_default(); + if !has_document_scripts && opens.is_empty() { + return; + } + let mut out = JsOutput::default(); + if has_document_scripts && opens.is_empty() { + match self.run_opening_javascript(id) { + Ok(o) => out.absorb(&o), + Err(e) => out.errors.push(e.to_string()), + } + } + // The first open action initializes the realm with the document scripts too. + out.append(self.run_actions(id, DocumentScriptEvent::CatalogOpen, &opens)); + unasked_requests(&mut out.requests); + if let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) { + doc.js_output.append(out); + } + } + + /// Run the catalog's JavaScript actions for document event `event` (`/AA /WS` before a save, + /// `/AA /WP` before printing, `/OpenAction` on opening) in document `id`'s retained realm. + /// Each action's field changes are one undoable edit. A failing action is reported in the + /// output and the next one still runs; it never stops the save or print, which must not + /// lose the user's work. Requests are limited to what an unasked script may make. With + /// JavaScript off, on an XFA form, or with no such action, nothing runs. + pub fn run_document_actions(&mut self, id: DocId, event: DocumentScriptEvent) -> JsOutput { + if self.js_off { + return JsOutput::default(); + } + let Some(doc) = self.get(id).filter(|d| d.info.xfa.is_none()) else { return JsOutput::default() }; + let scripts = doc.editor.as_ref().map(|e| pdfcraft_forms::document_action_scripts(&e.cos, event.trigger())).unwrap_or_default(); + self.run_actions(id, event, &scripts) + } + + fn run_actions(&mut self, id: DocId, event: DocumentScriptEvent, scripts: &[String]) -> JsOutput { + let mut out = JsOutput::default(); + for script in scripts { + match self.run_javascript_job(id, script, (None, None), &[], Some(event.name()), event == DocumentScriptEvent::CatalogOpen) { + Ok(o) => out.absorb(&o), + Err(e) => out.errors.push(format!("{} script: {e}", event.name())), + } + } + unasked_requests(&mut out.requests); + out + } + + /// Run an explicitly dispatched PDF document action in the document's retained runtime. + pub fn run_document_script_event(&mut self, id: DocId, event: DocumentScriptEvent, script: &str) -> Result { + self.run_javascript_job(id, script, (None, None), &[], Some(event.name()), false) + } + + /// Route generated XFA clicks to the XFA engine, or run a synchronous AcroForm job and apply its outcome. + fn run_javascript_job( + &mut self, + id: DocId, + script: &str, + (target, target_id): (Option<&str>, Option), + answers: &[Option], + event_name: Option<&'static str>, + opening: bool, + ) -> Result { if self.js_off { return Err(EditError::Invalid("JavaScript is turned off (Preferences ▸ JavaScript)".into())); } let doc = self.get(id).ok_or(EditError::NoDocument)?; let cos = doc.editor.as_ref().map(|e| e.cos.clone()).ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; + validate_script_target(target, target_id, &doc.form)?; // A button PdfCraft generated from an XFA template runs its XFA click script. if let Some(t) = target + && event_name.is_none_or(|n| n == "Mouse Up") && let Some(tpl) = doc.xfa_template.clone() - && let Some(field) = doc.form.iter().find(|f| f.name == t) + && let Some(field) = doc.form.iter().find(|f| f.name == t && target_id.is_none_or(|id| field_object_id(f.obj) == id)) + && field.kind == crate::FormFieldKind::PushButton && let Some(som) = crate::xfa::clickable_som(&cos, &tpl, field) { // The click returns only the output of its own edit; output from opening or from earlier edits stays. @@ -396,15 +1187,53 @@ impl Session { ..Default::default() }); } - let runner = JsRunner::new(&cos, &doc.name); + let mut runner = JsRunner::new(&cos, &doc.name, self.field_highlight); + runner.doc.zoom = doc.js_zoom; + runner.doc.page = doc.js_page; let fields = pdfcraft_forms::fields(&cos); - let states: Vec<_> = fields.iter().map(|f| field_state(&cos, f)).collect(); + let states = std::sync::Arc::new(fields.iter().map(|f| field_state(&cos, f, &runner.doc)).collect::>()); let event = match target { - Some(t) => pdfcraft_js::Event { will_commit: false, ..pdfcraft_js::Event::field("Mouse Up", t, "") }, - None => pdfcraft_js::Event::doc("Console"), + Some(t) => pdfcraft_js::Event { will_commit: false, target_id, ..pdfcraft_js::Event::field(event_name.unwrap_or("Mouse Up"), t, "") }, + None => pdfcraft_js::Event::doc(event_name.unwrap_or("Console")), }; - let mut o = pdfcraft_js::run(script, &event, &runner.doc, &states, &runner.doc_scripts, Limits::default()); - let changes = changes(&states, &o.changed); + let calculations = calculation_scripts(&cos, &fields); + let runtime = doc.js_runtime.clone(); + let o = runtime.lock().map_err(|_| EditError::Invalid("document script runtime is unavailable".into()))?.run( + pdfcraft_js::session::Job { + script: script.into(), + event, + doc: runner.doc, + fields: states.clone(), + doc_scripts: runner.doc_scripts, + calculations, + }, + answers, + ); + let discard = o.execution_refused && !o.realm_untouched; + let result = self.apply_script_outcome(id, &states, o, opening); + if discard || result.is_err() { + self.invalidate_javascript(id); + } + result + } + + /// Convert the worker snapshot delta and reset requests into permission-checked atomic document edits. + fn apply_script_outcome(&mut self, id: DocId, states: &[pdfcraft_js::FieldState], mut o: Outcome, opening: bool) -> Result { + if o.execution_refused { + o.changed.clear(); + o.requests.clear(); + return Ok(o); + } + if o.calculation_limit_exceeded { + return Err(EditError::Invalid(o.error.unwrap_or_else(|| "script calculation work limit exceeded".into()))); + } + if o.reset_limit_exceeded { + return Err(EditError::Invalid("script reset call limit exceeded; no script edits were committed".into())); + } + if o.requests.iter().any(|r| matches!(r, Request::Reset(_))) { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + validate_reset_work(&doc.form, states, &o.requests)?; + } let resets: Vec> = o .requests .iter() @@ -414,14 +1243,47 @@ impl Session { }) .collect(); o.requests.retain(|r| !matches!(r, Request::Reset(_))); + // Reset the PDF first, then apply the final live shadow. Compare against the + // reset baseline so writes restoring an original value are not mistaken for no-ops. + let mut baseline = std::borrow::Cow::Borrowed(states); + let mut reset_document = if resets.is_empty() { + None + } else { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; + Some(editor.cos.clone()) + }; + // The calculations the script's changes set off read the field highlight it asked for. + if let Some(on) = last_highlight(&o.requests) { + self.field_highlight = on; + } let mut edits = Vec::new(); + for names in resets { + let names = (!names.is_empty()) + .then(|| states.iter().filter(|field| reset_name_matches(&field.name, &names)).map(|field| field.name.clone()).collect::>()); + if let Some(doc) = reset_document.as_mut() { + // The preview includes dependent calculation writes as well as direct resets. + let (reset, recalculated) = pdfcraft_forms::reset_with_counts(doc, names.as_deref())?; + if !opening || reset != 0 || recalculated != 0 { + edits.push(Edit::ResetForm { names }); + } + } + } + if let Some(doc) = reset_document { + let mut values = std::collections::HashMap::new(); + for field in pdfcraft_forms::fields(&doc) { + values.entry((u64::from(field.obj.num) << 16) | u64::from(field.obj.generation)).or_insert(field.value); + } + for field in baseline.to_mut() { + if let Some(value) = field.object_id.and_then(|id| values.get(&id)) { + field.value.clone_from(value); + } + } + } + let changes = changes(&baseline, &o.changed, &o.formatted); if !changes.is_empty() { edits.push(Edit::ApplyScriptChanges { changes }); } - if !resets.is_empty() { - let all: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect(); - edits.push(Edit::ResetForm { names: reset_targets(&all, &resets) }); - } if !edits.is_empty() { self.apply(id, Edit::Batch { label: "Run JavaScript".into(), edits })?; } @@ -429,21 +1291,6 @@ impl Session { } } -/// The fields every `resetForm` call of one script asked for, as one reset: a reset is to the -/// defaults, so resetting the union once does what each call in turn would, and recalculates -/// once. `None` is the whole form (a call with no names). A field is named by a selector that -/// is its name or the name of a field above it, looked up in a set of the selectors, so the -/// work is the fields times their depth, not the fields times the selectors, which a script -/// controls. -fn reset_targets(fields: &[&str], requests: &[Vec]) -> Option> { - if requests.iter().any(Vec::is_empty) { - return None; - } - let wanted: std::collections::HashSet<&str> = requests.iter().flatten().map(String::as_str).collect(); - let matched = |name: &str| name.match_indices('.').map(|(i, _)| &name[..i]).chain(std::iter::once(name)).any(|p| wanted.contains(p)); - Some(fields.iter().filter(|n| matched(n)).map(|n| n.to_string()).collect()) -} - /// A page's words for form-field detection, in user space (underscore runs split from the /// text around them). fn detection_words(text: &pdfcraft_render::PageText, info: &pdfcraft_render::PageInfo) -> Vec { @@ -593,26 +1440,6 @@ impl Session { mod review_tests { use super::*; - /// Many `resetForm` calls with many selectors over a big form: one reset of the union, found - /// by looking each field's name and parents up, not by comparing every field with every - /// selector. - #[test] - fn reset_requests_are_coalesced_and_matched_through_an_index() { - let fields: Vec = (0..20_000).map(|i| format!("group{}.row{i}.cell", i % 100)).collect(); - let names: Vec<&str> = fields.iter().map(String::as_str).collect(); - let requests: Vec> = (0..1_000).map(|k| (0..20).map(|j| format!("nothing{}", k * 20 + j)).collect()).collect(); - let started = std::time::Instant::now(); - assert_eq!(reset_targets(&names, &requests), Some(Vec::new()), "no selector names a field"); - let mut requests = requests; - requests.push(vec!["group7".into(), "group7.row7.cell".into(), "group8.row8".into()]); - let hit = reset_targets(&names, &requests).unwrap(); - assert_eq!(hit.len(), 200 + 1, "group7 has 200 fields, one more by its exact name"); - assert!(hit.iter().all(|n| n.starts_with("group7.") || n == "group8.row8.cell")); - assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed()); - requests.push(Vec::new()); - assert_eq!(reset_targets(&names, &requests), None, "a call without names resets the whole form"); - } - /// A click returns its own alert when the output waiting in the document is full, and the /// waiting output stays. #[test] @@ -739,8 +1566,9 @@ mod review_tests { second.value = vec!["second".into()]; second.display = pdfcraft_js::DISPLAY_HIDDEN; let before = vec![first.clone(), second.clone()]; - assert!(changes(&before, std::slice::from_ref(&first)).is_empty()); - let actual = changes(&before, std::slice::from_ref(&second)); + let formatted = std::collections::BTreeSet::new(); + assert!(changes(&before, std::slice::from_ref(&first), &formatted).is_empty()); + let actual = changes(&before, std::slice::from_ref(&second), &formatted); assert_eq!( actual, vec![FieldChange { @@ -762,12 +1590,476 @@ mod review_tests { assert_eq!(outcome.value, "first"); assert_eq!(outcome.changed.len(), 1); assert_eq!( - changes(&before, &outcome.changed), + changes(&before, &outcome.changed, &outcome.formatted), vec![FieldChange { name: "duplicate".into(), value: Some(vec!["updated".into()]), ..Default::default() }] ); - assert!(changes(&before, &[]).is_empty()); + assert!(changes(&before, &[], &formatted).is_empty()); let mut missing = first; missing.name = "missing".into(); - assert!(changes(&before, &[missing]).is_empty()); + assert!(changes(&before, &[missing], &formatted).is_empty()); + } +} + +#[cfg(test)] +mod caption_tests { + use super::*; + + #[test] + /// A setter matching widget zero still updates other widgets whose normal captions differ. + fn setting_the_first_widgets_caption_still_emits_a_field_wide_change() { + let mut button = pdfcraft_js::FieldState::new("go", pdfcraft_js::FieldType::Button, vec![]); + button.caption = Some("First".into()); + button.caption_mixed = true; + let before = vec![button]; + let outcome = pdfcraft_js::run( + "getField('go').buttonSetCaption('First');", + &pdfcraft_js::Event::field("Mouse Up", "go", ""), + &pdfcraft_js::DocInfo { file_name: "buttons.pdf".into(), num_pages: 1, page: 0, info: vec![], xfa: false, ..Default::default() }, + &before, + &[], + Limits::default(), + ); + assert_eq!(outcome.error, None); + let changed = changes(&before, &outcome.changed, &outcome.formatted); + assert_eq!(changed.len(), 1, "other widgets may have different captions"); + assert_eq!(changed[0].caption.as_deref(), Some("First")); + } +} + +#[cfg(not(target_arch = "wasm32"))] +pub struct PendingScript { + pub id: DocId, + generation: u64, + runtime: Runtime, + states: std::sync::Arc>, + pub receiver: std::sync::mpsc::Receiver, +} +#[cfg(not(target_arch = "wasm32"))] +impl Session { + /// Check document identity/generation and the JavaScript preference before accepting a worker completion. + pub fn script_pending_current(&self, p: &PendingScript) -> bool { + !self.js_off && self.get(p.id).is_some_and(|d| d.generation == p.generation && std::sync::Arc::ptr_eq(&d.js_runtime, &p.runtime)) + } + /// Capture the current fields/view and start a native script without blocking the caller on its prompts. + pub fn start_javascript(&mut self, id: DocId, script: &str, target: Option<&str>) -> Result { + self.start_javascript_target(id, script, target, None) + } + /// Start an asynchronous button event retaining the clicked field's exact identity. + pub fn start_javascript_target( + &mut self, + id: DocId, + script: &str, + target: Option<&str>, + target_id: Option, + ) -> Result { + if self.js_off { + return Err(EditError::Invalid("JavaScript is disabled".into())); + } + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let cos = &doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?.cos; + let mut runner = JsRunner::new(cos, &doc.name, self.field_highlight); + runner.doc.zoom = doc.js_zoom; + runner.doc.page = doc.js_page; + let fields = pdfcraft_forms::fields(cos); + validate_script_target(target, target_id, &fields)?; + let states = std::sync::Arc::new(fields.iter().map(|f| field_state(cos, f, &runner.doc)).collect::>()); + let job = pdfcraft_js::session::Job { + script: script.into(), + event: target.map_or_else( + || pdfcraft_js::Event::doc("Console"), + |t| pdfcraft_js::Event { will_commit: false, target_id, ..pdfcraft_js::Event::field("Mouse Up", t, "") }, + ), + doc: runner.doc, + fields: states.clone(), + doc_scripts: runner.doc_scripts, + calculations: calculation_scripts(cos, &fields), + }; + let receiver = + doc.js_runtime.lock().map_err(|_| EditError::Invalid("script runtime unavailable".into()))?.start(job).map_err(EditError::Invalid)?; + Ok(PendingScript { id, generation: doc.generation, runtime: doc.js_runtime.clone(), states, receiver }) + } + /// Discard only the captured runtime, preserving a newer realm created by an intervening edit. + pub fn discard_javascript(&mut self, p: &PendingScript) { + if let Some(doc) = self.docs.iter_mut().find(|d| d.id == p.id) + && std::sync::Arc::ptr_eq(&doc.js_runtime, &p.runtime) + { + replace_runtime(&mut doc.js_runtime); + } + } + /// Discard a timed-out invocation and retain the worker slot until its thread exits. + pub fn timeout_javascript(&mut self, p: PendingScript) { + if self.script_pending_current(&p) + && let Ok(mut runtime) = p.runtime.lock() + { + runtime.retire_timed_out_worker(); + } else { + self.discard_javascript(&p); + } + } + + /// Apply a current worker result, or discard it and reset the realm when the document changed. + pub fn finish_javascript(&mut self, p: PendingScript, out: Outcome) -> Result { + if !self.script_pending_current(&p) { + self.discard_javascript(&p); + return Err(EditError::Invalid("document changed while the script was running".into())); + } + p.runtime.lock().map_err(|_| EditError::Invalid("script runtime unavailable".into()))?.acknowledge_completion(); + let discard = out.execution_refused && !out.realm_untouched; + let result = self.apply_script_outcome(p.id, &p.states, out, false); + if discard || result.is_err() { + self.invalidate_javascript(p.id); + } + result + } +} +pub use pdfcraft_js::interaction::{InteractiveEvent, MenuItem, Prompt}; +impl Session { + /// Supply current viewer state for script getters, clamping invalid zoom to the supported range. + pub fn set_javascript_view(&mut self, id: DocId, zoom: f64, highlight: bool, page: usize) { + self.set_field_highlight(highlight); + if let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) { + doc.js_zoom = if zoom.is_finite() { zoom.clamp(1.0, 6400.0) } else { 100.0 }; + + doc.js_page = page; + } + } +} + +impl Session { + /// Release this document runtime after cancellation or lifecycle changes; the next event initializes anew. + pub fn invalidate_javascript(&mut self, id: DocId) { + if let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) { + replace_runtime(&mut doc.js_runtime); + } + } +} + +#[cfg(test)] +thread_local! { + /// Page-tree snapshots and calculation-plan builds performed on this test thread. + static SNAPSHOT_WORK: std::cell::Cell<(usize,usize)> = const { std::cell::Cell::new((0,0)) }; +} + +#[cfg(test)] +mod snapshot_review_tests { + use super::*; + + #[test] + fn document_actions_use_the_document_realm_and_obey_javascript_setting() { + let mut session = Session::new(); + let bytes = session.create_blank(200.0, 300.0, 1).unwrap(); + let first = session.open("first.pdf", None, bytes.clone(), None).unwrap(); + let second = session.open("second.pdf", None, bytes, None).unwrap(); + session.run_javascript(first, "var counter = 4;", None).unwrap(); + + for (event, name) in + [(DocumentScriptEvent::CatalogOpen, "Open"), (DocumentScriptEvent::WillSave, "WillSave"), (DocumentScriptEvent::WillPrint, "WillPrint")] + { + let result = session.run_document_script_event(first, event, "[event.name,counter].join('/');").unwrap(); + assert_eq!(result.result, Some(format!("{name}/4"))); + } + let result = session.run_document_script_event(second, DocumentScriptEvent::WillSave, "typeof counter;").unwrap(); + assert_eq!(result.result.as_deref(), Some("undefined")); + session.set_javascript(false); + assert!(session.run_document_script_event(first, DocumentScriptEvent::WillPrint, "counter++;").is_err()); + } + + /// Real page/info edits and completed Save As refresh metadata without losing script state. + #[test] + fn retained_document_metadata_tracks_edits_undo_and_save_as() { + let mut session = Session::new(); + let bytes = session.create_blank(200.0, 300.0, 1).unwrap(); + let id = session.open("before.pdf", None, bytes, None).unwrap(); + assert_eq!(session.run_javascript(id, "var retained=41;numPages;", None).unwrap().result.as_deref(), Some("1")); + session.apply(id, Edit::InsertBlankPage { at: 1, width: 200.0, height: 300.0 }).unwrap(); + session.apply(id, Edit::SetInfo { key: "Title".into(), value: "After".into() }).unwrap(); + let out = session.run_javascript(id, "[numPages,info.title,retained].join('/');", None).unwrap(); + assert_eq!(out.result.as_deref(), Some("2/After/41")); + session.undo(id).unwrap(); + assert_eq!(session.run_javascript(id, "typeof info.title;", None).unwrap().result.as_deref(), Some("undefined")); + session.redo(id).unwrap(); + let saved = session.save_bytes(id).unwrap(); + session.mark_saved(id, saved, Some("after.pdf".into())).unwrap(); + assert_eq!( + session.run_javascript(id, "[documentFileName,numPages,info.title,retained].join('/');", None).unwrap().result.as_deref(), + Some("after.pdf/2/After/41") + ); + session.apply(id, Edit::DeletePages { pages: vec![1] }).unwrap(); + assert_eq!(session.run_javascript(id, "[numPages,retained].join('/');", None).unwrap().result.as_deref(), Some("1/41")); + session.undo(id).unwrap(); + assert_eq!(session.run_javascript(id, "numPages;", None).unwrap().result.as_deref(), Some("2")); + session.redo(id).unwrap(); + assert_eq!(session.run_javascript(id, "numPages;", None).unwrap().result.as_deref(), Some("1")); + } + + /// A batch child that changes page structure or calculation sources invalidates cached plans. + #[test] + fn batch_children_refresh_geometry_metadata_and_calculation_sources() { + let mut session = Session::new(); + let bytes = session.create_blank(200.0, 300.0, 1).unwrap(); + let id = session.open("batch.pdf", None, bytes, None).unwrap(); + for (i, name) in ["input", "total", "seen"].into_iter().enumerate() { + session + .apply( + id, + Edit::AddField { + page: 0, + rect: [20.0, 20.0 + i as f64 * 40.0, 120.0, 40.0 + i as f64 * 40.0], + kind: crate::NewField::Text { multiline: false }, + name: Some(name.into()), + }, + ) + .unwrap(); + } + session.apply(id, Edit::SetFieldValue { name: "input".into(), value: crate::FieldValue::Text("2".into()) }).unwrap(); + session + .apply( + id, + Edit::SetFieldScript { + name: "total".into(), + event: "calculate".into(), + script: Some("event.value=Number(getField('input').value)+1;".into()), + }, + ) + .unwrap(); + session + .apply( + id, + Edit::SetFieldScript { + name: "input".into(), + event: "validate".into(), + script: Some("calculateNow();getField('seen').value=numPages+'/'+getField('total').value;".into()), + }, + ) + .unwrap(); + session + .apply( + id, + Edit::Batch { + label: "Structural batch".into(), + edits: vec![ + Edit::SetFieldValue { name: "input".into(), value: crate::FieldValue::Text("3".into()) }, + Edit::InsertBlankPage { at: 1, width: 200.0, height: 300.0 }, + Edit::SetFieldScript { + name: "total".into(), + event: "calculate".into(), + script: Some("event.value=Number(getField('input').value)+10;".into()), + }, + Edit::SetFieldValue { name: "input".into(), value: crate::FieldValue::Text("4".into()) }, + ], + }, + ) + .unwrap(); + let out = session.run_javascript(id, "getField('seen').value;", None).unwrap(); + assert_eq!(out.error, None); + assert_eq!(out.result.as_deref(), Some("2/13")); + } + + #[test] + fn callback_field_cache_keeps_live_values_and_uses_object_identity() { + let mut session = Session::new(); + let bytes = session.create_blank(200.0, 300.0, 1).unwrap(); + let id = session.open("cache.pdf", None, bytes, None).unwrap(); + for (name, rect) in [("first", [20.0, 20.0, 100.0, 40.0]), ("second", [20.0, 60.0, 100.0, 80.0])] { + session.apply(id, Edit::AddField { page: 0, rect, kind: crate::NewField::Text { multiline: false }, name: Some(name.into()) }).unwrap(); + } + + let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos; + let mut fields = pdfcraft_forms::fields(cos); + fields[1].name = fields[0].name.clone(); + let mut runner = JsRunner::new(cos, "cache.pdf", false); + let initial = runner.callback_fields(cos, &fields); + assert_ne!(initial[0].object_id, initial[1].object_id); + assert_ne!(initial[0].rect, initial[1].rect); + fields[0].value = vec!["fresh".into()]; + let updated = runner.callback_fields(cos, &fields); + assert_eq!(updated[0].value, ["fresh"]); + assert_eq!(updated[1].value, initial[1].value); + assert_eq!(runner.snapshot_fields.len(), 2); + + fields[0].widgets[0].rect = [20.0, 100.0, 100.0, 120.0]; + runner.invalidate_field_snapshots(); + let moved = runner.callback_fields(cos, &fields[..1]); + assert_ne!(moved[0].rect, updated[0].rect); + assert_eq!(runner.snapshot_fields.len(), 1); + } + + /// A callback reuses the previous callback's snapshot, patching only values that changed, + /// and a snapshot a caller still holds is never changed under it. + #[test] + fn callback_snapshots_are_patched_in_place_and_copied_only_when_shared() { + let mut session = Session::new(); + let bytes = session.create_blank(200.0, 300.0, 1).unwrap(); + let id = session.open("patch.pdf", None, bytes, None).unwrap(); + for (name, rect) in [("first", [20.0, 20.0, 100.0, 40.0]), ("second", [20.0, 60.0, 100.0, 80.0])] { + session.apply(id, Edit::AddField { page: 0, rect, kind: crate::NewField::Text { multiline: false }, name: Some(name.into()) }).unwrap(); + } + let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos; + let mut fields = pdfcraft_forms::fields(cos); + let mut runner = JsRunner::new(cos, "patch.pdf", false); + let first = runner.callback_fields(cos, &fields); + let first_ptr = std::sync::Arc::as_ptr(&first); + drop(first); + // Nothing changed: the same snapshot comes back. + let same = runner.callback_fields(cos, &fields); + assert_eq!(std::sync::Arc::as_ptr(&same), first_ptr); + drop(same); + // A value changed and no one else holds the snapshot: it is patched in place. + fields[1].value = vec!["7".into()]; + let patched = runner.callback_fields(cos, &fields); + assert_eq!(std::sync::Arc::as_ptr(&patched), first_ptr); + assert_eq!(patched[1].value, ["7"]); + // A held snapshot keeps its values; the next callback gets a copy. + fields[0].value = vec!["held".into()]; + let copied = runner.callback_fields(cos, &fields); + assert_eq!(patched[0].value, Vec::::new()); + assert_eq!(copied[0].value, ["held"]); + assert_eq!(copied[1].value, ["7"]); + // Instance tokens are stamped before dispatch, so the worker does not copy it again. + assert!(copied.iter().all(|f| f.instance_id != 0)); + } + + /// Operation counts, rather than wall time, pin the absence of per-field tree/plan walks. + #[test] + fn field_events_reuse_page_geometry_and_calculation_plans() { + let mut session = Session::new(); + let bytes = session.create_blank(600.0, 800.0, 4).unwrap(); + let id = session.open("synthetic.pdf", None, bytes, None).unwrap(); + for i in 0..12 { + session + .apply( + id, + Edit::AddField { + page: i % 4, + rect: [20.0, 20.0, 100.0, 40.0], + kind: crate::NewField::Text { multiline: false }, + name: Some(format!("f{i}")), + }, + ) + .unwrap(); + } + let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos; + let mut fields = pdfcraft_forms::fields(cos); + fields[1].actions.scripts.calculate = Some("event.value=Number(getField('f0').value)+1;".into()); + SNAPSHOT_WORK.with(|c| c.set((0, 0))); + let mut runner = JsRunner::new(cos, "synthetic.pdf", false); + for i in 0..8 { + fields[0].value = vec![i.to_string()]; + let out = runner.run(FieldEvent::Calculate, "calculateNow();event.value=getField('f1').value;", &fields[1], "", &fields, cos); + assert_eq!(out.value, (i + 1).to_string()); + assert!(runner.output.errors.is_empty(), "{:?}", runner.output.errors); + } + let work = SNAPSHOT_WORK.with(std::cell::Cell::get); + eprintln!("geometry snapshots={}, calculation plan builds={}", work.0, work.1); + assert_eq!(work, (1, 1), "one geometry snapshot and one calculation plan per runner"); + } +} + +#[cfg(test)] +mod reset_budget_review_tests { + use super::*; + fn state(name: String) -> pdfcraft_js::FieldState { + pdfcraft_js::FieldState::new(name, pdfcraft_js::FieldType::Text, Vec::new()) + } + #[test] + fn reset_budget_accepts_one_selected_field_in_a_large_form() { + let states: Vec<_> = (0..1000).map(|i| state(format!("parent.field_{i:04}"))).collect(); + assert!(validate_reset_work(&[], &states, &[Request::Reset(vec!["parent.field_0001".into()])]).is_ok()); + assert!(validate_reset_work(&[], &states, &[Request::Reset(vec!["parent".into()])]).is_err()); + } + + #[test] + fn reset_budget_charges_expanded_descendants() { + let states: Vec<_> = (0..3000).map(|i| state(format!("parent.{i}"))).collect(); + assert!(validate_reset_work(&[], &states, &[Request::Reset(vec!["parent".into()])]).is_err()); + assert!(validate_reset_work(&[], &states, &[Request::Reset(Vec::new())]).is_ok()); + } + #[test] + fn reset_budget_charges_shared_prefix_comparisons() { + let prefix = "p".repeat(4096); + let states: Vec<_> = (0..128).map(|i| state(format!("{prefix}.{i}"))).collect(); + let names = (0..128).map(|i| format!("{prefix}.missing{i}")).collect(); + assert!(validate_reset_work(&[], &states, &[Request::Reset(names)]).is_err()); + } + #[test] + fn reset_budget_charges_full_form_name_copies() { + let states = vec![state("n".repeat(140_000))]; + let resets = vec![Request::Reset(Vec::new()); 64]; + assert!(validate_reset_work(&[], &states, &resets).is_err()); + assert!(validate_reset_work(&[], &states, &[Request::Reset(Vec::new())]).is_ok()); + } +} + +#[cfg(test)] +mod widget_budget_review_tests { + use super::*; + #[test] + fn reset_budget_charges_all_widgets() { + let widget = pdfcraft_forms::Widget { + obj: pdfcraft_cos::ObjRef { num: 1, generation: 0 }, + page: None, + rect: [0.0; 4], + on_state: None, + state: None, + tab: 0, + locked: false, + hidden: false, + read_only: false, + rotation: 0, + caption: None, + mouse_up_script: None, + list: None, + round: false, + }; + let mut field = Field { + name: "x".into(), + obj: widget.obj, + kind: FieldKind::Text, + value: vec![], + default: vec![], + flags: 0, + max_len: None, + options: vec![], + da: String::new(), + quadding: 0, + tooltip: None, + selected: vec![], + single_value_is_array: false, + widgets: vec![widget; 140_000], + actions: Default::default(), + button: None, + }; + let resets = vec![Request::Reset(Vec::new()); 64]; + assert!(validate_reset_work(std::slice::from_ref(&field), &[], &resets).is_err()); + field.widgets.truncate(1); + assert!(validate_reset_work(&[field], &[], &resets).is_ok()); + } +} + +#[cfg(test)] +mod icon_mixed_review_tests { + use super::*; + /// Mixed widgets need a field-wide icon delta even when the first icon is already correct. + #[test] + fn mixed_icons_emit_only_after_an_explicit_setter() { + for icon in [None, Some(7)] { + let mut before = pdfcraft_js::FieldState::new("go", pdfcraft_js::FieldType::Button, vec![]); + before.icon = icon; + before.icon_mixed = true; + let fields = vec![before.clone()]; + let script = + if icon.is_some() { "getField('go').buttonSetIcon(getField('go').buttonGetIcon());" } else { "getField('go').buttonSetIcon(null);" }; + let out = pdfcraft_js::run( + script, + &pdfcraft_js::Event::doc("Test"), + &pdfcraft_js::DocInfo::default(), + &fields, + &[], + pdfcraft_js::Limits::default(), + ); + assert_eq!(out.error, None); + assert_eq!(changes(&[before.clone()], &out.changed, &out.formatted)[0].icon, Some(icon)); + assert!(changes(&[before.clone()], &[before], &Default::default()).is_empty()); + } } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0dc7c231cb..adb69cec46 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -32,6 +32,7 @@ mod error; pub mod export; pub mod field_table; mod fill_sign; +pub mod find_replace; mod history; pub mod js; mod layers; @@ -40,6 +41,7 @@ pub mod model3d; mod object_clip; pub mod ocr; mod open; +pub mod open_parameters; pub mod optimizer; mod page_filter; mod pictures; @@ -54,6 +56,7 @@ mod session; pub mod signature_image; pub mod signature_scan; mod sources; +pub mod spooler; mod stamp_preview; #[cfg(test)] mod test_hooks; @@ -89,15 +92,17 @@ pub use pdfcraft_organize::check_custom_key; pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation}; pub use pdfcraft_cos::{Algorithm, civil_from_days}; -pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind}; +pub use pdfcraft_create::{ + CONVERTIBLE, CreateError, IMAGE_EXTENSIONS, ImageResolution, OFFICE_EXTENSIONS, SourceKind, TEXT_EXTENSIONS, decode_text, source_kind, +}; pub use pdfcraft_edit::added::{ CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, paste_place as added_paste_place, paste_rect as added_paste_rect, text_rect as added_text_rect, text_width as added_text_width, }; pub use pdfcraft_edit::paragraph_align; pub use pdfcraft_edit::{ - Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, Hairlines, HairlinesFixed, - HeaderFooter, Ink as TextInk, MarkKind, Watermark, + Added, AddedImage, AddedText, Align as TextAlign, Background, ColourTarget, ColoursConverted, Content as AddedContent, ConvertColours, + Family as FontFamily, Hairlines, HairlinesFixed, HeaderFooter, Ink as TextInk, MarkKind, PrinterMarks, Watermark, }; pub use pdfcraft_forms::{ BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, @@ -120,20 +125,24 @@ pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, scr pub use pdfcraft_annot::appearance as annot_text; pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle, LinkZoom}; pub use pdfcraft_annot::{ - Activation3d, AppearanceCache, AttachIcon, BorderShape, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, - OverlayLook, Props as CommentProps, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad, + Activation3d, AppearanceCache, AttachIcon, BorderShape, CALLOUT_KNEE_LINE, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, + OverlayFont, OverlayLook, Props as CommentProps, ReviewState, Rgb, Shape, Side as CalloutSide, StampGroup, StampKind, Style, callout_attach, + callout_knee, callout_side, rect_quad, }; pub use pdfcraft_annot::{MAX_MODEL_BYTES as MAX_3D_MODEL_BYTES, ModelFormat as Model3dFormat}; pub use pdfcraft_forms::detect; pub use pdfcraft_optimize as optimize; pub use pdfcraft_preflight as pdfa; pub use pdfcraft_print as print; +pub use pdfcraft_redact::Elsewhere as RedactionElsewhere; pub use pdfcraft_redact::codes::{CODE_SETS as REDACTION_CODE_SETS, CodeSet as RedactionCodeSet}; pub use pdfcraft_redact::patterns::{ MAX_WORDS as REDACT_MAX_WORDS, PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern, word_list as redact_word_list, }; +pub use pdfcraft_redact::redacted_file_name; pub use pdfcraft_redact::sanitize::{HIDDEN, Hidden}; pub use pdfcraft_redact::user_regex::{UserRegex as RedactRegex, VALIDATORS as REDACT_VALIDATORS, Validator as RedactValidator}; +pub use pdfcraft_redact::{Claim as RedactionClaim, Identification as RedactionIdentification}; pub use pdfcraft_sign as sign; pub use pdfcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, StatusCaptions, TrustStore}; pub use pdfcraft_xfdf::Format as DataFormat; @@ -159,7 +168,7 @@ use pictures::{embed_image, fit_picture, mark_source, model_poster}; use protection::unrestricted; pub use protection::{Changes, Printing, Protection, SecuritySummary}; pub use session::Session; -pub use sources::{CombineSource, Combined, combine_source_check, source_page_count}; +pub use sources::{CombineSource, Combined, combine_source_check, office_to_pdf, source_page_count}; use sources::{open_source, open_source_with}; #[cfg(test)] use test_hooks::{ @@ -188,5 +197,10 @@ fn display_config(config: &pdfcraft_render::RenderConfig) -> pdfcraft_render::Re config.clone().on_screen() } +/// How long a custom stamp's text line may be, in characters (#1741). +pub const MAX_STAMP_LINE: usize = 200; + +#[cfg(test)] +mod document_actions_tests; #[cfg(test)] mod tests; diff --git a/crates/engine/src/ocr.rs b/crates/engine/src/ocr.rs index 512c1c97ad..b2a4717e46 100644 --- a/crates/engine/src/ocr.rs +++ b/crates/engine/src/ocr.rs @@ -1156,6 +1156,7 @@ pub struct FileResult { } impl FileResult { + /// How many words were recognized on all of the file's pages. pub fn words(&self) -> usize { self.pages.iter().map(|p| p.words.len()).sum() } @@ -1202,19 +1203,21 @@ pub fn low_confidence_pages(pages: &[OcrPage], threshold: f32) -> Option<(usize, (count > 0).then_some((count, page_numbers)) } -/// Recognize text in multiple files: one PDF's bytes in, the searchable PDF out. `progress` -/// works as for [`OcrJob::run`]. A file that the skip-files option passes over comes back with -/// [`FileResult::file_skipped`] set and its original bytes; the caller decides what to do with -/// it, but it is never an OCR success. +/// Recognize text in multiple files: one PDF's bytes in, the searchable PDF out. `javascript` is +/// the caller's [`Session::javascript`] preference (when off, no open-time script runs). +/// `progress` works as for [`OcrJob::run`]. A file that the skip-files option passes over comes +/// back with [`FileResult::file_skipped`] set and its original bytes; the caller decides what to +/// do with it, but it is never an OCR success. pub fn recognize_file( name: &str, bytes: Arc>, password: Option<&str>, settings: OcrSettings, recognizers: &Recognizers, + javascript: bool, progress: impl FnMut(usize, usize) -> bool, ) -> Result { - match FileJob::open(name, bytes, password, settings)? { + match FileJob::open(name, bytes, password, settings, javascript)? { Opened::Skipped(result) => Ok(result), Opened::Read(mut file) => { let job = file.job.take().ok_or("the document could not be read")?; @@ -1242,12 +1245,9 @@ pub enum Opened { } impl FileJob { - pub fn open(name: &str, bytes: Arc>, password: Option<&str>, settings: OcrSettings) -> Result { - let mut session = Session::new(); - let id = session.open(name, None, bytes.clone(), password).map_err(|e| e.to_string())?; - if let Some(why) = session.get(id).and_then(|d| d.read_only_reason.clone()) { - return Err(why); - } + /// Open the file; `javascript` is as for [`recognize_file`]. + pub fn open(name: &str, bytes: Arc>, password: Option<&str>, settings: OcrSettings, javascript: bool) -> Result { + let (session, id) = open_file(name, bytes.clone(), password, javascript)?; let job = session.ocr_job(id, &[], settings.clone()).ok_or("the document could not be read")?; // The skip-files probe runs before any engine work: a file with text needs no engine at // all, and the original bytes come back unchanged. @@ -1280,11 +1280,38 @@ impl FileJob { } } +/// Open one of [`recognize_file`]'s files in a session of its own that keeps the caller's +/// JavaScript preference; a file that cannot be changed is refused with the reason. +fn open_file(name: &str, bytes: Arc>, password: Option<&str>, javascript: bool) -> Result<(Session, DocId), String> { + let mut s = Session::new(); + // Before the open: open-time (XFA) scripts read it there. + s.set_javascript(javascript); + let id = s.open(name, None, bytes, password).map_err(|e| e.to_string())?; + if let Some(why) = s.get(id).and_then(|d| d.read_only_reason.clone()) { + return Err(why); + } + Ok((s, id)) +} + #[cfg(test)] mod tests { use super::*; use crate::export; + /// #5080: [`recognize_file`] opens each file with the caller's JavaScript preference, so an + /// XFA initialize script (it sets an empty `qty` to 2) runs only when that is on. This checks + /// the open path without the recognition models, which the end-to-end test needs. + #[test] + fn files_open_for_recognition_with_the_callers_javascript_preference() { + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())); + let qty = |javascript| { + let (s, id) = open_file("scripted.pdf", bytes.clone(), None, javascript).unwrap(); + s.get(id).unwrap().form.iter().find(|f| f.name == "qty").unwrap().value.clone() + }; + assert!(qty(false).is_empty(), "the initialize script ran with JavaScript off"); + assert_eq!(qty(true), ["2"], "the initialize script did not run with JavaScript on"); + } + /// One 300 × 300 page drawing `content`, with Helvetica as `/F1`. fn page(content: &str) -> Arc> { let pdf = format!( @@ -1741,7 +1768,7 @@ mod tests { let bytes = s.create_from_text("t", "Already typed here").unwrap(); let (r, fake) = fake_recognizers(); let settings = OcrSettings { skip_text_files: true, ..Default::default() }; - let out = recognize_file("t.pdf", bytes.clone(), None, settings, &r, |_, _| true).unwrap(); + let out = recognize_file("t.pdf", bytes.clone(), None, settings, &r, true, |_, _| true).unwrap(); assert_eq!(fake.calls(), 0, "the engine never ran"); assert_eq!(out.bytes.as_ptr(), bytes.as_ptr(), "the original bytes came back"); assert_eq!(out.file_skipped.as_deref(), Some("the file already contains text")); @@ -1756,7 +1783,7 @@ mod tests { let bytes = s.create_from_text("t", "Already typed here").unwrap(); let (r, fake) = fake_recognizers(); let settings = OcrSettings { skip_text_files: true, force_ocr: true, ..Default::default() }; - let out = recognize_file("t.pdf", bytes, None, settings, &r, |_, _| true).unwrap(); + let out = recognize_file("t.pdf", bytes, None, settings, &r, true, |_, _| true).unwrap(); assert_eq!(fake.calls(), 1); assert_eq!(out.file_skipped, None); assert_eq!(out.words(), 1); @@ -1770,7 +1797,7 @@ mod tests { let bytes = s.create_from_text("t", "A page with text").unwrap(); let (r, fake) = fake_recognizers(); let settings = OcrSettings { force_ocr: true, ..Default::default() }; - let out = recognize_file("t.pdf", bytes, None, settings, &r, |_, _| true).unwrap(); + let out = recognize_file("t.pdf", bytes, None, settings, &r, true, |_, _| true).unwrap(); assert_eq!(fake.calls(), 1, "the page's own text did not stop the run"); assert_eq!(out.pages[0].skipped, None); } @@ -1782,12 +1809,12 @@ mod tests { let s = Session::new(); let bytes = s.create_from_text("t", "A page with text").unwrap(); let (r, fake) = fake_recognizers(); - let out = recognize_file("t.pdf", bytes, None, OcrSettings::default(), &r, |_, _| true).unwrap(); + let out = recognize_file("t.pdf", bytes, None, OcrSettings::default(), &r, true, |_, _| true).unwrap(); assert_eq!(fake.calls(), 0); assert_eq!(out.pages[0].skipped.as_deref(), Some("the page already contains text")); assert_eq!(out.words(), 0); // Second run over the same (already text-bearing) file: skipped pages again, no calls. - let again = recognize_file("t.pdf", out.bytes, None, OcrSettings::default(), &r, |_, _| true).unwrap(); + let again = recognize_file("t.pdf", out.bytes, None, OcrSettings::default(), &r, true, |_, _| true).unwrap(); assert_eq!(fake.calls(), 0); assert_eq!(again.pages[0].skipped.as_deref(), Some("the page already contains text")); } @@ -1863,14 +1890,14 @@ mod tests { let bytes = s.create_from_text("t", "Already typed here").unwrap(); let (r, fake) = fake_recognizers(); let skip = OcrSettings { skip_text_files: true, ..Default::default() }; - let Ok(Opened::Skipped(out)) = FileJob::open("t.pdf", bytes.clone(), None, skip) else { panic!("the file should be skipped") }; + let Ok(Opened::Skipped(out)) = FileJob::open("t.pdf", bytes.clone(), None, skip, true) else { panic!("the file should be skipped") }; assert_eq!(out.file_skipped.as_deref(), Some("the file already contains text")); let force = OcrSettings { force_ocr: true, ..Default::default() }; - let Ok(Opened::Read(mut file)) = FileJob::open("t.pdf", bytes.clone(), None, force.clone()) else { panic!("the file should be read") }; + let Ok(Opened::Read(mut file)) = FileJob::open("t.pdf", bytes.clone(), None, force.clone(), true) else { panic!("the file should be read") }; let pages = file.job.take().unwrap().run(&r, |_, _| true); let out = file.finish(pages).unwrap(); assert_eq!((fake.calls(), out.words(), out.file_skipped.clone()), (1, 1, None)); - let whole = recognize_file("t.pdf", bytes, None, force, &r, |_, _| true).unwrap(); + let whole = recognize_file("t.pdf", bytes, None, force, &r, true, |_, _| true).unwrap(); // The saved bytes carry a modification time, so two saves a second apart differ there alone. assert_eq!((out.pages, out.file_skipped), (whole.pages, whole.file_skipped), "the same as reading it in one call"); } @@ -2321,4 +2348,44 @@ mod tests { } } } + + /// The first and last letters of a line are read: ocrs' own 3-pixel margin clipped them at + /// 300 dpi ("The" read as "he", "and" as "an", full stops lost, about 4 % of the words). + /// Needs the OCR models (`cargo xtask models`). + #[test] + fn the_first_and_last_letters_of_a_line_are_read() { + // The default engine is ocrs: Tesseract alone makes `available()` true but cannot run it. + if Models::find().is_none() { + eprintln!("skipped: OCR models not installed"); + return; + } + let paragraph = "The annual report summarizes revenue, operating costs and the outlook for the coming year. \ + Sales in the northern region grew by twelve percent, while the southern offices remained flat. \ + Management expects stable margins despite higher prices for energy and transport. "; + let text = paragraph.repeat(6); + let s = Session::new(); + let pdf = s.create_from_text("t", &text).unwrap(); + let mut viewer = Session::new(); + let id = viewer.open("text.pdf", None, pdf, None).unwrap(); + assert_eq!(viewer.get(id).unwrap().info.pages.len(), 1, "the text fits one page"); + let png = export::Exporter::new(viewer.get(id).unwrap()).png(0, 300.0).unwrap(); + let scan = s.create_from_images(&[("1.png".into(), png)]).unwrap(); + let mut reader = Session::new(); + let id = reader.open("scan.pdf", None, scan, None).unwrap(); + let read = reader.recognize_text(id, &[], OcrSettings::default()).unwrap()[0].text(); + let (got, want): (Vec<&str>, Vec<&str>) = (read.split_whitespace().collect(), text.split_whitespace().collect()); + // Word-level edit distance. + let mut row: Vec = (0..=want.len()).collect(); + for g in &got { + let mut prev = row[0]; + row[0] += 1; + for (j, w) in want.iter().enumerate() { + let next = (prev + usize::from(g != w)).min(row[j] + 1).min(row[j + 1] + 1); + prev = row[j + 1]; + row[j + 1] = next; + } + } + let errors = row[want.len()]; + assert!(errors * 100 <= want.len(), "{errors} word errors in {} words:\n{read}", want.len()); + } } diff --git a/crates/engine/src/open.rs b/crates/engine/src/open.rs index a09acc9360..685ac50432 100644 --- a/crates/engine/src/open.rs +++ b/crates/engine/src/open.rs @@ -18,6 +18,7 @@ pub struct OpenContext { now: Option, js_off: bool, trust: Arc, + highlight: bool, captions: Arc, xfa_time_scale: u32, } @@ -39,6 +40,14 @@ struct DocumentOpenData<'a> { } impl OpenContext { + /// This context with Highlight fields `on`, which the document's scripts read as + /// `app.runtimeHighlight` while it opens. A new document opens with the remembered setting + /// while the session keeps the one of the document in front, which its scripts read. + pub fn with_highlight(mut self, on: bool) -> Self { + self.highlight = on; + self + } + /// Prepare a document without mutating the session that supplied this context. pub fn prepare_open( &self, @@ -60,6 +69,7 @@ impl Session { now: self.now(), js_off: self.js_off, trust: self.trust.clone(), + highlight: self.field_highlight, captions: self.captions.clone(), xfa_time_scale: self.xfa_time_scale.unwrap_or(1), } @@ -91,6 +101,7 @@ impl Session { Self::draw_captions(&mut document, self.captions.clone()); } self.docs.push(document); + self.run_open_scripts(id); id } @@ -100,6 +111,8 @@ impl Session { Ok(self.commit_open(prepared)) } + /// What [`OpenContext::prepare_open`] runs inside its panic guard: read, decrypt and lay out + /// the document, and run an XFA form's opening scripts. fn prepare_open_unguarded( context: &OpenContext, name: String, @@ -210,6 +223,7 @@ impl Session { out: &mut script_output, datasets: None, ran_away: false, + highlight: context.highlight, time_scale: context.xfa_time_scale, }; let changes = xfa::on_open(&mut work, &tpl, &mut run); @@ -368,9 +382,13 @@ impl Session { editor, config, js_output: Default::default(), + js_runtime: Default::default(), + js_zoom: 100.0, + js_page: 0, xfa, xfa_template: None, xfa_scripts_off: false, + xfa_exited: Default::default(), fonts_stale: false, xfa_warnings: Vec::new(), edit_notices: Vec::new(), diff --git a/crates/engine/src/open_parameters.rs b/crates/engine/src/open_parameters.rs new file mode 100644 index 0000000000..83aed4f02d --- /dev/null +++ b/crates/engine/src/open_parameters.rs @@ -0,0 +1,101 @@ +//! PDF open parameters on a path (#3425): help systems and other programs start a PDF reader with +//! `manual.pdf#nameddest=Intro` or `manual.pdf#page=12` to show one place in a document. The +//! parameters follow Adobe's public "Parameters for Opening PDF Files": `name=value` pairs after a +//! `#`, separated by `&` or `#`. PdfCraft reads `page` (1-based) and `nameddest`, and, as browsers' +//! PDF viewers do, a fragment without `=` as a destination name. Other parameters (`zoom`, `view`, +//! `pagemode`, `search`, …) are ignored, and the file still opens. + +use pdfcraft_render::{DestView, RemoteDestination}; + +/// How many `#` of one path are tried as the end of its file name. +const MAX_CANDIDATES: usize = 32; + +/// The file `path` names and the destination its open parameters ask for, when `path` is not a +/// file itself but what comes before one of its `#` is. The first such `#` counts, so a folder or +/// file name may contain `#` too. `None`: open `path` as it is. +pub fn split(path: &str, is_file: impl Fn(&str) -> bool) -> Option<(String, RemoteDestination)> { + if is_file(path) { + return None; + } + let (file, fragment) = path.match_indices('#').take(MAX_CANDIDATES).find_map(|(i, _)| { + // `#` is one byte, so `i` and `i + 1` are char boundaries. + let file = path.get(..i)?; + is_file(file).then(|| (file, path.get(i + 1..).unwrap_or_default())) + })?; + Some((file.to_string(), destination(fragment))) +} + +/// The destination of a fragment's parameters; the last `page` or `nameddest` wins, as each +/// parameter is applied in turn. A page before the first, or one that isn't a number, is ignored. +fn destination(fragment: &str) -> RemoteDestination { + if !fragment.contains('=') { + return if fragment.is_empty() { RemoteDestination::Default } else { RemoteDestination::Named(fragment.to_string()) }; + } + let mut destination = RemoteDestination::Default; + for (key, value) in fragment.split(['&', '#']).filter_map(|p| p.split_once('=')) { + if key.eq_ignore_ascii_case("page") { + if let Some(page) = value.trim().parse::().ok().and_then(|n| n.checked_sub(1)) { + destination = RemoteDestination::Page { page, view: DestView::Top }; + } + } else if key.eq_ignore_ascii_case("nameddest") && !value.is_empty() { + destination = RemoteDestination::Named(value.to_string()); + } + } + destination +} + +#[cfg(test)] +mod tests { + use super::*; + + fn files(names: &'static [&'static str]) -> impl Fn(&str) -> bool { + move |p| names.contains(&p) + } + + fn page(page: usize) -> RemoteDestination { + RemoteDestination::Page { page, view: DestView::Top } + } + + fn named(name: &str) -> RemoteDestination { + RemoteDestination::Named(name.to_string()) + } + + #[test] + fn a_named_destination_or_page_after_the_file_name_is_split_off() { + let manual = files(&[r"C:\Help\manual.pdf"]); + // #3425: DIgSILENT PowerFactory's F1 help. + assert_eq!(split(r"C:\Help\manual.pdf#nameddest=Load Flow", &manual), Some((r"C:\Help\manual.pdf".into(), named("Load Flow")))); + assert_eq!(split(r"C:\Help\manual.pdf#page=12", &manual), Some((r"C:\Help\manual.pdf".into(), page(11)))); + // A bare fragment names a destination, as in a browser. + assert_eq!(split(r"C:\Help\manual.pdf#intro", &manual), Some((r"C:\Help\manual.pdf".into(), named("intro")))); + } + + #[test] + fn several_parameters_apply_in_turn_and_unknown_ones_are_ignored() { + let pdf = files(&["/docs/a.pdf"]); + assert_eq!(split("/docs/a.pdf#page=3&zoom=200,0,0", &pdf), Some(("/docs/a.pdf".into(), page(2)))); + assert_eq!(split("/docs/a.pdf#zoom=50#nameddest=x", &pdf), Some(("/docs/a.pdf".into(), named("x")))); + assert_eq!(split("/docs/a.pdf#nameddest=x&page=4", &pdf), Some(("/docs/a.pdf".into(), page(3)))); + assert_eq!(split("/docs/a.pdf#PAGE=2", &pdf), Some(("/docs/a.pdf".into(), page(1)))); + // The file opens at its default view when nothing can be applied. + for odd in + ["/docs/a.pdf#", "/docs/a.pdf#page=0", "/docs/a.pdf#page=two", "/docs/a.pdf#page=-1", "/docs/a.pdf#nameddest=", "/docs/a.pdf#view=Fit"] + { + assert_eq!(split(odd, &pdf), Some(("/docs/a.pdf".into(), RemoteDestination::Default)), "{odd}"); + } + assert_eq!(split("/docs/a.pdf#page=99999999999999999999999", &pdf), Some(("/docs/a.pdf".into(), RemoteDestination::Default))); + } + + #[test] + fn a_path_that_names_a_file_is_left_alone() { + // A file whose name has a `#` in it, alone and with parameters of its own. + let hashed = files(&["/docs/C#.pdf", "/docs/#1/a.pdf"]); + assert_eq!(split("/docs/C#.pdf", &hashed), None); + assert_eq!(split("/docs/C#.pdf#page=2", &hashed), Some(("/docs/C#.pdf".into(), page(1)))); + assert_eq!(split("/docs/#1/a.pdf#page=2", &hashed), Some(("/docs/#1/a.pdf".into(), page(1)))); + // Nothing before a `#` is a file: the open fails on the path as given. + assert_eq!(split("/docs/missing.pdf#page=2", &hashed), None); + assert_eq!(split("/docs/plain.pdf", &hashed), None); + assert_eq!(split("#", &hashed), None); + } +} diff --git a/crates/engine/src/print_raster.rs b/crates/engine/src/print_raster.rs index 29df75b014..a6076d5b05 100644 --- a/crates/engine/src/print_raster.rs +++ b/crates/engine/src/print_raster.rs @@ -22,6 +22,9 @@ pub fn rasterize(doc: &Document, pages: &[usize], raster: &Raster, content: Cont let mut config = src.config.clone(); // The copy below holds only what prints, so the viewer's own comment hiding plays no part. config.hide_comments = false; + // Printing is not the View event: a layer the auto state turns off on screen keeps its + // configured state on paper, as in a print that goes out as vectors. + config.for_print = true; let (printed, layers) = guard(|| -> Result<(Vec, Option>), String> { let mut cos = pdfcraft_cos::Document::open_with_password(src.bytes.clone(), config.password.as_deref()).map_err(|e| e.to_string())?; pdfcraft_print::keep_printed(&mut cos, content); @@ -56,7 +59,7 @@ pub fn rasterize(doc: &Document, pages: &[usize], raster: &Raster, content: Cont } /// Premultiplied RGBA composed over white paper (opaque), in grey when asked (Rec. 601 luma). -fn over_white(rgba: &mut [u8], grayscale: bool) { +pub(crate) fn over_white(rgba: &mut [u8], grayscale: bool) { for px in rgba.as_chunks_mut::<4>().0 { let gap = 255 - u32::from(px[3]); let (r, g, b) = ((u32::from(px[0]) + gap).min(255), (u32::from(px[1]) + gap).min(255), (u32::from(px[2]) + gap).min(255)); diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 4f7951b236..187cc217d8 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -13,7 +13,7 @@ use crate::{ BookmarkPage, BookmarkRow, CombineSource, Combined, DataFormat, DocId, Document, Edit, EditError, Hairlines, HairlinesFixed, Hidden, ImageResolution, LOW_RESOLUTION_PRINT_DPI, Label, ObjectKind, OptimizeReport, RecoverySnapshot, SignOptions, SourceKind, SplitPart, StatusCaptions, SummarySort, TrustStore, XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, apply_layer_state, carry_verdict_marks, comment_list, - comment_summary, decode_text, display_bytes, display_config, guard, info_fields, js, local_utc_offset, note_warnings, open_source, + comment_summary, decode_text, display_bytes, display_config, guard, info_fields, js, local_utc_offset, note_warnings, office_to_pdf, open_source, open_source_with, optimize, outline_rows, parse_ymd, print, print_layer_states, print_raster, render_threads, scan_colours, set_default_layer_state, shown_under_redactions, signatures_of, source_kind, sync_xfa_state, undrawn_notices, use_layer_choices, writable, xfa, xfa_sync_datasets, @@ -45,6 +45,9 @@ pub struct Session { pub(crate) date_format: Option, /// Preferences ▸ Date format ▸ Language, when not following the interface language. pub(crate) date_language: Option, + /// Highlight fields, as the viewer shows it: what forms' scripts read as + /// `app.runtimeHighlight` (off by default, as in a session without a viewer). + pub(crate) field_highlight: bool, /// What the XFA scripts' time limits are multiplied by (`None`: 1); see /// [`Session::with_xfa_time_scale`]. pub(crate) xfa_time_scale: Option, diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs index e9f9bec19f..4a6f372fb4 100644 --- a/crates/engine/src/session/editing.rs +++ b/crates/engine/src/session/editing.rs @@ -8,6 +8,34 @@ use super::{ }; use crate::{EditNotice, MarkKind, print_preview_layers}; +/// Add scan cover and ink to edits of invisible OCR text, returning no edit for an unchanged draft. +fn prepare_ocr_text_edit(editor: &mut crate::history::Editor, info: &pdfcraft_render::DocInfo, edit: Edit) -> Option { + // Rewriting an OCR word (invisible text over a scan) also hides the scanned word: cover it + // in the paper's colour and draw the new text in the ink's. + Some(match edit { + Edit::EditTextBlock { page, block, text, mut style } => { + let plain = style == pdfcraft_edit::BlockStyle::default(); + // Only invisible text is sampled; the blocks are usually cached from opening it. + if style.cover.is_none() + && let Some(info) = info.pages.get(page) + && let Some(b) = editor.text_blocks(page).ok().and_then(|b| b.get(block).filter(|b| b.invisible).cloned()) + && let Some((paper, ink)) = guard(|| scan_colours::sample(&editor.cos, info, page, &b)).ok().flatten() + { + style.cover = Some(paper); + style.color = style.color.or(Some(ink)); + // A text edit alone leaves the words it doesn't change as the scan shows them. + style.changed_words_only = plain; + // Nothing to redraw: leave undo history and the dirty flag alone. + if plain && b.same_words(&text) { + return None; + } + } + Edit::EditTextBlock { page, block, text, style } + } + other => other, + }) +} + /// Whether the edit, or any edit inside a batch, deletes pages. fn deletes_pages(edit: &Edit) -> bool { match edit { @@ -17,6 +45,83 @@ fn deletes_pages(edit: &Edit) -> bool { } } +/// Keep edits to the working copy until both the operation and its script callbacks succeed. +fn run_checked_edit(next: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Result<(), EditError> { + let result = guard(|| run_edit(next, edit, cx)) + .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label())))); + let refusal = cx.js.as_mut().and_then(|runner| runner.refused.take()); + if refusal.is_some() || result.is_err() { + discard_edit_callbacks(cx); + } + refusal.map_or(result, |reason| Err(EditError::Invalid(reason))) +} + +/// Discard callback effects if PDF commit fails after any code entered the retained realm. +fn discard_edit_callbacks(cx: &EditCtx) { + if let Some(runner) = cx.js.as_ref() + && runner.callbacks_touched_realm + && let Ok(mut runtime) = runner.runtime.lock() + { + runtime.reset_after_refusal(); + } +} + +/// Compare the icon and all of its indirect resources. Conservatively retire on budget exhaustion. +fn icon_changed(previous: &pdfcraft_cos::Document, restored: &pdfcraft_cos::Document, root: pdfcraft_cos::ObjRef, budget: &mut usize) -> bool { + use pdfcraft_cos::Object; + + fn visit(object: &Object, refs: &mut Vec, budget: &mut usize, depth: usize) -> Option<()> { + *budget = budget.checked_sub(1)?; + if depth == 128 { + return None; + } + match object { + Object::Ref(reference) => refs.push(*reference), + Object::Array(values) => { + for value in values { + visit(value, refs, budget, depth + 1)?; + } + } + _ => { + if let Some(dict) = object.as_dict() { + for (_, value) in dict.iter() { + visit(value, refs, budget, depth + 1)?; + } + } + } + } + Some(()) + } + + let mut pending = vec![root]; + let mut seen = std::collections::HashSet::new(); + while let Some(reference) = pending.pop() { + if !seen.insert(reference) { + continue; + } + if *budget == 0 { + return true; + } + let before = previous.get(reference); + let after = restored.get(reference); + if (!Arc::ptr_eq(&before, &after) && before != after) || visit(&before, &mut pending, budget, 0).is_none() { + return true; + } + } + false +} + +fn sync_script_history(doc: &Document, previous: &pdfcraft_cos::Document, restored: &pdfcraft_cos::Document) { + if let Ok(mut runtime) = doc.js_runtime.lock() { + let mut budget = 250_000; + runtime.retire_changed_icons(|id| { + let object = pdfcraft_cos::ObjRef::new((id >> 16) as u32, id as u16); + icon_changed(previous, restored, object, &mut budget) + }); + runtime.sync_field_ids(doc.form.iter().map(|f| crate::js::field_object_id(f.obj))); + } +} + impl Session { /// Apply an edit. On success the previous state is undoable and the view data is refreshed. pub fn apply(&mut self, id: DocId, edit: Edit) -> Result<(), EditError> { @@ -36,8 +141,15 @@ impl Session { let now = self.now(); let today = self.today(); let js_off = self.js_off; + let highlight = self.field_highlight; let xfa_time_scale = self.xfa_time_scale.unwrap_or(1); let doc = self.doc_mut(id)?; + // An edit must never read globals from a completed-but-unapplied button invocation. + // Fork its realm before any edit-triggered calculation, even if the edit later fails. + #[cfg(not(target_arch = "wasm32"))] + if doc.js_runtime.lock().map_err(|_| EditError::Invalid("script runtime unavailable".into()))?.is_pending() { + js::replace_runtime(&mut doc.js_runtime); + } doc.edit_notices.clear(); let name = doc.name.clone(); let is_xfa = doc.info.xfa.is_some(); @@ -48,6 +160,7 @@ impl Session { } cx.xfa_time_scale = xfa_time_scale; cx.pages = doc.info.pages.len(); + cx.highlight = highlight; cx.today = today; let reason = doc.read_only_reason.clone().unwrap_or_default(); let signed = doc.is_signed(); @@ -55,29 +168,8 @@ impl Session { if let Some(p) = editor.cos.permissions() { check_permission(&edit, &p)?; } - // Rewriting an OCR word (invisible text over a scan) also hides the scanned word: cover it - // in the paper's colour and draw the new text in the ink's. - let edit = match edit { - Edit::EditTextBlock { page, block, text, mut style } => { - let plain = style == pdfcraft_edit::BlockStyle::default(); - // Only invisible text is sampled; the blocks are usually cached from opening it. - if style.cover.is_none() - && let Some(info) = doc.info.pages.get(page) - && let Some(b) = editor.text_blocks(page).ok().and_then(|b| b.get(block).filter(|b| b.invisible).cloned()) - && let Some((paper, ink)) = guard(|| scan_colours::sample(&editor.cos, info, page, &b)).ok().flatten() - { - style.cover = Some(paper); - style.color = style.color.or(Some(ink)); - // A text edit alone leaves the words it doesn't change as the scan shows them. - style.changed_words_only = plain; - // Nothing to redraw: leave undo history and the dirty flag alone. - if plain && b.same_words(&text) { - return Ok(None); - } - } - Edit::EditTextBlock { page, block, text, style } - } - other => other, + let Some(edit) = prepare_ocr_text_edit(editor, &doc.info, edit) else { + return Ok(None); }; // Nothing to bake: leave undo history and the dirty flag alone. if matches!(&edit, Edit::FlattenFillSign) && !pdfcraft_annot::has_visible_fill_sign(&editor.cos) { @@ -85,13 +177,23 @@ impl Session { } let mut next = editor.cos.clone(); if !js_off && uses_scripts(&edit) { - cx.js = Some(js::JsRunner::new(&next, &name)); + let mut runner = js::JsRunner::new(&next, &name, highlight); + runner.runtime = doc.js_runtime.clone(); + runner.doc.zoom = doc.js_zoom; + runner.doc.page = doc.js_page; + cx.js = Some(runner); } cx.renamed_fields.clear(); cx.redaction_overlays.clear(); // `next` is a copy: if the edit fails or crashes, the document is unchanged. - guard(|| run_edit(&mut next, &edit, &mut cx)) - .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; + run_checked_edit(&mut next, &edit, &mut cx)?; + // A field's enter or exit scripts that changed nothing leave no edit behind (no undo step, + // nothing unsaved); what they showed or asked for still goes out. Only an event on its own: + // a batch keeps the other edits it holds. + if cx.xfa_unchanged && matches!(edit, Edit::XfaEvent { .. }) { + take_xfa_output(doc, cx.xfa_out, cx.xfa_ran_away); + return Ok(None); + } // Redaction removed what its own interpreter places under the marks. The marks were drawn // over the page as the renderer shows it, so the renderer has the last word. if let Edit::ApplyRedactions { pages } = &edit { @@ -130,7 +232,8 @@ impl Session { }; // An XFA form's scripts answer the change: exit and validate scripts of the field, then // every calculation (and a reset recalculates). They read the data, so it is brought up - // to date first. + // to date first. The fields whose exit scripts ran are noted once the edit is kept. + let mut exited: Vec = Vec::new(); if let Some(tpl) = cx.xfa.clone() { if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) { let datasets = &mut cx.xfa_datasets; @@ -139,6 +242,8 @@ impl Session { .map_err(EditError::Write)?; cx.xfa_out.errors.extend(notes); } + /// The fields edit `e` (and the edits it batches) gives new values, and whether it + /// resets the form. fn collect(e: &Edit, changed: &mut Vec, reset: &mut bool) { match e { Edit::SetFieldValue { name, .. } => changed.push(name.clone()), @@ -151,19 +256,24 @@ impl Session { let mut reset = false; collect(&edit, &mut changed, &mut reset); if !changed.is_empty() || reset { + let highlight = cx.highlight(); let mut run = xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, + highlight, time_scale: cx.xfa_time_scale, }; // Every changed field's scripts, then the calculations once (after a reset, // only those). - let ran = guard(|| xfa::on_changes(&mut next, &tpl, &changed, &mut run)) + let ran = guard(|| xfa::on_changes(&mut next, &tpl, &changed, &mut run, &mut exited)) .unwrap_or_else(|m| Err(format!("the form's scripts failed unexpectedly ({m})"))); cx.xfa_datasets = run.datasets; cx.xfa_ran_away |= run.ran_away; + // The next edit of a batch reads the highlight this one's scripts set. + let on = run.highlight; + cx.set_highlight(on); if let Err(e) = ran { cx.xfa_out.errors.push(e); } @@ -175,12 +285,14 @@ impl Session { let (tpl, datasets) = (cx.xfa.as_deref(), &mut cx.xfa_datasets); xfa_notes = guard(|| xfa_sync_datasets(&mut next, tpl, datasets)) .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) - .map_err(EditError::Write)?; + .map_err(EditError::Write) + .inspect_err(|_| discard_edit_callbacks(&cx))?; // Rebuilding from stale data would discard accepted widget values. if xfa_notes.is_empty() { let reflow = guard(|| xfa::relayout_changed_text(&mut next, tpl, &doc.form)) .unwrap_or_else(|m| Err(format!("laying out the XFA text failed unexpectedly ({m})"))) - .map_err(EditError::Write)?; + .map_err(EditError::Write) + .inspect_err(|_| discard_edit_callbacks(&cx))?; if let Some(warnings) = reflow { xfa_notes.extend(warnings); // Layout replaces pages and widgets, even if the page count is unchanged. @@ -199,9 +311,16 @@ impl Session { } let rewrites = |c: &pdfcraft_cos::Document| c.full_save_required() || c.encryption_changed(); if signed && ((rewrites(&next) && !rewrites(&editor.cos)) || (next.is_modified() && next.revisions().is_empty())) { + discard_edit_callbacks(&cx); return Err(EditError::SignedRewrite(edit.label().to_string())); } - let notices = edit_notices(&edit, &editor.cos, &next); + let mut notices = edit_notices(&edit, &editor.cos, &next); + // Places applied redactions left that still hold the removed text, and what became of + // the PDF/A or PDF/UA identification in the XMP they removed: said, not silent. + notices.extend(cx.redaction_elsewhere.filter(|e| e.total() > 0 || e.unsearched).map(EditNotice::RedactionElsewhere)); + if cx.redaction_identification != pdfcraft_redact::Identification::default() { + notices.push(EditNotice::RedactionIdentification(cx.redaction_identification)); + } let previous = std::mem::replace(&mut editor.cos, next); let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone()); let previous_keys = std::mem::replace(&mut editor.keys, keys); @@ -214,6 +333,7 @@ impl Session { let refreshed = guard(|| if scope == Scope::Content { Self::refresh_content(doc, Some(&edit)) } else { Self::refresh_scoped(doc, scope) }) .unwrap_or_else(|m| Err(EditError::Reopen(m))); if let Err(e) = refreshed { + discard_edit_callbacks(&cx); // Roll back: the edit produced something we cannot display. if let Some(ed) = doc.editor.as_mut() && let Some((_, prev, keys, _)) = ed.undo.pop() @@ -228,8 +348,15 @@ impl Session { return Err(e); } doc.dirty = true; + if let Ok(mut runtime) = doc.js_runtime.lock() { + runtime.sync_field_ids(doc.form.iter().map(|f| crate::js::field_object_id(f.obj))); + } doc.generation += 1; doc.edit_notices = notices; + // Only this edit's exits are skipped on the next focus loss: one an earlier change ran is + // no reason to skip a later one. + doc.xfa_exited.clear(); + doc.xfa_exited.extend(exited); if !cx.xfa_removed.is_empty() { let removed: Vec = cx.xfa_removed.iter().map(|n| format!("{XFA_REMOVED_PREFIX}{n}")).collect(); doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); @@ -242,15 +369,11 @@ impl Session { if let Some(js) = cx.js { doc.js_output.append(js.output); } - doc.js_output.append(cx.xfa_out); - if cx.xfa_ran_away { - doc.xfa_template = None; - doc.xfa_scripts_off = true; - doc.js_output.errors.push(XFA_SCRIPTS_OFF.into()); - } + take_xfa_output(doc, cx.xfa_out, cx.xfa_ran_away); Ok(cx.prepared) } + /// Undo the last edit of document `id`, keeping it for [`Session::redo`]. Returns its label. pub fn undo(&mut self, id: DocId) -> Result { let doc = self.doc_mut(id)?; let editor = doc.editor.as_mut().ok_or(EditError::NothingToUndo)?; @@ -276,10 +399,18 @@ impl Session { } doc.edit_notices.clear(); Self::sync_dirty(doc); + if let Some(editor) = doc.editor.as_ref() + && let Some((_, previous, _, _)) = editor.redo.last() + { + sync_script_history(doc, previous, &editor.cos); + } + // The change whose exit ran is undone (or redone without its scripts): skip no exit. + doc.xfa_exited.clear(); doc.generation += 1; Ok(label) } + /// Redo the last edit of document `id` that [`Session::undo`] took back. Returns its label. pub fn redo(&mut self, id: DocId) -> Result { let doc = self.doc_mut(id)?; let editor = doc.editor.as_mut().ok_or(EditError::NothingToRedo)?; @@ -305,6 +436,13 @@ impl Session { } doc.edit_notices.clear(); Self::sync_dirty(doc); + if let Some(editor) = doc.editor.as_ref() + && let Some((_, previous, _, _)) = editor.undo.last() + { + sync_script_history(doc, previous, &editor.cos); + } + // The change whose exit ran is undone (or redone without its scripts): skip no exit. + doc.xfa_exited.clear(); doc.generation += 1; Ok(label) } @@ -444,7 +582,14 @@ impl Session { ) || matches!(e, Edit::MoveObjects { objects, .. } | Edit::DeleteObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added)) }); let touches_marks = edit.is_none_or(|e| { - matches!(e, Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. }) + matches!( + e, + Edit::AddHeaderFooter { .. } + | Edit::AddWatermark { .. } + | Edit::AddBackground { .. } + | Edit::AddPrinterMarks { .. } + | Edit::RemoveMarks { .. } + ) }); (touches_added, touches_marks) } @@ -574,6 +719,17 @@ impl Session { } } +/// Hand on what an edit's XFA scripts showed or asked for (`out`), and when one of them ran away, +/// turn the form's scripts off and say so. +fn take_xfa_output(doc: &mut Document, out: js::JsOutput, ran_away: bool) { + doc.js_output.append(out); + if ran_away { + doc.xfa_template = None; + doc.xfa_scripts_off = true; + doc.js_output.errors.push(XFA_SCRIPTS_OFF.into()); + } +} + /// What `edit`, taking `prev` to `next`, wants said: never silent (AGENTS.md §4). fn edit_notices(edit: &Edit, prev: &pdfcraft_cos::Document, next: &pdfcraft_cos::Document) -> Vec { // What it drew with `?` (a value or comment text its font can't draw, stored as typed). diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs index 40b4a4e050..2f2774ed22 100644 --- a/crates/engine/src/session/files.rs +++ b/crates/engine/src/session/files.rs @@ -1,9 +1,17 @@ use super::{ Arc, CombineSource, Combined, DataFormat, DocId, EditError, Hairlines, HairlinesFixed, Hidden, ImageResolution, LOW_RESOLUTION_PRINT_DPI, - OptimizeReport, SaveOptions, Session, SourceKind, SplitPart, decode_text, guard, open_source, open_source_with, optimize, print, + OptimizeReport, SaveOptions, Session, SourceKind, SplitPart, decode_text, guard, office_to_pdf, open_source, open_source_with, optimize, print, print_layer_states, print_raster, set_default_layer_state, source_kind, writable, write_full, }; +/// Whether a conversion may run LibreOffice for an Office document (seconds, in another +/// process) or must refuse it because the caller is on the frame thread. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Office { + Convert, + Refuse, +} + impl Session { /// The page count of another PDF (Replace Pages, Insert Pages dialogs). pub fn page_count_of(&self, name: &str, bytes: &Arc>) -> Result { @@ -41,33 +49,96 @@ impl Session { self.write_new(&doc) } - /// Convert a file Create understands (an image or plain text) to PDF bytes; a PDF is checked - /// (it must open and allow copying pages) and returned as it is. + /// Convert a file Create understands (an image, plain text or an Office document) to PDF + /// bytes; a PDF is checked (it must open and allow copying pages) and returned as it is. + /// An Office document runs LibreOffice ([`office_to_pdf`]), which takes seconds: keep it off + /// the frame thread ([`Self::convert_to_pdf_inline`] there). pub fn convert_to_pdf(&self, name: &str, bytes: &Arc>) -> Result<(SourceKind, Arc>), EditError> { + self.convert(name, bytes, Office::Convert) + } + + /// [`Self::convert_to_pdf`] for a caller on the frame thread, which must not wait seconds for + /// LibreOffice: an Office document is refused, pointing to File ▸ Open, which converts it in + /// the background. + pub fn convert_to_pdf_inline(&self, name: &str, bytes: &Arc>) -> Result<(SourceKind, Arc>), EditError> { + self.convert(name, bytes, Office::Refuse) + } + + fn convert(&self, name: &str, bytes: &Arc>, office: Office) -> Result<(SourceKind, Arc>), EditError> { let Some(kind) = source_kind(name, bytes) else { - return Err(EditError::Source(format!("{name}: this file type can't be converted; use a PDF, an image or a .txt file"))); + return Err(EditError::Source(format!( + "{name}: this file type can't be converted; use a PDF, an image, a .txt file or a Word, Excel or PowerPoint file" + ))); }; + if kind == SourceKind::Office && office == Office::Refuse { + return Err(EditError::Source(format!( + "{name}: Word, Excel and PowerPoint files can't be added here yet; open it with File ▸ Open, which converts it to a PDF" + ))); + } let title = name.rsplit_once('.').map_or(name, |(s, _)| s); // Image decoders read untrusted bytes: a panic in one must not take the app down. let created = guard(|| match kind { SourceKind::Pdf => open_source(name, bytes).map(|_| bytes.clone()), SourceKind::Image => self.create_from_images(&[(name.to_string(), bytes.to_vec())]), SourceKind::Text => self.create_from_text(title, &decode_text(bytes)), + SourceKind::Office => office_to_pdf(name, bytes), }) .map_err(|_| EditError::Source(format!("{name}: the file could not be read")))?; Ok((kind, created?)) } - /// A file for Combine as PDF bytes: an image or text file is converted; a PDF is returned as - /// it is, unchecked (Combine reads it itself, with its password), as is a file named .pdf - /// without a `%PDF-` header, which the reader may still open. + /// A file for Insert Pages into `dest`, as PDF bytes and its page count: an image, text or + /// Office file is converted, and a PDF is checked as the insert itself checks it, so a file + /// under `dest`'s own protection that withholds only page extraction is accepted, as pasted + /// pages are (#5081). An Office document runs LibreOffice: keep it off the frame thread + /// ([`Self::insert_source_inline`] there). + pub fn insert_source(&self, dest: DocId, name: &str, bytes: &Arc>) -> Result<(usize, Arc>), EditError> { + self.insert_source_with(dest, name, bytes, Office::Convert) + } + + /// [`Self::insert_source`] on the frame thread: Office documents are refused, as + /// [`Self::convert_to_pdf_inline`] refuses them. PDFs are still checked as the insert itself + /// checks them (#5081). + pub fn insert_source_inline(&self, dest: DocId, name: &str, bytes: &Arc>) -> Result<(usize, Arc>), EditError> { + self.insert_source_with(dest, name, bytes, Office::Refuse) + } + + fn insert_source_with(&self, dest: DocId, name: &str, bytes: &Arc>, office: Office) -> Result<(usize, Arc>), EditError> { + if !matches!(source_kind(name, bytes), Some(SourceKind::Pdf)) { + let (_, pdf) = self.convert(name, bytes, office)?; + return Ok((self.page_count_of(name, &pdf)?, pdf)); + } + let src = crate::sources::open_source_into(name, bytes, self.cos(dest)?)?; + Ok((pdfcraft_organize::page_count(&src)?, bytes.clone())) + } + + /// The page count of a PDF for Replace Pages in `dest`, checked as the replace itself checks + /// it: as with [`Self::insert_source`], a file under `dest`'s own protection that withholds + /// only page extraction is accepted (#5081). + pub fn replace_source(&self, dest: DocId, name: &str, bytes: &Arc>) -> Result { + Ok(pdfcraft_organize::page_count(&crate::sources::open_source_into(name, bytes, self.cos(dest)?)?)?) + } + + /// A file for Combine as PDF bytes: an image, text or Office file is converted; a PDF is + /// returned as it is, unchecked (Combine reads it itself, with its password), as is a file + /// named .pdf without a `%PDF-` header, which the reader may still open. pub fn as_pdf(&self, name: &str, bytes: &Arc>) -> Result>, EditError> { + self.as_pdf_with(name, bytes, Office::Convert) + } + + /// [`Self::as_pdf`] on the frame thread: Office documents are refused, as + /// [`Self::convert_to_pdf_inline`] refuses them. + pub fn as_pdf_inline(&self, name: &str, bytes: &Arc>) -> Result>, EditError> { + self.as_pdf_with(name, bytes, Office::Refuse) + } + + fn as_pdf_with(&self, name: &str, bytes: &Arc>, office: Office) -> Result>, EditError> { let pdf = match source_kind(name, bytes) { Some(SourceKind::Pdf) => true, - Some(SourceKind::Image | SourceKind::Text) => false, + Some(SourceKind::Image | SourceKind::Text | SourceKind::Office) => false, None => name.to_ascii_lowercase().ends_with(".pdf"), }; - if pdf { Ok(bytes.clone()) } else { self.convert_to_pdf(name, bytes).map(|(_, pdf)| pdf) } + if pdf { Ok(bytes.clone()) } else { self.convert(name, bytes, office).map(|(_, pdf)| pdf) } } /// Reduce File Size: Acrobat's defaults (colour and grey images above 225 ppi to 150 ppi, @@ -116,6 +187,24 @@ impl Session { .map_err(EditError::Write) } + /// What Convert colours would change on `pages` of `id`, without changing the document. + pub fn count_colours(&self, id: DocId, pages: &[usize], settings: &pdfcraft_edit::ConvertColours) -> pdfcraft_edit::ColoursConverted { + let Some(doc) = self.get(id) else { return Default::default() }; + let Some(editor) = doc.editor.as_ref() else { return Default::default() }; + let mut cos = editor.cos.clone(); + let mut recolour = |doc: &pdfcraft_cos::Document, s: &pdfcraft_cos::Stream, t: pdfcraft_edit::ColourTarget| { + let target = match t { + pdfcraft_edit::ColourTarget::Gray => pdfcraft_optimize::ImageTarget::Gray, + pdfcraft_edit::ColourTarget::Rgb => pdfcraft_optimize::ImageTarget::Rgb, + pdfcraft_edit::ColourTarget::Cmyk => pdfcraft_optimize::ImageTarget::Cmyk, + }; + pdfcraft_optimize::recolour_image(doc, s, target) + }; + // The preview runs the image decoders over the document's own bytes; a panic in one is + // a count of nothing, not a crash. + guard(|| pdfcraft_edit::convert_colours(&mut cos, pages, settings, &mut recolour).unwrap_or_default()).unwrap_or_default() + } + /// How many hairlines [`Edit::FixHairlines`] would change on `pages` of `id`, and on how many /// pages, without changing the document (the edit itself refuses when there is nothing to fix). pub fn count_hairlines(&self, id: DocId, pages: &[usize], settings: &Hairlines) -> HairlinesFixed { @@ -211,7 +300,13 @@ impl Session { Err(_) => return Vec::new(), }, }; - (0..doc.info.pages.len()).map(|i| pages.contains(&i) && pdfcraft_print::text_reads_up(&cos, i)).collect() + // The page tree is read once, and each page is looked up, not searched for, in `pages`. + let mut out = vec![false; doc.info.pages.len()]; + let wanted: Vec = pages.iter().copied().filter(|&p| p < out.len()).collect::>().into_iter().collect(); + for (p, up) in wanted.iter().zip(pdfcraft_print::texts_read_up(&cos, &wanted)) { + out[*p] = up; + } + out } /// Combine whole files, in order, into new PDF bytes (one bookmark per file). @@ -373,7 +468,7 @@ impl Session { /// New PDF bytes containing copies of `pages` of the document (Extract Pages). pub fn extract(&self, id: DocId, pages: &[usize]) -> Result>, EditError> { let src = self.cos(id)?; - if src.permissions().is_some_and(|p| !p.assemble()) { + if src.permissions().is_some_and(|p| !p.extract_pages()) { return Err(EditError::NotPermitted("extracting pages")); } let out = pdfcraft_organize::extract_pages(src, pages)?; @@ -383,7 +478,7 @@ impl Session { /// Split the document into several new PDFs. pub fn split(&self, id: DocId, by: &pdfcraft_organize::SplitBy) -> Result, EditError> { let src = self.cos(id)?; - if src.permissions().is_some_and(|p| !p.assemble()) { + if src.permissions().is_some_and(|p| !p.extract_pages()) { return Err(EditError::NotPermitted("splitting the document")); } let n = pdfcraft_organize::page_count(src)?; @@ -401,7 +496,7 @@ impl Session { /// images count once per part) fits; a page larger than the limit is a part of its own. pub fn split_by_size(&self, id: DocId, max_bytes: usize) -> Result, EditError> { let src = self.cos(id)?; - if src.permissions().is_some_and(|p| !p.assemble()) { + if src.permissions().is_some_and(|p| !p.extract_pages()) { return Err(EditError::NotPermitted("splitting the document")); } let n = pdfcraft_organize::page_count(src)?; diff --git a/crates/engine/src/session/save.rs b/crates/engine/src/session/save.rs index a727ce6329..ec0033d318 100644 --- a/crates/engine/src/session/save.rs +++ b/crates/engine/src/session/save.rs @@ -71,8 +71,15 @@ impl Session { let keys = editor.keys.clone(); // The saved file carries no Password-field values (#3943, #3944); the open document keeps them. let typed = pdfcraft_forms::password_values(&editor.cos); - editor.cos = + // An incremental save keeps the old byte prefix and its object identities. A + // rewrite can reuse the same numbers for unrelated fields and icons. + let preserves_objects = bytes.starts_with(editor.saved.as_slice()) || bytes.starts_with(editor.cos.bytes().as_slice()); + let reopened = pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; + if !preserves_objects { + crate::js::replace_runtime(&mut doc.js_runtime); + } + editor.cos = reopened; pdfcraft_forms::restore_password_values(&mut editor.cos, &typed).map_err(|e| EditError::Reopen(e.to_string()))?; if !typed.is_empty() && doc.info.xfa.is_some() { let mut datasets = None; @@ -125,8 +132,9 @@ impl Session { } /// File ▸ Revert: back to the last saved version (or the file as opened). Undo history is - /// cleared, as in Acrobat. + /// cleared, as in Acrobat, and the document's opening scripts run again. pub fn revert(&mut self, id: DocId) -> Result<(), EditError> { + self.invalidate_javascript(id); let doc = self.doc_mut(id)?; let editor = doc.editor.as_mut().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; let base = editor.saved.clone(); @@ -145,8 +153,14 @@ impl Session { editor.redo.clear(); Self::adopt_keys(doc); Self::sync_dirty(doc); + // The changes whose exits ran are gone with the rest: skip no exit. + doc.xfa_exited.clear(); doc.generation += 1; - Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) + Self::refresh(doc).inspect_err(|_| Self::show_released(doc))?; + // Reverting reopens the saved file, in a fresh realm: its opening scripts run again, + // as they do on opening it (#269). + self.run_open_scripts(id); + Ok(()) } /// The document's file changed on disk (a LaTeX or Typst build rewrote it, #431): open @@ -157,6 +171,12 @@ impl Session { pub fn reload(&mut self, id: DocId, bytes: Arc>) -> Result<(), OpenError> { let old = self.docs.iter().position(|d| d.id == id).ok_or_else(|| OpenError::Invalid("the document is not open".into()))?; let doc = &self.docs[old]; + // Opening the replacement can initialize a new worker. Wait for the old + // invocation to finish before allowing repeated reloads of this document. + #[cfg(not(target_arch = "wasm32"))] + if doc.js_runtime.lock().map_err(|_| OpenError::Invalid("script runtime unavailable".into()))?.is_executing() { + return Err(OpenError::Invalid("a script is still running in this document; retry the reload once it finishes".into())); + } let (name, path, generation) = (doc.name.clone(), doc.path.clone(), doc.generation); let password = doc.editor.as_ref().and_then(|e| e.keys.reopen.clone()).or_else(|| doc.password.clone()); let new_id = self.open(name, path, bytes, password.as_deref())?; diff --git a/crates/engine/src/session/view.rs b/crates/engine/src/session/view.rs index f6e75c2ba9..7232f64631 100644 --- a/crates/engine/src/session/view.rs +++ b/crates/engine/src/session/view.rs @@ -62,20 +62,54 @@ impl Session { return None; } - /// Today's date in local time: (year, month, day). With an injected clock (tests) the clock - /// is taken as local time. /// A dynamic stamp's second line: "By Ada at 2:14 pm, Oct 02, 2026" (local time). pub fn stamp_by_line(&self, author: &str) -> String { + let (date, time) = self.stamp_date_time(); + let who = if author.trim().is_empty() { String::new() } else { format!("By {} ", author.trim()) }; + format!("{who}at {time}, {date}") + } + + /// A custom stamp's text line (#1741): `template` with `{name}` (the author), `{date}` + /// ("Oct 02, 2026") and `{time}` ("2:14 pm") filled in now, in local time, as the dynamic + /// stamps' "By" line has them. Cut to [`MAX_STAMP_LINE`](crate::MAX_STAMP_LINE) characters. + pub fn stamp_line(&self, template: &str, author: &str) -> String { + let (date, time) = self.stamp_date_time(); + // One pass over the template, so a placeholder the author's name happens to contain + // (`Ada {date}`) stays as written rather than being filled in too. + let mut filled = String::new(); + let mut rest = template.trim(); + while let Some(c) = rest.chars().next() { + if let Some(after) = rest.strip_prefix("{name}") { + filled.push_str(author.trim()); + rest = after; + } else if let Some(after) = rest.strip_prefix("{date}") { + filled.push_str(&date); + rest = after; + } else if let Some(after) = rest.strip_prefix("{time}") { + filled.push_str(&time); + rest = after; + } else { + filled.push(c); + rest = rest.get(c.len_utf8()..).unwrap_or_default(); + } + } + filled.chars().take(crate::MAX_STAMP_LINE).collect() + } + + /// Now, as the stamps write it: ("Oct 02, 2026", "2:14 pm"), local time. + fn stamp_date_time(&self) -> (String, String) { let offset = self.local_offset(); let d = self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).unwrap_or_default(); let num = |a: usize, b: usize| d.get(a..b).and_then(|s| s.parse::().ok()).unwrap_or(0); let (y, mo, day, hh, mm) = (num(2, 6), num(6, 8), num(8, 10), num(10, 12), num(12, 14)); const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; let h12 = if hh % 12 == 0 { 12 } else { hh % 12 }; - let who = if author.trim().is_empty() { String::new() } else { format!("By {} ", author.trim()) }; - format!("{who}at {h12}:{mm:02} {}, {} {day:02}, {y}", if hh < 12 { "am" } else { "pm" }, MONTHS[(mo.clamp(1, 12) - 1) as usize]) + let month = MONTHS.get(mo.clamp(1, 12) as usize - 1).copied().unwrap_or("Jan"); + (format!("{month} {day:02}, {y}"), format!("{h12}:{mm:02} {}", if hh < 12 { "am" } else { "pm" })) } + /// Today's date in local time: (year, month, day). With an injected clock (tests) the clock + /// is taken as local time. pub fn today(&self) -> (i64, u32, u32) { let offset = self.local_offset(); self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).as_deref().and_then(parse_ymd).unwrap_or((1970, 1, 1)) diff --git a/crates/engine/src/signature_image.rs b/crates/engine/src/signature_image.rs index 074adfc82d..198904850d 100644 --- a/crates/engine/src/signature_image.rs +++ b/crates/engine/src/signature_image.rs @@ -241,6 +241,7 @@ impl SignatureImage { name: if initials { "Initials" } else { "Signature" }.into(), file: MarkFile { name: "signature.png".into(), bytes: self.bytes.clone(), page: 0 }, author: author.into(), + line: None, }], }) } diff --git a/crates/engine/src/sources.rs b/crates/engine/src/sources.rs index 91d6efa3ee..4d1b5ab133 100644 --- a/crates/engine/src/sources.rs +++ b/crates/engine/src/sources.rs @@ -15,6 +15,16 @@ pub struct Combined { pub renamed_fields: Vec<(String, String)>, } +/// Convert the Office document `bytes` named `name` (Word, Excel, PowerPoint, OpenDocument, +/// RTF) to PDF with an installed LibreOffice, and check the result opens. It runs a separate +/// process for seconds, so callers keep it off the frame thread. Desktop only: on the web it +/// returns an error. +pub fn office_to_pdf(name: &str, bytes: &Arc>) -> Result>, EditError> { + let pdf = Arc::new(pdfcraft_create::office_to_pdf(name, bytes)?); + open_source(name, &pdf)?; + Ok(pdf) +} + /// Parse another PDF to copy pages from. pub(crate) fn open_source(name: &str, bytes: &Arc>) -> Result { open_source_with(name, bytes, None) @@ -25,6 +35,19 @@ pub(crate) fn open_source_with(name: &str, bytes: &Arc>, password: Optio source_document(bytes, password).map_err(|p| EditError::Source(format!("{name}: {p}"))) } +/// [`open_source`] for pages inserted into `dest`. A source that withholds page extraction but +/// allows assembly is accepted when `dest` will be saved under the very same protection (file +/// key and `/Encrypt` values), as when pages cut or copied from a document are pasted back into +/// it: they never leave its protection (#5081). +pub(crate) fn open_source_into(name: &str, bytes: &Arc>, dest: &pdfcraft_cos::Document) -> Result { + let same_protection = |src: &pdfcraft_cos::Document| match (src.security(), dest.output_handler()) { + (Some(s), Some(d)) => s.file_key() == d.file_key() && s.dict() == d.dict(), + _ => false, + }; + let permitted = |src: &pdfcraft_cos::Document| src.permissions().is_none_or(|p| p.extract_pages() || (p.assemble() && same_protection(src))); + source_document_if(bytes, None, permitted).map_err(|p| EditError::Source(format!("{name}: {p}"))) +} + /// Import the comments of `src` onto the pages of `doc` with the same numbers. pub(crate) fn import_pdf_comments(doc: &mut pdfcraft_cos::Document, src: &pdfcraft_cos::Document) -> Result<(), EditError> { if pdfcraft_organize::import_comments(doc, src)? == 0 { @@ -44,8 +67,17 @@ pub(crate) fn comment_source(bytes: &Arc>) -> Result>, password: Option<&str>) -> Result { + source_document_if(bytes, password, |d| d.permissions().is_none_or(|p| p.extract_pages())) +} + +/// Open `bytes` as a page source, refused as [`SourceProblem::NotPermitted`] unless `permitted`. +fn source_document_if( + bytes: &Arc>, + password: Option<&str>, + permitted: impl Fn(&pdfcraft_cos::Document) -> bool, +) -> Result { match std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)) { - Ok(Ok(d)) if d.permissions().is_some_and(|p| !p.assemble()) => Err(SourceProblem::NotPermitted), + Ok(Ok(d)) if !permitted(&d) => Err(SourceProblem::NotPermitted), Ok(Ok(d)) => Ok(d), Ok(Err(pdfcraft_cos::CosError::NeedsPassword)) => Err(SourceProblem::Password), Ok(Err(pdfcraft_cos::CosError::WrongPassword)) => Err(SourceProblem::WrongPassword), diff --git a/crates/engine/src/spooler.rs b/crates/engine/src/spooler.rs new file mode 100644 index 0000000000..5c76bd6739 --- /dev/null +++ b/crates/engine/src/spooler.rs @@ -0,0 +1,635 @@ +//! The print-ready PDF as the system spooler gets it. + +use std::sync::Arc; + +use pdfcraft_cos::{Dict, Object, SaveOptions, Stream, write_full}; +use pdfcraft_render::{PRINT_TAG, PageRenderer, RenderConfig, RenderRequest, Tile}; + +use crate::guard; + +/// The resolution [`spooler_copy`] flattens a sheet at, unless the sheet is too large for it +/// ([`MAX_FLATTENED_PIXELS`], [`MAX_FLATTENED_SIDE`]). +pub const FLATTEN_DPI: f64 = 300.0; +/// The side of the largest tile a flattened sheet is drawn with, in pixels: 2000 × 2000 stays +/// under the 2²² pixels past which Windows' PDF renderer shrinks an image (#5131), so [`tiled`] +/// leaves the tiles alone, and under the renderer's own tile limit. +pub const FLATTEN_TILE: u32 = 2000; +/// The most pixels one flattened sheet has. A 24 × 36 in sheet at 300 dpi (78 MP) keeps its +/// resolution; a larger one is drawn at a lower one. The sheet is rendered and encoded a tile at +/// a time, so this bounds the encoded picture (240 MB as raw RGB) and the time it takes, not the +/// memory one render needs. +pub const MAX_FLATTENED_PIXELS: f64 = 80.0e6; +/// The most pixels along a flattened sheet's longer side: a long banner gets a lower resolution +/// rather than hundreds of tiles, each of which interprets the whole sheet's content again. +pub const MAX_FLATTENED_SIDE: f64 = 16_000.0; +/// The most bytes of encoded pictures one copy holds. A sheet whose picture would pass it keeps +/// its vectors and prints as it would have without flattening. +const MAX_FLATTENED_BYTES: usize = 512 << 20; + +/// The print-ready PDF `bytes` ([`crate::Session::print_pdf`]) as they go to the system spooler +/// with `job`. Three things happen on Windows only, in this order; elsewhere the bytes go as they +/// are. +/// +/// - A job in grayscale is made grey here first ([`greyed`]), and `job.grey_pages` says so. +/// Windows has no colour conversion of its own, and a driver that lists no grey option (Microsoft +/// Print to PDF, or one with only a vendor's own grey setting) refused the job outright +/// (#5806). The driver is still asked for grey where it has it. +/// - With `flatten_transparency`, each sheet that uses transparency becomes an opaque picture of +/// itself at [`FLATTEN_DPI`] ([`flattened`]). It is meant for GDI (v3) drivers, which get the +/// job through Windows' XPS-to-GDI conversion: that conversion fails on soft masks (#6155), +/// prints halos around them (#5838) or prints transparency as black boxes (#5940). It comes +/// after greying, so the picture is of the grey sheet. +/// - Large images are drawn as tiles ([`pdfcraft_optimize::tile_large_images`], #5131): Windows' +/// PDF renderer prints an image of more than 2²² pixels from a shrunk copy, so a scan comes out +/// at the printer's resolution. A flattened sheet's tiles are already under that. +/// +/// When the copy can't be read or written, the bytes go as they are: no step ever stops a print, +/// and a grayscale job whose pages couldn't be made grey keeps asking the driver for grey, +/// strictly, as before. +pub fn spooler_copy(bytes: Vec, job: &mut crate::print::spool::Job, flatten_transparency: bool) -> Vec { + if !cfg!(windows) { + return bytes; + } + let mut bytes = Arc::new(bytes); + if job.grayscale { + match greyed(bytes.clone()) { + Some(Some(grey)) => { + bytes = Arc::new(grey); + job.grey_pages = true; + } + Some(None) => job.grey_pages = true, + None => {} + } + } + if flatten_transparency && let Some(flat) = flattened(bytes.clone(), FLATTEN_DPI) { + bytes = Arc::new(flat); + } + // Once `tiled` returns, its document is gone and `bytes` is the only owner left: no copy. + tiled(bytes.clone()).unwrap_or_else(|| Arc::try_unwrap(bytes).unwrap_or_else(|shared| (*shared).clone())) +} + +/// `bytes` with every colour on every page, its comments and form fields included, turned grey by +/// Convert colours (black kept black, pictures re-encoded where they can be): `Some(Some(_))`. +/// `Some(None)` when nothing needed converting, and `None` when the copy can't be read or written. +/// +/// Convert colours leaves shadings, patterns, inline images and pictures it can't re-encode +/// (indexed, CMYK, JPEG 2000 …) as they are; the driver is still asked for grey, so a driver with a +/// grey option makes those grey too, and one without prints them as they are. +pub fn greyed(bytes: Arc>) -> Option>> { + guard(|| { + let mut doc = pdfcraft_cos::Document::open(bytes).ok()?; + let pages: Vec = (0..pdfcraft_model::pages(&doc).len()).collect(); + let settings = + pdfcraft_edit::ConvertColours { target: pdfcraft_edit::ColourTarget::Gray, preserve_black: true, images: true, annotations: true }; + let mut recolour = |doc: &pdfcraft_cos::Document, s: &pdfcraft_cos::Stream, _: pdfcraft_edit::ColourTarget| { + pdfcraft_optimize::recolour_image(doc, s, pdfcraft_optimize::ImageTarget::Gray) + }; + let done = pdfcraft_edit::convert_colours(&mut doc, &pages, &settings, &mut recolour).ok()?; + if done.colours == 0 && done.images == 0 { + return Some(None); + } + write_full(&doc, &SaveOptions::default()).ok().map(Some) + }) + .ok() + .flatten() +} + +/// `bytes` with their large images drawn as tiles, or `None` when there is none, or the copy +/// can't be read or written. +pub fn tiled(bytes: Arc>) -> Option> { + guard(|| { + let mut doc = pdfcraft_cos::Document::open(bytes).ok()?; + let report = pdfcraft_optimize::tile_large_images(&mut doc); + (report.tiled > 0).then(|| write_full(&doc, &SaveOptions::default()).ok()).flatten() + }) + .ok() + .flatten() +} + +/// `bytes` with each page that uses transparency ([`pdfcraft_preflight::page_uses_transparency`]) +/// replaced by an opaque picture of itself, or `None` when no page needs it, none could be +/// rendered, or the copy can't be read or written. Pages without transparency stay as they are. +/// +/// The picture is at `dpi` (72 to 600; [`FLATTEN_DPI`] if not a number), lower on a sheet past +/// [`MAX_FLATTENED_PIXELS`] or [`MAX_FLATTENED_SIDE`], in tiles of at most [`FLATTEN_TILE`] +/// pixels a side, each one image (RGB over white, Flate) rendered and encoded on its own. The +/// page keeps its boxes, `/Rotate` and `/UserUnit`, so the sheet keeps its size and orientation. +/// Its transparency group goes, since nothing on it is transparent any more, and so do its +/// annotations, which the picture already shows. A print-ready PDF has none: +/// [`pdfcraft_print::impose`] draws them into the sheet. +/// +/// A page whose picture can't be rendered keeps its vectors, as does one whose picture would take +/// the pictures past 512 MB encoded. +pub fn flattened(bytes: Arc>, dpi: f64) -> Option> { + guard(|| { + let mut doc = pdfcraft_cos::Document::open(bytes.clone()).ok()?; + let pages = pdfcraft_model::pages(&doc); + let transparent: Vec = + pages.iter().enumerate().filter(|(_, p)| pdfcraft_preflight::page_uses_transparency(&doc, &p.dict)).map(|(i, _)| i).collect(); + if transparent.is_empty() { + return None; + } + // As Print as image renders (`print_raster`): printing is not the View event, comments + // are drawn, and the sheet clears to white; `PRINT_TAG` turns reading colours off. The + // print-ready PDF already holds the layers as they print. + let config = RenderConfig { for_print: true, ..RenderConfig::default() }; + let mut renderer = PageRenderer::new(bytes, config); + // The renderer must list the same pages, or a sheet could get another one's picture. + if renderer.page_count() != pages.len() { + return None; + } + let dpi = if dpi.is_finite() { dpi.clamp(72.0, 600.0) } else { FLATTEN_DPI }; + let mut budget = MAX_FLATTENED_BYTES; + let mut done = 0usize; + for i in transparent { + let Some(page) = pages.get(i) else { continue }; + let Some(picture) = sheet_picture(&doc, &mut renderer, page, i, dpi, &mut budget) else { continue }; + let mut xobjects = Dict::new(); + for (name, image) in picture.tiles { + xobjects.set(name, Object::Ref(doc.add(Object::Stream(image)))); + } + let mut resources = Dict::new(); + resources.set("XObject", Object::Dict(xobjects)); + let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), &picture.content))); + let replaced = doc.update_dict(page.obj, |d| { + d.set("Contents", Object::Ref(contents)); + d.set("Resources", Object::Dict(resources)); + d.remove(b"Annots"); + d.remove(b"Group"); + }); + if replaced.is_ok() { + done += 1; + } + } + (done > 0).then(|| write_full(&doc, &SaveOptions::default()).ok()).flatten() + }) + .ok() + .flatten() +} + +/// A page drawn as tiles: the content that draws them, and each tile's resource name and image. +struct Picture { + content: Vec, + tiles: Vec<(Vec, Stream)>, +} + +/// The scale (pixels per point) a `w` × `h` pt sheet is flattened at for `dpi`: lowered so the +/// sheet stays within [`MAX_FLATTENED_PIXELS`] and [`MAX_FLATTENED_SIDE`]. +fn flatten_scale(w: f64, h: f64, dpi: f64) -> f64 { + let (w, h) = (w.max(1.0), h.max(1.0)); + (dpi / 72.0).min((MAX_FLATTENED_PIXELS / (w * h)).sqrt()).min(MAX_FLATTENED_SIDE / w.max(h)) +} + +/// Page `index` of `doc` rendered by `renderer` as tiles at `dpi` (see [`flattened`]), with the +/// content that draws them where the page shows. `None` when a tile doesn't render, or the +/// tiles' encoded size would pass what is left of `budget`, which a page that succeeds is +/// charged. +fn sheet_picture( + doc: &pdfcraft_cos::Document, + renderer: &mut PageRenderer, + page: &pdfcraft_model::Page, + index: usize, + dpi: f64, + budget: &mut usize, +) -> Option { + // The renderer draws the page as displayed (crop box, `/UserUnit`, `/Rotate`), y down; the + // view matrix takes that display space (y up) back to the page's user space. + let (w, h) = page.display_size(doc); + let scale = flatten_scale(w, h, dpi) as f32; + if !(scale.is_finite() && scale > 0.0) { + return None; + } + let (width, height) = (pdfcraft_render::device_pixels(w as f32, scale), pdfcraft_render::device_pixels(h as f32, scale)); + let s = f64::from(scale); + let mut content = format!("q {} cm\n", matrix(page.view_matrix(doc))); + let mut tiles = Vec::new(); + let mut used = 0usize; + for y in (0..height).step_by(FLATTEN_TILE as usize) { + for x in (0..width).step_by(FLATTEN_TILE as usize) { + let (tw, th) = (FLATTEN_TILE.min(width - x), FLATTEN_TILE.min(height - y)); + let r = + renderer.render(RenderRequest { page: index, scale, tile: Some(Tile { x, y, w: tw, h: th }), tag: PRINT_TAG, ..Default::default() }); + let pixels = (tw as usize) * (th as usize); + if r.error.is_some() || r.width != tw || r.height != th || r.rgba.len() != pixels * 4 { + return None; + } + let mut rgba = r.rgba.to_vec(); + crate::print_raster::over_white(&mut rgba, false); + let rgb: Vec = rgba.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(); + let mut image = Dict::new(); + image.set("Type", Object::name("XObject")); + image.set("Subtype", Object::name("Image")); + image.set("Width", Object::Int(i64::from(tw))); + image.set("Height", Object::Int(i64::from(th))); + image.set("ColorSpace", Object::name("DeviceRGB")); + image.set("BitsPerComponent", Object::Int(8)); + let image = Stream::flate(image, &rgb); + used = used.saturating_add(image.raw.len()); + if used > *budget { + return None; + } + // The tile's place in display space, y up: its top edge is `y` pixels below the top. + // The last row and column may reach past the page by under a pixel, which the crop + // box clips. + let name = format!("PCFlat{}", tiles.len()); + let place = [f64::from(tw) / s, 0.0, 0.0, f64::from(th) / s, f64::from(x) / s, h - f64::from(y + th) / s]; + content.push_str(&format!("q {} cm /{name} Do Q\n", matrix(place))); + tiles.push((name.into_bytes(), image)); + } + } + content.push_str("Q\n"); + *budget -= used; + Some(Picture { content: content.into_bytes(), tiles }) +} + +/// `m` as the six operands of `cm`, at full precision. +fn matrix(m: [f64; 6]) -> String { + let mut out = Vec::new(); + for (i, v) in m.into_iter().enumerate() { + if i > 0 { + out.push(b' '); + } + pdfcraft_cos::serialize(&Object::Real(v), &mut out); + } + String::from_utf8_lossy(&out).into_owned() +} + +#[cfg(test)] +mod tests { + use pdfcraft_cos::{Dict, Document, Object, Stream}; + + use super::*; + use crate::print::spool::Job; + + /// A one-page PDF drawing a `w` × `h` grey image over the page. + fn page_with_image(w: i64, h: i64) -> Vec { + let mut doc = Document::new_empty(); + let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap(); + let mut image = Dict::new(); + image.set("Type", Object::name("XObject")); + image.set("Subtype", Object::name("Image")); + image.set("Width", Object::Int(w)); + image.set("Height", Object::Int(h)); + image.set("ColorSpace", Object::name("DeviceGray")); + image.set("BitsPerComponent", Object::Int(8)); + let samples: Vec = (0..w * h).map(|i| (i % 251) as u8).collect(); + let image = doc.add(Object::Stream(Stream::flate(image, &samples))); + let mut xobjects = Dict::new(); + xobjects.set("Im", Object::Ref(image)); + let mut resources = Dict::new(); + resources.set("XObject", Object::Dict(xobjects)); + let content = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"q 612 0 0 792 0 0 cm /Im Do Q".to_vec()))); + let mut page = Dict::new(); + page.set("Type", Object::name("Page")); + page.set("Parent", Object::Ref(pages)); + page.set("MediaBox", Object::Array([0, 0, 612, 792].map(Object::Int).to_vec())); + page.set("Resources", Object::Dict(resources)); + page.set("Contents", Object::Ref(content)); + let page = doc.add(Object::Dict(page)); + doc.update_dict(pages, |d| { + d.set("Count", Object::Int(1)); + d.set("Kids", Object::Array(vec![Object::Ref(page)])); + }) + .unwrap(); + write_full(&doc, &SaveOptions::default()).unwrap() + } + + #[test] + fn a_large_image_goes_to_the_spooler_as_tiles() { + let tiled = tiled(Arc::new(page_with_image(2100, 2100))).expect("tiled"); + let doc = Document::open(Arc::new(tiled)).unwrap(); + let largest = doc + .scan_objects() + .filter_map(|(_, o)| match &*o { + Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => Some(s.dict.int(b"Width")? * s.dict.int(b"Height")?), + _ => None, + }) + .max(); + // The page's image is a form now; its tiles are the images it draws. + assert_eq!(largest, Some(2001 * 2001)); + } + + #[test] + fn a_print_with_nothing_to_tile_or_unreadable_goes_as_it_is() { + assert_eq!(tiled(Arc::new(page_with_image(100, 100))), None); + assert_eq!(tiled(Arc::new(b"not a PDF".to_vec())), None); + let mut job = Job::default(); + assert_eq!(spooler_copy(b"not a PDF".to_vec(), &mut job, false), b"not a PDF"); + let small = page_with_image(100, 100); + assert_eq!(spooler_copy(small.clone(), &mut job, false), small); + assert!(!job.grey_pages); + // A grayscale job whose copy can't be read asks the driver for grey as before. + let mut grey = Job { grayscale: true, ..Job::default() }; + assert_eq!(spooler_copy(b"not a PDF".to_vec(), &mut grey, false), b"not a PDF"); + assert!(!grey.grey_pages); + } + + /// A one-page PDF filling the page in red. + fn red_page() -> Vec { + let mut doc = Document::new_empty(); + let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap(); + let content = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"1 0 0 rg 0 0 612 792 re f".to_vec()))); + let mut page = Dict::new(); + page.set("Type", Object::name("Page")); + page.set("Parent", Object::Ref(pages)); + page.set("MediaBox", Object::Array([0, 0, 612, 792].map(Object::Int).to_vec())); + page.set("Contents", Object::Ref(content)); + let page = doc.add(Object::Dict(page)); + doc.update_dict(pages, |d| { + d.set("Count", Object::Int(1)); + d.set("Kids", Object::Array(vec![Object::Ref(page)])); + }) + .unwrap(); + write_full(&doc, &SaveOptions::default()).unwrap() + } + + /// The operators of the first page's content. + fn first_page_content(bytes: Vec) -> String { + let doc = Document::open(Arc::new(bytes)).unwrap(); + let page = pdfcraft_model::pages(&doc)[0].obj; + let contents = doc.get(page).as_dict().and_then(|d| d.reference(b"Contents")).unwrap(); + match &*doc.get(contents) { + Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(), + other => panic!("{other:?}"), + } + } + + /// #5806: a page in colour is made grey for a grayscale job, which says so. + #[test] + fn a_colour_page_is_made_grey_for_a_grayscale_job() { + let grey = greyed(Arc::new(red_page())).expect("readable").expect("converted"); + let content = first_page_content(grey.clone()); + assert!(!content.contains(" rg"), "{content}"); + assert!(content.contains(" g"), "{content}"); + // Already grey: nothing to convert, and the pages are still grey. + assert_eq!(greyed(Arc::new(grey)), Some(None)); + assert_eq!(greyed(Arc::new(b"not a PDF".to_vec())), None); + } + + /// On Windows a grayscale job's copy is grey and the job says its pages are; elsewhere the + /// bytes go as they are and the driver is asked, as before. A colour job is never converted. + #[test] + fn a_grayscale_job_sends_grey_pages_on_windows_only() { + let red = red_page(); + let mut job = Job { grayscale: true, ..Job::default() }; + let sent = spooler_copy(red.clone(), &mut job, false); + if cfg!(windows) { + assert!(job.grey_pages); + assert!(!first_page_content(sent).contains(" rg")); + } else { + assert!(!job.grey_pages); + assert_eq!(sent, red); + } + let mut colour = Job::default(); + assert_eq!(spooler_copy(red.clone(), &mut colour, false), red); + assert!(!colour.grey_pages); + } + + /// On Windows the spooler gets the tiled copy ([`tiled`]); elsewhere the bytes as they are. + #[test] + fn the_spooler_gets_tiles_on_windows_only() { + let large = page_with_image(2100, 2100); + let sent = spooler_copy(large.clone(), &mut Job::default(), false); + if cfg!(windows) { + assert_eq!(Some(&sent), tiled(Arc::new(large.clone())).as_ref()); + assert_ne!(sent, large); + } else { + assert_eq!(sent, large); + } + } + + /// A PDF with the pages `make` returns, each given its type, its parent and, when it has + /// none, a US Letter MediaBox. `make` adds what the pages refer to. + fn pdf_of(make: impl FnOnce(&mut Document) -> Vec) -> Vec { + let mut doc = Document::new_empty(); + let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap(); + let made = make(&mut doc); + let count = made.len() as i64; + let kids: Vec = made + .into_iter() + .map(|mut page| { + page.set("Type", Object::name("Page")); + page.set("Parent", Object::Ref(pages)); + if !page.contains(b"MediaBox") { + page.set("MediaBox", Object::Array([0, 0, 612, 792].map(Object::Int).to_vec())); + } + Object::Ref(doc.add(Object::Dict(page))) + }) + .collect(); + doc.update_dict(pages, |d| { + d.set("Count", Object::Int(count)); + d.set("Kids", Object::Array(kids)); + }) + .unwrap(); + write_full(&doc, &SaveOptions::default()).unwrap() + } + + /// A page drawing `content` with `resources`. + fn page(doc: &mut Document, resources: Dict, content: &[u8]) -> Dict { + let content = doc.add(Object::Stream(Stream::from_raw(Dict::new(), content.to_vec()))); + let mut page = Dict::new(); + page.set("Resources", Object::Dict(resources)); + page.set("Contents", Object::Ref(content)); + page + } + + /// An image XObject dictionary of `w` × `h` 8-bit samples in `space`. + fn image_dict(w: i64, h: i64, space: &str) -> Dict { + let mut image = Dict::new(); + image.set("Type", Object::name("XObject")); + image.set("Subtype", Object::name("Image")); + image.set("Width", Object::Int(w)); + image.set("Height", Object::Int(h)); + image.set("ColorSpace", Object::name(space)); + image.set("BitsPerComponent", Object::Int(8)); + image + } + + /// A blue square, the content of a page without transparency. + const BLUE_SQUARE: &[u8] = b"0 0 1 rg 100 100 200 200 re f"; + + /// A page without transparency: a blue square. + fn vector_page(doc: &mut Document) -> Dict { + page(doc, Dict::new(), BLUE_SQUARE) + } + + /// A page drawing a red image over a blue square, with a soft mask that fades the image in + /// from left to right. + fn soft_masked_page(doc: &mut Document) -> Dict { + let alpha: Vec = (0..64 * 64).map(|i| (i % 64 * 4) as u8).collect(); + let mask = doc.add(Object::Stream(Stream::flate(image_dict(64, 64, "DeviceGray"), &alpha))); + let mut image = image_dict(64, 64, "DeviceRGB"); + image.set("SMask", Object::Ref(mask)); + let red: Vec = (0..64 * 64).flat_map(|_| [255, 0, 0]).collect(); + let image = doc.add(Object::Stream(Stream::flate(image, &red))); + let mut xobjects = Dict::new(); + xobjects.set("Im", Object::Ref(image)); + let mut resources = Dict::new(); + resources.set("XObject", Object::Dict(xobjects)); + page(doc, resources, b"0 0 1 rg 100 100 200 200 re f q 300 0 0 300 150 150 cm /Im Do Q") + } + + /// Page `n`'s dictionary, inherited entries filled in, and the operators of its content. + fn page_of(doc: &Document, n: usize) -> (Dict, String) { + let page = pdfcraft_model::pages(doc).swap_remove(n); + let contents = page.dict.reference(b"Contents").unwrap(); + let content = match &*doc.get(contents) { + Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(), + other => panic!("{other:?}"), + }; + (page.dict, content) + } + + /// The images among page `n`'s XObjects, as (width, height, has a soft mask), sorted. + fn page_images(doc: &Document, n: usize) -> Vec<(i64, i64, bool)> { + let (page, _) = page_of(doc, n); + let resources = doc.resolve(page.get(b"Resources").unwrap()); + let xobjects = doc.resolve(resources.as_dict().unwrap().get(b"XObject").unwrap()); + let mut images: Vec<(i64, i64, bool)> = xobjects + .as_dict() + .unwrap() + .iter() + .filter_map(|(_, x)| match &*doc.resolve(x) { + Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => { + Some((s.dict.int(b"Width")?, s.dict.int(b"Height")?, s.dict.contains(b"SMask"))) + } + _ => None, + }) + .collect(); + images.sort(); + images + } + + /// #6155, #5838, #5940: a page with a soft-masked image becomes an opaque picture in tiles + /// under Windows' image cap, on a sheet of the same size, and tiling leaves them alone. + #[test] + fn a_soft_masked_page_is_flattened_into_tiles() { + let flat = flattened(Arc::new(pdf_of(|doc| vec![soft_masked_page(doc)])), FLATTEN_DPI).expect("flattened"); + let doc = Document::open(Arc::new(flat.clone())).unwrap(); + // US Letter at 300 dpi is 2550 × 3300 pixels: two tiles across, two down. + assert_eq!(page_images(&doc, 0), [(550, 1300, false), (550, 2000, false), (2000, 1300, false), (2000, 2000, false)]); + assert!(doc.scan_objects().all(|(_, o)| !matches!(&*o, Object::Stream(s) if s.dict.contains(b"SMask")))); + let (page, content) = page_of(&doc, 0); + assert_eq!(page.get(b"MediaBox"), Some(&Object::Array([0, 0, 612, 792].map(Object::Int).to_vec()))); + assert_eq!(content.matches(" Do").count(), 4, "{content}"); + assert!(!pdfcraft_preflight::page_uses_transparency(&doc, &page)); + assert_eq!(tiled(Arc::new(flat)), None); + } + + /// A page without transparency, or with a transparency group around opaque content only (as + /// a print-ready sheet draws a page that Word gave one), keeps its vectors; so does a copy + /// that can't be read. + #[test] + fn a_page_without_transparency_or_unreadable_is_not_flattened() { + assert_eq!(flattened(Arc::new(pdf_of(|doc| vec![vector_page(doc)])), FLATTEN_DPI), None); + assert_eq!(flattened(Arc::new(page_with_image(100, 100)), FLATTEN_DPI), None); + let grouped = pdf_of(|doc| { + let mut group = Dict::new(); + group.set("S", Object::name("Transparency")); + let mut form = Dict::new(); + form.set("Type", Object::name("XObject")); + form.set("Subtype", Object::name("Form")); + form.set("BBox", Object::Array([0, 0, 612, 792].map(Object::Int).to_vec())); + form.set("Group", Object::Dict(group)); + let form = doc.add(Object::Stream(Stream::from_raw(form, BLUE_SQUARE.to_vec()))); + let mut xobjects = Dict::new(); + xobjects.set("Fm", Object::Ref(form)); + let mut resources = Dict::new(); + resources.set("XObject", Object::Dict(xobjects)); + vec![page(doc, resources, b"/Fm Do")] + }); + assert_eq!(flattened(Arc::new(grouped), FLATTEN_DPI), None); + assert_eq!(flattened(Arc::new(b"not a PDF".to_vec()), FLATTEN_DPI), None); + } + + /// Of two pages, only the one with transparency becomes a picture; the other keeps its + /// content as it was. + #[test] + fn only_the_transparent_page_of_two_is_flattened() { + let source = pdf_of(|doc| vec![vector_page(doc), soft_masked_page(doc)]); + let flat = flattened(Arc::new(source), 150.0).expect("flattened"); + let doc = Document::open(Arc::new(flat)).unwrap(); + assert_eq!(page_of(&doc, 0).1, String::from_utf8_lossy(BLUE_SQUARE)); + // US Letter at 150 dpi is 1275 × 1650 pixels: one tile. + assert_eq!(page_images(&doc, 1), [(1275, 1650, false)]); + assert_eq!(page_of(&doc, 1).1.matches(" Do").count(), 1); + } + + /// The picture lands where the page showed what it pictures, on a page turned a quarter + /// with a crop box away from the origin. + #[test] + fn a_flattened_turned_page_looks_as_it_did() { + let source = pdf_of(|doc| { + let mut half = Dict::new(); + half.set("ca", Object::Real(0.5)); + let mut states = Dict::new(); + states.set("Half", Object::Dict(half)); + let mut resources = Dict::new(); + resources.set("ExtGState", Object::Dict(states)); + // Half-transparent red over the crop box's lower left 100 × 100 pt. + let mut page = page(doc, resources, b"/Half gs 1 0 0 rg 50 0 100 100 re f"); + page.set("MediaBox", Object::Array([0, 0, 300, 200].map(Object::Int).to_vec())); + page.set("CropBox", Object::Array([50, 0, 300, 200].map(Object::Int).to_vec())); + page.set("Rotate", Object::Int(90)); + vec![page] + }); + let flat = flattened(Arc::new(source.clone()), 72.0).expect("flattened"); + let (page, _) = page_of(&Document::open(Arc::new(flat.clone())).unwrap(), 0); + assert_eq!(page.get(b"Rotate"), Some(&Object::Int(90))); + assert_eq!(page.get(b"CropBox"), Some(&Object::Array([50, 0, 300, 200].map(Object::Int).to_vec()))); + let render = |bytes: Vec| { + let r = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { + page: 0, + scale: 1.0, + tag: PRINT_TAG, + ..Default::default() + }); + assert!(r.error.is_none(), "{:?}", r.error); + r + }; + let (before, after) = (render(source), render(flat)); + // Turned a quarter clockwise, the page shows 200 × 250 pt with the red at its top left. + assert_eq!((before.width, before.height), (200, 250)); + assert_eq!((after.width, after.height), (200, 250)); + let px = |r: &pdfcraft_render::RenderedPage, x: u32, y: u32| { + let i = ((y * r.width + x) * 4) as usize; + [r.rgba[i], r.rgba[i + 1], r.rgba[i + 2]] + }; + let pink = px(&before, 50, 50); + assert!(pink[0] > 240 && (100..160).contains(&pink[1]), "{pink:?}"); + assert_eq!(px(&before, 150, 200), [255, 255, 255]); + // Sampled away from the red's edges (at 100 px), where pixels may blend. + for y in (5..250).step_by(10) { + for x in (5..200).step_by(10) { + let (b, a) = (px(&before, x, y), px(&after, x, y)); + assert!(b.iter().zip(a).all(|(b, a)| b.abs_diff(a) <= 3), "({x}, {y}): {b:?} became {a:?}"); + } + } + } + + /// A sheet keeps the full resolution up to 24 × 36 in; a larger one gets a lower one within + /// the pixel bound, and a long banner one within the side bound. + #[test] + fn a_large_sheet_is_flattened_at_a_lower_resolution() { + let dpi = |w: f64, h: f64| flatten_scale(w * 72.0, h * 72.0, FLATTEN_DPI) * 72.0; + assert!((dpi(8.5, 11.0) - 300.0).abs() < 1e-9); + assert!((dpi(24.0, 36.0) - 300.0).abs() < 1e-9); + let large = dpi(36.0, 48.0); + assert!(large < 300.0 && 36.0 * 48.0 * large * large <= MAX_FLATTENED_PIXELS * 1.000_001, "{large}"); + assert!((dpi(36.0, 1200.0) * 1200.0 - MAX_FLATTENED_SIDE).abs() < 1e-6); + } + + /// With the flag off a transparent page goes to the spooler as it is; on, Windows gets the + /// flattened copy. + #[test] + fn the_spooler_flattens_transparency_only_when_asked() { + let source = pdf_of(|doc| vec![soft_masked_page(doc)]); + assert_eq!(spooler_copy(source.clone(), &mut Job::default(), false), source); + let sent = spooler_copy(source.clone(), &mut Job::default(), true); + if cfg!(windows) { + assert_eq!(Some(&sent), flattened(Arc::new(source), FLATTEN_DPI).as_ref()); + } else { + assert_eq!(sent, source); + } + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 28976be9fa..4e24a4f608 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -106,6 +106,7 @@ fn custom_image_stamp_keeps_displayed_orientation_on_rotated_pages() { name: "Original quadrants".into(), file: MarkFile { name: "quadrants.png".into(), bytes: bytes.clone(), page: 0 }, author: "Test".into(), + line: None, }, ) .unwrap(); @@ -441,6 +442,7 @@ fn explicit_image_stamps_keep_displayed_orientation_on_rotated_pages() { name: "Original quadrants".into(), file: MarkFile { name: "quadrants.png".into(), bytes: bytes.clone(), page: 0 }, author: "Test".into(), + line: None, }, ) .unwrap(); @@ -493,6 +495,7 @@ fn pdf_page_stamps_keep_displayed_orientation_on_rotated_pages() { name: "Half red".into(), file: MarkFile { name: "half.pdf".into(), bytes: bytes.clone(), page: 0 }, author: "Test".into(), + line: None, }, ) .unwrap(); @@ -522,6 +525,7 @@ fn pdf_page_stamps_fit_inside_an_explicit_box() { name: "Diagram".into(), file: MarkFile { name: "diagram.pdf".into(), bytes: bytes.clone(), page: 0 }, author: "Test".into(), + line: None, }; session.apply(id, stamp([56.0, 400.0, 539.0, 700.0])).unwrap(); assert_eq!(session.get(id).unwrap().info.annotations[0].rect, [56.0, 429.25, 539.0, 670.75]); @@ -1571,8 +1575,13 @@ fn a_page_with_a_form_field_shown_twice_keeps_one_field() { #[test] fn files_that_cannot_be_converted_are_refused_clearly() { let s = Session::new(); - let err = s.convert_to_pdf("report.docx", &Arc::new(b"PK\x03\x04".to_vec())).unwrap_err(); - assert_eq!(err, EditError::Source("report.docx: this file type can't be converted; use a PDF, an image or a .txt file".into())); + let err = s.convert_to_pdf("archive.zip", &Arc::new(b"PK\x03\x04".to_vec())).unwrap_err(); + assert_eq!( + err, + EditError::Source( + "archive.zip: this file type can't be converted; use a PDF, an image, a .txt file or a Word, Excel or PowerPoint file".into() + ) + ); assert!(matches!(s.convert_to_pdf("empty", &Arc::new(Vec::new())), Err(EditError::Source(_)))); // Damaged inputs of a known type fail with an error, not a panic. assert!(s.convert_to_pdf("bad.png", &Arc::new(b"\x89PNG\r\n\x1a\nnope".to_vec())).is_err()); @@ -1593,8 +1602,8 @@ fn files_for_combine_are_made_into_pdfs_and_pdfs_pass_through() { assert!(Arc::ptr_eq(&s.as_pdf("old.PDF", &headless).unwrap(), &headless)); assert!(s.as_pdf("scan.jpg", &Arc::new(jpeg_bytes())).unwrap().starts_with(b"%PDF-")); assert!(s.as_pdf("notes.txt", &Arc::new(b"hello".to_vec())).unwrap().starts_with(b"%PDF-")); - let err = s.as_pdf("report.docx", &Arc::new(b"PK\x03\x04".to_vec())).unwrap_err(); - assert!(matches!(err, EditError::Source(m) if m.starts_with("report.docx: this file type can't be converted"))); + let err = s.as_pdf("archive.zip", &Arc::new(b"PK\x03\x04".to_vec())).unwrap_err(); + assert!(matches!(err, EditError::Source(m) if m.starts_with("archive.zip: this file type can't be converted"))); } fn protected(user: &str, owner: &str, permissions: i32) -> Arc> { @@ -1611,6 +1620,206 @@ fn protected(user: &str, owner: &str, permissions: i32) -> Arc> { Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()) } +/// Two pages protected with `algorithm` under the permissions password "boss" (no open +/// password), restricting changes to `changes`. +fn protected_with_changes(algorithm: pdfcraft_cos::Algorithm, changes: Changes) -> Arc> { + protected_pages(2, algorithm, changes, 8) +} + +/// [`protected_with_changes`] with `pages` pages; `seed` gives the file key and salts, so two +/// files with the same arguments but `pages` share their protection exactly. +fn protected_pages(pages: usize, algorithm: pdfcraft_cos::Algorithm, changes: Changes, seed: u8) -> Arc> { + let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(pages))).unwrap(); + let protection = Protection { permissions_password: Some("boss".into()), changes, algorithm, ..Protection::default() }; + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm, + user_password: "", + owner_password: "boss", + permissions: protection.permission_bits(), + encrypt_metadata: true, + seed: [seed; 32], + }) + .unwrap(); + Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()) +} + +/// "Any except extracting pages" (#5081) withholds page extraction on every handler revision. At +/// revision 3 and later the preset (bit 4 without bit 11) allows no page changes either; at +/// revision 2, where bit 4 covers assembly, extraction, splitting and being a page source are +/// still refused because copying is withheld. +#[test] +fn extraction_permissions_refuse_any_except_extract_on_every_revision() { + use pdfcraft_cos::Algorithm::{Aes128, Aes256, Rc4_40, Rc4_128}; + + for algorithm in [Rc4_128, Aes128, Aes256] { + let bytes = protected_with_changes(algorithm, Changes::AnyExceptExtract); + let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), None).unwrap(); + let permissions = doc.permissions().unwrap(); + assert!(!permissions.extract_pages(), "{algorithm:?}, /P={:#x}", permissions.bits); + assert!(!permissions.assemble(), "{algorithm:?}: bit 4 without bit 11 allows no page changes"); + } + let r2 = protected_with_changes(Rc4_40, Changes::AnyExceptExtract); + let mut session = Session::new(); + let r2_id = session.open("r2.pdf", None, r2.clone(), None).unwrap(); + let permissions = session.get(r2_id).unwrap().permissions().unwrap(); + assert!(permissions.assemble() && !permissions.extract_pages(), "revision 2, /P={:#x}", permissions.bits); + assert_eq!(session.extract(r2_id, &[0]), Err(EditError::NotPermitted("extracting pages"))); + assert_eq!(session.split(r2_id, &pdfcraft_organize::SplitBy::PageCount(1)), Err(EditError::NotPermitted("splitting the document"))); + assert_eq!(crate::combine_source_check(&r2, None), Err(crate::SourceProblem::NotPermitted)); +} + +/// "Any except extracting pages" (#5081): Extract, Split and Combine refuse the file without the +/// permissions password and leave it unchanged; Combine takes it with that password. +#[test] +fn extraction_permissions_cover_revision_three_to_six_and_leave_no_partial_output() { + let bytes = protected_with_changes(pdfcraft_cos::Algorithm::Aes256, Changes::AnyExceptExtract); + assert_eq!(crate::combine_source_check(&bytes, None), Err(crate::SourceProblem::NotPermitted)); + assert_eq!(crate::combine_source_check(&bytes, Some("boss")), Ok(())); + let mut restricted = Session::new(); + let id = restricted.open("restricted.pdf", None, bytes.clone(), None).unwrap(); + let before = restricted.get(id).unwrap().bytes.clone(); + assert_eq!(restricted.extract(id, &[0]), Err(EditError::NotPermitted("extracting pages"))); + assert_eq!(restricted.split(id, &pdfcraft_organize::SplitBy::PageCount(1)), Err(EditError::NotPermitted("splitting the document"))); + assert_eq!(restricted.split_by_size(id, 1), Err(EditError::NotPermitted("splitting the document"))); + let doc = restricted.get(id).unwrap(); + assert_eq!(doc.info.pages.len(), 2); + assert!(!doc.dirty); + assert_eq!(doc.bytes, before, "a refused extraction leaves the source untouched"); + + let sources = vec![("restricted.pdf".into(), bytes.clone(), None)]; + assert!(matches!(restricted.combine_unlocked(&sources, &[None]), Err(EditError::Source(_)))); + let combined = restricted.combine_unlocked(&sources, &[Some("boss")]).unwrap(); + assert_eq!(pdfcraft_organize::page_count(&pdfcraft_cos::Document::open(combined).unwrap()).unwrap(), 2); +} + +/// A file under "Any except extracting pages" (#5081) is refused as an Insert Pages or Replace +/// Pages source for an unprotected document, which is left as it was, with nothing to undo. +#[test] +fn extraction_permissions_refuse_a_restricted_page_source() { + let bytes = protected_with_changes(pdfcraft_cos::Algorithm::Aes256, Changes::AnyExceptExtract); + let plain = Arc::new(fixture(1)); + let mut destination = Session::new(); + let dest = destination.open("destination.pdf", None, plain.clone(), None).unwrap(); + let insert = Edit::InsertPagesFrom { + name: "restricted.pdf".into(), + bytes: bytes.clone(), + pages: None, + at: 1, + field_conflicts: pdfcraft_organize::FieldConflicts::Rename, + }; + assert!(matches!(destination.apply(dest, insert), Err(EditError::Source(_)))); + assert_eq!(destination.get(dest).unwrap().bytes, plain, "a refused insert leaves the destination untouched"); + assert_eq!(destination.get(dest).unwrap().info.pages.len(), 1); + assert_eq!(destination.get(dest).unwrap().can_undo(), None); + assert!(matches!( + destination.apply(dest, Edit::ReplacePages { pages: vec![0], name: "restricted.pdf".into(), bytes: bytes.clone(), src_pages: vec![0] }), + Err(EditError::Source(_)) + )); + assert_eq!(destination.get(dest).unwrap().bytes, plain, "a refused replace leaves the destination untouched"); +} + +/// Opened with the permissions password, a file under "Any except extracting pages" (#5081) can +/// be extracted from and split. +#[test] +fn extraction_permissions_yield_to_the_owner_password() { + let bytes = protected_with_changes(pdfcraft_cos::Algorithm::Aes256, Changes::AnyExceptExtract); + let mut owner = Session::new(); + let owner_id = owner.open("restricted.pdf", None, bytes, Some("boss")).unwrap(); + assert!(owner.get(owner_id).unwrap().permissions().unwrap().extract_pages()); + assert!(owner.extract(owner_id, &[0]).is_ok()); + assert_eq!(owner.split(owner_id, &pdfcraft_organize::SplitBy::PageCount(1)).unwrap().len(), 2); + assert_eq!(owner.split_by_size(owner_id, 1).unwrap().len(), 2); +} + +/// A document that allows page changes but not copying, so not page extraction (#5081), takes +/// pages back from a file under exactly its own protection (a cut and paste, or another file +/// made with the same key and `/Encrypt` values), through Insert Pages' file check as well, and +/// through Replace Pages and its check, and keeps the restriction; the same file is refused as a +/// source for an unprotected or a differently protected document. +#[test] +fn pages_under_the_same_protection_are_inserted_without_page_extraction() { + use pdfcraft_cos::Algorithm::Aes256; + + let bytes = protected_pages(2, Aes256, Changes::Pages, 8); + let mut same = Session::new(); + let same_id = same.open("same.pdf", None, bytes.clone(), None).unwrap(); + let permissions = same.get(same_id).unwrap().permissions().unwrap(); + assert!(permissions.assemble() && !permissions.extract_pages(), "/P={:#x}", permissions.bits); + let insert = |name: &str, bytes: &Arc>, at| Edit::InsertPagesFrom { + name: name.into(), + bytes: bytes.clone(), + pages: None, + at, + field_conflicts: pdfcraft_organize::FieldConflicts::Rename, + }; + // Its own bytes, as Paste inserts them after a cut or copy. + same.apply(same_id, insert("same.pdf", &bytes, 2)).unwrap(); + assert_eq!(same.get(same_id).unwrap().info.pages.len(), 4); + assert!(same.get(same_id).unwrap().dirty); + + // A separate file made with the same key and /Encrypt values, and other pages. + let other = protected_pages(3, Aes256, Changes::Pages, 8); + let (a, b) = (pdfcraft_cos::Document::open(bytes.clone()).unwrap(), pdfcraft_cos::Document::open(other.clone()).unwrap()); + let (a, b) = (a.security().unwrap(), b.security().unwrap()); + assert!(a.file_key() == b.file_key() && a.dict() == b.dict(), "the two files share their protection"); + assert!(matches!(same.page_count_of("other.pdf", &other), Err(EditError::Source(_))), "a plain page source must allow extraction"); + assert_eq!(same.insert_source(same_id, "other.pdf", &other).map(|(n, _)| n), Ok(3)); + same.apply(same_id, insert("other.pdf", &other, 4)).unwrap(); + // Replace Pages takes them on the same terms. + let replace = |bytes: &Arc>| Edit::ReplacePages { pages: vec![0], name: "other.pdf".into(), bytes: bytes.clone(), src_pages: vec![2] }; + assert_eq!(same.replace_source(same_id, "other.pdf", &other), Ok(3)); + same.apply(same_id, replace(&other)).unwrap(); + assert_eq!(same.get(same_id).unwrap().info.pages.len(), 7); + let saved = same.save_bytes(same_id).unwrap(); + let reopened = pdfcraft_cos::Document::open_with_password(saved, None).unwrap(); + assert_eq!(pdfcraft_organize::page_count(&reopened).unwrap(), 7); + assert!(!reopened.permissions().unwrap().extract_pages(), "same-protection assembly keeps the extraction restriction"); + + // Neither an unprotected document nor one under other protection takes them. + for destination in [Arc::new(fixture(1)), protected_pages(1, Aes256, Changes::Pages, 9)] { + let mut session = Session::new(); + let dest = session.open("destination.pdf", None, destination, None).unwrap(); + let before = session.get(dest).unwrap().bytes.clone(); + assert!(matches!(session.insert_source(dest, "same.pdf", &bytes), Err(EditError::Source(_)))); + assert!(matches!(session.apply(dest, insert("same.pdf", &bytes, 1)), Err(EditError::Source(_)))); + assert!(matches!(session.replace_source(dest, "same.pdf", &bytes), Err(EditError::Source(_)))); + assert!(matches!(session.apply(dest, replace(&bytes)), Err(EditError::Source(_)))); + assert_eq!(session.get(dest).unwrap().bytes, before, "a refused insert or replace leaves the destination untouched"); + assert_eq!(session.get(dest).unwrap().info.pages.len(), 1); + } +} + +/// Taking pages out also needs copying (bit 5, #5081): a document that withholds only copying +/// refuses Extract, Split and being a page source, on a revision-2 handler too, whose reserved +/// bit 11 is always set. The owner password still lifts it. +#[test] +fn page_extraction_needs_the_copy_permission_on_every_revision() { + for algorithm in [pdfcraft_cos::Algorithm::Rc4_40, pdfcraft_cos::Algorithm::Aes256] { + let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(2))).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm, + user_password: "", + owner_password: "boss", + permissions: -1 ^ 0b1_0000, // everything but copying + encrypt_metadata: true, + seed: [9; 32], + }) + .unwrap(); + let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()); + let mut s = Session::new(); + let id = s.open("no-copy.pdf", None, bytes.clone(), None).unwrap(); + let permissions = s.get(id).unwrap().permissions().unwrap(); + assert!(permissions.assemble() && !permissions.extract_pages(), "{algorithm:?}"); + assert!(s.get(id).unwrap().allows_assembly() && !s.get(id).unwrap().allows_page_extraction(), "{algorithm:?}"); + assert_eq!(s.extract(id, &[0]), Err(EditError::NotPermitted("extracting pages")), "{algorithm:?}"); + assert_eq!(s.split(id, &pdfcraft_organize::SplitBy::PageCount(1)), Err(EditError::NotPermitted("splitting the document"))); + assert_eq!(crate::combine_source_check(&bytes, None), Err(crate::SourceProblem::NotPermitted), "{algorithm:?}"); + assert_eq!(crate::combine_source_check(&bytes, Some("boss")), Ok(()), "{algorithm:?}"); + let owner = s.open("no-copy.pdf", None, bytes, Some("boss")).unwrap(); + assert!(s.extract(owner, &[0]).is_ok(), "{algorithm:?}"); + } +} + #[test] fn encrypted_documents_open_edit_and_save_encrypted() { let bytes = protected("pw", "owner", -1); @@ -2364,6 +2573,29 @@ fn highlight_multiplies_over_text() { assert!(bg[0] > 240 && bg[1] > 240 && bg[2] < 30, "{bg:?}"); } +/// Converting the appearances of comments and form fields needs the permission to comment; +/// converting the page alone needs only the permission to modify. +#[test] +fn converting_colours_of_comments_needs_the_comment_permission() { + let mut cos = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap(); + let params = pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "own", + permissions: !(1 << 5), + encrypt_metadata: true, + seed: [7; 32], + }; + cos.set_encryption(¶ms).unwrap(); + let bytes = pdfcraft_cos::write_full(&cos, &pdfcraft_cos::SaveOptions::default()).unwrap(); + let mut s = Session::new(); + let id = s.open("locked.pdf", None, Arc::new(bytes), None).unwrap(); + let with = ConvertColours { annotations: true, ..ConvertColours::default() }; + assert_eq!(s.apply(id, Edit::ConvertColours { pages: vec![0], settings: with }), Err(EditError::NotPermitted("comments"))); + let without = s.apply(id, Edit::ConvertColours { pages: vec![0], settings: ConvertColours::default() }); + assert!(!matches!(without, Err(EditError::NotPermitted(_))), "{without:?}"); +} + #[test] fn comment_permission_is_enforced() { let mut cos = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap(); @@ -3280,19 +3512,7 @@ fn one_line_page_with(resources: &str, stream_entries: &str, body: &str) -> Vec< "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), format!("<< /Length {} {stream_entries} >>\nstream\n{body}\nendstream", body.len()), ]; - let mut out = b"%PDF-1.7\n".to_vec(); - let mut offsets = Vec::new(); - for (i, o) in objs.iter().enumerate() { - offsets.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); - } - let xref = out.len(); - out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); - for o in offsets { - out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); - } - out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); - out + pdf_of(&objs) } /// Put a redaction mark over `needle` where page 1 shows it, as Find text and redact does. @@ -3359,6 +3579,55 @@ fn text_after_an_inline_image_is_redacted_where_the_page_shows_it() { ); } +/// #2539 review: Apply redactions tells the caller where the removed text is still held that +/// it leaves as it is (here a bookmark title), and the next edit, or an undo, forgets it. +#[test] +fn applied_redactions_report_a_bookmark_that_still_holds_the_removed_text() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let page = one_line_page("/Font << /F1 4 0 R >>", "BT /F1 10 Tf 20 150 Td (Codename BLUEFIN report) Tj ET"); + let id = s.open("bookmark.pdf", None, Arc::new(page), None).unwrap(); + s.apply(id, Edit::AddBookmark { parent: vec![], index: 0, title: "Bluefin plans".into(), page: 0, left: None, top: None }).unwrap(); + s.apply(id, Edit::AddBookmark { parent: vec![], index: 1, title: "Annual figures".into(), page: 0, left: None, top: None }).unwrap(); + mark_shown_text(&mut s, id, "BLUEFIN"); + assert_eq!(s.get(id).unwrap().redaction_elsewhere(), None, "only applying redactions reports it"); + s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap(); + let found = s.get(id).unwrap().redaction_elsewhere().expect("applying reports what it left"); + assert_eq!(found, crate::RedactionElsewhere { bookmarks: 1, ..Default::default() }); + // One of the edit notices, so tools and the interface say it with the others. + let said: Vec = s.get(id).unwrap().edit_notices.iter().map(ToString::to_string).collect(); + assert_eq!(said, ["the redacted text also appears in 1 bookmark title, which redaction doesn't change (Remove hidden information removes them)"]); + assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Bluefin plans→1", "Annual figures→1"], "the bookmark is reported, not changed"); + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().redaction_elsewhere(), None, "an undo forgets it"); +} + +/// #2539 review: applying redactions that remove the document's XMP packet, which named the +/// redacted word and said the file conforms to PDF/A-2b, writes the identification back and +/// says so; tools list it with the other edit notices, and an undo forgets it. +#[test] +fn applied_redactions_report_the_pdfa_identification_written_back() { + let xmp = r#"Bluefin report"#; + let body = "BT /F1 10 Tf 20 150 Td (Codename BLUEFIN report) Tj ET"; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /Metadata 6 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 300] >>".to_string(), + "<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>".to_string(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()), + ]; + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("pdfa.pdf", None, Arc::new(pdf_of(&objs)), None).unwrap(); + mark_shown_text(&mut s, id, "BLUEFIN"); + s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap(); + let kept = crate::RedactionIdentification { pdfa: crate::RedactionClaim::Kept, pdfua: crate::RedactionClaim::None }; + assert_eq!(s.get(id).unwrap().redaction_identification(), Some(kept)); + let said: Vec = s.get(id).unwrap().edit_notices.iter().map(ToString::to_string).collect(); + assert_eq!(said, ["the document's XMP metadata held the redacted text and was removed; its PDF/A identification was written back on its own"]); + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().redaction_identification(), None, "an undo forgets it"); +} + /// A word whose font and size an ExtGState /Font entry (`gs`) sets is placed where the renderer /// draws it, so a mark drawn over it as displayed takes it out of the file. #[test] @@ -3790,6 +4059,45 @@ fn printing_lays_out_sheets_that_render() { assert_eq!(s4.print_pdf(id4, &print::Settings { pages: vec![0], ..Default::default() }), Err(EditError::NotPermitted("printing"))); } +/// A dynamic XFA form's screen-only and print-only objects (`relevant`): on screen, the +/// screen-only field and draw show and the print-only ones don't; printed, the screen-only field +/// is left off and the print-only one is on paper. (Draws follow the layers' state on screen +/// when printed until printing applies the layers' print usage; other viewers read it from the +/// file.) +#[test] +fn xfa_screen_only_and_print_only_objects_show_and_print_where_the_form_says() { + let field = |name: &str, attrs: &str| { + format!( + r#"{name} value"# + ) + }; + let draw = |name: &str, attrs: &str| { + format!(r#"{name} text"#) + }; + let xdp = format!( + r##" +"##, + field("screenField", r#"relevant="-print""#), + field("paperField", r#"presence="invisible" relevant="+print""#), + draw("screenDraw", r#"relevant="-print""#), + draw("paperDraw", r#"presence="invisible" relevant="+print""#), + ); + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&xdp)), None).expect("opens"); + let screen = page_texts(&s, id).join("\n"); + assert!(screen.contains("screenField value") && screen.contains("screenDraw text"), "on screen: {screen}"); + assert!(!screen.contains("paperField value") && !screen.contains("paperDraw text"), "on screen: {screen}"); + let sheet = s.print_pdf(id, &print::Settings { pages: vec![0], ..Default::default() }).unwrap(); + let mut out = Session::new(); + let printed = out.open("print.pdf", None, Arc::new(sheet), None).unwrap(); + let paper = page_texts(&out, printed).join("\n"); + assert!(!paper.contains("screenField value"), "printed: {paper}"); + assert!(paper.contains("paperField value"), "printed: {paper}"); +} + /// A one-page document whose red square is in a layer; `off` switches the layer off by default. fn red_layer_pdf(off: bool) -> Vec { let content = "/OC /L1 BDC 1 0 0 rg 0 0 100 100 re f EMC"; @@ -5164,6 +5472,85 @@ fn comment_summary_includes_replies_to_replies() { assert!(text.contains(" NESTED_REPLY"), "the nested reply is indented under its parent: {text}"); } +/// ISO 32000-2 §12.5.6.2: a group subordinate (`/RT /Group`) is part of its primary's comment, +/// not a reply: the summary leaves it out and writes the replies to it as the comment's. +#[test] +fn comment_summary_leaves_group_members_out_and_keeps_their_replies() { + let (mut s, id) = session_with(1); + s.apply(id, rect_comment(0, [40.0, 40.0, 90.0, 90.0])).unwrap(); + s.apply(id, Edit::ReplyToAnnotation { page: 0, index: 0, text: "MEMBER_TEXT".into(), author: "Bob".into() }).unwrap(); + s.apply(id, Edit::ReplyToAnnotation { page: 0, index: 1, text: "REPLY_TO_MEMBER".into(), author: "Carol".into() }).unwrap(); + let mut all = s.get(id).unwrap().info.annotations.clone(); + all.iter_mut().find(|a| a.contents.as_deref() == Some("MEMBER_TEXT")).unwrap().grouped = true; + let text = comment_summary("doc.pdf", &all, SummarySort::Page); + assert!(!text.contains("MEMBER_TEXT") && !text.contains("Bob"), "{text}"); + assert!(text.lines().any(|l| l == " REPLY_TO_MEMBER"), "the reply is the comment's, one level in: {text}"); +} + +/// The summary follows `/IRT` down from a comment at most 64 steps, group subordinates included: +/// a reply under a longer chain of subordinates is left out, one under a short chain is written, +/// and a subordinate listed at its own primary's position (a loop, from a malformed listing) +/// ends the walk. +#[test] +fn comment_summary_follows_a_group_chain_a_bounded_number_of_steps() { + let (mut s, id) = session_with(1); + s.apply(id, rect_comment(0, [40.0, 40.0, 90.0, 90.0])).unwrap(); + let top = s.get(id).unwrap().info.annotations.iter().find(|a| a.is_comment()).unwrap().clone(); + let at = |a: &pdfcraft_render::Annotation| (a.page, a.index); + let under = |index: usize, parent: (usize, usize), grouped: bool, text: &str| pdfcraft_render::Annotation { + index, + parent: Some(parent), + grouped, + contents: Some(text.into()), + ..top.clone() + }; + for (members, written) in [(10, true), (100, false)] { + let mut all = vec![top.clone()]; + let mut up = at(&top); + for k in 0..members { + let m = under(1000 + k, up, true, "MEMBER"); + up = at(&m); + all.push(m); + } + all.push(under(5000, up, false, "DEEP_REPLY")); + let text = comment_summary("doc.pdf", &all, SummarySort::Page); + assert_eq!(text.contains("DEEP_REPLY"), written, "{members}: {text}"); + assert!(!text.contains("MEMBER"), "{members}: {text}"); + } + let looped = vec![top.clone(), under(top.index, at(&top), true, "LOOP")]; + let text = comment_summary("doc.pdf", &looped, SummarySort::Page); + assert!(!text.contains("LOOP"), "{text}"); +} + +/// Deleting a group's primary removes the subordinate's `/IRT` in the same edit, as Acrobat does: +/// the subordinate lists as a comment of its own and keeps `/RT /Group`. One undo puts the group +/// back, `/IRT` included. +#[test] +fn deleting_a_group_primary_releases_its_member_in_one_undo_step() { + let bytes = pdf_with_annots(&[ + b"<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /NM (primary) /Contents (PRIMARY_TEXT) >>", + b"<< /Type /Annot /Subtype /Circle /Rect [60 60 90 90] /NM (member) /IRT 4 0 R /RT /Group >>", + ]); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("group.pdf", None, Arc::new(bytes), None).unwrap(); + let names = |s: &Session| -> Vec<(Option, bool, bool)> { + s.get(id).unwrap().info.annotations.iter().map(|a| (a.name.clone(), a.is_comment(), a.is_group_member())).collect() + }; + // Listed by top edge: the Circle is the higher one. + let grouped = vec![(Some("member".into()), false, true), (Some("primary".into()), true, false)]; + assert_eq!(names(&s), grouped); + s.apply(id, Edit::DeleteAnnotation { page: 0, index: 0 }).unwrap(); + assert_eq!(names(&s), [(Some("member".into()), true, false)], "a comment of its own"); + let member = |s: &Session| { + let cos = &s.get(id).unwrap().editor.as_ref().unwrap().cos; + cos.get(pdfcraft_cos::ObjRef::new(5, 0)).as_dict().cloned().unwrap() + }; + assert!(member(&s).get(b"IRT").is_none() && member(&s).name(b"RT") == Some(b"Group".as_slice()), "{:?}", member(&s)); + s.undo(id).unwrap(); + assert_eq!(names(&s), grouped, "one undo puts the group back"); + assert!(member(&s).reference(b"IRT").is_some()); +} + #[test] fn reading_colors_set_and_invalidated() { let (mut s, id) = session_with(1); @@ -6443,6 +6830,43 @@ fn form_javascript_validates_calculates_and_formats() { assert!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().read_only()); assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript")); + s.apply(id, Edit::InsertBlankPage { at: 1, width: 200.0, height: 300.0 }).unwrap(); + s.apply(id, Edit::DuplicateField { name: "go".into(), pages: vec![1] }).unwrap(); + let props = Box::new(FieldProps { caption: Some((0, "Continue".into())), ..Default::default() }); + s.apply(id, Edit::SetFieldProps { name: "go".into(), props }).unwrap(); + s.apply(id, Edit::SetFieldProps { name: "go".into(), props: Box::new(FieldProps { caption: Some((1, "Old".into())), ..Default::default() }) }) + .unwrap(); + let caption = + s.run_javascript(id, "getField('go').buttonSetCaption('Continue'); event.value = getField('go').buttonGetCaption();", Some("go")).unwrap(); + assert_eq!(caption.error, None); + assert_eq!(caption.result.as_deref(), Some("Continue")); + assert_eq!( + s.get(id).unwrap().form.iter().find(|f| f.name == "go").map(|f| f.widgets.iter().map(|w| w.caption.as_deref()).collect::>()), + Some(vec![Some("Continue"), Some("Continue")]), + "setting a caption already shown by the first widget updates every widget", + ); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript")); + s.undo(id).unwrap(); + assert_eq!( + s.get(id).unwrap().form.iter().find(|f| f.name == "go").and_then(|f| f.widgets.first()).and_then(|w| w.caption.as_deref()), + Some("Continue"), + "undo keeps the first widget's original caption", + ); + assert_eq!( + s.get(id).unwrap().form.iter().find(|f| f.name == "go").and_then(|f| f.widgets.get(1)).and_then(|w| w.caption.as_deref()), + Some("Old"), + "undo restores the second widget's original caption", + ); + s.redo(id).unwrap(); + assert_eq!( + s.get(id).unwrap().form.iter().find(|f| f.name == "go").and_then(|f| f.widgets.first()).and_then(|w| w.caption.as_deref()), + Some("Continue") + ); + assert_eq!( + s.get(id).unwrap().form.iter().find(|f| f.name == "go").and_then(|f| f.widgets.get(1)).and_then(|w| w.caption.as_deref()), + Some("Continue") + ); + // JavaScript off: scripts don't run. s.set_javascript(false); s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap(); @@ -6450,12 +6874,302 @@ fn form_javascript_validates_calculates_and_formats() { assert!(s.run_javascript(id, "1", None).is_err()); } +#[test] +fn javascript_field_colours_persist_and_undo_without_changing_other_appearance_properties() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap(); + s.apply( + id, + Edit::SetFieldProps { + name: "price".into(), + props: Box::new(FieldProps { + appearance: Some(pdfcraft_forms::LookPatch { + border: Some(Some([0.1, 0.2, 0.3])), + font: Some(pdfcraft_forms::FieldFont::Courier), + ..Default::default() + }), + ..Default::default() + }), + }, + ) + .unwrap(); + let before = s.get(id).unwrap().form.iter().find(|f| f.name == "price").unwrap().value.clone(); + let out = s.run_javascript(id, "var f = getField('price'); f.fillColor = color.red; f.textColor = color.blue;", None).unwrap(); + assert_eq!(out.error, None); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript")); + + let inspect = |bytes: Arc>| { + let doc = pdfcraft_cos::Document::open(bytes).unwrap(); + let f = pdfcraft_forms::fields(&doc).into_iter().find(|f| f.name == "price").unwrap(); + let look = pdfcraft_forms::look(&doc, &f); + assert_eq!(look.fill, Some([1.0, 0.0, 0.0])); + assert_eq!(look.text, [0.0, 0.0, 1.0]); + assert_eq!(look.border, Some([0.1, 0.2, 0.3])); + assert_eq!(look.font, pdfcraft_forms::FieldFont::Courier); + assert_eq!(f.value, before); + }; + inspect(s.save_bytes(id).unwrap()); + s.undo(id).unwrap(); + let undone = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + let f = pdfcraft_forms::fields(&undone).into_iter().find(|f| f.name == "price").unwrap(); + assert_eq!(pdfcraft_forms::look(&undone, &f).fill, None); + s.redo(id).unwrap(); + inspect(s.save_bytes(id).unwrap()); + + let reopened = s.open("saved.pdf", None, s.save_bytes(id).unwrap(), None).unwrap(); + let reopened_doc = pdfcraft_cos::Document::open(s.save_bytes(reopened).unwrap()).unwrap(); + let f = pdfcraft_forms::fields(&reopened_doc).into_iter().find(|f| f.name == "price").unwrap(); + assert_eq!(pdfcraft_forms::look(&reopened_doc, &f).fill, Some([1.0, 0.0, 0.0])); + assert_eq!(pdfcraft_forms::look(&reopened_doc, &f).text, [0.0, 0.0, 1.0]); +} + +#[test] +fn javascript_invalid_field_colour_array_is_refused_without_partial_changes() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap(); + let before = s.save_bytes(id).unwrap(); + let err = s.run_javascript(id, "var f = getField('price'); f.value = '9'; f.fillColor = ['RGB', 2, 0, 0];", None).unwrap_err(); + assert!(err.to_string().contains("finite components between 0 and 1"), "{err}"); + assert_eq!(s.save_bytes(id).unwrap(), before, "a refused script changes nothing"); +} + +#[test] +fn javascript_field_colours_respect_document_fill_permissions() { + let mut plain = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap(); + plain + .set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: 0, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let bytes = Arc::new(pdfcraft_cos::write_full(&plain, &Default::default()).unwrap()); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("locked.pdf", None, bytes, None).unwrap(); + assert!(!s.get(id).unwrap().allows_form_filling()); + assert_eq!( + s.run_javascript(id, "getField('price').fillColor = color.red;", None).unwrap_err(), + EditError::NotPermitted("filling in form fields") + ); + assert!(!s.get(id).unwrap().dirty); +} + +#[test] +/// Synchronous calculation exposes pending values immediately and persists the invocation as one undo transaction. +fn calculate_now_updates_live_fields_in_one_undo_step() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("calculate-now.pdf", None, Arc::new(scripted_form()), None).unwrap(); + let value = |s: &Session, name: &str| s.get(id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone(); + let before_price = value(&s, "price"); + let before_qty = value(&s, "qty"); + let out = s + .run_javascript( + id, + "getField('price').value = 3; getField('qty').value = 2; calculateNow(); var seen = getField('total').value; console.println(seen);", + Some("go"), + ) + .unwrap(); + assert!(out.console.iter().any(|line| line == "6"), "next statement sees calculation result: {out:?}"); + let total = s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value.clone(); + assert_eq!(total, ["6"]); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript")); + s.undo(id).unwrap(); + assert_eq!(value(&s, "price"), before_price); + assert_eq!(value(&s, "qty"), before_qty); + assert!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value.is_empty()); + s.redo(id).unwrap(); + assert_eq!(value(&s, "price"), ["3"]); + assert_eq!(value(&s, "qty"), ["2"]); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value, ["6"]); + let bytes = s.save_bytes(id).unwrap(); + let reopened = s.open("reopened.pdf", None, bytes, None).unwrap(); + assert_eq!(s.get(reopened).unwrap().form.iter().find(|f| f.name == "total").unwrap().value, ["6"]); +} + +#[test] +fn calculate_now_in_a_document_script_helper_gets_the_calculation_list() { + let helpers = "function times\\(a, b\\) { return a * b; } function recalc\\(\\) { calculateNow\\(\\); }"; + let mut session = Session::new().with_clock(|| 1_700_000_000); + let id = session.open("calculate-helper.pdf", None, Arc::new(scripted_form_with(helpers)), None).unwrap(); + let out = session + .run_javascript(id, "getField('price').value = 3; getField('qty').value = 2; recalc(); console.println(getField('total').value);", None) + .unwrap(); + + assert!(out.error.is_none(), "{out:?}"); + assert!(out.console.iter().any(|line| line == "6"), "the document helper recalculated total: {out:?}"); +} + +#[test] +/// Respect explicit calculation order, restore the outer event and stop recursive calculation requests. +fn calculate_now_uses_calculation_order_and_bounds_recursion() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("calculate-chain.pdf", None, Arc::new(scripted_form()), None).unwrap(); + for (name, script) in + [("qty", "event.value = Number(getField('price').value) + 1;"), ("total", "event.value = Number(getField('qty').value) * 2;")] + { + s.apply(id, Edit::SetFieldScript { name: name.into(), event: "calculate".into(), script: Some(script.into()) }).unwrap(); + } + s.apply(id, Edit::SetCalculationOrder { names: vec!["qty".into(), "total".into()] }).unwrap(); + let out = s.run_javascript(id, "getField('price').value = 3; calculateNow(); console.println(getField('total').value);", None).unwrap(); + assert!(out.error.is_none(), "{out:?}"); + assert!(out.console.iter().any(|line| line == "8"), "{out:?}"); + + s.set_javascript(false); // Installing scripts must not change the 4/8 starting values. + for (name, script) in + [("qty", "event.value = Number(getField('total').value) + 1;"), ("total", "event.value = Number(getField('qty').value) + 1;")] + { + s.apply(id, Edit::SetFieldScript { name: name.into(), event: "calculate".into(), script: Some(script.into()) }).unwrap(); + } + s.set_javascript(true); + let starting = |name: &str| s.get(id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone(); + assert_eq!(starting("qty"), ["4"]); + assert_eq!(starting("total"), ["8"]); + let cyclic = + s.run_javascript(id, "calculateNow(); console.println(getField('qty').value); console.println(getField('total').value);", None).unwrap(); + assert!(cyclic.error.is_none(), "a cyclic calculation runs once in order: {cyclic:?}"); + let field = |name: &str| s.get(id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone(); + assert_eq!(cyclic.console, ["9", "10"], "the explicit ordered pass runs exactly once"); + // Applying the value delta invokes the existing automatic form-recalculation pass. + assert_eq!(field("qty"), ["11"]); + assert_eq!(field("total"), ["12"]); + + let recursive = + Edit::SetFieldScript { name: "total".into(), event: "calculate".into(), script: Some("calculateNow(); event.value = 12;".into()) }; + let refused = s.apply(id, recursive.clone()).unwrap_err(); + assert!(refused.to_string().contains("calculateNow recursion limit exceeded"), "{refused:?}"); + s.set_javascript(false); + s.apply(id, recursive).unwrap(); + s.set_javascript(true); + let error = s.run_javascript(id, "calculateNow();", None).unwrap_err(); + assert!(error.to_string().contains("calculateNow recursion limit exceeded"), "{error:?}"); + s.set_javascript(false); + assert!(s.run_javascript(id, "calculateNow();", None).is_err()); + s.set_javascript(true); + assert!(s.run_javascript(id, "1 + 1", None).unwrap().error.is_none()); +} + +#[test] +fn calculation_order_cache_tracks_script_edits_in_a_batch() { + let mut session = Session::new().with_clock(|| 1_700_000_000); + let id = session.open("calculate-batch.pdf", None, Arc::new(scripted_form()), None).unwrap(); + session + .apply( + id, + Edit::Batch { + label: "Set calculations".into(), + edits: vec![ + Edit::SetFieldScript { + name: "qty".into(), + event: "calculate".into(), + script: Some("event.value = Number(getField('price').value) + 1;".into()), + }, + Edit::SetFieldScript { + name: "total".into(), + event: "calculate".into(), + script: Some("event.value = Number(getField('qty').value) * 2;".into()), + }, + Edit::SetCalculationOrder { names: vec!["qty".into(), "total".into()] }, + Edit::SetFieldValue { name: "price".into(), value: FieldValue::Text("3".into()) }, + ], + }, + ) + .unwrap(); + let total = session.get(id).unwrap().form.iter().find(|field| field.name == "total").unwrap().value.clone(); + assert_eq!(total, ["8"]); +} + +#[test] +fn calculation_order_cache_tracks_field_property_edits_in_a_batch() { + use form_scripts::{CalcOp, Calculate}; + + let mut session = Session::new().with_clock(|| 1_700_000_000); + let id = session.open("calculate-props-batch.pdf", None, Arc::new(scripted_form()), None).unwrap(); + session + .apply( + id, + Edit::Batch { + label: "Change calculation".into(), + edits: vec![ + Edit::SetFieldScript { + name: "qty".into(), + event: "format".into(), + script: Some("calculateNow(); console.println(getField('total').value);".into()), + }, + Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("3".into()) }, + Edit::SetFieldProps { + name: "total".into(), + props: Box::new(FieldProps { + calculate: Some(Calculate::Simple { op: CalcOp::Sum, fields: vec!["qty".into()] }), + ..FieldProps::default() + }), + }, + Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("4".into()) }, + ], + }, + ) + .unwrap(); + + let total = session.get(id).unwrap().form.iter().find(|field| field.name == "total").unwrap().value.clone(); + assert_eq!(total, ["4"]); + let output = session.take_js_output(id); + assert!(output.console.iter().any(|line| line == "4"), "calculateNow observed the refreshed Sum: {output:?}"); +} + +#[test] +fn calculation_order_cache_refreshes_after_inserting_pages() { + use pdfcraft_forms::{FieldEvent, Scripts}; + + let mut doc = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap(); + pdfcraft_forms::set_value(&mut doc, "qty", &FieldValue::Text("3".into())).unwrap(); + let fields = pdfcraft_forms::fields(&doc); + let qty = fields.iter().find(|field| field.name == "qty").unwrap(); + let mut js = crate::js::JsRunner::new(&doc, "destination.pdf", false); + js.run(FieldEvent::Format, "calculateNow();", qty, "", &fields, &doc); + + let source = pdfcraft_testkit::Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /CO [4 0 R] >> >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>") + .obj("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Annots [4 0 R] >>") + .obj("<< /Type /Annot /Subtype /Widget /FT /Tx /T (importedTotal) /Rect [20 200 180 220] /V (0) /AA << /C << /S /JavaScript /JS (event.value = 9; console.println('inserted field calculated');) >> >> >>") + .build(); + let mut cx = EditCtx::new(None, 0); + cx.js = Some(js); + run_edit( + &mut doc, + &Edit::InsertPagesFrom { + name: "source.pdf".into(), + bytes: Arc::new(source), + pages: None, + at: 1, + field_conflicts: pdfcraft_organize::FieldConflicts::Rename, + }, + &mut cx, + ) + .unwrap(); + + let fields = pdfcraft_forms::fields(&doc); + let qty = fields.iter().find(|field| field.name == "qty").unwrap(); + let js = cx.js.as_mut().unwrap(); + let result = js.run(FieldEvent::Format, "calculateNow();", qty, "", &fields, &doc); + assert!( + result.changes.iter().any(|change| { + change.name == "importedTotal" && change.value.as_ref().is_some_and(|value| value.len() == 1 && value.first().is_some_and(|v| v == "9")) + }), + "imported calculation result: {:?}", + result.changes + ); + assert!(js.output.console.iter().any(|line| line == "inserted field calculated"), "{:#?}", js.output); +} + #[test] fn javascript_field_rect_moves_first_widget_and_undoes() { let mut s = Session::new(); let id = s.open("rect.pdf", None, Arc::new(scripted_form()), None).unwrap(); let initial = s.get(id).unwrap().form.iter().find(|f| f.name == "price").unwrap().widgets[0].rect; - s.run_javascript(id, "getField('price').rect = [30, 40, 130, 60];", Some("go")).unwrap(); + s.run_javascript(id, "getField('price').rect = [30, 60, 130, 40];", Some("go")).unwrap(); assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "price").unwrap().widgets[0].rect, [30.0, 40.0, 130.0, 60.0]); assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript")); s.undo(id).unwrap(); @@ -6483,20 +7197,39 @@ fn scripts_in_an_xfa_form_see_the_xfa_plugin_installed() { /// helper stays defined, the form calculates, and the error is reported once. #[test] fn a_document_script_that_throws_still_lets_the_form_calculate() { - let helpers = - "function times\\(a, b\\) { return a * b; } this.getField\\('go'\\).buttonSetIcon\\(this.getField\\('go'\\).buttonGetIcon\\(\\)\\);"; + let helpers = "function times\\(a, b\\) { return a * b; } this.getField\\('go'\\).missingTestMethod\\(\\);"; let mut s = Session::new().with_clock(|| 1_700_000_000); let id = s.open("order.pdf", None, Arc::new(scripted_form_with(helpers)), None).unwrap(); - let _ = s.take_js_output(id); + let initial = s.take_js_output(id); + assert_eq!(initial.doc_errors.len(), 1, "the initializer error is reported at open"); s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("4".into()) }).unwrap(); let total = s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value.clone(); assert_eq!(total, ["10"], "calculated through the helper the failing script defined"); let out = s.take_js_output(id); - assert_eq!(out.errors.len(), 1, "reported once, not once per script: {out:?}"); - assert!(out.errors[0].starts_with("document script 1: TypeError"), "{out:?}"); - // The console runs too, and says which document script failed. + assert!(out.errors.is_empty(), "the initializer is not replayed: {out:?}"); + // The console runs too, without replaying the failed initializer. let o = s.run_javascript(id, "times(2, 3)", None).unwrap(); - assert_eq!((o.error, o.result.as_deref(), o.doc_errors.len()), (None, Some("6"), 1)); + assert_eq!((o.error, o.result.as_deref(), o.doc_errors.len()), (None, Some("6"), 0)); +} + +/// A looping initializer is reported once and later initializers and field events still run. +#[test] +fn a_document_script_at_the_vm_limit_does_not_block_field_edits() { + let mut cos = pdfcraft_cos::Document::open(Arc::new(scripted_form_with(r"while \(true\) {}"))).unwrap(); + pdfcraft_forms::set_document_script(&mut cos, "later", Some("function times(a, b) { return a * b; }")).unwrap(); + let bytes = Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("order.pdf", None, bytes, None).unwrap(); + let initial = s.take_js_output(id); + assert_eq!(initial.doc_errors.len(), 1, "{initial:?}"); + assert!(initial.doc_errors[0].contains("RuntimeLimitError"), "{initial:?}"); + + for (qty, total) in [("4", "10"), ("6", "15")] { + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text(qty.into()) }).unwrap(); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value, [total]); + let out = s.take_js_output(id); + assert!(out.errors.is_empty() && out.doc_errors.is_empty(), "{out:?}"); + } } #[test] @@ -6563,7 +7296,7 @@ fn inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts() { let mut cos = pdfcraft_cos::Document::open(Arc::new(bytes)).unwrap(); pdfcraft_forms::set_document_script(&mut cos, "helper", Some("function twice(n) { return n * 2; } app.alert('PDF message');")).unwrap(); // The raw dynamic shell has not been laid out: XFA's presence alone is not enough. - let runner = js::JsRunner::new(&cos, "control.pdf"); + let runner = js::JsRunner::new(&cos, "control.pdf", false); let out = pdfcraft_js::run("twice(3)", &pdfcraft_js::Event::doc("Console"), &runner.doc, &[], &runner.doc_scripts, Default::default()); assert!(out.error.is_none(), "{out:?}"); assert_eq!(out.result.as_deref(), Some("6")); @@ -6571,6 +7304,109 @@ fn inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts() { } } +#[test] +/// Read inherited page boxes without dirtying the document and reject invalid box/page requests. +fn js_get_page_box_reads_effective_boxes_without_editing_the_document() { + let (mut s, id) = session_with(1); + let before = s.get(id).expect("opened document").can_undo().map(str::to_owned); + let out = s.run_javascript(id, "JSON.stringify([this.getPageBox(), this.getPageBox('Media', 0)])", None).expect("script runs"); + assert_eq!(out.error, None, "{out:?}"); + assert_eq!(out.result.as_deref(), Some("[[0,300,200,0],[0,300,200,0]]")); + assert_eq!(s.get(id).expect("document remains open").can_undo(), before.as_deref()); + + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).expect("rotate page"); + let before = s.get(id).expect("rotated document").can_undo().map(str::to_owned); + let out = s.run_javascript(id, "JSON.stringify(this.getPageBox('Crop', 0))", None).expect("script runs"); + assert_eq!(out.error, None, "{out:?}"); + assert_eq!(out.result.as_deref(), Some("[0,200,300,0]")); + assert_eq!(s.get(id).expect("document remains open").can_undo(), before.as_deref()); +} + +#[test] +fn automatic_field_scripts_cannot_print() { + use pdfcraft_forms::{FieldEvent, Scripts}; + let cos = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap(); + let fields = pdfcraft_forms::fields(&cos); + let target = fields.iter().find(|f| f.name == "qty").unwrap(); + for event in [FieldEvent::Keystroke, FieldEvent::Validate, FieldEvent::Calculate, FieldEvent::Format] { + let mut runner = js::JsRunner::new(&cos, "order.pdf", false); + let result = runner.run( + event, + "console.println('before'); this.print(); app.execMenuItem('SaveAs'); app.execMenuItem('ShowHideFileAttachment'); \ + this.pageNum = 0; this.print(); app.alert('ok'); event.value = '4';", + target, + "2", + &fields, + &cos, + ); + assert!(result.rc, "{event:?}: {result:?}"); + assert_eq!(result.value, "4"); + assert_eq!(runner.output.requests, [js::Request::GoToPage(0)], "{event:?}: {:?}", runner.output); + assert_eq!(runner.output.alerts, [js::Message::plain("ok")]); + assert!(runner.output.console.contains(&"before".to_string())); + assert!(runner.output.console.iter().any(|line| line.contains("Print") && line.contains("ignored")), "{:?}", runner.output); + assert_eq!(runner.output.console.iter().filter(|line| line.contains("only a button may")).count(), 2, "{:?}", runner.output); + assert!(runner.output.errors.is_empty()); + + runner.output = js::JsOutput::default(); + runner.run(event, "this.print(); throw new Error('broken script');", target, "2", &fields, &cos); + assert!(runner.output.requests.is_empty(), "{event:?}: {:?}", runner.output); + assert!(runner.output.errors.iter().any(|e| e.contains("broken script"))); + } +} + +#[test] +fn automatic_print_suppression_preserves_document_errors() { + use pdfcraft_forms::{FieldEvent, Scripts}; + let mut cos = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap(); + pdfcraft_forms::set_document_script(&mut cos, "failure", Some("throw new Error('document failure');")).unwrap(); + let fields = pdfcraft_forms::fields(&cos); + let target = fields.iter().find(|f| f.name == "qty").unwrap(); + let mut runner = js::JsRunner::new(&cos, "order.pdf", false); + for event in [FieldEvent::Keystroke, FieldEvent::Validate, FieldEvent::Calculate, FieldEvent::Format] { + runner.run(event, "this.print(); throw new Error('field failure');", target, "2", &fields, &cos); + } + let out = runner.output; + assert!(out.requests.is_empty(), "{out:?}"); + assert_eq!(out.console.iter().filter(|line| line.contains("Print") && line.contains("ignored")).count(), 4, "{out:?}"); + assert_eq!(out.doc_errors.len(), 1, "the document script's error is reported once: {out:?}"); + assert!(out.doc_errors[0].contains("document failure"), "{out:?}"); + assert_eq!(out.errors.iter().filter(|e| e.contains("document failure")).count(), 1, "{out:?}"); + assert_eq!(out.errors.iter().filter(|e| e.contains("field failure")).count(), 4, "each field script's error is retained: {out:?}"); +} + +#[test] +fn form_calculations_cannot_print_but_buttons_and_console_can() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap(); + s.apply( + id, + Edit::SetFieldScript { + name: "total".into(), + event: "calculate".into(), + script: Some("this.print(); event.value = times(getField('price').value, getField('qty').value);".into()), + }, + ) + .unwrap(); + s.take_js_output(id); + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("4".into()) }).unwrap(); + let out = s.take_js_output(id); + assert!(out.requests.is_empty(), "{out:?}"); + assert!(out.console.iter().any(|line| line.contains("Print") && line.contains("ignored")), "{out:?}"); + assert!(out.errors.is_empty(), "{out:?}"); + assert!(page_texts(&s, id)[0].contains("USD 10.00")); + + for (target, qty, formatted) in [(Some("go"), 6, "USD 15.00"), (None, 8, "USD 20.00")] { + let out = s.run_javascript(id, &format!("getField('qty').value = {qty}; this.print();"), target).unwrap(); + assert_eq!(out.requests, [js::Request::Print]); + let automatic = s.take_js_output(id); + assert!(automatic.requests.is_empty(), "{automatic:?}"); + assert!(automatic.console.iter().any(|line| line.contains("Print") && line.contains("ignored")), "{automatic:?}"); + assert!(automatic.errors.is_empty(), "{automatic:?}"); + assert!(page_texts(&s, id)[0].contains(formatted)); + } +} + #[test] fn edit_history_does_not_replay_field_script_side_effects() { let mut s = Session::new().with_clock(|| 1_700_000_000); @@ -6866,13 +7702,15 @@ fn comparing_two_versions_of_a_document() { assert!(marks[0].contents.as_deref().unwrap().starts_with("Replaced: \"Monday.\"")); } +/// Built-in and custom actions run their steps on a file (with JavaScript on), report each +/// step's progress, and fail cleanly on a file that is not a PDF. #[test] fn actions_run_their_steps_on_files() { let s = Session::new(); let src = s.create_from_text("t", "Quarterly numbers").unwrap(); let built = actions::builtin(); let numbers = built.iter().find(|a| a.name == "Add Page Numbers").unwrap(); - let r = actions::run_on(numbers, "q.pdf", src.clone(), |_, _| {}).unwrap(); + let r = actions::run_on(numbers, "q.pdf", src.clone(), true, |_, _| {}).unwrap(); let mut s2 = Session::new(); let id = s2.open("out.pdf", None, r.bytes, None).unwrap(); assert!(page_texts(&s2, id)[0].contains("Page 1 of 1"), "{:?}", page_texts(&s2, id)); @@ -6890,14 +7728,38 @@ fn actions_run_their_steps_on_files() { builtin: false, }; let mut steps = Vec::new(); - let r = actions::run_on(&custom, "q.pdf", src, |i, n| steps.push((i, n))).unwrap(); + let r = actions::run_on(&custom, "q.pdf", src, true, |i, n| steps.push((i, n))).unwrap(); assert_eq!(steps, [(0, 4), (1, 4), (2, 4), (3, 4)]); assert_eq!(r.log.len(), 4, "{:?}", r.log); let id = s2.open("out2.pdf", None, r.bytes, None).unwrap(); let d = s2.get(id).unwrap(); assert_eq!(d.info.title.as_deref(), Some("Q3")); assert!(page_texts(&s2, id)[0].contains("DRAFT")); - assert!(actions::run_on(&custom, "bad.pdf", Arc::new(b"nope".to_vec()), |_, _| {}).is_err()); + assert!(actions::run_on(&custom, "bad.pdf", Arc::new(b"nope".to_vec()), true, |_, _| {}).is_err()); +} + +/// #5080: a "Run a JavaScript" step honours the caller's JavaScript preference. With it on, the +/// step runs and writes the field; with it off, the step does not run, so `run_on` reports the +/// refused script's error instead of saving a script-produced value. Before the fix each file +/// opened in a fresh JavaScript-on session, so the step ran whatever the caller chose. +#[test] +fn an_action_runs_a_javascript_step_only_when_javascript_is_on() { + let action = actions::Action { + name: "Set price".into(), + description: String::new(), + steps: vec![actions::Step::RunJavaScript("getField('price').value = 3;".into())], + builtin: false, + }; + let src = Arc::new(scripted_form()); + // JavaScript on: the step runs, so the saved file has price = 3. + let on = actions::run_on(&action, "f.pdf", src.clone(), true, |_, _| {}).unwrap(); + let mut s = Session::new(); + s.set_javascript(false); // read the saved state without re-running any script + let id = s.open("on.pdf", None, on.bytes, None).unwrap(); + let price = s.get(id).unwrap().form.iter().find(|f| f.name == "price").unwrap().value.clone(); + assert_eq!(price, ["3"], "the script ran with JavaScript on"); + // JavaScript off: the step does not run, so run_on fails rather than writing a scripted value. + assert!(actions::run_on(&action, "f.pdf", src, false, |_, _| {}).is_err()); } /// A removal step that fails (here: a signed file can't be rewritten without its hidden data) @@ -6914,7 +7776,7 @@ fn actions_fail_when_hidden_information_could_not_be_removed() { let signed = s.sign(id, &digital_id, SignOptions { page: 1, ..SignOptions::default() }).unwrap(); let built = actions::builtin(); let sanitize = built.iter().find(|a| a.name == "Sanitize Documents").unwrap(); - let e = actions::run_on(sanitize, "signed.pdf", signed, |_, _| {}).map(|r| r.log).unwrap_err(); + let e = actions::run_on(sanitize, "signed.pdf", signed, true, |_, _| {}).map(|r| r.log).unwrap_err(); assert!(e.starts_with("Sanitize document:"), "{e}"); } @@ -7059,6 +7921,8 @@ fn guard_turns_a_panic_into_an_error() { assert_eq!(guard(|| -> u8 { panic!("page {n} is bad") }), Err("page 3 is bad".to_string())); } +/// Measurement edits are single undoable steps that leave the file unchanged when they fail, and +/// respect the document's annotation permissions. #[test] fn measurement_edits_are_transactional_and_honour_annotation_permissions() { let mut session = Session::default(); @@ -7094,6 +7958,172 @@ fn measurement_edits_are_transactional_and_honour_annotation_permissions() { ); } +/// The synthetic focus form ([`pdfcraft_xfa::fixtures::focus_template`]). +fn focus_form() -> Arc> { + Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::focus_template())) +} + +/// The value of field `name` in document `id`. +fn field_text(s: &Session, id: DocId, name: &str) -> String { + s.get(id).and_then(|d| d.form.iter().find(|f| f.name == name).and_then(|f| f.value.first().cloned())).unwrap_or_default() +} + +/// A field's exit scripts run every time it loses focus, changed or not, and its enter scripts +/// every time it gains it; each run that changes the form is one undo step. +#[test] +fn xfa_enter_and_exit_scripts_run_on_every_focus_change() { + use crate::js::FieldFocus::{Enter, Exit}; + let mut s = Session::new(); + let id = s.open("form.pdf", None, focus_form(), None).expect("opens"); + for _ in 0..3 { + assert!(s.field_focus(id, "probe", 0, Enter).unwrap(), "the enter script counted"); + assert!(s.field_focus(id, "probe", 0, Exit).unwrap(), "the exit script counted"); + } + assert_eq!((field_text(&s, id, "exits"), field_text(&s, id, "enters")), ("3".into(), "3".into())); + assert!(s.get(id).unwrap().dirty); + s.undo(id).unwrap(); + assert_eq!(field_text(&s, id, "exits"), "2", "each run is its own undo step"); +} + +/// A value typed into a field runs its exit with the change; losing focus then doesn't run it +/// again. The next visit runs it as usual. +#[test] +fn an_exit_the_fields_change_ran_does_not_run_twice() { + use crate::js::FieldFocus::{Enter, Exit}; + let mut s = Session::new(); + let id = s.open("form.pdf", None, focus_form(), None).expect("opens"); + s.field_focus(id, "probe", 0, Enter).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "probe".into(), value: FieldValue::Text("x".into()) }).unwrap(); + assert_eq!(field_text(&s, id, "exits"), "1", "the change ran the exit"); + assert!(!s.field_focus(id, "probe", 0, Exit).unwrap(), "not again on losing focus"); + assert_eq!(field_text(&s, id, "exits"), "1"); + s.field_focus(id, "probe", 0, Enter).unwrap(); + s.field_focus(id, "probe", 0, Exit).unwrap(); + assert_eq!(field_text(&s, id, "exits"), "2", "the next visit's exit runs"); +} + +/// The skip lasts only until the next edit, undo or redo: a change that is undone, or followed by +/// another edit, doesn't make a later focus loss skip the field's exit. +#[test] +fn an_exit_skip_does_not_outlive_its_change() { + use crate::js::FieldFocus::{Enter, Exit}; + let mut s = Session::new(); + let id = s.open("form.pdf", None, focus_form(), None).expect("opens"); + s.field_focus(id, "probe", 0, Enter).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "probe".into(), value: FieldValue::Text("x".into()) }).unwrap(); + s.undo(id).unwrap(); + let before = field_text(&s, id, "exits"); + s.field_focus(id, "probe", 0, Exit).unwrap(); + assert_eq!(field_text(&s, id, "exits"), (before.parse::().unwrap() + 1).to_string(), "after an undo, the exit runs"); + // A change, then an edit of another field: the change's skip is gone. + s.field_focus(id, "probe", 0, Enter).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "probe".into(), value: FieldValue::Text("y".into()) }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "quiet".into(), value: FieldValue::Text("z".into()) }).unwrap(); + let before = field_text(&s, id, "exits"); + s.field_focus(id, "probe", 0, Exit).unwrap(); + assert_eq!(field_text(&s, id, "exits"), (before.parse::().unwrap() + 1).to_string(), "after another edit, the exit runs"); +} + +/// A form-script event can't be batched with other edits: its scripts and those the batch's value +/// edits run would interleave (the field's exit running twice). The batch is refused whole, and +/// the document is left as it was. +#[test] +fn a_form_script_event_in_a_batch_is_refused() { + let mut s = Session::new(); + let id = s.open("form.pdf", None, focus_form(), None).expect("opens"); + let som_of = |s: &Session, name: &str| { + let doc = s.get(id).unwrap(); + let f = doc.form.iter().find(|f| f.name == name).unwrap(); + crate::xfa::widget_som(&doc.editor.as_ref().unwrap().cos, f, 0).unwrap() + }; + let batch = |name: &str, som: String| Edit::Batch { + label: "Fill and leave".into(), + edits: vec![Edit::SetFieldValue { name: name.into(), value: FieldValue::Text("z".into()) }, Edit::XfaEvent { som, activity: "exit".into() }], + }; + // An exit that counts: it would run once for the event and once for the change. + let probe = som_of(&s, "probe"); + assert!(matches!(s.apply(id, batch("probe", probe)), Err(EditError::Invalid(_)))); + assert_eq!((field_text(&s, id, "probe"), field_text(&s, id, "exits")), (String::new(), "0".into())); + // An exit that changes nothing: the batch's value isn't kept either, nor dropped silently. + let quiet = som_of(&s, "quiet"); + assert!(s.apply(id, batch("quiet", quiet)).is_err()); + assert_eq!(field_text(&s, id, "quiet"), ""); + assert!(!s.get(id).unwrap().dirty, "nothing was applied"); +} + +/// A field's exit is noted as run (for the focus loss to skip) only when an exit script ran: +/// with every script skipped, as when the event's budget is spent or a script ran away, nothing +/// is noted and the next focus loss runs the exit. +#[test] +fn on_changes_notes_only_the_exits_that_ran() { + let mut s = Session::new(); + let id = s.open("form.pdf", None, focus_form(), None).expect("opens"); + let doc = s.get(id).unwrap(); + let mut cos = doc.editor.as_ref().unwrap().cos.clone(); + let tpl = doc.xfa_template.clone().unwrap(); + let names = vec!["probe".to_string()]; + let mut exited = Vec::new(); + let mut out = crate::js::JsOutput::default(); + let mut stopped = crate::xfa::XfaRun { page_count: 1, out: &mut out, datasets: None, ran_away: true, highlight: false, time_scale: 1 }; + crate::xfa::on_changes(&mut cos, &tpl, &names, &mut stopped, &mut exited).unwrap(); + assert!(exited.is_empty(), "no exit ran: {exited:?}"); + let mut out = crate::js::JsOutput::default(); + let mut running = crate::xfa::XfaRun { page_count: 1, out: &mut out, datasets: None, ran_away: false, highlight: false, time_scale: 1 }; + crate::xfa::on_changes(&mut cos, &tpl, &names, &mut running, &mut exited).unwrap(); + assert_eq!(exited.len(), 1, "the exit ran: {exited:?}"); +} + +/// Scripts that change nothing leave no edit: the document stays saved, with nothing to undo, +/// and what they showed still reaches the viewer. A field without such scripts runs nothing. +#[test] +fn focus_scripts_that_change_nothing_leave_the_document_clean() { + use crate::js::FieldFocus::{Enter, Exit}; + let mut s = Session::new(); + let id = s.open("form.pdf", None, focus_form(), None).expect("opens"); + s.field_focus(id, "quiet", 0, Enter).unwrap(); + assert!(!s.field_focus(id, "quiet", 0, Exit).unwrap(), "nothing changed"); + let doc = s.get(id).unwrap(); + assert!(!doc.dirty && doc.can_undo().is_none(), "no edit kept"); + let out = s.take_js_output(id); + assert_eq!(out.alerts.iter().map(|m| m.text.as_str()).collect::>(), ["left quiet"]); + // A field without focus scripts, and a field that doesn't exist. + assert!(!s.field_focus(id, "exits", 0, Exit).unwrap()); + assert!(!s.field_focus(id, "nowhere", 0, Enter).unwrap()); + assert!(!s.get(id).unwrap().dirty); +} + +/// A widget the field doesn't have gains or loses no focus: no script runs, rather than the +/// field's own. +#[test] +fn a_widget_the_field_doesnt_have_runs_no_focus_scripts() { + use crate::js::FieldFocus::{Enter, Exit}; + let mut s = Session::new(); + let id = s.open("form.pdf", None, focus_form(), None).expect("opens"); + assert!(!s.field_focus(id, "probe", 3, Enter).unwrap(), "probe has one widget"); + assert!(!s.field_focus(id, "probe", usize::MAX, Exit).unwrap()); + assert_eq!((field_text(&s, id, "enters"), field_text(&s, id, "exits")), ("0".into(), "0".into())); + assert!(!s.get(id).unwrap().dirty); +} + +/// Each radio button of a group is a field of its own: reaching the second button runs its +/// enter script, reaching the first (which has none) runs nothing. Choosing the button changes +/// the group's value, which runs the group's scripts, not the button's: leaving the button +/// still runs its exit. +#[test] +fn a_radio_buttons_own_focus_scripts_run() { + use crate::js::FieldFocus::{Enter, Exit}; + let mut s = Session::new(); + let id = s.open("form.pdf", None, focus_form(), None).expect("opens"); + let group = s.get(id).unwrap().form.iter().find(|f| f.widgets.len() == 2).map(|f| f.name.clone()).expect("the radio group"); + assert!(!s.field_focus(id, &group, 0, Enter).unwrap(), "the first button has no enter script"); + assert!(s.field_focus(id, &group, 1, Enter).unwrap(), "the second button's ran"); + assert!(!s.field_focus(id, &group, 2, Enter).unwrap(), "there is no third button"); + assert_eq!(field_text(&s, id, "picks"), "1"); + s.apply(id, Edit::SetFieldValue { name: group.clone(), value: FieldValue::Radio(Some("L".into())) }).unwrap(); + assert!(s.field_focus(id, &group, 1, Exit).unwrap(), "the button's exit runs on leaving it"); + assert_eq!(field_text(&s, id, "drops"), "1"); +} + /// A laid-out XFA field that doesn't scroll (`hScrollPolicy="off"`) opens as a text field with /// DoNotScroll, and its text box says what fits it: what the editor takes no more than. #[test] @@ -7142,6 +8172,8 @@ fn round_xfa_check_buttons_open_drawn_round() { } } +/// A dynamic XFA form with usage rights is saved without them once laid out, as the layout voids +/// them, with a note saying so. #[test] fn a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided() { let xdp = pdfcraft_xfa::fixtures::template(3); @@ -7168,6 +8200,8 @@ fn a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided() { } } +/// A dynamic XFA form is laid out from its template on opening (an appended revision, not an +/// edit), takes values from the datasets, and saves its filled values incrementally. #[test] fn dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally() { let original = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(3))); @@ -7207,6 +8241,40 @@ fn dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally() { assert_eq!(d2.form.iter().find(|f| f.name == "answer").unwrap().value, vec!["N".to_string()]); } +/// A signature box's XFA preSign script runs as one undoable step, showing its notice and +/// changing the form; its postSign script runs too; a box without them runs nothing and changes +/// nothing. +#[test] +fn xfa_signing_scripts_run_before_and_after_signing() { + use crate::js::SignEvent; + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::signing_events_template())); + let mut s = Session::new(); + let id = s.open("signing.pdf", None, bytes, None).expect("opens"); + let field = + |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n || f.name.ends_with(&format!(".{n}[0]"))).cloned().unwrap(); + let sign = field(&s, "sign").name; + let plain = field(&s, "plain").name; + assert!(s.run_sign_event(id, &sign, SignEvent::Before).unwrap(), "it has a preSign script"); + assert_eq!(field(&s, "when").value, ["signed"]); + assert!(field(&s, "when").read_only(), "and it locks the field"); + assert_eq!(s.take_js_output(id).alerts, [crate::js::Message::plain("Signing notice")]); + assert!(s.run_sign_event(id, &sign, SignEvent::After).unwrap()); + assert_eq!(field(&s, "after").value, ["done"]); + // Each one is an undo step of its own. + s.undo(id).unwrap(); + assert!(field(&s, "after").value.is_empty()); + assert_eq!(field(&s, "when").value, ["signed"]); + // No signing scripts: nothing runs, nothing to undo. + let label = s.get(id).unwrap().undo_label().cloned(); + assert!(!s.run_sign_event(id, &plain, SignEvent::Before).unwrap()); + assert!(!s.run_sign_event(id, "no such field", SignEvent::Before).unwrap()); + assert_eq!(s.get(id).unwrap().undo_label().cloned(), label); + // Nor with JavaScript off. + s.set_javascript(false); + assert!(!s.run_sign_event(id, &sign, SignEvent::Before).unwrap()); +} + +/// A signature box the file already holds a signature for is drawn with that signature's picture. #[test] fn a_signed_xfa_signature_box_shows_the_picture_the_file_holds() { let mut s = Session::new().with_clock(|| 1_700_000_000); @@ -7318,6 +8386,160 @@ fn static_xfa_forms_take_their_values_from_the_datasets_and_write_them_back() { assert!(!d2.info.warnings.iter().any(|w| w.contains("taken from this form's XFA data")), "nothing was rewritten"); } +/// A static XFA exclusion group laid out as a radio group with `/Opt [(1) (2)]` and on states +/// named by position (`/0`, `/1`; ISO 32000-2 Table 230): the data's item `1` is the first +/// button, not the second one's state `/1`, and a click stores the clicked button's item. +/// Public benefit forms are built this way; their "English" came back as "French". +#[test] +fn a_static_xfa_radio_group_with_numeric_items_keeps_the_picked_button() { + let lang = "form1[0].page1[0].lang[0]"; + let data_of = |bytes: Arc>| { + let cos = pdfcraft_cos::Document::open(bytes).unwrap(); + let data = pdfcraft_xfa::parse_datasets(&pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp).unwrap(); + data.text_at(&pdfcraft_xfa::som_to_path(lang)).map(str::to_owned) + }; + let states = |s: &Session, id: DocId| { + let f = s.get(id).unwrap().form.iter().find(|f| f.name == lang).unwrap().clone(); + (f.value.clone(), f.widgets.iter().map(|w| w.state.clone().unwrap_or_default()).collect::>()) + }; + let form = |data: &str| Arc::new(pdfcraft_xfa::fixtures::static_radio_shell(&format!("{data}"))); + let mut s = Session::new().with_clock(|| 1_700_000_000); + // Data filled elsewhere: item 1 (English) turns the first button on, item 2 the second. + let id = s.open("one.pdf", None, form("1"), None).unwrap(); + assert_eq!(states(&s, id), (vec!["0".to_string()], vec!["0".to_string(), "Off".to_string()])); + let id = s.open("two.pdf", None, form("2"), None).unwrap(); + assert_eq!(states(&s, id), (vec!["1".to_string()], vec!["Off".to_string(), "1".to_string()])); + // A click on a button (its on state, as the page sends it) stores that button's item. + let id = s.open("blank.pdf", None, form(""), None).unwrap(); + for (state, item) in [("0", "1"), ("1", "2")] { + s.apply(id, Edit::SetFieldValue { name: lang.into(), value: FieldValue::Radio(Some(state.into())) }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + assert_eq!(data_of(saved.clone()).as_deref(), Some(item), "clicked {state}"); + // Reopened, the same button is on and nothing is rewritten. + let again = s.open("again.pdf", None, saved, None).unwrap(); + assert_eq!(states(&s, again).0, vec![state.to_string()], "clicked {state}"); + assert!(!s.get(again).unwrap().info.warnings.iter().any(|w| w.contains("taken from this form's XFA data")), "clicked {state}"); + } +} + +/// The XFA data layer keeps its own copy of the forms crate's name spelling (it can't depend on +/// forms), and a radio group's value only finds its button while the two agree: UTF-8 as +/// itself, `#` and bytes that aren't UTF-8 as `#XX`. +#[test] +fn xfa_and_forms_spell_names_alike() { + for bytes in [&b"Yes"[..], b"", b"a b", b"A#1", "はい".as_bytes(), b"\x82\xcd\x82\xa2", b"\xff#\x00", b"\x7f\x20~"] { + assert_eq!(pdfcraft_xfa::data::name_text(bytes), pdfcraft_forms::name_text(bytes), "{bytes:?}"); + assert_eq!(pdfcraft_forms::name_bytes(&pdfcraft_xfa::data::name_text(bytes)), bytes, "{bytes:?}"); + } +} + +/// Taking the XFA out of a radio group whose buttons carry `/PCOn` as well as `/Opt` (the item +/// saving writes ahead of the `/Opt` entry) notes no lost data when the data holds that item: +/// one equal to the on state, and one equal to neither the state nor the `/Opt` entry. +#[test] +fn stripping_xfa_takes_a_radio_items_pcon_as_the_fields_value() { + let lang = "form1[0].page1[0].lang[0]"; + let lost = |s: &Session, id: DocId| { + s.get(id).unwrap().info.warnings.iter().filter(|w| w.contains("held a value other than the form's XFA data")).cloned().collect::>() + }; + let mut s = Session::new().with_clock(|| 1_700_000_000); + // `/PCOn` equals the state: the data's `0` is the first button, whose `/Opt` entry is `1`. + let form = pdfcraft_xfa::fixtures::static_radio_shell_with_pcon("0", ["0", "1"]); + let id = s.open("pcon-state.pdf", None, Arc::new(form), None).unwrap(); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == lang).unwrap().value, ["0"]); + s.apply(id, Edit::XfaToStatic).unwrap(); + assert_eq!(lost(&s, id), Vec::::new()); + // `/PCOn` is neither: a click writes `en` to the data for the first button. + let form = pdfcraft_xfa::fixtures::static_radio_shell_with_pcon("", ["en", "fr"]); + let id = s.open("pcon-item.pdf", None, Arc::new(form), None).unwrap(); + s.apply(id, Edit::SetFieldValue { name: lang.into(), value: FieldValue::Radio(Some("0".into())) }).unwrap(); + s.apply(id, Edit::XfaToStatic).unwrap(); + assert_eq!(lost(&s, id), Vec::::new()); + // The data names a button by its `/Opt` entry (`2`, as data from elsewhere does), while + // saving would write that button's `/PCOn` (`fr`). Opening turned that button on, so the + // field shows what the data selects and nothing is lost, although the spellings differ. + let form = pdfcraft_xfa::fixtures::static_radio_shell_with_pcon("2", ["en", "fr"]); + let id = s.open("opt-item.pdf", None, Arc::new(form), None).unwrap(); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == lang).unwrap().value, ["1"]); + s.apply(id, Edit::XfaToStatic).unwrap(); + assert_eq!(lost(&s, id), Vec::::new()); +} + +/// Opening reads a radio group's data as saving writes it: with `/PCOn` items `4` `1` next to +/// `/Opt [(1) (2)]` and states `/0` `/1`, a click on the second button saves its item `1`, and +/// reopening turns the second button on again, not the first one whose `/Opt` entry is `1`; the +/// first button saves `4` and comes back too. Taking the XFA out then notes no lost data. +#[test] +fn a_radio_item_that_is_another_buttons_export_reopens_on_its_own_button() { + let lang = "form1[0].page1[0].lang[0]"; + let data_of = |bytes: Arc>| { + let cos = pdfcraft_cos::Document::open(bytes).unwrap(); + let data = pdfcraft_xfa::parse_datasets(&pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp).unwrap(); + data.text_at(&pdfcraft_xfa::som_to_path(lang)).map(str::to_owned) + }; + let value = |s: &Session, id: DocId| s.get(id).unwrap().form.iter().find(|f| f.name == lang).unwrap().value.clone(); + let form = Arc::new(pdfcraft_xfa::fixtures::static_radio_shell_with_pcon("", ["4", "1"])); + let mut s = Session::new().with_clock(|| 1_700_000_000); + for (state, item) in [("1", "1"), ("0", "4")] { + let id = s.open("blank.pdf", None, form.clone(), None).unwrap(); + s.apply(id, Edit::SetFieldValue { name: lang.into(), value: FieldValue::Radio(Some(state.into())) }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + assert_eq!(data_of(saved.clone()).as_deref(), Some(item), "clicked {state}"); + let again = s.open("again.pdf", None, saved, None).unwrap(); + assert_eq!(value(&s, again), [state], "clicked {state}"); + s.apply(again, Edit::XfaToStatic).unwrap(); + assert!(!s.get(again).unwrap().info.warnings.iter().any(|w| w.contains("held a value other than the form's XFA data")), "clicked {state}"); + } + // Data from elsewhere that no button saves is still read as an export value, then a state. + let id = s + .open( + "two.pdf", + None, + Arc::new(pdfcraft_xfa::fixtures::static_radio_shell_with_pcon("2", ["4", "1"])), + None, + ) + .unwrap(); + assert_eq!(value(&s, id), ["1"]); + let id = s + .open( + "zero.pdf", + None, + Arc::new(pdfcraft_xfa::fixtures::static_radio_shell_with_pcon("0", ["4", "1"])), + None, + ) + .unwrap(); + assert_eq!(value(&s, id), ["0"]); +} + +/// A radio group whose buttons carry `/PCOn` items `1` `2` next to `/Opt [(1) (2)]` and on +/// states named by position (`/0`, `/1`): a click on either button saves that button's own item +/// to the datasets, reopening turns the same button on with nothing rewritten, and taking the XFA +/// out notes no lost data. The state `/1` used to match the first button's item `1`, so the +/// second button saved `1` and came back as the first. +#[test] +fn a_radio_group_with_pcon_items_saves_and_reopens_the_clicked_button() { + let lang = "form1[0].page1[0].lang[0]"; + let data_of = |bytes: Arc>| { + let cos = pdfcraft_cos::Document::open(bytes).unwrap(); + let data = pdfcraft_xfa::parse_datasets(&pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp).unwrap(); + data.text_at(&pdfcraft_xfa::som_to_path(lang)).map(str::to_owned) + }; + let value = |s: &Session, id: DocId| s.get(id).unwrap().form.iter().find(|f| f.name == lang).unwrap().value.clone(); + let form = Arc::new(pdfcraft_xfa::fixtures::static_radio_shell_with_pcon("", ["1", "2"])); + let mut s = Session::new().with_clock(|| 1_700_000_000); + for (state, item) in [("0", "1"), ("1", "2")] { + let id = s.open("blank.pdf", None, form.clone(), None).unwrap(); + s.apply(id, Edit::SetFieldValue { name: lang.into(), value: FieldValue::Radio(Some(state.into())) }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + assert_eq!(data_of(saved.clone()).as_deref(), Some(item), "clicked {state}"); + let again = s.open("again.pdf", None, saved, None).unwrap(); + assert_eq!(value(&s, again), [state], "clicked {state}"); + assert!(!s.get(again).unwrap().info.warnings.iter().any(|w| w.contains("taken from this form's XFA data")), "clicked {state}"); + s.apply(again, Edit::XfaToStatic).unwrap(); + assert!(!s.get(again).unwrap().info.warnings.iter().any(|w| w.contains("held a value other than the form's XFA data")), "clicked {state}"); + } +} + /// A static XFA form whose values are only in its datasets takes every one on opening, as /// filling the fields one at a time would leave it: values, appearances and the note; a /// read-only field keeps its own value. @@ -7663,12 +8885,12 @@ fn xfa_data_import_adds_and_removes_rows_of_a_dynamic_form() { assert_eq!(names(&s).iter().filter(|n| n.starts_with("amount")).count(), 3, "{:?}", names(&s)); assert_eq!(value(&s, "total"), vec!["15".to_string()], "qty 3 × price 5, calculated after the import: {:?}", s.take_js_output(id)); assert_eq!(value(&s, "grand"), vec!["42.5".to_string()]); - // More rows than the layout shows: the document says so. - let rows: String = (0..60).map(|i| format!("r{i}1")).collect(); + // More rows than the layout shows (1 000 for one repeating subform): the document says so. + let rows: String = (0..1010).map(|i| format!("r{i}1")).collect(); let file = format!("
1{rows}
"); s.apply(id, Edit::ImportData { name: "many.xml".into(), bytes: Arc::new(file.into_bytes()) }).unwrap(); let out = s.take_js_output(id); - assert!(out.errors.iter().chain(out.console.iter()).any(|l| l.contains("60 rows")), "{out:?}"); + assert!(out.errors.iter().chain(out.console.iter()).any(|l| l.contains("1010 rows") && l.contains("first 1000")), "{out:?}"); // A file with one row takes the others away. let file = "
1only7
"; s.apply(id, Edit::ImportData { name: "one.xml".into(), bytes: Arc::new(file.as_bytes().to_vec()) }).unwrap(); @@ -7740,6 +8962,119 @@ fn the_xfa_open_budget_stops_the_calculations_it_has_no_time_for() { assert_eq!(pdfcraft_js::xfa_session::engines_started(), engines, "no script ran, so no engine started"); } +/// Opening lays the form out once after its initialize scripts, not after each one that +/// hides a subform (XFA 3.3 pp. 388, 390: initialize fires once data binding is complete; +/// the layout process has not run yet). Laying it out after every script spent the opening's +/// time on the first few, and a form whose initialize scripts hide its optional pages was +/// saved half initialized: the next opening hid more of it, and reset the data typed into +/// what it hid. +#[test] +fn xfa_initialize_scripts_that_hide_subforms_lay_the_form_out_once() { + // Shaped like a form with optional extra pages: a check box, then more sections than one + // event may lay the form out again for, each hiding itself while the box is clear. + let sections = 60; + let mut body = String::from( + r#"100"#, + ); + for n in 0..sections { + body.push_str(&format!( + r#""# + )); + } + let tpl = format!( + r#" + + + +"# + ); + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)); + let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(100); + let id = s.open("extras.pdf", None, bytes, None).expect("opens"); + let out = s.take_js_output(id); + assert!(out.errors.is_empty(), "{:?}", out.errors); + let names = |s: &Session, id| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::>(); + let opened = names(&s, id); + assert!(opened.iter().all(|n| !n.contains("note")), "every extra section is hidden on opening: {opened:?}"); + assert!(opened.iter().any(|n| n.contains("wanted")), "{opened:?}"); + // User input survives repeated saves and openings, with stable field and page counts + // (strengthened by wireless89 in the review of #4791). + let wanted = opened.iter().find(|n| n.contains("wanted")).unwrap().clone(); + s.apply(id, Edit::SetFieldValue { name: wanted.clone(), value: FieldValue::Check(true) }).unwrap(); + let pages = s.get(id).unwrap().info.pages.len(); + let mut saved = s.save_bytes(id).unwrap(); + for cycle in 0..3 { + let again = s.open("again.pdf", None, saved, None).expect("reopens"); + let out = s.take_js_output(again); + assert!(out.errors.is_empty(), "cycle {cycle}: {out:?}"); + assert_eq!(names(&s, again), opened, "cycle {cycle}: stable fields"); + let doc = s.get(again).unwrap(); + assert_eq!(doc.info.pages.len(), pages, "cycle {cycle}: stable pages"); + assert_eq!(doc.form.iter().find(|f| f.name == wanted).unwrap().value, ["1"], "cycle {cycle}: input survives"); + saved = s.save_bytes(again).unwrap(); + } +} + +/// A script that resets several subforms one after another (`xfa.host.resetData` once for +/// each, as forms with optional pages do) resets every one of them, and nothing else; resets +/// in a row are applied together. +#[test] +fn xfa_resets_in_a_row_reset_every_named_subform_and_nothing_else() { + let sections = 5; + let mut body = String::from( + r#"100"#, + ); + let resets: String = (0..sections).map(|n| format!("xfa.host.resetData('form.extra{n}');")).collect(); + body.push_str(&format!( + r#""# + )); + body.push_str(r#""#); + // A field no script touches: the resets must leave what was typed in it. + body.push_str(r#"default"#); + for n in 0..sections { + body.push_str(&format!( + r#"default"# + )); + } + let notes: String = (0..sections).map(|n| format!("typed {n}")).collect(); + let tpl = format!( + r#" + + +
typedtyped other{notes}
+
+"# + ); + let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(100); + // Without the resets the sections show what was typed. + let untouched = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl.replace(&resets, ""))); + let plain = s.open("plain.pdf", None, untouched, None).expect("opens"); + let typed: Vec<_> = s.get(plain).unwrap().form.iter().filter(|f| f.name.contains("note")).map(|f| f.value.clone()).collect(); + assert_eq!(typed, (0..sections).map(|n| vec![format!("typed {n}")]).collect::>()); + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)); + let id = s.open("resets.pdf", None, bytes, None).expect("opens"); + let out = s.take_js_output(id); + assert!(out.errors.is_empty(), "{:?}", out.errors); + let form = &s.get(id).unwrap().form; + let notes: Vec<_> = form.iter().filter(|f| f.name.contains("note")).map(|f| f.value.clone()).collect(); + assert_eq!(notes.len(), sections, "{form:?}"); + assert!(notes.iter().all(|v| v == &vec!["default".to_string()]), "every section is reset: {notes:?}"); + let keep = form.iter().find(|f| f.name.contains("keep")).map(|f| f.value.clone()); + assert_eq!(keep, Some(vec!["kept".to_string()]), "the script's later write stands"); + let other = form.iter().find(|f| f.name.contains("other")).map(|f| f.value.clone()); + assert_eq!(other, Some(vec!["typed other".to_string()]), "a field outside the named subforms keeps its value"); +} + /// The scripts of one event share the form as the engine holds it: a failed script's /// half-done writes reach nobody, and a script reads the saved value a choice list was given /// by its shown text. @@ -7895,6 +9230,20 @@ fn an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error() { s.apply(id, Edit::SetFieldValue { name: "leave".into(), value: FieldValue::Check(true) }).expect("can be checked now"); } +/// A laid-out XFA form tabs the way its template's traversal says: the probe form, which +/// another viewer tabs `start A2 A3 A4 A1 B1 B3 B2 B4 C3 D1 D4 D6 finish C1 C2`. +#[test] +fn xfa_forms_tab_in_their_templates_traversal_order() { + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::traversal_template())); + let mut s = Session::new(); + let id = s.open("tab.pdf", None, bytes, None).unwrap(); + let doc = s.get(id).unwrap(); + let mut tabs: Vec<(usize, &str)> = doc.form.iter().flat_map(|f| f.widgets.iter().map(move |w| (w.tab, f.name.as_str()))).collect(); + tabs.sort_unstable(); + let names: Vec<&str> = tabs.iter().map(|t| t.1).filter(|n| !["log", "D5"].contains(n)).collect(); + assert_eq!(names, ["start", "A2", "A3", "A4", "A1", "B1", "B3", "B2", "B4", "C3", "D1", "D4", "D6", "finish", "C1", "C2"]); +} + /// A scripted form end to end: initialize and calculate on open, recalculation after a change, /// a failing validation's message, buttons that add and remove rows and show messages, a check /// box that shows a subform (undoable), FormCalc, and a save and reopen that keep it all. @@ -7911,6 +9260,57 @@ fn repeated_xfa_print_buttons_run_their_click_script() { } } +/// A button on the page master runs its click script as the copy on the page that was clicked: +/// the field it writes next to it changes on that page only. +#[test] +fn a_page_master_button_writes_the_field_on_its_own_page() { + let template = pdfcraft_xfa::fixtures::repeated_print_button_template().replace( + "", + r#" +