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 fafd037d33..b5867bcf8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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=1ee01a4c58729d781638b8afbd8982726dbdefcb#1ee01a4c58729d781638b8afbd8982726dbdefcb" 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=3c978dfcf3d460577fabd87aeea7a84ffdcfb396)", "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" @@ -4416,6 +4420,7 @@ dependencies = [ "sha2 0.11.1", "stringprep", "thiserror 2.0.21", + "zeroize", ] [[package]] @@ -4564,6 +4569,7 @@ dependencies = [ "pdfcraft-fonts", "png 0.18.1", "rten", + "rten-imageproc", "serde_json", "sha2 0.11.1", "tempfile", @@ -4618,6 +4624,7 @@ version = "0.18.1" dependencies = [ "async-channel", "futures-lite", + "log", "rustix 1.1.5", "zbus", ] @@ -4656,11 +4663,14 @@ name = "pdfcraft-print" version = "0.18.1" 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]] @@ -4747,6 +4757,7 @@ dependencies = [ "sha2 0.11.1", "sha3", "thiserror 2.0.21", + "zeroize", ] [[package]] @@ -4768,6 +4779,7 @@ dependencies = [ "craft-quit", "craft-ui", "craft-update", + "edit-session", "eframe", "egui", "egui_extras", @@ -4808,6 +4820,7 @@ dependencies = [ "web-time", "winsafe", "xattr", + "zeroize", ] [[package]] @@ -8310,7 +8323,29 @@ 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=3c978dfcf3d460577fabd87aeea7a84ffdcfb396#3c978dfcf3d460577fabd87aeea7a84ffdcfb396" +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 = "winprint" +version = "0.2.1" +source = "git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49#5f80b9baf6b58937c401656634e42d33f00ddf49" dependencies = [ "flate2", "fmt-derive", @@ -8845,14 +8880,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 b0f3411624..30367fa35c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,8 @@ 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,7 +26,7 @@ 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 = "1ee01a4c58729d781638b8afbd8982726dbdefcb" } # 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. @@ -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 92324ebccb..f7573603c7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,8 @@ # 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) @@ -29,6 +32,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) @@ -37,10 +41,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) @@ -239,6 +252,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. @@ -293,6 +326,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. @@ -301,7 +335,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. @@ -332,7 +373,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. @@ -449,6 +493,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **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. @@ -593,6 +639,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. @@ -823,14 +872,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. | @@ -838,8 +901,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. | diff --git a/apps/pdfcraft-cli/src/commands.rs b/apps/pdfcraft-cli/src/commands.rs index cb3365a4fe..b5effbc118 100644 --- a/apps/pdfcraft-cli/src/commands.rs +++ b/apps/pdfcraft-cli/src/commands.rs @@ -434,7 +434,10 @@ 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 _ = 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/annot/src/create.rs b/crates/annot/src/create.rs index e592b5d934..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, @@ -535,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) } diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index 99c3264006..e91bb94e0a 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -123,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, @@ -340,6 +340,9 @@ 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(); + 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 { 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 eb77e3f973..ae2222b387 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -4,7 +4,7 @@ use super::create::{ }; use super::draw::{draw_appearance, redraw_or_restore, set_appearance}; 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, is_fill_sign, page_ref, set_annots}; +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, @@ -117,7 +117,10 @@ pub(crate) fn touch(d: &mut Dict, meta: &Meta) { /// go together from either ([`own_deletion`]). pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Result<(), AnnotError> { let index = deleted_index(doc, page, index)?; - lift_annotation(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 @@ -133,6 +136,10 @@ fn deleted_index(doc: &Document, page: usize, index: usize) -> Result 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/automation/README.md b/crates/automation/README.md index e25ff9b8b2..cf9e82982b 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. diff --git a/crates/automation/src/a11y/form_tools.rs b/crates/automation/src/a11y/form_tools.rs index ae9b624035..dee8eff646 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 { @@ -14,6 +14,7 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value { 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() }), } } 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/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 d3850d1d8f..3b40832b83 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 { @@ -730,6 +731,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)?, diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs index e1439aa89f..c00034521c 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 { diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 2fd51038de..37f1ed2cbb 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, } } diff --git a/crates/automation/src/links.rs b/crates/automation/src/links.rs index a6228cc5bc..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()) } @@ -222,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 7bab26e6c1..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,7 +509,9 @@ 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 dd3f949db2..c2a330a095 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -23,47 +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), and tells -/// CUPS which way the laid-out `sheets` run, as the Print dialog does (#2713): an orientation the -/// caller chose in `options` stands. -fn spool_job(a: &Args, printer: &str, title: String, duplex: Option, paper: usize, sheets: &[print::Sheet]) -> 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 @@ -81,10 +138,12 @@ fn spool_job(a: &Args, printer: &str, title: String, duplex: Option = options.into_iter().chain(print::media_option(paper)).collect(); + let mut options: Vec<(String, String)> = options.into_iter().chain(media.map(|m| ("media".to_string(), m.to_string()))).collect(); print::add_cups_orientation(&mut options, sheets); options }, @@ -159,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 { @@ -218,6 +292,7 @@ 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), @@ -226,7 +301,15 @@ impl Automation { }, 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")); } @@ -241,7 +324,18 @@ impl Automation { }; 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:?}")))?; - let (paper_index, paper) = paper_named(a.opt_str("paper")?)?; + // 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) { @@ -253,20 +347,32 @@ 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(); + // 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(); 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")?) { + match (a.opt_str("path")?, printer) { (Some(p), None) => { let path = self.resolve(p, true)?; 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, &laid_out)?; + let actual_printer = resolved_printer.as_deref().unwrap_or(printer); + let mut job = spool_job(a, actual_printer, self.doc(a)?.name.clone(), duplex, media.as_deref(), settings.raster, &laid_out)?; + let bytes = pdfcraft_engine::spooler::spooler_copy(bytes, &mut job); 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\")")), @@ -275,21 +381,59 @@ impl Automation { } } +/// "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 @@ -312,7 +456,7 @@ mod tests { #[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, 0, s).unwrap().options; + 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())] }; @@ -328,8 +472,8 @@ mod tests { #[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. @@ -338,14 +482,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/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 e1320972b8..56c654fa1c 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -183,7 +183,7 @@ pub fn tools() -> Vec { )), 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.") + 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!({}), &[])), @@ -212,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." }, @@ -276,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( @@ -743,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. 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!({ @@ -764,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." }, }), @@ -854,7 +874,7 @@ pub fn tools() -> Vec { 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"], @@ -1395,7 +1415,7 @@ 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 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, attachment) 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.") .cmd("tools.js_console") .with(schema( json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." } }), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index bc9d2e9ee5..9516a76542 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()); @@ -4388,6 +4397,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"); @@ -4592,6 +4632,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"); @@ -6200,10 +6285,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"); @@ -7350,6 +7457,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(); @@ -7357,6 +7501,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); @@ -7371,6 +7516,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(); @@ -7419,6 +7566,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(); @@ -9325,9 +9480,19 @@ 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]"); 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!([])); @@ -12073,6 +12238,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] @@ -12111,6 +12302,53 @@ 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] diff --git a/crates/automation/tests/it/render_warnings.rs b/crates/automation/tests/it/render_warnings.rs index 013c287d5d..aba200d3d8 100644 --- a/crates/automation/tests/it/render_warnings.rs +++ b/crates/automation/tests/it/render_warnings.rs @@ -123,6 +123,45 @@ fn page_render_reports_a_recovered_malformed_indexed_image() { 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/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..925a7c879d 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -507,17 +507,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 @@ -1079,7 +1068,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/tests.rs b/crates/cos/src/document/tests.rs index 274672bc7c..68215efc98 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) }; 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 97a3b60ab9..f2c9760c12 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -384,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> { 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..8b520d281d 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; @@ -147,9 +148,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 +191,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 +249,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 +290,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 +307,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 +349,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 +364,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 +458,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 +496,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 +526,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 +538,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 +549,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 +608,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 +625,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 +659,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 +677,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 +742,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 +759,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 +395,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 +425,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/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 d0f6d2c863..f02930a8e5 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -61,8 +61,9 @@ 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}; @@ -93,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/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 d56240ee87..e9bf8370f8 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:?}" + ); + } } } } @@ -8096,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(); @@ -9684,6 +10012,102 @@ 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. + "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. + "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", + ]; + 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 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(), 2, "{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] @@ -12041,16 +12465,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. @@ -13610,6 +14037,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"); @@ -14277,6 +14822,29 @@ 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}"); +} + /// 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 ada90d8484..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}; 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/lines.rs b/crates/edit/src/text/lines.rs index 9eadb65107..e1c4b2d312 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -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::>()) 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/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/commands.rs b/crates/engine/src/commands.rs index 1996bcd793..dfbc33d658 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -198,6 +198,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 diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index be1cb1a0a4..bfbdc5f7e5 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -167,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"). diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index eb213f3f64..ebb8a5f323 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -420,13 +420,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 @@ -511,6 +508,14 @@ fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usi Ok(()) } +/// After an edit that changes fields, their scripts or the calculation order: the edit's +/// script runner reads the document as it now is, and builds its `calculateNow()` list again. +fn invalidate_calculations(cx: &mut EditCtx, doc: &pdfcraft_cos::Document) { + if let Some(js) = cx.js.as_mut() { + js.invalidate_calculations(doc); + } +} + /// 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 { @@ -539,6 +544,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_calculations(cx, doc); } Edit::AddBookmark { parent, index, title, page, left, top } => { pdfcraft_organize::add_bookmark_at(doc, parent, *index, title, *page, *left, *top)?; @@ -791,22 +797,32 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E } Edit::AddField { page, rect, kind, name } => { pdfcraft_forms::add_field(doc, *page, *rect, kind, name.as_deref())?; + invalidate_calculations(cx, doc); } Edit::SetFieldProps { name, props } => { pdfcraft_forms::set_props(doc, name, props)?; + invalidate_calculations(cx, doc); } 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_calculations(cx, doc); + } + Edit::DeleteField { name } => { + pdfcraft_forms::delete_field(doc, name)?; + invalidate_calculations(cx, doc); + } + Edit::DeleteWidget { name, widget } => { + pdfcraft_forms::delete_widget(doc, name, *widget)?; + invalidate_calculations(cx, doc); } - 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)?; + invalidate_calculations(cx, doc); match cx.js.as_mut() { Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, None => pdfcraft_forms::recalculate(doc)?, @@ -834,6 +850,7 @@ 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())?; + invalidate_calculations(cx, doc); match cx.js.as_mut() { Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, None => pdfcraft_forms::recalculate(doc)?, 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/js.rs b/crates/engine/src/js.rs index 31f1aed19e..13939aad54 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -81,20 +81,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()), } } @@ -221,7 +227,11 @@ fn field_state_with_display(f: &Field, display: i32) -> pdfcraft_js::FieldState } /// 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(); } @@ -238,6 +248,7 @@ fn changes(before: &[pdfcraft_js::FieldState], after: &[pdfcraft_js::FieldState] let c = FieldChange { name: a.name.clone(), 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), @@ -260,6 +271,7 @@ pub struct JsRunner { // 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, + calculations: Option>>, } impl JsRunner { @@ -268,12 +280,15 @@ impl JsRunner { /// 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 pages = pdfcraft_model::pages(cos); let doc = pdfcraft_js::DocInfo { file_name: file_name.to_string(), - num_pages: pdfcraft_model::pages(cos).len(), + num_pages: pages.len(), page: 0, info: info(cos), highlight, + page_boxes: pdfcraft_organize::page_boxes(cos).unwrap_or_default(), + page_rotations: pages.iter().map(|page| page.rotation(cos)).collect(), xfa, }; // Once XFA owns the form, the PDF container's scripts belong to its fallback view. @@ -282,8 +297,61 @@ impl JsRunner { // 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(), cos: cos.clone(), displays: std::collections::HashMap::new(), calculations: None } } + + fn calculations(&mut self, fields: &[Field]) -> std::sync::Arc> { + if let Some(calculations) = &self.calculations { + return std::sync::Arc::clone(calculations); + } + let calculations = std::sync::Arc::new(calculation_scripts(&self.cos, fields)); + self.calculations = Some(std::sync::Arc::clone(&calculations)); + calculations + } + + pub(crate) fn invalidate_calculations(&mut self, cos: &pdfcraft_cos::Document) { + self.cos = cos.clone(); + self.displays.clear(); + self.calculations = None; + } +} + +fn calculation_scripts(cos: &pdfcraft_cos::Document, fields: &[Field]) -> Vec<(String, String)> { + #[cfg(test)] + CALCULATION_SCRIPT_BUILDS.with(|calls| calls.set(calls.get().saturating_add(1))); + pdfcraft_forms::calculation_order(cos, fields) + .fields + .iter() + .filter_map(|ordered| { + let field = fields.iter().find(|f| f.name == ordered.name)?; + 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((field.name.clone(), script)) + }) + .collect() +} + +#[cfg(test)] +thread_local! { + pub(crate) static CALCULATION_SCRIPT_BUILDS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +fn calls_calculate_now(script: &str, doc_scripts: &[String]) -> bool { + script.contains("calculateNow") || doc_scripts.iter().any(|source| source.contains("calculateNow")) +} + +fn calculations_for_script( + cos: &pdfcraft_cos::Document, + fields: &[Field], + script: &str, + doc_scripts: &[String], +) -> std::sync::Arc> { + if calls_calculate_now(script, doc_scripts) { std::sync::Arc::new(calculation_scripts(cos, fields)) } else { std::sync::Arc::new(Vec::new()) } } /// The field highlight the last of `requests` that sets it asks for (`app.runtimeHighlight`). @@ -340,7 +408,16 @@ impl JsRunner { .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()); + let calculations = if calls_calculate_now(script, &self.doc_scripts) { self.calculations(fields) } else { std::sync::Arc::new(Vec::new()) }; + let o = pdfcraft_js::run_shared_calculations_shared( + script, + &ev, + &self.doc, + std::sync::Arc::clone(&states), + &self.doc_scripts, + &calculations, + Limits::default(), + ); if log { self.output.absorb(&o); } @@ -355,7 +432,7 @@ impl JsRunner { rc: o.rc, value: o.value.clone(), value_set: o.value_set, - changes: changes(&states, &o.changed), + changes: changes(&states, &o.changed, &o.formatted), message: (!o.rc).then(|| o.alerts.last().map(|m| m.text.clone())).flatten(), }) } @@ -510,10 +587,12 @@ impl Session { } 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()))?; - // A button PdfCraft generated from an XFA template runs its XFA click script. + // A button PdfCraft generated from an XFA template runs its XFA click script (the app runs + // click scripts for buttons only, so other fields' click events don't run here either). if let Some(t) = target && let Some(tpl) = doc.xfa_template.clone() && let Some(field) = doc.form.iter().find(|f| f.name == t) + && 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. @@ -529,13 +608,22 @@ impl Session { } let runner = JsRunner::new(&cos, &doc.name, self.field_highlight); 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)).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"), }; - let mut o = pdfcraft_js::run(script, &event, &runner.doc, &states, &runner.doc_scripts, Limits::default()); - let changes = changes(&states, &o.changed); + let calculations = calculations_for_script(&cos, &fields, script, &runner.doc_scripts); + let mut o = pdfcraft_js::run_shared_calculations_shared( + script, + &event, + &runner.doc, + std::sync::Arc::clone(&states), + &runner.doc_scripts, + &calculations, + Limits::default(), + ); + let changes = changes(&states, &o.changed, &o.formatted); let resets: Vec> = o .requests .iter() @@ -874,8 +962,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 { @@ -897,12 +986,12 @@ 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()); } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 17f196cf6a..161b2c1d34 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; diff --git a/crates/engine/src/ocr.rs b/crates/engine/src/ocr.rs index 512c1c97ad..539a03a6aa 100644 --- a/crates/engine/src/ocr.rs +++ b/crates/engine/src/ocr.rs @@ -2321,4 +2321,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_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/spooler.rs b/crates/engine/src/spooler.rs new file mode 100644 index 0000000000..56347d8f09 --- /dev/null +++ b/crates/engine/src/spooler.rs @@ -0,0 +1,223 @@ +//! The print-ready PDF as the system spooler gets it. + +use std::sync::Arc; + +use pdfcraft_cos::{SaveOptions, write_full}; + +use crate::guard; + +/// The print-ready PDF `bytes` ([`crate::Session::print_pdf`]) as they go to the system spooler +/// with `job`. Two things happen on Windows only; 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. +/// - 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. +/// +/// When the copy can't be read or written, the bytes go as they are: neither 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) -> 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 => {} + } + } + // 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() +} + +#[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), b"not a PDF"); + let small = page_with_image(100, 100); + assert_eq!(spooler_copy(small.clone(), &mut job), 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), 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); + 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), 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()); + if cfg!(windows) { + assert_eq!(Some(&sent), tiled(Arc::new(large.clone())).as_ref()); + assert_ne!(sent, large); + } else { + assert_eq!(sent, large); + } + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3912bfd5ec..56972abaa6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6632,6 +6632,226 @@ fn form_javascript_validates_calculates_and_formats() { assert!(s.run_javascript(id, "1", None).is_err()); } +#[test] +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 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!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value.is_empty()); + s.redo(id).unwrap(); + 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_applies_format_script_to_updated_appearance() { + let mut session = Session::new().with_clock(|| 1_700_000_000); + let id = session.open("calculate-format.pdf", None, Arc::new(scripted_form()), None).unwrap(); + session.run_javascript(id, "getField('price').value = 3; getField('qty').value = 2; calculateNow();", None).unwrap(); + + assert_eq!(session.get(id).unwrap().form.iter().find(|field| field.name == "total").unwrap().value, ["6"]); + assert!(page_texts(&session, id)[0].contains("USD 6.00"), "the appearance uses the Format script"); +} + +#[test] +fn calculation_scripts_are_built_only_for_scripts_that_call_calculate_now() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("calculate-list.pdf", None, Arc::new(scripted_form()), None).unwrap(); + crate::js::CALCULATION_SCRIPT_BUILDS.with(|calls| calls.set(0)); + + s.run_javascript(id, "1 + 1", None).unwrap(); + assert_eq!(crate::js::CALCULATION_SCRIPT_BUILDS.with(std::cell::Cell::get), 0); + + s.run_javascript(id, "calculateNow();", None).unwrap(); + assert_eq!(crate::js::CALCULATION_SCRIPT_BUILDS.with(std::cell::Cell::get), 1); + + 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 == "go").unwrap(); + let mut runner = crate::js::JsRunner::new(&cos, "calculate-list.pdf", false); + crate::js::CALCULATION_SCRIPT_BUILDS.with(|calls| calls.set(0)); + + runner.run(FieldEvent::Calculate, "1 + 1", target, "", &fields); + assert_eq!(crate::js::CALCULATION_SCRIPT_BUILDS.with(std::cell::Cell::get), 0); + + runner.run(FieldEvent::Calculate, "calculateNow();", target, "", &fields); + assert_eq!(crate::js::CALCULATION_SCRIPT_BUILDS.with(std::cell::Cell::get), 1); + runner.run(FieldEvent::Calculate, "calculateNow();", target, "", &fields); + assert_eq!(crate::js::CALCULATION_SCRIPT_BUILDS.with(std::cell::Cell::get), 1); +} + +#[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] +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:?}"); + + 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(); + } + let cyclic = s.run_javascript(id, "calculateNow();", None).unwrap(); + assert!(cyclic.error.is_none(), "a cyclic calculation runs once in order: {cyclic:?}"); + + s.apply(id, Edit::SetFieldScript { name: "total".into(), event: "calculate".into(), script: Some("calculateNow(); event.value = 12;".into()) }) + .unwrap(); + let out = s.run_javascript(id, "calculateNow();", None).unwrap(); + assert!(out.console.iter().any(|line| line.contains("calculateNow recursion limit exceeded")), "{out:?}"); + 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); + + 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); + 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(); @@ -6753,6 +6973,109 @@ fn inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts() { } } +#[test] +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), this.getPageBox('Trim', 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],[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, + ); + 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); + 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); + } + 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); @@ -8550,6 +8873,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. @@ -11338,6 +11675,61 @@ fn exporting_office_keeps_dotted_leader_entries_as_paragraphs() { assert!(positions.windows(2).all(|pair| pair[0] < pair[1]), "reading order: {html}"); } +/// `run_javascript` runs an XFA click script only for a push button, as the app does: a text +/// field with a click event of its own, named as the target, asks to open no attached file. +#[test] +fn run_javascript_runs_xfa_click_scripts_only_for_buttons() { + let xdp = r##" + + + +"##; + let mut s = Session::new(); + let id = s.open("guide.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens"); + s.take_js_output(id); + let o = s.run_javascript(id, "", Some("guide")).unwrap(); + assert_eq!(o.requests, [pdfcraft_js::Request::OpenAttachment { name: "Guide.pdf".into(), open: true }], "the button's click: {o:?}"); + let o = s.run_javascript(id, "", Some("note")).unwrap(); + assert!(o.requests.is_empty(), "a text field's click script doesn't run here: {o:?}"); +} + +/// In an XFA form a button's click script may open an attached file; the same request from a +/// text field's exit script is noted in the console and not made. +#[test] +fn an_xfa_button_may_open_an_attached_file_and_other_events_may_not() { + let xdp = r##" + + + +"##; + let mut s = Session::new(); + let id = s.open("guide.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens"); + s.take_js_output(id); + s.apply(id, Edit::XfaEvent { som: "form[0].guide[0]".into(), activity: "click".into() }).unwrap(); + let out = s.take_js_output(id); + assert_eq!(out.requests, [pdfcraft_js::Request::OpenAttachment { name: "Guide.pdf".into(), open: true }], "{out:?}"); + s.apply(id, Edit::SetFieldValue { name: "note".into(), value: FieldValue::Text("x".into()) }).unwrap(); + let out = s.take_js_output(id); + assert!(out.requests.is_empty(), "only a click opens a file: {out:?}"); + assert!(out.console.iter().any(|c| c.contains("open an attached file")), "{:?}", out.console); +} + /// A field whose layout:ready script only reads the page count keeps the value its script gives /// it: only a field the script gives the page call's result is numbered by the layout. #[test] @@ -13237,10 +13629,35 @@ fn redacting_an_optimized_jbig2_page_stores_the_rest_of_its_group_again() { assert_eq!(globals(&doc, images[2]), None, "the DeviceN page is coded alone"); pdfcraft_redact::apply(&mut doc, None).unwrap(); let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); - let named = saved.scan_objects().filter(|(_, o)| matches!(&**o, Object::Stream(s) if saved.jbig2_globals_ref(s).is_some())).count(); + // Optimize writes the parameters direct, so a stream that names the dictionary holds the key. + let names = |s: &Stream| { + let mut bytes = Vec::new(); + pdfcraft_cos::serialize(&Object::Dict(s.dict.clone()), &mut bytes); + bytes.windows(12).any(|w| w == b"JBIG2Globals") + }; + let named = saved.scan_objects().filter(|(_, o)| matches!(&**o, Object::Stream(s) if names(s))).count(); assert_eq!(named, 0, "no image names the dictionary, which left with them"); } +#[test] +fn resizing_an_added_image_back_inside_the_page_keeps_its_identity() { + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 2, 2); + enc.set_color(png::ColorType::Rgb); + enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap(); + } + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([-30.0, -20.0, 70.0, 80.0]), name: "edge.png".into(), bytes: Arc::new(png) }).unwrap(); + let original = s.get(id).unwrap().added()[0].obj; + let target = [10.0, 10.0, 60.0, 60.0]; + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move(target) }).unwrap(); + let added = s.get(id).unwrap().added(); + assert_eq!(added.len(), 1); + assert_eq!(added[0].obj, original); + assert_eq!(added[0].content.rect(), target); +} + #[test] fn fill_sign_multiline_text_survives_engine_save_reopen_and_render() { let (mut session, id) = rotated_blank(0); @@ -13404,3 +13821,32 @@ fn a_document_encrypting_only_its_embedded_files_opens_and_saves_without_a_passw let reopened = pdfcraft_cos::Document::open(saved).unwrap(); assert!(reopened.embedded_files_locked(), "the attachment stays encrypted"); } + +#[test] +fn calculate_now_simple_calculation_reads_formatted_values_and_groups_as_the_native_path_does() { + // amount (4), group b (5) with kids b.c (6) and b.d (7), total (8) = SUM(amount, b). + let widget = |rest: &str, y: u32| format!("<< /Type /Annot /Subtype /Widget /Rect [20 {y} 180 {}] /P 3 0 R /F 4 {rest} >>", y + 20); + let pdf = pdfcraft_testkit::Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 8 0 R] /CO [8 0 R] /DA (/Helv 0 Tf 0 g) >> >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>") + .obj("<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R 8 0 R] >>") + .obj(widget("/FT /Tx /T (amount) /V (1,234.50)", 250)) + .obj("<< /FT /Tx /T (b) /Kids [6 0 R 7 0 R] >>") + .obj(widget("/Parent 5 0 R /T (c) /V ($10)", 220)) + .obj(widget("/Parent 5 0 R /T (d) /V (1.234,5)", 190)) + .obj(widget( + "/FT /Tx /T (total) /AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"SUM\", new Array\\(\"amount\", \"b\"\\)\\);) >> >>", + 160, + )) + .build(); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("simple.pdf", None, Arc::new(pdf), None).unwrap(); + let total = |s: &Session| s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value.clone(); + // The commit path (native Simple calculation) on an edit elsewhere. + s.apply(id, Edit::SetFieldValue { name: "b.c".into(), value: FieldValue::Text("$10".into()) }).unwrap(); + assert_eq!(total(&s), ["2479"], "native Simple calculation"); + let out = s.run_javascript(id, "getField('amount').value = '2,000.25'; calculateNow(); console.println(getField('total').value);", None).unwrap(); + assert!(out.error.is_none(), "{out:?}"); + assert!(out.console.iter().any(|line| line == "3244.75"), "calculateNow agrees with the native path: {out:?}"); + assert_eq!(total(&s), ["3244.75"]); +} diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 393b997af5..9b28320a55 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -53,6 +53,7 @@ fn click_only(e: &XfaEffect) -> Option<&'static str> { XfaEffect::Print => Some("print"), XfaEffect::SaveAs => Some("save"), XfaEffect::LaunchUrl(_) => Some("open a link"), + XfaEffect::OpenAttachment { .. } => Some("open an attached file"), XfaEffect::ShowPanel(_) => Some("show a side panel"), XfaEffect::SetFocus(_) => Some("move the focus"), _ => None, @@ -910,6 +911,7 @@ impl<'a, 'b> Runner<'a, 'b> { XfaEffect::Print => self.run.out.requests.push(Request::Print), XfaEffect::SaveAs => self.run.out.requests.push(Request::SaveAs), XfaEffect::LaunchUrl(u) => self.run.out.requests.push(Request::LaunchUrl(u.clone())), + XfaEffect::OpenAttachment { name, open } => self.run.out.requests.push(Request::OpenAttachment { name: name.clone(), open: *open }), XfaEffect::ShowPanel(p) => self.run.out.requests.push(Request::ShowPanel(*p)), XfaEffect::SetFocus(som) => { if let Some(name) = self.by_som().get(som).cloned() { diff --git a/crates/engine/src/xfa_form.rs b/crates/engine/src/xfa_form.rs index 5739c57160..be2ff1883e 100644 --- a/crates/engine/src/xfa_form.rs +++ b/crates/engine/src/xfa_form.rs @@ -15,13 +15,10 @@ pub(crate) fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result Result<(), String> { - for f in pdfcraft_forms::fields(doc) { - if f.kind == pdfcraft_forms::FieldKind::Signature && doc.get(f.obj).as_dict().is_some_and(|d| d.contains(b"V")) { - continue; - } - pdfcraft_forms::redraw_field(doc, &f.name).map_err(|e| format!("{}: {e}", f.name))?; - } - Ok(()) + let signed = |doc: &pdfcraft_cos::Document, f: &pdfcraft_forms::Field| { + f.kind == pdfcraft_forms::FieldKind::Signature && doc.get(f.obj).as_dict().is_some_and(|d| d.contains(b"V")) + }; + pdfcraft_forms::redraw_fields(doc, |doc, f| !signed(doc, f)).map_err(|e| e.to_string()) } /// A warning for each field whose appearance PdfCraft drew with `?` for a character its font diff --git a/crates/engine/tests/form_date_language.rs b/crates/engine/tests/form_date_language.rs index 6a70406cfe..55a701b6a9 100644 --- a/crates/engine/tests/form_date_language.rs +++ b/crates/engine/tests/form_date_language.rs @@ -21,7 +21,7 @@ fn names_lock() -> MutexGuard<'static, ()> { /// `util.printd(fmt)` of 18 October 2026, then of what `util.scand(fmt, typed)` reads. fn printd(fmt: &str, typed: &str) -> String { - let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false }; + let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false, ..Default::default() }; let script = format!("event.value = util.printd('{fmt}', new Date(2026, 9, 18)) + '|' + util.printd('{fmt}', util.scand('{fmt}', '{typed}'));"); let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default()); assert_eq!(o.error, None); @@ -75,7 +75,7 @@ fn form_dates_follow_the_date_language() { #[test] fn an_english_weekday_before_a_shared_month_name_reads_as_english() { let _names = names_lock(); - let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false }; + let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false, ..Default::default() }; let mar17 = DateTime { y: 2026, m: 3, d: 17, hh: 0, mm: 0, ss: 0 }; let mut bad = Vec::new(); for language in ["it", "es", "ro", "sq"] { @@ -111,7 +111,7 @@ fn an_english_weekday_before_a_shared_month_name_reads_as_english() { /// letter that writes a name, through the form path and through `util.scand`. Called from the /// one test, which owns the process-wide names. fn every_date_language_reads_back_what_it_writes() { - let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false }; + let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false, ..Default::default() }; let mut bad = Vec::new(); for l in pdfcraft_engine::dates::DATE_LANGUAGES.iter() { use_form_date_language(l.code); @@ -319,7 +319,7 @@ fn an_english_run_that_prints_the_stored_value_is_drawn() { /// What `util.scand(fmt, text)` reads, as `yyyy-mm-dd`, or `null`. fn scanned(fmt: &str, text: &str) -> String { - let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false }; + let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false, ..Default::default() }; let script = format!("var d = util.scand('{fmt}', {text:?}); event.value = d ? util.printd('yyyy-mm-dd', d) : 'null';"); let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default()); assert_eq!(o.error, None); diff --git a/crates/engine/tests/it/ocr_edit.rs b/crates/engine/tests/it/ocr_edit.rs index 078815e172..ee191add98 100644 --- a/crates/engine/tests/it/ocr_edit.rs +++ b/crates/engine/tests/it/ocr_edit.rs @@ -419,18 +419,21 @@ fn a_formatting_change_redraws_the_whole_ocr_phrase() { assert_eq!(drawn.join(" "), "John Smyth Example"); } -/// A layer whose words follow one another with no position of their own: a word that went would -/// move those after it, so keeping them isn't possible and the whole phrase is redrawn instead. +/// A layer whose words follow one another with no position of their own: a word that goes leaves +/// its advance behind (ISO 32000-2 §9.4.4), so the words after it stay where they were and are +/// kept as the scan, with nothing drawn. #[test] -fn words_that_depend_on_the_one_before_are_redrawn_rather_than_moved() { +fn words_that_depend_on_the_one_before_are_kept_in_place() { let bytes = under_scan("BT /F1 10 Tf 3 Tr 20 44 Td (John) Tj ( ) Tj (Smith) Tj ( ) Tj (Example) Tj ET"); let mut s = Session::new(); let id = s.open("relative.pdf", None, bytes, None).unwrap(); assert_eq!(texts(&s, id), ["John Smith Example"]); + let right = |s: &Session| s.get(id).unwrap().text_lines(0).iter().find(|l| l.text.contains("Example")).map(|l| l.rect[2]); + let before = right(&s).unwrap(); edit_block(&mut s, id, 0, "John Example"); let (scan, drawn) = scan_and_drawn(&s, id); - assert!(scan.is_empty(), "no word is left to shift under the scan: {scan:?}"); - assert_eq!(drawn, ["John Example"]); + assert_eq!((scan, drawn), (vec!["John".to_owned(), "Example".to_owned()], Vec::::new())); + assert_eq!(right(&s), Some(before), "Example stays where it was"); } /// The edit survives a save and reopen, and each piece can be edited again. @@ -460,19 +463,21 @@ fn a_draft_with_the_same_words_changes_nothing() { } } -/// Visible text positioned after the end of an OCR word depends on that word. An edit may not -/// leave the word gone and the visible text somewhere else while calling the rest kept. +/// Visible text positioned after the end of an OCR word depends on that word. The word that goes +/// leaves its advance behind, so the visible text stays where it was and the word kept stays the +/// scan's. #[test] -fn visible_text_that_follows_an_ocr_word_stops_it_being_kept_alone() { +fn visible_text_that_follows_a_removed_ocr_word_stays_in_place() { let bytes = under_scan("BT /F1 10 Tf 3 Tr 20 44 Td (John) Tj ( ) Tj (Smith) Tj 0 Tr /F1 14 Tf ( Label) Tj ET"); let mut s = Session::new(); let id = s.open("label.pdf", None, bytes, None).unwrap(); let label = |s: &Session| s.get(id).unwrap().text_lines(0).iter().find(|l| l.text.contains("Label")).map(|l| l.rect[0]); - assert_eq!(scan_and_drawn(&s, id).0, ["John Smith"]); + assert_eq!(scan_and_drawn(&s, id), (vec!["John Smith".to_owned()], vec!["Label".to_owned()])); let before = label(&s).unwrap(); edit_block(&mut s, id, 0, "John"); - let (scan, _) = scan_and_drawn(&s, id); - assert!(scan.is_empty(), "John can't stay alone when Label would move with it: {scan:?}, Label at {:?} from {before}", label(&s)); + // Nothing new is drawn: the visible text is the page's own. + assert_eq!(scan_and_drawn(&s, id), (vec!["John".to_owned()], vec!["Label".to_owned()])); + assert_eq!(label(&s), Some(before), "Label stays where it was"); } /// A large word right before a small one, as OCR's sizes can make them: the large word's cover, diff --git a/crates/export/src/as_html.rs b/crates/export/src/as_html.rs index 8c250ca484..e2b3379ac2 100644 --- a/crates/export/src/as_html.rs +++ b/crates/export/src/as_html.rs @@ -1,5 +1,5 @@ use super::as_docx::{hex, rgb8}; -use super::layout::{Item, Page, items}; +use super::layout::{Item, Page, Run, Table, items}; /// Whether a character may appear in the output. XML 1.0 forbids the C0 controls other than tab, /// line feed and carriage return, and U+FFFE/U+FFFF. Text extracted from PDFs often holds them @@ -94,6 +94,64 @@ pub fn html_simple_with_language(pages: &[Page], title: &str, language: Option<& html_with_mode(pages, title, true, language) } +fn style_color(color: [f64; 3], simple: bool) -> String { + if simple { String::new() } else { hex(color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")) } +} + +fn wrap_inline(mut text: String, bold: bool, italic: bool) -> String { + if text.trim().is_empty() { + return text; + } + if italic { + text = format!("{text}"); + } + if bold { + text = format!("{text}"); + } + text +} + +fn para_html(runs: &[Run<'_>], lvl: u8, simple: bool) -> Option { + let non_empty: Vec<_> = runs.iter().map(|r| (r.block, esc(&r.text))).filter(|(_, text)| !text.is_empty()).collect(); + let &(first, _) = non_empty.iter().find(|(_, text)| !text.trim().is_empty())?; + let mut t = String::new(); + for (block, text) in non_empty { + if text.trim().is_empty() { + t.push_str(&text); + continue; + } + let mut run = wrap_inline(text, block.bold && lvl == 0, block.italic); + // The paragraph has its first visible run's colour; a run in another colour says so. + let colour = rgb8(block.color); + if !simple && colour != rgb8(first.color) { + let [red, green, blue] = colour; + run = format!("{run}"); + } + t.push_str(&run); + } + let style = style_color(first.color, simple); + Some(match lvl.min(6) { + 0 => format!("{t}

\n"), + l => format!("{t}\n"), + }) +} + +fn table_html(t: &Table, simple: bool) -> String { + let mut s = String::from("\n"); + for row in &t.rows { + s.push_str(""); + for c in row { + let body = wrap_inline(esc(&c.text), c.bold, c.italic); + let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() }; + let style = style_color(c.color, simple); + s.push_str(&format!("{body}")); + } + s.push_str("\n"); + } + s.push_str("
\n"); + s +} + fn html_with_mode(pages: &[Page], title: &str, simple: bool, language: Option<&str>) -> String { let lang = language_tag(language).map(|tag| format!(" lang=\"{}\"", esc(tag))).unwrap_or_default(); let mut s = format!( @@ -108,50 +166,11 @@ fn html_with_mode(pages: &[Page], title: &str, simple: bool, language: Option<&s for it in items(pages) { match it { Item::Para(runs, lvl) => { - let Some(first) = runs.first().map(|r| r.block) else { continue }; - let mut t = String::new(); - for r in &runs { - let mut run = esc(&r.text); - if r.block.italic { - run = format!("{run}"); - } - if r.block.bold && lvl == 0 { - run = format!("{run}"); - } - // The paragraph has its first run's colour; a run in another colour says so. - let colour = rgb8(r.block.color); - if !simple && colour != rgb8(first.color) { - let [red, green, blue] = colour; - run = format!("{run}"); - } - t.push_str(&run); - } - let style = if simple { String::new() } else { hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")) }; - match lvl { - 0 => s.push_str(&format!("{t}

\n")), - l => s.push_str(&format!("{t}\n")), + if let Some(p) = para_html(&runs, lvl, simple) { + s.push_str(&p); } } - Item::Table(t) => { - s.push_str("\n"); - for row in &t.rows { - s.push_str(""); - for c in row { - let mut body = esc(&c.text); - if c.italic { - body = format!("{body}"); - } - if c.bold { - body = format!("{body}"); - } - let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() }; - let style = if simple { String::new() } else { hex(c.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")) }; - s.push_str(&format!("{body}")); - } - s.push_str("\n"); - } - s.push_str("
\n"); - } + Item::Table(t) => s.push_str(&table_html(&t, simple)), Item::Img(im) => { let mime = if im.ext == "jpg" { "image/jpeg" } else { "image/png" }; s.push_str(&format!("

\"\"

\n", base64(&im.bytes))); @@ -162,3 +181,71 @@ fn html_with_mode(pages: &[Page], title: &str, simple: bool, language: Option<&s s.push_str("\n\n"); s } + +#[cfg(test)] +mod tests { + use super::{html, para_html, table_html}; + use crate::layout::{Block, Cell, Page, Run, Table}; + + fn block(text: &str, x0: f64, y0: f64, x1: f64, y1: f64) -> Block { + Block { + text: text.into(), + rect: [x0, y0, x1, y1], + size: 12.0, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + } + } + + #[test] + fn empty_or_control_only_runs_and_blocks_do_not_emit_empty_tags() { + let page = Page { + width: 612.0, + height: 792.0, + blocks: vec![ + Block { bold: true, italic: true, color: [1.0, 0.0, 0.0], ..block("\u{1}\u{FFFF}", 72.0, 700.0, 90.0, 712.0) }, + Block { color: [0.0, 0.5, 0.0], ..block("Hello", 90.5, 700.0, 140.0, 712.0) }, + Block { bold: true, ..block("\u{0}\u{2}", 72.0, 660.0, 120.0, 672.0) }, + ], + images: Vec::new(), + background: None, + rules: Vec::new(), + }; + let out = html(&[page], "Clean"); + assert!(out.contains("

Hello

"), "{out}"); + assert!(!out.contains(""), "{out}"); + assert!(!out.contains(""), "{out}"); + assert_eq!(out.matches(" Visible

\n") + ); + } + + #[test] + fn heading_level_is_clamped_and_empty_styled_cells_stay_clean() { + let b = block("Deep Heading", 72.0, 700.0, 200.0, 712.0); + let runs = [Run { text: "Deep Heading".into(), block: &b }]; + assert_eq!(para_html(&runs, 9, true).as_deref(), Some("
Deep Heading
\n")); + + let table = Table { + rect: [0.0, 0.0, 100.0, 20.0], + cols: vec![0.0, 50.0], + rows: vec![vec![ + Cell { text: "\u{1}".into(), size: 11.0, bold: true, italic: true, color: [0.0; 3], span: 1 }, + Cell { text: "Val".into(), size: 11.0, bold: true, italic: false, color: [0.0; 3], span: 1 }, + ]], + ruled: None, + }; + assert_eq!(table_html(&table, true), "\n\n
Val
\n"); + } +} diff --git a/crates/filters/src/lib.rs b/crates/filters/src/lib.rs index 9342213577..4bee6b451c 100644 --- a/crates/filters/src/lib.rs +++ b/crates/filters/src/lib.rs @@ -212,6 +212,18 @@ pub fn decode_tolerant_counted(chain: &[(Filter, Params)], data: &[u8], max_outp run(chain, data, max_output, Some(max_output), false, false, None) } +/// [`decode_tolerant_counted`] with the two bounds apart: `max_stage` bounds every stage, as +/// [`decode_tolerant`]'s bound does, and `max_output` the bytes all stages decode together. A +/// stage stopped by either gives [`FilterError::LimitExceeded`] and the bytes decoded before it. +pub fn decode_tolerant_counted_each( + chain: &[(Filter, Params)], + data: &[u8], + max_stage: usize, + max_output: usize, +) -> Result<(Vec, bool, usize), (FilterError, usize)> { + run(chain, data, max_stage, Some(max_output), false, false, None) +} + /// [`decode_tolerant`], spending from `work`, what decoding may still cost across a run of images: /// a step for each byte of `data`, paid before anything is decoded, [`FLATE_READ_WORK`] for each /// byte a Flate stage reads, paid before it runs (again if it reads them twice), and one for each @@ -451,6 +463,30 @@ mod tests { assert_eq!(super::decode(&chain(super::MAX_CHAIN), &data, 1 << 20).ok(), Some(data)); } + /// [`decode_tolerant_counted_each`] holds every stage to its own bound as well as the stages + /// to their total: a Flate stage that would make more than that bound stops there, though + /// the total has room, and the stage after it gets nothing; within both, the chain reads + /// whole, one stage's output charged on top of another's. + #[test] + fn each_stage_is_held_to_its_own_bound_within_the_total() { + let hex = "00".repeat(1 << 16).into_bytes(); + let data = super::flate::encode(&hex); + let chain = [(Filter::Flate, Params::default()), (Filter::AsciiHex, Params::default())]; + // The total alone lets the Flate stage make all of the hex text, and the hex stage read it. + STAGE_BYTES.with(|c| c.set(0)); + let whole = decode_tolerant_counted(&chain, &data, 3 << 16).map(|(v, _, _)| v); + assert_eq!(whole.ok(), Some(vec![0u8; 1 << 16])); + assert_eq!(STAGE_BYTES.with(|c| c.get()), data.len() + hex.len()); + // Each stage held to 64 KiB, the Flate stage stops there and the hex stage gets nothing. + STAGE_BYTES.with(|c| c.set(0)); + let held = decode_tolerant_counted_each(&chain, &data, 1 << 16, 3 << 16); + assert!(matches!(held, Err((FilterError::LimitExceeded(m), 0)) if m == 1 << 16), "{held:?}"); + assert_eq!(STAGE_BYTES.with(|c| c.get()), data.len()); + // Within both, it reads whole. + let (out, _, spent) = decode_tolerant_counted_each(&chain, &data, 2 << 16, 3 << 16).unwrap(); + assert_eq!((out.len(), spent), (1 << 16, hex.len() + (1 << 16))); + } + use super::*; #[test] diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 9d8d0d6b85..f68761e02e 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -14,7 +14,7 @@ use std::borrow::Cow; use std::collections::{BinaryHeap, HashMap, HashSet}; -use pdfcraft_cos::{DecodeBudget, Dict, Document, Object}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, Object, Stream}; /// How the bytes of a string map to codes. #[derive(Clone, Debug, PartialEq)] @@ -67,6 +67,11 @@ pub struct Metrics { /// Subset CIDFontType2 only: one bit per CID (below 65 536) whose glyph has an outline in the /// embedded program. See [`cids_with_outlines`]. outlined_cids: Vec, + /// A stream these metrics come from (a CMap, the program, `/CIDToGIDMap`, a Type 3 glyph + /// description) was left unread because the decode allowance [`Metrics::from_dict_within`] + /// was given was spent: what it would have decided (code lengths, CIDs, widths, meanings, a + /// Type 3 font's height) is a guess. + pub past_allowance: bool, } fn nums(doc: &Document, o: Option<&Object>) -> Vec { @@ -79,6 +84,12 @@ fn dict(doc: &Document, o: Option<&Object>) -> Option { o.and_then(|o| doc.resolve(o).as_dict().cloned()) } +/// Whether `font`'s `/Encoding` is the name Identity-H, given directly or through a reference, +/// as [`Metrics::from_dict`] reads it (a Type 0 font's `/Encoding` may be indirect). +fn identity_h(doc: &Document, font: &Dict) -> bool { + font.get(b"Encoding").is_some_and(|e| doc.resolve(e).as_name() == Some(b"Identity-H".as_slice())) +} + /// Whether `base_font` (subset tag allowed) names a legacy Indic font family that is laid out in /// visual order (Kruti Dev, DevLys, Chanakya, Shusha, Shivaji, Walkman, Akruti, APS-DV, DV-TT…). fn legacy_indic_font(base_font: &str) -> bool { @@ -92,14 +103,17 @@ fn legacy_indic_font(base_font: &str) -> bool { /// A source PDF's sfnt program for an in-memory editor. Never a bundled font or an export /// fallback. Type 1/Type 3 programs cannot be consumed by an sfnt text renderer. pub fn embedded_sfnt(doc: &Document, font: &Dict) -> Option> { - embedded_sfnt_within(doc, font, &mut DecodeBudget::new(2 * MAX_SFNT, 0)) + // No total: one font's two programs, and the metrics read that may adapt one, are each + // bounded by that stream's own limit. + embedded_sfnt_within(doc, font, &mut DecodeBudget::new(usize::MAX, 0)) } /// The largest embedded sfnt program read for the editor. const MAX_SFNT: usize = 16 << 20; /// [`embedded_sfnt`], decoding against `budget`, which an enumeration of a page's fonts shares: -/// each program decoded is charged whether or not it turns out to be usable. +/// each program decoded is charged whether or not it turns out to be usable, and so are the +/// streams of the metrics read that adapts a program without a Unicode cmap. pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudget) -> Option> { use skrifa::{MetadataProvider as _, raw::TableProvider as _}; let descendant = dict(doc, font.get(b"DescendantFonts").and_then(|o| doc.resolve(o).as_array().and_then(|a| a.first()).cloned()).as_ref()); @@ -108,7 +122,10 @@ pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudg let Some(object) = descriptor.get(key) else { continue }; let object = doc.resolve(object); let Object::Stream(stream) = object.as_ref() else { continue }; - let Ok(mut bytes) = budget.decode_within(&doc.with_direct_parms(stream), MAX_SFNT) else { continue }; + // As in `Reads::decode`: each stage within the limit, the program, the last, too, read + // through a view of the stream; once the allowance is spent a program with data is refused + // before its parameters are resolved. + let Ok(mut bytes) = budget.decode_stages_within(doc, stream, MAX_SFNT) else { continue }; let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue }; if face.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()).units_per_em == 0 { continue; @@ -120,13 +137,32 @@ pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudg if own_cmap && !base_font.rsplit('+').next().is_some_and(|f| f.starts_with("THSarabunPSK")) { return Some(bytes); } + // A composite font's cmap is rebuilt only through Identity-H codes (`composite_glyph_maps`): + // with any other `/Encoding` nothing is, so its metrics aren't read, and a program with a + // cmap of its own is kept as it is, as when the rebuild finds nothing. + if font.name(b"Subtype") == Some(b"Type0") && !identity_h(doc, font) { + if own_cmap { + return Some(bytes); + } + continue; + } // PDF subsets may remove the Unicode cmap entirely. Rebuild an editor-only // cmap from ToUnicode and the actual CID-to-glyph map; source bytes stay unchanged. - let metrics = Metrics::from_dict(doc, font); + // Metrics read past the allowance, its widths' read of the program again included, + // adapt no copy. + let metrics = Metrics::from_dict_within(doc, font, budget); + if metrics.past_allowance { + continue; + } let mut mappings = std::collections::BTreeMap::new(); if metrics.composite { + let refused = budget.refused(); if let Some((map, _)) = composite_glyph_maps(doc, font, &bytes, &metrics, budget) { mappings = map; + } else if budget.refused() > refused { + // The map the cmap is rebuilt from was left unread for want of the allowance: no + // copy is listed, adapted or as it is, as for metrics read past it. + continue; } if own_cmap { let charmap = face.charmap(); @@ -166,7 +202,7 @@ pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudg /// The CIDs (below 65 536) of a Type 0 font over an embedded TrueType program whose glyph has /// outline data, through `/CIDToGIDMap`; empty when the program or the map can't be read. /// Subsetters keep glyph ids and empty the glyphs they drop, so an empty glyph is a missing one. -fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec { +fn cids_with_outlines(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Vec { use skrifa::raw::TableProvider as _; let Some(descendant) = dict(doc, font.get(b"DescendantFonts").and_then(|o| doc.resolve(o).as_array().and_then(|a| a.first()).cloned()).as_ref()) else { @@ -175,13 +211,13 @@ fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec { let Some(program) = dict(doc, descendant.get(b"FontDescriptor")).and_then(|d| d.get(b"FontFile2").cloned()) else { return Vec::new() }; let program = doc.resolve(&program); let Object::Stream(stream) = program.as_ref() else { return Vec::new() }; - let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(16 << 20) else { return Vec::new() }; + let Some(bytes) = reads.decode(stream, MAX_SFNT) else { return Vec::new() }; let Some(loca) = skrifa::FontRef::from_index(&bytes, 0).ok().and_then(|face| face.loca(None).ok()) else { return Vec::new() }; let outlined = |gid: u32| loca.get_raw(gid as usize).zip(loca.get_raw(gid as usize + 1)).is_some_and(|(start, end)| end > start); match descendant.get(b"CIDToGIDMap").map(|o| doc.resolve(o)).as_deref() { None | Some(Object::Name(_)) => bit_set_below_64k((0..loca.len().min(0x1_0000) as u32).filter(|&gid| outlined(gid))), Some(Object::Stream(map)) => { - let Ok(map) = doc.with_direct_parms(map).decoded_within(1 << 17) else { return Vec::new() }; + let Some(map) = reads.decode(map, MAX_CID_TO_GID) else { return Vec::new() }; bit_set_below_64k( map.as_chunks::<2>() .0 @@ -200,7 +236,7 @@ fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec { /// wider and leave the end of a line outside a redaction mark (#2130). The glyph is looked up /// the way the renderer does: the encoding's character in the Unicode cmap, else the code itself /// (as in a symbolic cmap), else `.notdef`. Only sfnt programs are read; others keep the guess. -fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics) { +fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics, reads: &mut Reads<'_>) { use skrifa::instance::{LocationRef, Size}; use skrifa::{GlyphId, MetadataProvider as _, raw::TableProvider as _}; let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return }; @@ -208,7 +244,7 @@ fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics) { let Some(object) = descriptor.get(key) else { continue }; let object = doc.resolve(object); let Object::Stream(stream) = object.as_ref() else { continue }; - let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(16 << 20) else { continue }; + let Some(bytes) = reads.decode(stream, MAX_SFNT) else { continue }; let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue }; let upem = f64::from(face.metrics(Size::unscaled(), LocationRef::default()).units_per_em); if upem == 0.0 { @@ -354,8 +390,48 @@ const MAX_CMAP_ENTRIES: usize = 1 << 20; /// thousand. const MAX_CMAP_RANGES: usize = 1 << 16; /// Largest decoded Encoding or ToUnicode CMap stream read; real CMaps stay well under it, and a larger -/// one is not decoded at all. +/// one is left unread (behind one filter not decoded past it; behind several, see `Reads::decode`). const MAX_CMAP_BYTES: usize = 16 << 20; +/// Largest decoded `/CIDToGIDMap` stream read: two bytes for each CID below 65,536. +const MAX_CID_TO_GID: usize = 1 << 17; + +/// The streams one reading of a font decodes, against the caller's allowance, and whether one was +/// left unread because that allowance was spent. +struct Reads<'a> { + doc: &'a Document, + budget: &'a mut DecodeBudget, + refused: bool, +} + +impl<'a> Reads<'a> { + fn new(doc: &'a Document, budget: &'a mut DecodeBudget) -> Self { + Self { doc, budget, refused: false } + } + + /// `stream` decoded within `max` and the allowance, its `/DecodeParms` read where they are given + /// by reference too, through a view of the stream that copies no more of its dictionary than + /// its filters and parameters; `None` when it can't be, noting when the allowance is why. + /// + /// Each of its filter stages is held to `max`, as a decode without an allowance holds it, and + /// the stages together to `max` for each of them ([`DecodeBudget::decode_stages_within`]): a + /// map of 65,536 CIDs behind ASCIIHex and Flate, its Flate data smaller than the map, makes + /// all of `MAX_CID_TO_GID` in its last stage, with the hex stage's output on top. So a stream + /// reads here when it reads without an allowance and what is left pays for it. A stream with a + /// stage past `max` is unread. When what is left is more than what the stages before that one + /// made and `max`, it is charged those two and isn't refused; otherwise, with more left than + /// the stages' total (behind one filter, `max`), it is charged that total and isn't refused; + /// with no more left than that total, it spends all of it and counts as refused. + fn decode(&mut self, stream: &Stream, max: usize) -> Option> { + match self.budget.decode_stages_within(self.doc, stream, max) { + Ok(data) => Some(data), + Err(_) => { + self.refused |= self.budget.remaining() == 0; + None + } + } + } +} + /// Most Type 3 glyph descriptions read for their `d1` box. A simple font shows at most 256 codes. const MAX_TYPE3_GLYPHS: usize = 256; /// Largest decoded Type 3 glyph description read for its `d1` box; a larger one is skipped. @@ -372,8 +448,8 @@ const MAX_TYPE3_EXTENT: f64 = 1e6; /// no glyph declares one (`d0` glyphs don't). The descriptions the encoding's `/Differences` /// names are chosen, since it "shall specify the complete character encoding" (Table 110); only a /// font without one has its first descriptions chosen. Reads at most [`MAX_TYPE3_GLYPHS`] -/// descriptions within [`MAX_TYPE3_FONT_BYTES`]. -fn type3_glyph_box(doc: &Document, font: &Dict) -> Option<([f64; 4], bool)> { +/// descriptions within [`MAX_TYPE3_FONT_BYTES`], each charged to the reading's allowance too. +fn type3_glyph_box(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Option<([f64; 4], bool)> { let procs = dict(doc, font.get(b"CharProcs"))?; let mut names: Vec<&[u8]> = Vec::new(); // Whether every glyph that can be drawn is read; if not, the union may be short of them. @@ -401,12 +477,30 @@ fn type3_glyph_box(doc: &Document, font: &Dict) -> Option<([f64; 4], bool)> { } else { names.iter().filter_map(|n| procs.get(n)).collect() }; - let mut budget = DecodeBudget::new(MAX_TYPE3_FONT_BYTES, 0); + // What is left of the font's own total: the descriptions decode against the reading's + // allowance, which charges them, and each within what is left of this. + let mut left = MAX_TYPE3_FONT_BYTES; let mut union: Option<[f64; 4]> = None; for proc in chosen { let Object::Stream(s) = &*doc.resolve(proc) else { continue }; - let Ok(data) = budget.decode_within(&doc.with_direct_parms(s), MAX_TYPE3_GLYPH_BYTES) else { - if budget.remaining() == 0 { + // Once the reading's allowance is spent the rest go unread, before their parameters are + // resolved. + if reads.budget.refuses(s) { + reads.refused = true; + complete = false; + break; + } + let before = reads.budget.remaining(); + let read = reads.budget.decode_with_damage(doc, s, MAX_TYPE3_GLYPH_BYTES.min(left)).map(|(data, _)| data); + left = left.saturating_sub(before.saturating_sub(reads.budget.remaining())); + let Ok(data) = read else { + if reads.budget.remaining() == 0 { + // The rest go unread too, for want of the reading's allowance. + reads.refused = true; + complete = false; + break; + } + if left == 0 { // The rest go unread too. complete = false; break; @@ -785,11 +879,25 @@ impl Metrics { code_len: 1, explicit_reverse: (32..127u8).map(|c| (char::from(c), u32::from(c))).collect(), outlined_cids: Vec::new(), + past_allowance: false, } } + /// The metrics of `font`. Each stream they come from decodes within its own limit (16 MiB for + /// a CMap or a program, 4 MiB for a Type 3 font's glyph descriptions together), and again on + /// every call: a caller that reads many fonts, or one font many times, shares an allowance + /// through [`Metrics::from_dict_within`]. pub fn from_dict(doc: &Document, font: &Dict) -> Self { - let mut m = Self::read_metrics(doc, font); + // No total, so nothing is refused: each stream stays within its own limit, as it always has. + Self::from_dict_within(doc, font, &mut DecodeBudget::new(usize::MAX, 0)) + } + + /// [`Metrics::from_dict`], decoding against `budget`, which the caller shares across the fonts + /// it reads (a page's, say): each stream pays for what it decodes, usable or not. A stream the + /// allowance can't pay for is left unread, and [`Metrics::past_allowance`] says so. + pub fn from_dict_within(doc: &Document, font: &Dict, budget: &mut DecodeBudget) -> Self { + let mut reads = Reads::new(doc, budget); + let mut m = Self::read_metrics(doc, font, &mut reads); let base = font .name(b"BaseFont") .map(|name| String::from_utf8_lossy(name).into_owned()) @@ -806,17 +914,18 @@ impl Metrics { m.italic |= italic; m.base_font = base; if m.composite && m.subset { - m.outlined_cids = cids_with_outlines(doc, font); + m.outlined_cids = cids_with_outlines(doc, font, &mut reads); } m.code_len = match &m.codes { Codes::One => 1, Codes::Two => 2, Codes::Ranges(r) => r.last().map_or(2, |x| x.0), }; - m.unicode = unicode_map(doc, font, m.composite); + m.unicode = unicode_map(doc, font, m.composite, &mut reads); if m.std14.is_some() { - embedded_widths(doc, font, &mut m); + embedded_widths(doc, font, &mut m, &mut reads); } + m.past_allowance = reads.refused; let mut reverse = HashMap::new(); for (&code, text) in m.unicode.explicit.iter().map(|(c, (_, t))| (c, t)).chain(m.unicode.encoding.iter()) { if let Some(ch) = only_char(text) @@ -837,7 +946,7 @@ impl Metrics { m } - fn read_metrics(doc: &Document, font: &Dict) -> Self { + fn read_metrics(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Self { let mut m = Metrics::fallback(); m.std14 = None; let subtype = font.name(b"Subtype").unwrap_or(b"Type1").to_vec(); @@ -850,7 +959,7 @@ impl Metrics { m.codes = Codes::Two; if let Some(e) = font.get(b"Encoding").map(|e| doc.resolve(e)) && let Object::Stream(s) = &*e - && let Ok(data) = doc.with_direct_parms(s).decoded_within(MAX_CMAP_BYTES) + && let Some(data) = reads.decode(s, MAX_CMAP_BYTES) { let (spaces, cids) = parse_cmap(&data); if !spaces.is_empty() { @@ -915,7 +1024,7 @@ impl Metrics { // A union cut short by a limit may miss glyphs that are drawn, so a nonzero // `/FontBBox` comes first then; all zero, the partial union is still better // than no box. A box with no height in text space gives way to the next. - let glyphs = type3_glyph_box(doc, font); + let glyphs = type3_glyph_box(doc, font, reads); // All zero: no assumption about glyph sizes (Table 110). let bbox = <[f64; 4]>::try_from(nums(doc, font.get(b"FontBBox"))).ok().filter(|r| r.iter().any(|&v| v != 0.0)); let union = |complete: bool| glyphs.filter(|g| g.1 == complete).map(|(r, _)| (r, 0.0, 0.0)); @@ -1088,8 +1197,14 @@ impl Metrics { } /// The bytes that show `text` in this font, or `None` if some character has no code or - /// no glyph in it (the caller then substitutes another font). + /// no glyph in it, or the metrics were read past their allowance (the caller then substitutes + /// another font). Empty text needs no verified glyph and always encodes. pub fn encode(&self, text: &str) -> Option> { + // A refused read cannot establish the mappings or glyphs needed to write new text. + // Empty text needs neither and remains usable for erasing existing text. + if self.past_allowance && !text.is_empty() { + return None; + } // The lowest code showing each wanted character. Explicit entries and the encoding are // checked one by one; each range is asked for the wanted characters it can show. let wanted: HashSet = text.chars().collect(); @@ -1432,7 +1547,7 @@ pub fn glyph_unicode(name: &str) -> Option { /// to [`symbolic_mac_roman_texts`]). A code whose glyph has no name with a Unicode value reads /// as WinAnsi, as the renderer reads it (vendored hayro-interpret patch (79)), so the editor and /// extraction agree: `€` for code 0x80, which Standard leaves without a character. -fn symbolic_glyph_texts(doc: &Document, font: &Dict) -> Vec<(u32, String)> { +fn symbolic_glyph_texts(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Vec<(u32, String)> { use skrifa::GlyphId; use skrifa::raw::{FontData, FontRead as _, TableProvider as _, tables::cmap::PlatformId, tables::post::Post, types::Tag}; let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return Vec::new() }; @@ -1443,7 +1558,7 @@ fn symbolic_glyph_texts(doc: &Document, font: &Dict) -> Vec<(u32, String)> { let Some(object) = descriptor.get(b"FontFile2") else { return Vec::new() }; let object = doc.resolve(object); let Object::Stream(stream) = object.as_ref() else { return Vec::new() }; - let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(MAX_SFNT) else { return Vec::new() }; + let Some(bytes) = reads.decode(stream, MAX_SFNT) else { return Vec::new() }; let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { return Vec::new() }; let Ok(cmap) = face.cmap() else { return Vec::new() }; // A `post` version written with its 16-bit halves swapped (`00 00 00 02` for 2.0, as ScanSoft @@ -1534,7 +1649,7 @@ fn post_name<'a>(post: &skrifa::raw::tables::post::Post<'a>, custom: &[&'a str], /// Code → Unicode for a font: its ToUnicode CMap (for a composite font, also the name /// `/Identity-H` or `/Identity-V`, read as code = Unicode), else (simple fonts) its encoding. -fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode { +fn unicode_map(doc: &Document, font: &Dict, composite: bool, reads: &mut Reads<'_>) -> Unicode { let mut out = Unicode::default(); if !composite { let enc = font.get(b"Encoding").map(|e| doc.resolve(e)); @@ -1579,8 +1694,8 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode { // A font whose `post` table names its glyphs reads by those names; one that names none // and keeps the Macintosh order reads as Mac OS Roman, over the WinAnsi fallback. if base_name.is_none() && !symbolic_builtin { - out.encoding.extend(symbolic_glyph_texts(doc, font)); - out.encoding.extend(symbolic_mac_roman_texts(doc, font)); + out.encoding.extend(symbolic_glyph_texts(doc, font, reads)); + out.encoding.extend(symbolic_mac_roman_texts(doc, font, reads)); } if let Some(Object::Dict(d)) = enc.as_deref() && let Some(diffs) = d.get(b"Differences").map(|x| doc.resolve(x)).and_then(|x| x.as_array().cloned()) @@ -1606,10 +1721,8 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode { } match font.get(b"ToUnicode").map(|t| doc.resolve(t)).as_deref() { Some(Object::Stream(s)) => { - // With `/DecodeParms` given by reference, as every stream of the font is read here, - // and as the redaction proof reads the map: the two must get the same text, or a copy - // redaction rightly left counts as a survivor. - if let Ok(data) = doc.with_direct_parms(s).decoded_within(MAX_CMAP_BYTES) { + // Reads resolves indirect parameters too, matching the redaction proof's text. + if let Some(data) = reads.decode(s, MAX_CMAP_BYTES) { parse_to_unicode(&data, &mut out); } } @@ -1657,7 +1770,7 @@ fn identity_to_unicode() -> Vec { /// renderer finds it: the Windows (3, 0) and (3, 1) subtables first, with the code and the code at /// 0xF000, 0xF100 and 0xF200, then the Macintosh and Unicode-platform ones. The renderer's text /// layer reads the same codes the same way (vendored hayro-interpret patch (101)). -fn symbolic_mac_roman_texts(doc: &Document, font: &Dict) -> Vec<(u32, String)> { +fn symbolic_mac_roman_texts(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Vec<(u32, String)> { use skrifa::GlyphId; use skrifa::raw::{TableProvider as _, tables::cmap::PlatformId, tables::post::DEFAULT_GLYPH_NAMES, types::Tag}; let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return Vec::new() }; @@ -1668,7 +1781,7 @@ fn symbolic_mac_roman_texts(doc: &Document, font: &Dict) -> Vec<(u32, String)> { let Some(object) = descriptor.get(b"FontFile2") else { return Vec::new() }; let object = doc.resolve(object); let Object::Stream(stream) = object.as_ref() else { return Vec::new() }; - let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(MAX_SFNT) else { return Vec::new() }; + let Some(bytes) = reads.decode(stream, MAX_SFNT) else { return Vec::new() }; let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { return Vec::new() }; // A CFF program in `/FontFile2` selects its glyphs by name, not through the `cmap`. A `post` // table of version 1.0 or 2.0 (also with its 16-bit halves swapped, as the renderer reads it) diff --git a/crates/fonts/src/pdf/tests.rs b/crates/fonts/src/pdf/tests.rs index 2d95465944..975b71289e 100644 --- a/crates/fonts/src/pdf/tests.rs +++ b/crates/fonts/src/pdf/tests.rs @@ -178,9 +178,10 @@ fn a_font_without_widths_measures_with_its_embedded_program() { } } -#[test] -fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() { - use skrifa::{MetadataProvider as _, raw::TableProvider as _}; +/// Inter with its cmap replaced by a Mac format-6 one mapping code 65 to Ω's glyph, which egui +/// can't use; and that glyph. +fn mac_subset() -> (Vec, skrifa::GlyphId) { + use skrifa::MetadataProvider as _; let original = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); let face = skrifa::FontRef::new(original).unwrap(); let expected = face.charmap().map('Ω').unwrap(); @@ -194,6 +195,13 @@ fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() { subset[entry + 8..entry + 12].copy_from_slice(&offset.to_be_bytes()); subset[entry + 12..entry + 16].copy_from_slice(&(cmap.len() as u32).to_be_bytes()); subset.extend_from_slice(&cmap); + (subset, expected) +} + +#[test] +fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() { + use skrifa::{MetadataProvider as _, raw::TableProvider as _}; + let (subset, expected) = mac_subset(); let face = skrifa::FontRef::new(&subset).unwrap(); assert!(!face.charmap().has_map(), "egui cannot directly use Mac format 6"); assert_eq!(face.cmap().unwrap().map_codepoint(65_u32), Some(expected)); @@ -214,6 +222,331 @@ fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() { assert!(append_editor_cmap(&mut broken, &std::collections::BTreeMap::from([(65, 1)])).is_none()); } +/// The metrics read that adapts a program's cmap is paid for from the editor's allowance too: a +/// step short of the program, its ToUnicode and the program again for its widths and for the +/// readings of its codes by glyph name and as Mac OS Roman, the metrics are read past the allowance +/// and no copy is adapted. +#[test] +fn adapting_a_programs_cmap_is_paid_for() { + use skrifa::MetadataProvider as _; + let (subset, expected) = mac_subset(); + let mut doc = Document::new_empty(); + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), &subset))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let cmap = b"1 beginbfchar <41> <03A9> endbfchar"; + let unicode = doc.add(Object::Stream(Stream::flate(Dict::new(), cmap))); + let source = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor)), ("ToUnicode", Object::Ref(unicode))]); + let cost = 4 * subset.len() + cmap.len(); + let mut budget = DecodeBudget::new(cost, 0); + let editor = embedded_sfnt_within(&doc, &source, &mut budget).unwrap(); + assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected)); + assert_eq!(budget.remaining(), 0); + assert!(embedded_sfnt_within(&doc, &source, &mut DecodeBudget::new(cost - 1, 0)).is_none()); + // A composite font whose codes aren't Identity-H can't be adapted, so its metrics aren't + // read: the program is all it pays for. + let mut budget = DecodeBudget::new(cost, 0); + let mut composite = source.clone(); + composite.set(b"Subtype".to_vec(), Object::name("Type0")); + assert!(embedded_sfnt_within(&doc, &composite, &mut budget).is_none()); + assert_eq!(budget.remaining(), cost - subset.len()); +} + +/// A Type 0 subset font whose `/Encoding`, program, `/CIDToGIDMap` and ToUnicode are streams, and +/// what each decodes to. +fn subset_type0(doc: &mut Document, to_unicode: &[u8]) -> (Dict, [usize; 4]) { + let encoding = b"1 begincodespacerange <0000> endcodespacerange"; + let program = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); + let map = [0u8, 0, 0, 1, 0, 2]; + let mut stream = |data: &[u8]| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), data)))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), stream(program)); + let mut descendant = Dict::new(); + descendant.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + descendant.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + descendant.set(b"CIDToGIDMap".to_vec(), stream(&map)); + let entries = vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("ABCDEF+Inter")), + ("Encoding", stream(encoding)), + ("DescendantFonts", Object::Array(vec![Object::Dict(descendant)])), + ("ToUnicode", stream(to_unicode)), + ]; + (font(doc, entries), [encoding.len(), program.len(), map.len(), to_unicode.len()]) +} + +/// Each stream a font's metrics come from is paid for from the allowance they are read within, +/// down to the byte; a stream it can't pay for is left unread, and the metrics say so. +#[test] +fn a_fonts_streams_are_paid_for_from_its_allowance() { + let mut doc = Document::new_empty(); + let (f, sizes) = subset_type0(&mut doc, b"1 beginbfchar <0001> <0058> endbfchar"); + let cost: usize = sizes.iter().sum(); + let whole = Metrics::from_dict(&doc, &f); + assert!(!whole.past_allowance); + assert_eq!((whole.code_len, whole.text_of(1).as_deref()), (2, Some("X"))); + assert!(!whole.outlined_cids.is_empty(), "the program and its map were read"); + let mut budget = DecodeBudget::new(cost, 0); + assert_eq!(Metrics::from_dict_within(&doc, &f, &mut budget), whole); + assert_eq!(budget.remaining(), 0); + // A byte short, the ToUnicode, read last, is left unread; what came before is kept. + let mut budget = DecodeBudget::new(cost - 1, 0); + let m = Metrics::from_dict_within(&doc, &f, &mut budget); + assert!(m.past_allowance); + assert_eq!((m.code_len, m.text_of(1)), (2, None)); + assert_eq!(m.outlined_cids, whole.outlined_cids); + // With nothing left, none of them is read. + let m = Metrics::from_dict_within(&doc, &f, &mut budget); + assert!(m.past_allowance && m.outlined_cids.is_empty()); + assert_eq!(m.codes, Codes::Two, "the Encoding CMap was left unread"); + // Only a subset's program says which glyphs it lacks: the whole font pays for its CMaps alone. + let mut whole_font = f.clone(); + whole_font.set(b"BaseFont".to_vec(), Object::name("Inter")); + let mut budget = DecodeBudget::new(cost, 0); + let m = Metrics::from_dict_within(&doc, &whole_font, &mut budget); + assert!(!m.past_allowance && m.outlined_cids.is_empty()); + assert_eq!(cost - budget.remaining(), sizes[0] + sizes[3]); +} + +/// With nothing left, an empty stream still reads, as the allowance reads one, and is no reason to +/// call the font read past it; a stream with data is left unread, and is. +#[test] +fn an_empty_stream_reads_with_nothing_left() { + let past = |data: &[u8]| { + let mut doc = Document::new_empty(); + let unicode = Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), data.to_vec())))); + let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("ToUnicode", unicode)]); + Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(0, 0)).past_allowance + }; + assert!(!past(b"")); + assert!(past(b"1 beginbfchar <41> <0058> endbfchar")); +} + +/// A stream behind one filter past its own limit, with more left than its limit, is left unread as +/// it always was, paying for the limit, and is no reason to call the font read past the allowance. +#[test] +fn a_cmap_past_its_own_limit_is_not_past_the_allowance() { + let mut doc = Document::new_empty(); + let mut cmap = b"1 beginbfchar <0001> <0058> endbfchar".to_vec(); + cmap.resize(MAX_CMAP_BYTES + 1, b' '); + let (f, sizes) = subset_type0(&mut doc, &cmap); + let mut budget = DecodeBudget::new(usize::MAX / 2, 0); + let m = Metrics::from_dict_within(&doc, &f, &mut budget); + assert!(!m.past_allowance); + assert_eq!(m.text_of(1), None); + assert_eq!(usize::MAX / 2 - budget.remaining(), sizes[..3].iter().sum::() + MAX_CMAP_BYTES); +} + +/// A `/CIDToGIDMap` as long as a map can be, which compresses well, reads the same behind no +/// filter, Flate, or ASCIIHex and Flate: each stage may make as much as the map's limit, so the hex +/// stage's output on top of it doesn't leave it unread. A map past the limit is unread behind each. +#[test] +fn a_full_cid_map_reads_behind_one_or_two_filters() { + let read = |entries: usize, filters: usize| { + let mut doc = Document::new_empty(); + let (mut f, _) = subset_type0(&mut doc, b""); + let map: Vec = (0..entries).flat_map(|cid| u16::try_from(cid % 2000).unwrap().to_be_bytes()).collect(); + let flate = Stream::flate(Dict::new(), &map); + let stream = match filters { + 0 => Stream::from_raw(Dict::new(), map), + 1 => flate, + _ => { + let hex: String = flate.raw.iter().map(|b| format!("{b:02X}")).chain([">".to_string()]).collect(); + 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.into_bytes()) + } + }; + let map = Object::Ref(doc.add(Object::Stream(stream))); + let Some(Object::Array(mut descendants)) = f.get(b"DescendantFonts").cloned() else { panic!("descendant") }; + if let Some(Object::Dict(d)) = descendants.get_mut(0) { + d.set(b"CIDToGIDMap".to_vec(), map); + } + f.set(b"DescendantFonts".to_vec(), Object::Array(descendants)); + Metrics::from_dict(&doc, &f).outlined_cids + }; + let full = read(1 << 16, 1); + assert!(!full.is_empty()); + assert_eq!(read(1 << 16, 0), full); + assert_eq!(read(1 << 16, 2), full); + for filters in 0..=2 { + assert!(read((1 << 16) + 1, filters).is_empty(), "{filters} filters"); + } +} + +/// The editor's program read holds every filter stage to the program's limit: a program behind +/// ASCIIHex and Flate whose Flate stage would make 24 MiB stops at 16 MiB, charged that and what +/// the hex stage made, where the stages' total alone would let it make all 24 MiB. A program +/// within the limit behind the same two filters reads (see also +/// `an_editors_program_reads_behind_ascii_hex_and_flate_within_its_limit`). +#[test] +fn an_editors_program_read_holds_each_stage_to_its_limit() { + let mut doc = Document::new_empty(); + let flate = Stream::flate(Dict::new(), &vec![b' '; 24 << 20]); + let hex: Vec = flate.raw.iter().flat_map(|b| format!("{b:02X}").into_bytes()).chain(*b">").collect(); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")])); + let program = doc.add(Object::Stream(Stream::from_raw(dict, hex))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]); + let mut budget = DecodeBudget::new(usize::MAX / 2, 0); + assert!(embedded_sfnt_within(&doc, &f, &mut budget).is_none()); + assert_eq!(usize::MAX / 2 - budget.remaining(), flate.raw.len() + MAX_SFNT); +} + +/// Each stage of a stream a font's metrics are read from is held to the stream's limit, as a +/// decode without an allowance holds it: a `/CIDToGIDMap` behind Flate and ASCIIHex whose Flate +/// stage makes hex text past the map's limit is unread, as `Stream::decoded_within` leaves it, +/// though the two stages together make less than twice the limit; with its hex text within the +/// limit it reads. +#[test] +fn each_stage_of_a_metrics_stream_is_held_to_its_limit() { + let read = |cids: usize| { + let mut doc = Document::new_empty(); + let (mut f, _) = subset_type0(&mut doc, b""); + let map: Vec = (0..cids).flat_map(|cid| u16::try_from(cid % 2000).unwrap().to_be_bytes()).collect(); + let hex: Vec = map.iter().flat_map(|b| format!("{b:02X}").into_bytes()).collect(); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("ASCIIHexDecode")])); + let stream = Stream::from_raw(dict, Stream::flate(Dict::new(), &hex).raw.to_vec()); + let unread = stream.decoded_within(MAX_CID_TO_GID).is_err(); + let map = Object::Ref(doc.add(Object::Stream(stream))); + let Some(Object::Array(mut descendants)) = f.get(b"DescendantFonts").cloned() else { panic!("descendant") }; + if let Some(Object::Dict(d)) = descendants.get_mut(0) { + d.set(b"CIDToGIDMap".to_vec(), map); + } + f.set(b"DescendantFonts".to_vec(), Object::Array(descendants)); + (Metrics::from_dict(&doc, &f).outlined_cids.is_empty(), unread) + }; + // 0.6 of the limit: its hex text, 1.2 of it, is past the limit; the stages make 1.8 of it. + assert_eq!(read(MAX_CID_TO_GID * 6 / 20), (true, true)); + assert_eq!(read(MAX_CID_TO_GID / 4), (false, false)); +} + +/// The editor's program is held to its limit as the metrics reads' streams are: a 12 MiB program +/// whose Flate data is about 8 MiB reads behind ASCIIHex and Flate as behind Flate alone, though +/// the two stages make about 20 MiB together. A program of the limit reads behind either, and one a +/// byte past it is unread behind either. +#[test] +fn an_editors_program_reads_behind_ascii_hex_and_flate_within_its_limit() { + let program = |len: usize, noise: usize| { + let mut program = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf").to_vec(); + // Bytes Flate can't shrink, then zeros. + let mut x = 1u32; + program.extend((0..noise).map(|_| { + x = x.wrapping_mul(1_103_515_245).wrapping_add(12_345); + x.to_be_bytes()[0] + })); + program.resize(len, 0); + program + }; + let read = |program: &[u8], hex: bool| { + let mut doc = Document::new_empty(); + let flate = Stream::flate(Dict::new(), program); + let stream = if hex { + let digits = b"0123456789ABCDEF"; + let mut data: Vec = flate.raw.iter().flat_map(|b| [digits[usize::from(b >> 4)], digits[usize::from(b & 15)]]).collect(); + data.push(b'>'); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")])); + Stream::from_raw(dict, data) + } else { + flate + }; + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(doc.add(Object::Stream(stream)))); + let simple = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor))]); + embedded_sfnt(&doc, &simple) + }; + let near = program(12 << 20, 8 << 20); + let full = program(MAX_SFNT, 0); + for hex in [false, true] { + assert_eq!(read(&near, hex).as_deref(), Some(near.as_slice()), "behind ASCIIHex too: {hex}"); + assert_eq!(read(&full, hex).as_deref(), Some(full.as_slice()), "behind ASCIIHex too: {hex}"); + assert!(read(&program(MAX_SFNT + 1, 0), hex).is_none(), "behind ASCIIHex too: {hex}"); + } +} + +/// `data` in rows of `columns` bytes behind Flate and a PNG predictor, each row tagged Up (the +/// difference from the row above), so read without the predictor it is garbled; with its +/// `/DecodeParms` given by reference or direct. +fn predicted(doc: &mut Document, data: &[u8], columns: usize, by_reference: bool) -> Object { + let mut above = vec![0u8; columns]; + let mut rows = Vec::new(); + for row in data.chunks(columns) { + rows.push(2); + rows.extend(row.iter().zip(&above).map(|(b, a)| b.wrapping_sub(*a))); + above[..row.len()].copy_from_slice(row); + } + let mut s = Stream::flate(Dict::new(), &rows); + let parms: Dict = + [(b"Predictor".to_vec(), Object::Int(12)), (b"Columns".to_vec(), Object::Int(i64::try_from(columns).unwrap()))].into_iter().collect(); + let parms = if by_reference { Object::Ref(doc.add(Object::Dict(parms))) } else { Object::Dict(parms) }; + s.dict.set(b"DecodeParms".to_vec(), parms); + Object::Ref(doc.add(Object::Stream(s))) +} + +/// A stream whose `/DecodeParms` are given by reference reads as it does with them direct: a PNG +/// predictor's tag bytes are taken out of the map, the CMap and the editor's program, not left in. +#[test] +fn parameters_given_by_reference_are_read() { + let read = |by_reference: bool| { + let mut doc = Document::new_empty(); + let (mut f, _) = subset_type0(&mut doc, b""); + let map: Vec = (0..1000u16).flat_map(u16::to_be_bytes).collect(); + let map = predicted(&mut doc, &map, 2, by_reference); + let mut cmap = b"1 beginbfchar <0001> <0058> endbfchar".to_vec(); + cmap.resize(48, b' '); + let unicode = predicted(&mut doc, &cmap, 16, by_reference); + let Some(Object::Array(mut descendants)) = f.get(b"DescendantFonts").cloned() else { panic!("descendant") }; + if let Some(Object::Dict(d)) = descendants.get_mut(0) { + d.set(b"CIDToGIDMap".to_vec(), map); + } + f.set(b"DescendantFonts".to_vec(), Object::Array(descendants)); + f.set(b"ToUnicode".to_vec(), unicode); + let m = Metrics::from_dict(&doc, &f); + (m.outlined_cids.clone(), m.text_of(1).map(Cow::into_owned)) + }; + let direct = read(false); + assert!(!direct.0.is_empty()); + assert_eq!(direct.1.as_deref(), Some("X")); + assert_eq!(read(true), direct); + let mut program = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf").to_vec(); + program.resize(program.len().next_multiple_of(4), 0); + for by_reference in [false, true] { + let mut doc = Document::new_empty(); + let file = predicted(&mut doc, &program, 4, by_reference); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), file); + let simple = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor))]); + assert_eq!(embedded_sfnt(&doc, &simple).as_deref(), Some(program.as_slice()), "by reference: {by_reference}"); + } +} + +/// Fonts that each name one large ToUnicode share the allowance they are read within: it pays for +/// as many readings as it holds, and the rest are left unread, where each reading used to decode +/// it afresh. +#[test] +fn fonts_naming_one_cmap_share_one_allowance() { + let mut doc = Document::new_empty(); + let mut cmap = b"1 beginbfchar <41> <0058> endbfchar".to_vec(); + cmap.resize(1_000_000, b' '); + let unicode = doc.add(Object::Stream(Stream::flate(Dict::new(), &cmap))); + let fonts: Vec = (0..100) + .map(|_| { + font(&mut doc, vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Helvetica")), ("ToUnicode", Object::Ref(unicode))]) + }) + .collect(); + let mut budget = DecodeBudget::new(64 << 20, 0); + let read: Vec = fonts.iter().map(|f| Metrics::from_dict_within(&doc, f, &mut budget)).collect(); + let whole = read.iter().filter(|m| !m.past_allowance).count(); + assert_eq!(whole, (64 << 20) / cmap.len()); + assert!(read[..whole].iter().all(|m| m.text_of(0x41).as_deref() == Some("X"))); + assert!(read[whole..].iter().all(|m| m.text_of(0x41).as_deref() == Some("A")), "the encoding's meaning, unread past the allowance"); + assert_eq!(budget.remaining(), 0); +} + /// The names read once give what `Post::glyph_name` gives, for every glyph of a font with its /// own names (Inter's version 2.0 table), and none past the last glyph. #[test] @@ -388,6 +721,21 @@ fn a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names() { ], ); assert_eq!(Metrics::from_dict(&doc, &f).decode(&codes), ""); + // The names are read within the allowance the metrics are read within: the program is paid + // for by the glyph-name reading, by the Mac OS Roman one (which finds the glyphs named and + // reads nothing) and for the widths. A byte short of the first, the names go unread. + let mut doc = Document::new_empty(); + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), &subset))); + let mut descriptor = Dict::new(); + descriptor.set(b"Flags".to_vec(), Object::Int(4)); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]); + let mut budget = DecodeBudget::new(3 * subset.len(), 0); + assert_eq!(Metrics::from_dict_within(&doc, &f, &mut budget), symbolic); + assert_eq!(budget.remaining(), 0); + let short = Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(subset.len() - 1, 0)); + assert!(short.past_allowance); + assert_ne!(short.decode(&codes), TEXT, "the names unread"); } /// The glyph list specification's `uni` and `u` forms spell their hex digits in uppercase @@ -603,6 +951,24 @@ fn type3_glyph_byte_limits_leave_honest_metrics() { assert_eq!((m.ascent, m.descent), (5.0, -2.0), "the /FontBBox, not the first glyph's box alone"); } +/// A Type 3 font's glyph descriptions are paid for from the allowance its metrics are read within, +/// each what it decodes to: one the allowance can't pay for is left unread, and the metrics say so. +#[test] +fn type3_glyph_boxes_are_paid_for_from_the_allowance() { + let mut doc = Document::new_empty(); + let procs = ["1 0 0 -20 1 300 d1", "1 0 0 0 1 400 d1"]; + let f = type3(&mut doc, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], [0, 0, 0, 0], &procs); + let cost: usize = procs.iter().map(|p| p.len()).sum(); + let whole = Metrics::from_dict(&doc, &f); + assert!(!whole.past_allowance); + assert_eq!((whole.ascent, whole.descent), (400.0, -20.0)); + let mut budget = DecodeBudget::new(cost, 0); + assert_eq!(Metrics::from_dict_within(&doc, &f, &mut budget), whole); + assert_eq!(budget.remaining(), 0); + let m = Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(cost - 1, 0)); + assert!(m.past_allowance, "a byte short, the second description is left unread"); +} + /// A Type 3 font measured from its glyphs' `d1` boxes keeps them over a descriptor's `/Ascent` /// and `/Descent`, which are optional for Type 3 (ISO 32000-2 Table 120). #[test] @@ -1142,7 +1508,7 @@ fn malformed_cmaps_never_panic() { let mut enc = Dict::new(); enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(i64::MAX), Object::name("A"), Object::name("B")])); let f = font(&mut doc, vec![("Subtype", Object::name("Type1")), ("Encoding", Object::Dict(enc))]); - assert_eq!(unicode_map(&doc, &f, false).text(u32::MAX).as_deref(), Some("B")); + assert_eq!(unicode_map(&doc, &f, false, &mut Reads::new(&doc, &mut DecodeBudget::new(usize::MAX, 0))).text(u32::MAX).as_deref(), Some("B")); } #[test] @@ -1373,6 +1739,29 @@ fn low_codes_resolve_to_the_covering_range() { assert_eq!((u.covering(0xFFFF).is_some(), u.covering(0x1_0001).is_some(), u.covering(0x1_0002).is_none()), (true, true, true)); } +#[test] +fn refused_subset_reads_do_not_encode_unverified_glyphs() { + let mut doc = Document::new_empty(); + let (mut f, sizes) = subset_type0(&mut doc, b""); + f.set(b"Encoding".to_vec(), Object::name("Identity-H")); + f.set(b"ToUnicode".to_vec(), Object::name("Identity-H")); + if let Some(Object::Array(descendants)) = f.get_mut(b"DescendantFonts") + && let Some(Object::Dict(descendant)) = descendants.first_mut() + { + descendant.set(b"DW".to_vec(), Object::Int(1000)); + } + let whole = Metrics::from_dict(&doc, &f); + assert!(!whole.past_allowance && !whole.outlined_cids.is_empty()); + assert!(whole.encode("\u{ffff}").is_none()); + // Refuse the program, then the CID map after reading the complete program. + for allowance in [0, sizes[1]] { + let partial = Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(allowance, 0)); + assert!(partial.past_allowance); + assert_eq!(partial.encode(""), Some(Vec::new())); + assert!(partial.encode("\u{ffff}").is_none(), "allowance {allowance} must not turn /DW into proof of a glyph"); + } +} + /// [`cmap_only_sfnt_named`] without a `post` table. fn cmap_only_sfnt(mac: Option<&[(u8, u8)]>, windows: &[(u16, u16)]) -> Vec { cmap_only_sfnt_named(mac, windows, false) @@ -1457,7 +1846,7 @@ fn a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman( descriptor.set(b"Flags".to_vec(), Object::Int(flags)); descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]); - let u = unicode_map(&doc, &f, false); + let u = unicode_map(&doc, &f, false, &mut Reads::new(&doc, &mut DecodeBudget::new(usize::MAX, 0))); codes.map(|c| u.text(c).unwrap_or_default().into_owned()) }; // `agrave` and `eacute` are glyphs 106 and 112 of the standard Macintosh order, `endash` and @@ -1492,7 +1881,7 @@ fn the_mac_roman_reading_needs_no_base_encoding_and_no_symbolic_builtin() { let mut all = vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]; all.extend(entries); let f = font(&mut doc, all); - let u = unicode_map(&doc, &f, false); + let u = unicode_map(&doc, &f, false, &mut Reads::new(&doc, &mut DecodeBudget::new(usize::MAX, 0))); [0x88, 0x8E].map(|c| u.text(c).unwrap_or_default().into_owned()) }; assert_eq!(read(vec![]), ["à", "é"]); @@ -1512,8 +1901,40 @@ fn the_mac_roman_reading_needs_no_base_encoding_and_no_symbolic_builtin() { assert_eq!(read(vec![("Encoding", Object::Dict(notdef))]), ["à", ""], "/Differences naming .notdef"); } +/// The program reads that tell a symbolic TrueType font's codes by its glyph names and as Mac OS +/// Roman are each paid for from the allowance the metrics are read within: one it can't pay for is +/// left unread, and the metrics say so. #[test] -fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program() { +fn the_symbolic_readings_of_a_program_are_paid_for() { + let program = cmap_only_sfnt(Some(&[(0x88, 106)]), &[(0xF088, 106)]); + let mut doc = Document::new_empty(); + let stream = doc.add(Object::Stream(Stream::flate(Dict::new(), &program))); + let mut descriptor = Dict::new(); + descriptor.set(b"Flags".to_vec(), Object::Int(4)); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(stream)); + // With `/Widths` the program isn't read for its advances: these are its only reads. + let entries = vec![ + ("Subtype", Object::name("TrueType")), + ("FontDescriptor", Object::Dict(descriptor)), + ("FirstChar", Object::Int(0x88)), + ("Widths", Object::Array(vec![Object::Int(500)])), + ]; + let f = font(&mut doc, entries); + let whole = Metrics::from_dict(&doc, &f); + assert!(!whole.past_allowance); + assert_eq!(whole.text_of(0x88).as_deref(), Some("à")); + let mut budget = DecodeBudget::new(2 * program.len(), 0); + assert_eq!(Metrics::from_dict_within(&doc, &f, &mut budget), whole); + assert_eq!(budget.remaining(), 0); + let m = Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(2 * program.len() - 1, 0)); + assert!(m.past_allowance, "a byte short, the Mac OS Roman reading's read is left unread"); + assert_eq!(m.text_of(0x88), None); +} + +/// A Type 0 font over Inter's program without its `cmap`, Identity-H, whose `/CIDToGIDMap` sends +/// CID 17 to Inter's Ω and whose ToUnicode says code 17 is Ω; also the program object, the +/// program, Ω's glyph and what the ToUnicode and the map decode to. +fn cmapless_composite(doc: &mut Document) -> (Dict, pdfcraft_cos::ObjRef, Vec, skrifa::GlyphId, [usize; 2]) { use skrifa::{MetadataProvider as _, raw::TableProvider as _}; let original = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); let expected = skrifa::FontRef::new(original).unwrap().charmap().map('Ω').unwrap(); @@ -1525,7 +1946,6 @@ fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without subset[12 + index * 16..28 + index * 16].copy_from_slice(record); } assert!(skrifa::FontRef::new(&subset).unwrap().cmap().is_err()); - let mut doc = Document::new_empty(); let program = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &subset))); let mut descriptor = Dict::new(); descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); @@ -1535,9 +1955,10 @@ fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without let mut descendant = Dict::new(); descendant.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); descendant.set(b"CIDToGIDMap".to_vec(), Object::Ref(gids)); - let unicode = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), b"1 beginbfchar <0011> <03a9> endbfchar"))); + let cmap = b"1 beginbfchar <0011> <03a9> endbfchar"; + let unicode = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), cmap))); let source = font( - &mut doc, + doc, vec![ ("Subtype", Object::name("Type0")), ("Encoding", Object::name("Identity-H")), @@ -1545,6 +1966,14 @@ fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without ("ToUnicode", Object::Ref(unicode)), ], ); + (source, program, subset, expected, [cmap.len(), 36]) +} + +#[test] +fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program() { + use skrifa::MetadataProvider as _; + let mut doc = Document::new_empty(); + let (source, program, subset, expected, _) = cmapless_composite(&mut doc); let editor = embedded_sfnt(&doc, &source).expect("the source outlines must be usable in the editor"); assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected)); let shaped = crate::source_shaping::shape(&editor, "Ω").expect("the rebuilt cmap must work in the actual shaping engine too"); @@ -1552,3 +1981,74 @@ fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without let Object::Stream(saved) = doc.get(program).as_ref().clone() else { panic!("fixture") }; assert_eq!(saved.decoded().unwrap(), subset, "the source PDF font program stays byte-for-byte intact"); } + +/// The metrics read that rebuilds a composite subset's cmap is paid for from the editor's +/// allowance, as a simple font's is: the program, its ToUnicode, then its `/CIDToGIDMap`. With the +/// ToUnicode left unread no copy is adapted, though what is left would pay for the map. +#[test] +fn adapting_a_composite_subsets_cmap_is_paid_for() { + use skrifa::MetadataProvider as _; + let mut doc = Document::new_empty(); + let (source, _, subset, expected, [cmap, map]) = cmapless_composite(&mut doc); + let cost = subset.len() + cmap + map; + let mut budget = DecodeBudget::new(cost, 0); + let editor = embedded_sfnt_within(&doc, &source, &mut budget).unwrap(); + assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected)); + assert_eq!(budget.remaining(), 0); + assert!(cmap > map); + assert!(embedded_sfnt_within(&doc, &source, &mut DecodeBudget::new(subset.len() + cmap - 1, 0)).is_none()); +} + +/// A composite font whose `/Encoding` isn't Identity-H can't have its cmap rebuilt +/// (`composite_glyph_maps` reads only Identity-H codes), so the editor reads its program and no +/// more: its ToUnicode and `/CIDToGIDMap` are left for what is left of the allowance. +#[test] +fn a_composite_font_that_cant_be_adapted_spends_only_its_program() { + let mut doc = Document::new_empty(); + let (source, _, subset, _, [cmap, map]) = cmapless_composite(&mut doc); + for encoding in ["Identity-V", "UniJIS-UCS2-H"] { + let mut f = source.clone(); + f.set(b"Encoding".to_vec(), Object::name(encoding)); + let mut budget = DecodeBudget::new(subset.len() + cmap + map, 0); + assert!(embedded_sfnt_within(&doc, &f, &mut budget).is_none(), "{encoding}"); + assert_eq!(budget.remaining(), cmap + map, "{encoding}"); + } +} + +/// A composite font's `/Encoding` given through a reference is the name it refers to, as the +/// metrics read it: Identity-H by reference is adapted as Identity-H written directly is. +#[test] +fn a_composite_fonts_encoding_given_by_reference_is_read() { + use skrifa::MetadataProvider as _; + let mut doc = Document::new_empty(); + let (mut source, _, _, expected, _) = cmapless_composite(&mut doc); + let identity = doc.add(Object::name("Identity-H")); + source.set(b"Encoding".to_vec(), Object::Ref(identity)); + assert_eq!(Metrics::from_dict(&doc, &source).code_len, 2); + let editor = embedded_sfnt(&doc, &source).expect("Identity-H by reference is adapted"); + assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected)); +} + +/// The `/CIDToGIDMap` a composite subset's cmap is rebuilt from reads as the font's other streams +/// do: a full map, which compresses well, reads behind ASCIIHex and Flate, though the hex stage's +/// output comes on top of the map. +#[test] +fn a_full_cid_map_rebuilds_a_composite_subsets_cmap_behind_two_filters() { + use skrifa::MetadataProvider as _; + let mut doc = Document::new_empty(); + let (mut source, _, _, expected, _) = cmapless_composite(&mut doc); + let mut map = vec![0u8; MAX_CID_TO_GID]; + map[34..36].copy_from_slice(&(expected.to_u32() as u16).to_be_bytes()); + let flate = Stream::flate(Dict::new(), &map); + let hex: String = flate.raw.iter().map(|b| format!("{b:02X}")).chain([">".to_string()]).collect(); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")])); + let map = Object::Ref(doc.add(Object::Stream(Stream::from_raw(dict, hex.into_bytes())))); + let Some(Object::Array(mut descendants)) = source.get(b"DescendantFonts").cloned() else { panic!("descendant") }; + if let Some(Object::Dict(d)) = descendants.get_mut(0) { + d.set(b"CIDToGIDMap".to_vec(), map); + } + source.set(b"DescendantFonts".to_vec(), Object::Array(descendants)); + let editor = embedded_sfnt(&doc, &source).expect("the map behind two filters is read"); + assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected)); +} diff --git a/crates/fonts/src/pdf/thai.rs b/crates/fonts/src/pdf/thai.rs index ac151a5a18..84075f87e8 100644 --- a/crates/fonts/src/pdf/thai.rs +++ b/crates/fonts/src/pdf/thai.rs @@ -7,7 +7,7 @@ //! does (a mark takes none); correct maps and unrelated fonts are never changed. No font //! artwork is bundled. -use super::{Dict, Document, Metrics, dict, only_char, unicode_map}; +use super::{Dict, Document, Metrics, Reads, dict, identity_h, only_char, unicode_map}; use pdfcraft_cos::{DecodeBudget, Object}; use std::collections::BTreeMap; @@ -19,15 +19,18 @@ pub fn recovered_thai_map(doc: &Document, font: &Dict) -> Option Option> { let descriptor = dict(doc, descendant.get(b"FontDescriptor"))?; let program = doc.resolve(descriptor.get(b"FontFile2")?); let Object::Stream(program) = program.as_ref() else { return None }; - DecodeBudget::new(super::MAX_SFNT, 0).decode_within(&doc.with_direct_parms(program), super::MAX_SFNT).ok() + DecodeBudget::new(super::MAX_SFNT, 0).decode_with_damage(doc, program, super::MAX_SFNT).ok().map(|(data, _)| data) } /// Source glyph positions and the original PDF character codes that draw them. Empty codes @@ -190,14 +193,15 @@ pub(super) fn composite_glyph_maps( budget: &mut DecodeBudget, ) -> Option<(BTreeMap, BTreeMap)> { use skrifa::{MetadataProvider as _, raw::TableProvider as _}; - if font.name(b"Encoding") != Some(b"Identity-H") { + if !identity_h(doc, font) { return None; } let descendant = dict(doc, font.get(b"DescendantFonts").and_then(|a| doc.resolve(a).as_array().and_then(|a| a.first()).cloned()).as_ref())?; let cid_map = match descendant.get(b"CIDToGIDMap").map(|m| doc.resolve(m)).as_deref() { None => None, Some(Object::Name(n)) if n == b"Identity" => None, - Some(Object::Stream(s)) => Some(budget.decode_within(&doc.with_direct_parms(s), 1 << 17).ok()?), + // Read as the font's other streams are, a map behind several filters too. + Some(Object::Stream(s)) => Some(Reads::new(doc, budget).decode(s, super::MAX_CID_TO_GID)?), _ => return None, }; let face = skrifa::FontRef::new(bytes).ok()?; @@ -294,13 +298,17 @@ impl LegacyThaiFont { /// Thai glyphs are not in the legacy order: the nominal and alternate marks this shaper /// assumes can't be trusted there, so it is shaped as an ordinary source font. /// - /// Its streams are decoded against `budget`, which the caller shares between every font it - /// reads: what they keep is the programs decoded and the editor cmaps added to them. + /// Its streams, its metrics' too, are decoded against `budget`, which the caller shares + /// between every font it reads: what they keep is the programs decoded and the editor cmaps + /// added to them. Metrics read past it adapt nothing: the font is refused. pub fn from_pdf(doc: &Document, font: &Dict, space: f64, budget: &mut DecodeBudget) -> Result, String> { - let m = Metrics::from_dict(doc, font); + let m = Metrics::from_dict_within(doc, font, budget); if !m.base_font.rsplit('+').next().unwrap_or(&m.base_font).starts_with("THSarabunPSK") { return Ok(None); } + if m.past_allowance { + return Err("the source Thai font's metrics are past the edit's allowance".into()); + } if !space.is_finite() || space < 0.0 { return Err("the source space width is invalid".into()); } @@ -528,6 +536,46 @@ mod tests { /// An intact legacy map: nominal Thai and two tone alternates. const INTACT: &[u8] = b"2 beginbfrange <00d6> <010f> <0e01> <0110> <012a> <0e3f> endbfrange 2 beginbfchar <0160> <0e48> <0161> <0e49> endbfchar"; + /// A THSarabunPSK subset whose own cmap is Latin-only has its Thai mappings rebuilt from its + /// `/CIDToGIDMap`. With that map left unread for want of the editor's allowance, the editor + /// lists no copy, rather than the program without its Thai mappings. + #[test] + fn a_map_left_unread_for_the_allowance_lists_no_copy() { + let mut doc = Document::new_empty(); + let program = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); + let file = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), program)))); + let map: Vec = (0u16..512).flat_map(u16::to_be_bytes).collect(); + let cid_to_gid = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &map)))); + let font = thai_font_over(&mut doc, file, cid_to_gid, INTACT); + let mut budget = DecodeBudget::new(usize::MAX / 2, 0); + assert!(crate::pdf::embedded_sfnt_within(&doc, &font, &mut budget).is_some()); + let cost = usize::MAX / 2 - budget.remaining(); + // The map the cmap is rebuilt from is read last: a byte short of the cost, it is refused. + let mut short = DecodeBudget::new(cost - 1, 0); + assert!(crate::pdf::embedded_sfnt_within(&doc, &font, &mut short).is_none(), "the program is listed without its Thai mappings"); + assert_eq!(short.refused(), 1); + } + + /// `LegacyThaiFont::from_pdf` reads its metrics within the edit's allowance too: the subset's + /// program, for its outlined glyphs, and its ToUnicode are paid for on top of what the + /// editor's read of it pays. Metrics read past the allowance refuse the font before its + /// program is read. + #[test] + fn a_legacy_fonts_metrics_are_paid_for_from_the_edits_allowance() { + let mut doc = Document::new_empty(); + let program = thai_ordered_program(); + let font = thai_font(&mut doc, &program, INTACT); + let mut editor = DecodeBudget::new(usize::MAX / 2, 0); + assert!(crate::pdf::embedded_sfnt_within(&doc, &font, &mut editor).is_some()); + let editor_cost = usize::MAX / 2 - editor.remaining(); + let mut budget = DecodeBudget::new(usize::MAX / 2, 0); + LegacyThaiFont::from_pdf(&doc, &font, 0.25, &mut budget).unwrap(); + assert_eq!(usize::MAX / 2 - budget.remaining(), editor_cost + program.len() + INTACT.len()); + let mut short = DecodeBudget::new(program.len() + INTACT.len() - 1, 0); + let error = LegacyThaiFont::from_pdf(&doc, &font, 0.25, &mut short).err().unwrap_or_default(); + assert!(error.contains("past the edit's allowance"), "{error}"); + } + /// The exporter's damaged map: intact anchors, Latin/control destinations for the other Thai /// codes, and the oversized range first. const DAMAGED: &[u8] = b"begincmap 1 begincodespacerange <0000> endcodespacerange \ @@ -547,6 +595,32 @@ mod tests { let recovered: HashMap<_, _> = recovered_thai_map(&doc, &font).unwrap().into_iter().collect(); assert_eq!(recovered[&262], 'ั'); assert_eq!(recovered[&297], '๘'); + // `/Encoding` given through a reference is the name it refers to. + let mut indirect = font.clone(); + indirect.set(b"Encoding".to_vec(), Object::Ref(doc.add(Object::name("Identity-H")))); + assert_eq!(recovered_thai_map(&doc, &indirect), recovered_thai_map(&doc, &font)); + // `/CIDToGIDMap` given through a reference is the name it refers to, and a missing one is + // taken as Identity, the default of PDF 1.x (ISO 32000-1), as the renderer takes it. + let with_map = |map: Option| { + let mut f = font.clone(); + let Some(Object::Array(desc)) = f.get(b"DescendantFonts").cloned() else { panic!("fixture") }; + let Some(Object::Dict(mut desc)) = desc.first().cloned() else { panic!("fixture") }; + match map { + Some(map) => desc.set(b"CIDToGIDMap".to_vec(), map), + None => { + desc.remove(b"CIDToGIDMap"); + } + } + f.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(desc)])); + f + }; + let by_reference = with_map(Some(Object::Ref(doc.add(Object::name("Identity"))))); + assert_eq!(recovered_thai_map(&doc, &by_reference), recovered_thai_map(&doc, &font), "a map by reference"); + assert_eq!(recovered_thai_map(&doc, &with_map(None)), recovered_thai_map(&doc, &font), "a missing map"); + // Any other map is refused: one that moves glyphs, and another name. + let moved = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &[0, 1, 0, 0])))); + assert_eq!(recovered_thai_map(&doc, &with_map(Some(moved))), None, "a map that moves glyphs"); + assert_eq!(recovered_thai_map(&doc, &with_map(Some(Object::name("Foo")))), None, "another name"); // 259–261 are ฮ, ฯ and ะ, between the last anchor (อ, 258) and the first mark: a font that // put Mai Han-akat one glyph earlier passes every anchor and only this check sees it. let mut shifted = thai_ordered_program(); diff --git a/crates/fonts/tests/stream_views.rs b/crates/fonts/tests/stream_views.rs new file mode 100644 index 0000000000..de210637ae --- /dev/null +++ b/crates/fonts/tests/stream_views.rs @@ -0,0 +1,62 @@ +//! Memory regression for the font reads charged to an allowance: a stream whose `/DecodeParms` +//! is given by reference is read through a view that shares its data and copies no more of its +//! dictionary than its filters and parameters, not through a copy of the whole stream. A test +//! binary of its own, so that the one test is all the process runs while its peak is measured. + +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_fonts::pdf::{Metrics, embedded_sfnt_within}; + +/// How much the process's peak resident memory grows, in KiB, while `f` runs. `None` where the +/// peak can't be reset (`/proc/self/clear_refs` is Linux's). +fn peak_growth(f: impl FnOnce() -> T) -> Option<(T, u64)> { + fn peak_kib() -> Option { + let status = std::fs::read_to_string("/proc/self/status").ok()?; + status.lines().find_map(|l| l.strip_prefix("VmHWM:"))?.split_whitespace().next()?.parse().ok() + } + std::fs::write("/proc/self/clear_refs", "5").ok()?; + let before = peak_kib()?; + let out = f(); + Some((out, peak_kib()?.saturating_sub(before))) +} + +/// A stream of `data`, its `/DecodeParms` the reference `parms`, its dictionary holding `junk`. +fn stream(doc: &mut Document, data: &[u8], parms: ObjRef, junk: &Object) -> Object { + let mut s = Stream::flate(Dict::new(), data); + s.dict.set(b"DecodeParms".to_vec(), Object::Ref(parms)); + s.dict.set(b"Junk".to_vec(), junk.clone()); + Object::Ref(doc.add(Object::Stream(s))) +} + +/// A font whose program and ToUnicode each hold an unrelated array of a million numbers in their +/// dictionaries, with `/DecodeParms` given by reference, read eight times within an allowance by +/// the editor's program read and by the metrics read: neither copies an array. A read through a +/// copy of the stream would add about as much as one array holds. +#[test] +fn reading_a_fonts_streams_copies_no_dictionary() { + let junk = Object::Array(vec![Object::Int(0); 1 << 20]); + let array_kib = (std::mem::size_of::() << 20) as u64 >> 10; + let mut doc = Document::new_empty(); + let parms = doc.add(Object::Dict(Dict::from_iter([(b"Predictor".to_vec(), Object::Int(1))]))); + let program = stream(&mut doc, include_bytes!("../../../assets/fonts/Inter-Regular.ttf"), parms, &junk); + let unicode = stream(&mut doc, b"1 beginbfchar <41> <0058> endbfchar", parms, &junk); + drop(junk); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), program); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("TrueType")); + font.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + font.set(b"ToUnicode".to_vec(), unicode); + let Some(((), growth)) = peak_growth(|| { + for _ in 0..8 { + let mut budget = DecodeBudget::new(64 << 20, 0); + assert!(embedded_sfnt_within(&doc, &font, &mut budget).is_some()); + let m = Metrics::from_dict_within(&doc, &font, &mut budget); + assert_eq!(m.text_of(0x41).as_deref(), Some("X")); + } + }) else { + eprintln!("the peak can't be measured here"); + return; + }; + assert!(growth < array_kib / 2, "the peak grew {growth} KiB, an array is {array_kib} KiB"); +} diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index 2ea52df387..12f3ee5ea9 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -98,6 +98,12 @@ pub enum Calculate { Simple { op: CalcOp, fields: Vec }, /// Simplified field notation, e.g. `Price * Quantity`. Notation(String), + /// A custom calculation script, written as given (Field Properties ▸ Calculate ▸ Custom + /// calculation script). Reading a field never gives this: a script that isn't one of the + /// forms above stays in [`Actions::unsupported`] and [`Scripts::calculate`], where the + /// JavaScript engine runs it. Authoring writes it back byte for byte; a blank one writes no + /// script. + Custom(String), } /// A field's recognised scripts. diff --git a/crates/forms/src/af/calc.rs b/crates/forms/src/af/calc.rs index 66738246c2..b7f9bd4c4c 100644 --- a/crates/forms/src/af/calc.rs +++ b/crates/forms/src/af/calc.rs @@ -445,6 +445,7 @@ impl Calculate { match self { Calculate::None => Vec::new(), Calculate::Simple { fields, .. } => unique(fields.iter().cloned()), + Calculate::Custom(js) => calculation_reads(js), Calculate::Notation(expr) => unique(notation_tokens(expr).into_iter().filter_map(|(_, token)| match token { Notation::Name(name) => Some(name), _ => None, @@ -620,7 +621,8 @@ const MAX_EXPANDED_VALUES: usize = 1 << 20; pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec) -> Option { let num = |s: &String| make_number(s).unwrap_or(0.0); match c { - Calculate::None => None, + // A custom script is the JavaScript engine's to run. + Calculate::None | Calculate::Custom(_) => None, // A product of no values is 0, as an empty sum, average, minimum or maximum is, not the // empty product 1: a blank quantity × price row reads 0, and a total over the rows // doesn't count it as 1. (The form's `values` leaves out a field that has no value.) diff --git a/crates/forms/src/af/script.rs b/crates/forms/src/af/script.rs index 8212da5bf2..3af5e2dca3 100644 --- a/crates/forms/src/af/script.rs +++ b/crates/forms/src/af/script.rs @@ -383,9 +383,12 @@ fn notation_text(src: &str) -> &str { } } +/// The `/AA /C` JavaScript that stores `c`; `None` when it stores none (no calculation, or a +/// blank custom script). pub fn calculate_js(c: &Calculate) -> Option { match c { Calculate::None => None, + Calculate::Custom(js) => (!js.trim().is_empty()).then(|| js.clone()), Calculate::Simple { op, fields } => Some(format!( "AFSimple_Calculate({}, new Array({}));", js_str(op.code()), diff --git a/crates/forms/src/af/tests.rs b/crates/forms/src/af/tests.rs index b4b6452a3c..d19d9f2a5f 100644 --- a/crates/forms/src/af/tests.rs +++ b/crates/forms/src/af/tests.rs @@ -487,6 +487,20 @@ fn destructured_for_of_targets_keep_regex_iterables_opaque() { } } +/// A custom calculation script (#6630) is written as given, a blank one as no script; it reads the +/// fields it names, and running it is the JavaScript engine's, not the AF calculator's. +#[test] +fn a_custom_calculation_script_is_kept_as_written() { + let js = "event.value = this.getField(\"a\").value * getField(\"b\").value; // getField(\"c\")"; + let c = Calculate::Custom(js.into()); + assert_eq!(calculate_js(&c).as_deref(), Some(js)); + for blank in ["", " \r\n\t"] { + assert_eq!(calculate_js(&Calculate::Custom(blank.into())), None, "{blank:?}"); + } + assert_eq!(c.reads(), ["a", "b"]); + assert_eq!(calculate(&c, &|_| vec!["2".into()]), None); +} + #[test] fn simple_calculations_fold_a_repeated_group_without_collecting_it() { let group = |_: &str| vec!["2".to_string(); 3]; diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 715db946e4..ee6eef518b 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -21,7 +21,7 @@ pub(crate) use tree::ancestor_sets; pub use edit::set_props; pub use lifecycle::{add_field, delete_field, delete_field_object, delete_widget, duplicate_field}; pub(crate) use look::button_appearance; -pub use look::{BorderStyle, CheckStyle, FieldFont, Look, LookPatch, check_style, look, redraw_field, set_button_icon}; +pub use look::{BorderStyle, CheckStyle, FieldFont, Look, LookPatch, check_style, look, redraw_field, redraw_fields, set_button_icon}; pub(crate) use tree::{ensure_form, form_fonts_as_objects}; /// The smallest width and height of a field's widget, in points: a smaller one is refused. diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs index 2c727779eb..ea718369bf 100644 --- a/crates/forms/src/author/edit.rs +++ b/crates/forms/src/author/edit.rs @@ -94,6 +94,21 @@ fn button_export_options(f: &Field, index: usize, value: &str) -> Result Option { + let js = crate::af::calculate_js(c)?; + let text = match c { + crate::af::Calculate::Custom(_) => PdfString::text(&js), + _ => PdfString::literal(js.into_bytes()), + }; + let mut a = Dict::new(); + a.set(b"S".to_vec(), Object::name("JavaScript")); + a.set(b"JS".to_vec(), text); + Some(Object::Dict(a)) +} + /// Change a field's properties (General and Options tabs) and redraw it. pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result { if props.font_size.is_some_and(|size| !size.is_finite() || size < 0.0) { @@ -328,10 +343,12 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result<(), FormError> { /// `FormError::NoSuchField` when no field is named `name`. pub(super) fn redraw_widgets(doc: &mut Document, name: &str, only: Option) -> Result<(), FormError> { let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + redraw_widgets_of(doc, &f, only) +} + +/// [`redraw_field`] for each field [`fields`] lists that `keep` accepts. The form's fields are +/// read once: drawing a field changes only its own widgets' appearances, so the list stays true +/// for the rest. Looking each one up by name instead reads the whole form again for every field, +/// which grows with the square of the field count (a laid out XFA form with a thousand rows holds +/// thousands of fields). A failure names the field it stopped at. +pub fn redraw_fields(doc: &mut Document, mut keep: impl FnMut(&Document, &Field) -> bool) -> Result<(), FormError> { + for f in fields(doc) { + if keep(doc, &f) { + redraw_widgets_of(doc, &f, None).map_err(|e| FormError::Invalid(format!("{}: {e}", f.name)))?; + } + } + Ok(()) +} + +/// [`redraw_widgets`] for the field `f`, as `fields(doc)` read it. +fn redraw_widgets_of(doc: &mut Document, f: &Field, only: Option) -> Result<(), FormError> { let mut undrawn = false; for (index, w) in f.widgets.iter().enumerate() { if only.is_some_and(|only| only != index) { @@ -323,7 +342,7 @@ pub(super) fn redraw_widgets(doc: &mut Document, name: &str, only: Option let mut missing = None; let ap = match f.kind { FieldKind::Text | FieldKind::Combo | FieldKind::List => { - let (mut s, m) = appearance::field_appearance_drawn(doc, &f, w, &f.value, true); + let (mut s, m) = appearance::field_appearance_drawn(doc, f, w, &f.value, true); missing = m; // Thai glyphs are streams of their own. pdfcraft_fonts::shaped_text::store_streams(doc, &mut s.dict); diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 236310f268..c1c91f8872 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -27,7 +27,7 @@ pub mod table; pub use actions::{FieldAction, Trigger, field_action_chains, field_actions, set_field_actions}; pub use author::{ BorderStyle, CheckStyle, FieldFont, FieldProps, Look, LookPatch, MIN_FIELD_SIDE, NewField, add_field, check_style, delete_field, - delete_field_object, delete_widget, duplicate_field, look, redraw_field, set_button_icon, set_props, + delete_field_object, delete_widget, duplicate_field, look, redraw_field, redraw_fields, set_button_icon, set_props, }; pub use calc_order::{CalculatedField, CalculationIssue, CalculationOrder, calculation_order, set_calculation_order}; pub use scripting::{ @@ -51,7 +51,7 @@ pub use tab_order::{TabOrder, move_in_tab_order, set_tab_order, tab_orders}; #[cfg(test)] use values::has_password_flag; use values::write_value; -pub(crate) use values::{ask_viewers_to_redraw, merged_appearance}; +pub(crate) use values::{ask_viewers_to_redraw, merged_appearance, redraw_calculated_value}; pub use values::{ calculations_changed, clear_password_values, has_password_values, password_values, recalculate, recalculate_with, reset, restore_password_values, set_value, set_value_with, set_values, diff --git a/crates/forms/src/scripting.rs b/crates/forms/src/scripting.rs index eda371c0a3..09fa1ea803 100644 --- a/crates/forms/src/scripting.rs +++ b/crates/forms/src/scripting.rs @@ -38,6 +38,8 @@ impl FieldEvent { pub struct FieldChange { pub name: String, pub value: Option>, + /// This value came from a Calculate script; redraw it through the field's Format script. + pub formatted: bool, pub read_only: Option, pub required: Option, /// `display.visible` 0, `hidden` 1, `noPrint` 2, `noView` 3. @@ -101,7 +103,7 @@ pub(crate) fn accept(doc: &mut Document, f: &Field, value: &str, scripts: &mut d let Some(js) = script else { continue }; let all = fields(doc); let r = scripts.run(event, js, f, &v, &all); - apply_changes(doc, &r.changes, &f.name)?; + apply_changes(doc, &r.changes, &f.name, scripts)?; if !r.rc { return Err(FormError::Invalid(r.message.unwrap_or_else(|| format!("{default} [ {} ]", f.name)))); } @@ -127,7 +129,7 @@ pub(crate) fn formatted_in_english(doc: &Document, f: &Field, value: &str, scrip /// Apply script changes to other fields (values written without re-running their scripts; /// read-only, required and visibility as flags). `except` is the event's own field, whose value /// the event sets. -pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except: &str) -> Result<(), FormError> { +pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except: &str, scripts: &mut dyn Scripts) -> Result<(), FormError> { if changes.is_empty() { return Ok(()); } @@ -149,6 +151,9 @@ pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except: tmp.flags &= !flags::NO_TOGGLE_TO_OFF; tmp.actions = Default::default(); crate::write_value(doc, &tmp, &value, &mut NoScripts)?; + if c.formatted { + crate::redraw_calculated_value(doc, f, v, scripts)?; + } } let mut ff = f.flags; for (on, bit) in [(c.read_only, flags::READ_ONLY), (c.required, flags::REQUIRED)] { @@ -187,7 +192,7 @@ pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except: /// Apply what a button or console script changed (values, read-only, required, visibility), /// then recalculate. pub fn apply_script_changes(doc: &mut Document, changes: &[FieldChange], scripts: &mut dyn Scripts) -> Result<(), FormError> { - apply_changes(doc, changes, "")?; + apply_changes(doc, changes, "", scripts)?; crate::recalculate_with(doc, scripts)?; Ok(()) } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index b69e24d8dc..55cb97c887 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1726,6 +1726,70 @@ fn a_script_that_spells_the_markers_in_a_string_runs_as_a_script() { assert_eq!(field(&fields(&doc), "total").value, ["ran"]); } +/// The `/JS` string of field `name`'s `/AA /C` action, as stored. +fn calculate_action_js(doc: &Document, name: &str) -> Option { + let f = field(&fields(doc), name).obj; + let aa = doc.get(f).as_dict().and_then(|d| d.get(b"AA").cloned()).map(|a| doc.resolve(&a))?; + let c = aa.as_dict().and_then(|aa| aa.get(b"C").cloned()).map(|c| doc.resolve(&c))?; + Some(c.as_dict().and_then(|c| c.get(b"JS")).and_then(|s| s.as_string()).expect("/JS is a string").clone()) +} + +/// Field Properties ▸ Calculate ▸ Custom calculation script (#6630): the script is written as +/// given, as a text string, and reads back as the field's custom script, in the calculation +/// order; a blank one removes the script and the field's place in the order. +#[test] +fn a_custom_calculation_script_is_written_as_given_and_a_blank_one_removes_it() { + use crate::af::Calculate; + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &text, Some("codice")).unwrap(); + add_field(&mut doc, 0, [50.0, 660.0, 250.0, 680.0], &text, Some("barcode")).unwrap(); + let js = "// Code 39: tra asterischi, così\r\nevent.value = \"*\" + this.getField(\"codice\").value + \"*\";"; + let custom = |js: &str| FieldProps { calculate: Some(Calculate::Custom(js.into())), ..FieldProps::default() }; + set_props(&mut doc, "barcode", &custom(js)).unwrap(); + for doc in [doc.clone(), reopen(&doc)] { + let all = fields(&doc); + let actions = &field(&all, "barcode").actions; + assert_eq!((&actions.calculate, actions.scripts.calculate.as_deref()), (&Calculate::None, Some(js)), "read back as a script"); + assert!(actions.unsupported.contains(&"calculate")); + let stored = calculate_action_js(&doc, "barcode").expect("an /AA /C action"); + assert_eq!(stored.bytes, pdfcraft_cos::PdfString::text(js).bytes, "a text string, so ì isn't UTF-8 bytes another viewer reads as ì's pieces"); + let order = calculation_order(&doc, &all); + assert_eq!(order.fields.iter().map(|f| (f.name.as_str(), f.listed)).collect::>(), [("barcode", true)]); + assert_eq!(order.fields[0].reads, ["codice"]); + } + for blank in ["", " \n\t"] { + let mut doc = doc.clone(); + set_props(&mut doc, "barcode", &custom(blank)).unwrap(); + let all = fields(&doc); + let actions = &field(&all, "barcode").actions; + assert_eq!((&actions.calculate, actions.scripts.calculate.as_deref()), (&Calculate::None, None), "{blank:?}"); + assert!(calculate_action_js(&doc, "barcode").is_none(), "{blank:?} writes no /C"); + assert!(calculation_order(&doc, &all).fields.is_empty(), "{blank:?} leaves the calculation order"); + } +} + +/// A sum and a notation are still written as the AF calls they are, byte for byte, beside the +/// custom script that is written as a text string (#6630). +#[test] +fn a_sum_and_a_notation_are_written_as_their_af_calls() { + use crate::af::{CalcOp, Calculate, calculate_js}; + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + for (i, name) in ["a", "b", "total"].into_iter().enumerate() { + let y = 700.0 - 40.0 * i as f64; + add_field(&mut doc, 0, [50.0, y, 250.0, y + 20.0], &text, Some(name)).unwrap(); + } + let sum = Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b".into()] }; + for c in [sum, Calculate::Notation("a * b".into())] { + set_props(&mut doc, "total", &FieldProps { calculate: Some(c.clone()), ..FieldProps::default() }).unwrap(); + let js = calculate_js(&c).unwrap(); + assert_eq!(calculate_action_js(&doc, "total").map(|s| s.bytes), Some(js.into_bytes()), "{c:?}"); + let all = fields(&reopen(&doc)); + assert_eq!((&field(&all, "total").actions.calculate, &field(&all, "total").actions.scripts.calculate), (&c, &None)); + } +} + /// An unsupported first AF call keeps the entire script and reaches the caller's interpreter. #[test] fn unsupported_first_validation_call_preserves_custom_script_dispatch() { @@ -3551,6 +3615,7 @@ fn recalc_chain_where_script_changes_checkbox() { changes: vec![FieldChange { name: "agree".into(), value: Some(vec!["Yes".into()]), + formatted: false, read_only: None, required: None, display: None, @@ -5721,6 +5786,34 @@ fn set_values_reads_the_form_once_not_per_value() { assert_eq!(reads, 3, "one to fill, one when f0 comes again, one to recalculate"); } +/// Redrawing every field of a large form reads the form once, not once per field: a laid out XFA +/// form with a thousand rows holds thousands of fields, and reading them all again for each one +/// took 15 s a relayout. The fields `keep` turns down keep the appearance they had. +#[test] +fn redraw_fields_reads_the_form_once_however_many_fields_it_holds() { + for n in [250u32, 1000] { + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + ]; + let kids: Vec = (0..n).map(|i| format!("{} 0 R", i + 5)).collect(); + objs.push(format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", kids.join(" "))); + objs.push(format!("<< /Fields [{}] /DA (/Helv 10 Tf 0 g) >>", kids.join(" "))); + for i in 0..n { + objs.push(format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (f{i}) /V (v{i}) /Rect [50 0 250 10] /P 3 0 R >>")); + } + let mut doc = document(&objs); + let before = form_reads(); + redraw_fields(&mut doc, |_, f| f.name != "f0").unwrap(); + assert_eq!(form_reads() - before, 1, "{n} fields"); + let all = fields(&doc); + let has_ap = |name: &str| field(&all, name).widgets.iter().all(|w| doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"AP"))); + assert!(!has_ap("f0"), "turned down: left as it was"); + assert!((1..n).all(|i| has_ap(&format!("f{i}"))), "{n} fields"); + assert!(ap(&doc, &field(&all, &format!("f{}", n - 1)).widgets[0]).contains(&format!("(v{})", n - 1))); + } +} + /// A radio group `size` with the buttons Small (on), Medium and Large, a per-widget `/Opt`, and /// the group's buttons in the calculation order; and a text field `name` with two widgets. fn radio_group_form() -> Document { diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs index a479e412eb..b1d50a87ae 100644 --- a/crates/forms/src/values.rs +++ b/crates/forms/src/values.rs @@ -224,7 +224,7 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result // field to the event, so that value is the result unless the script also assigned // `event.value`, even the value it already had. let own = r.changes.iter().find(|c| c.name == f.name).and_then(|c| c.value.as_ref()).map(|v| v.first().cloned().unwrap_or_default()); - scripting::apply_changes(doc, &r.changes, &f.name)?; + scripting::apply_changes(doc, &r.changes, &f.name, scripts)?; if !r.changes.is_empty() { all = fields(doc); } @@ -473,6 +473,11 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr } } +/// Redraw a calculated value without running input validation or calculation again. +pub(crate) fn redraw_calculated_value(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Scripts) -> Result<(), FormError> { + redraw(doc, f, values, scripts) +} + /// A list box's first visible row (`/TI`) before and after scrolling so the `picked` positions of /// its `count` options are among the rows it draws, as in other viewers: a pick below the last /// visible row would otherwise not show (#1524). `None` for any other field. diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index f4792ba64b..7e171bc369 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -100,6 +100,9 @@ struct Interp<'h> { /// burning a core to the loop limit). #[cfg(not(target_arch = "wasm32"))] deadline: Option, + /// `deadline` passed and stopped the script: the run reports as abandoned. + #[cfg(not(target_arch = "wasm32"))] + out_of_time: bool, } type R = Result; @@ -151,6 +154,8 @@ pub(crate) fn run_formcalc_at( exited: false, #[cfg(not(target_arch = "wasm32"))] deadline: deadline.and_then(|d| std::time::Instant::now().checked_add(d)), + #[cfg(not(target_arch = "wasm32"))] + out_of_time: false, }; let mut out = XfaOutcome::default(); match interp.block(&body) { @@ -165,6 +170,19 @@ pub(crate) fn run_formcalc_at( } Err(e) => out.error = Some(e), } + // The caller stops waiting at the same time limit, so which of the two notices first is + // down to the scheduler. The outcome must not depend on it: stopped by the clock, the run + // reports what an abandoned one does, the error and no changes. Keeping the changes made + // it a race: a loop adding rows until the clock stopped it left the form unchanged on one + // run and 1000 rows longer on the next (each laid out again, tens of seconds). + #[cfg(not(target_arch = "wasm32"))] + if let (true, Some(d)) = (interp.out_of_time, deadline) { + return XfaOutcome { + error: Some(format!("the script ran longer than {:.1} s and was abandoned", d.as_secs_f64())), + abandoned: true, + ..Default::default() + }; + } out.notes = host.notes(); out.effects = host.effects; out.console = host.console; diff --git a/crates/js/src/formcalc/access.rs b/crates/js/src/formcalc/access.rs index 5f279ea4f6..26de7ba9eb 100644 --- a/crates/js/src/formcalc/access.rs +++ b/crates/js/src/formcalc/access.rs @@ -25,6 +25,7 @@ impl Interp<'_> { && let Some(d) = self.deadline && std::time::Instant::now() > d { + self.out_of_time = true; return Err(RAN_TOO_LONG.into()); } #[cfg(target_arch = "wasm32")] diff --git a/crates/js/src/globals.rs b/crates/js/src/globals.rs index 297e7604d1..1acc9bb89f 100644 --- a/crates/js/src/globals.rs +++ b/crates/js/src/globals.rs @@ -1,10 +1,10 @@ use super::document::{field_object, get_field, get_nth_field_name, noop, num_fields, page_num_get, page_num_set, print, reset_form, submit_form}; use super::format::{printd, printf, printx, scand}; -use super::host::{arg, function, host, s, text, text_line}; +use super::host::{arg, function, host, s, strings, text, text_line}; use super::model::{DISPLAY_HIDDEN, DISPLAY_NO_PRINT, DISPLAY_NO_VIEW, DISPLAY_VISIBLE, Event, Panel, Request}; use super::{ - Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, Message, MessageButtons, MessageIcon, NativeFunction, ObjectInitializer, - js_string, + Arc, Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, Message, MessageButtons, MessageIcon, NativeFunction, ObjectInitializer, + Source, error, js_string, }; // ── app, util, console ────────────────────────────────────────────────────────────────────── @@ -120,6 +120,36 @@ fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsResult<(String, bool)> { + let first = arg(args, 0); + // Any object of named parameters is read by key, one without `cName` too, so a missing name + // is refused rather than read as the object's text. + let named = first.as_object().filter(|o| !o.is_array() && !o.is_callable()); + let name = match &named { + Some(o) => text_line(&o.get(js_string!("cName"), ctx)?, ctx)?, + None => text_line(&first, ctx)?, + }; + if name.is_empty() { + return Err(error("exportDataObject needs cName, the attached file's name")); + } + let launch = match &named { + Some(o) => o.get(js_string!("nLaunch"), ctx)?, + None => arg(args, 3), + }; + let launch = if launch.is_undefined() || launch.is_null() { 0.0 } else { launch.to_number(ctx)? }; + Ok((name, launch == 1.0 || launch == 2.0)) +} + +/// `this.exportDataObject(…)`: asks the viewer to open or save an attached file. +fn export_data_object(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let (name, open) = data_object(args, ctx)?; + host(ctx)?.borrow_mut().request(Request::OpenAttachment { name, open }); + Ok(JsValue::undefined()) +} + /// What `app.execMenuItem(name)` asks for, for the menu items forms use: the side panels, Print /// and Save As. Other items do nothing. pub(crate) fn menu_item(name: &str) -> Option { @@ -147,6 +177,55 @@ fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult< Ok(JsValue::undefined()) } +/// Return a page box in Acrobat's rotated-user-space order: left, top, right, bottom. +fn get_page_box(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let name = arg(args, 0); + let name = if name.is_undefined() { + "Crop".to_string() + } else { + name.as_string().ok_or_else(|| error("getPageBox box name must be a string"))?.to_std_string_escaped() + }; + let which = match name.as_str() { + "Media" | "MediaBox" => 0, + "Crop" | "CropBox" => 1, + "Bleed" | "BleedBox" => 2, + "Trim" | "TrimBox" => 3, + "Art" | "ArtBox" => 4, + _ => return Err(error("getPageBox box name must be Media, Crop, Trim, Bleed, or Art")), + }; + let page_value = arg(args, 1); + let page = if page_value.is_undefined() { + 0 + } else { + if !page_value.is_number() { + return Err(error("getPageBox page must be a non-negative in-range integer")); + } + let page = page_value.to_number(ctx)?; + if !page.is_finite() || page < 0.0 || page.fract() != 0.0 || page >= usize::MAX as f64 { + return Err(error("getPageBox page must be a non-negative in-range integer")); + } + page as usize + }; + let (rect, rotation, crop) = { + let h = host(ctx)?.borrow(); + let boxes = h.doc.page_boxes.get(page).ok_or_else(|| error("getPageBox page is outside the document"))?; + let rotation = h.doc.page_rotations.get(page).copied().unwrap_or(0); + (boxes[which], rotation, boxes[1]) + }; + let [x0, y0, x1, y1] = rect; + let [cx0, cy0, cx1, cy1] = crop; + let rotated = match rotation { + 90 => [y0 - cy0, cx1 - x0, y1 - cy0, cx1 - x1], + 180 => [cx1 - x1, cy1 - y0, cx1 - x0, cy1 - y1], + 270 => [cy1 - y1, x1 - cx0, cy1 - y0, x0 - cx0], + _ => [x0 - cx0, y1 - cy0, x1 - cx0, y0 - cy0], + }; + if !rotated.iter().all(|value| value.is_finite()) { + return Err(error("getPageBox returned non-finite coordinates")); + } + Ok(JsArray::from_iter(rotated.into_iter().map(JsValue::from), ctx).into()) +} + /// `app.response(…)`: always `null`, the answer for a canceled dialog, since no one is there to /// type one. fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { @@ -165,6 +244,149 @@ fn console_clear(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult JsResult { + const MAX_CALCULATE_DEPTH: usize = 8; + let calculations = { + let shared = host(ctx)?; + let mut h = shared.borrow_mut(); + if h.calculate_depth >= MAX_CALCULATE_DEPTH { + return Err(error("calculateNow recursion limit exceeded")); + } + h.calculate_depth += 1; + Arc::clone(&h.calculations) + }; + let previous_event = match ctx.global_object().get(js_string!("event"), ctx) { + Ok(event) => event, + Err(error) => { + if let Ok(shared) = host(ctx) { + let mut h = shared.borrow_mut(); + h.calculate_depth = h.calculate_depth.saturating_sub(1); + } + return Err(error); + } + }; + let result = (|| { + for (name, script) in calculations.iter() { + { + let mut h = host(ctx)?.borrow_mut(); + if h.calculate_steps >= super::run::MAX_CALCULATE_STEPS { + return Err(error("calculateNow evaluation limit exceeded")); + } + h.calculate_steps = h.calculate_steps.saturating_add(1); + } + let Some(field) = host(ctx)?.borrow().fields.iter().find(|f| f.name == *name).cloned() else { continue }; + if let Some(why) = super::run::refuse(script) { + host(ctx)?.borrow_mut().print(format!("calculation script for {name} failed: {why}")); + continue; + } + let event = Event::field("Calculate", name, &field.value.first().cloned().unwrap_or_default()); + let (object, _) = event_object(ctx, &event)?; + ctx.global_object().set(js_string!("event"), object.clone(), true, ctx)?; + let (fields_before, changed_before, formatted_before) = { + let h = host(ctx)?.borrow(); + (Arc::clone(&h.fields), h.changed.clone(), h.formatted.clone()) + }; + if let Err(cause) = ctx.eval(Source::from_bytes(script)) { + let mut h = host(ctx)?.borrow_mut(); + h.fields = fields_before; + h.changed = changed_before; + h.formatted = formatted_before; + h.print(format!("calculation script for {name} failed: {cause}")); + continue; + } + let rc = object.get(js_string!("rc"), ctx)?; + if !rc.to_boolean() { + continue; + } + let value = object.get(js_string!("value"), ctx)?; + let value = text(&value, ctx)?; + let shared = host(ctx)?; + let mut h = shared.borrow_mut(); + if let Some((index, _)) = h.fields.iter().enumerate().find(|(_, f)| f.name == *name) { + let target = Arc::make_mut(&mut h.fields).get_mut(index).ok_or_else(|| error("the field is no longer available"))?; + target.value = vec![value]; + h.changed.insert(index); + h.formatted.insert(index); + } + } + Ok(JsValue::undefined()) + })(); + let restored = ctx.global_object().set(js_string!("event"), previous_event, true, ctx).map(|_| JsValue::undefined()); + if let Ok(shared) = host(ctx) { + let mut h = shared.borrow_mut(); + h.calculate_depth = h.calculate_depth.saturating_sub(1); + } + result.and(restored) +} + +/// Acrobat's built-in calculation helper, used by the scripts written by its Calculate tab. +/// `AFSimple_Calculate(op, names)`, with the semantics of the native Simple calculation +/// (`pdfcraft_forms::af::calculate`), so a total read right after `calculateNow()` is the one the +/// commit path writes: a name also stands for every field under it (`b` sums `b.c` and `b.d`), +/// each field counts its first value, and values are read as `AFMakeNumber` reads typed text +/// (currency, `%`, group separators, a comma decimal separator), anything else counting as 0. +fn af_simple_calculate(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + const MAX_EXPANDED_VALUES: usize = 1 << 20; + let op = text(&arg(args, 0), ctx)?; + let names = strings(&arg(args, 1), ctx)?; + let values: Vec = { + let shared = host(ctx)?; + let h = shared.borrow(); + let mut values = Vec::new(); + for name in &names { + let prefix = format!("{name}."); + for f in h.fields.iter().filter(|f| f.name == *name || f.name.starts_with(&prefix)) { + if let Some(v) = f.value.first() { + if values.len() >= MAX_EXPANDED_VALUES { + return Ok(s("0")); + } + values.push(make_number(v).unwrap_or(0.0)); + } + } + } + values + }; + let result = match op.as_str() { + "SUM" => values.iter().sum(), + // An empty product is 0, as the native calculation has it, not 1. + "PRD" if values.is_empty() => 0.0, + "PRD" => values.iter().product(), + "AVG" if values.is_empty() => 0.0, + "AVG" => values.iter().sum::() / values.len() as f64, + "MIN" => values.iter().copied().fold(f64::INFINITY, f64::min), + "MAX" => values.iter().copied().fold(f64::NEG_INFINITY, f64::max), + _ => 0.0, + }; + Ok(s(&if result.is_finite() { js_number(result) } else { "0".into() })) +} + +/// `AFMakeNumber`'s reading of typed text, as `pdfcraft_forms::af::make_number` has it: currency +/// symbols, spaces, `'` and `%` ignored, `(7)` negative, and a comma the decimal separator when +/// it comes last (`1.234,56`) or is not followed by exactly three digits. +fn make_number(s: &str) -> Option { + let t: String = s.trim().chars().filter(|c| !c.is_whitespace() && !matches!(c, '$' | '€' | '£' | '¥' | '\'' | '%')).collect(); + if t.is_empty() { + return None; + } + let neg = t.starts_with('(') && t.ends_with(')'); + let t = t.trim_start_matches('(').trim_end_matches(')'); + let normalized = match (t.rfind(','), t.rfind('.')) { + (Some(c), Some(d)) if c > d => t.replace('.', "").replace(',', "."), + (Some(c), None) if t.len() - c - 1 != 3 => t.replace(',', "."), + _ => t.replace(',', ""), + }; + let v: f64 = normalized.parse().ok()?; + v.is_finite().then_some(if neg { -v } else { v }) +} + +/// A number as JavaScript prints it (integers without a fraction), as the native calculation +/// writes it. +fn js_number(v: f64) -> String { + if v.fract() == 0.0 && v.abs() < 1e15 { format!("{}", v as i64) } else { format!("{v}") } +} + fn timer(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { // Timers never fire in the sandbox; scripts get an object they can pass to clear*. Ok(ObjectInitializer::new(ctx).build().into()) @@ -199,20 +421,23 @@ fn color_object(ctx: &mut Context) -> JsObject { const XFA_VERSION: f64 = 3.5; /// Defines the form object model's globals: the document's functions (`getField`, `resetForm`, -/// `print`, `submitForm` and others) and properties (`numFields`, `pageNum`, `numPages`, -/// `documentFileName`, `info`, the XFA version), and the `app`, `util`, `console`, `display` and -/// `color` objects. +/// `print`, `submitForm`, `exportDataObject` and others) and properties (`numFields`, `pageNum`, +/// `numPages`, `documentFileName`, `info`, the XFA version), and the `app`, `util`, `console`, +/// `display` and `color` objects. pub(crate) fn install(ctx: &mut Context) -> JsResult<()> { - let globals: [(&str, usize, NativeFunction); 9] = [ + let globals: [(&str, usize, NativeFunction); 12] = [ ("getField", 1, NativeFunction::from_fn_ptr(get_field)), ("getNthFieldName", 1, NativeFunction::from_fn_ptr(get_nth_field_name)), ("resetForm", 1, NativeFunction::from_fn_ptr(reset_form)), ("print", 1, NativeFunction::from_fn_ptr(print)), ("submitForm", 1, NativeFunction::from_fn_ptr(submit_form)), ("mailDoc", 1, NativeFunction::from_fn_ptr(noop)), - ("calculateNow", 0, NativeFunction::from_fn_ptr(noop)), + ("calculateNow", 0, NativeFunction::from_fn_ptr(calculate_now)), + ("AFSimple_Calculate", 2, NativeFunction::from_fn_ptr(af_simple_calculate)), + ("getPageBox", 2, NativeFunction::from_fn_ptr(get_page_box)), ("syncAnnotScan", 0, NativeFunction::from_fn_ptr(noop)), ("dirty", 0, NativeFunction::from_fn_ptr(noop)), + ("exportDataObject", 4, NativeFunction::from_fn_ptr(export_data_object)), ]; for (name, len, f) in globals { ctx.register_global_callable(JsString::from(name), len, f)?; diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index 1419ba2a86..d57cccbe74 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -8,10 +8,14 @@ use super::{ pub(crate) struct Host { pub(crate) fields: Arc>, pub(crate) changed: BTreeSet, + pub(crate) formatted: BTreeSet, pub(crate) alerts: Vec, pub(crate) console: Vec, pub(crate) requests: Vec, pub(crate) doc: DocInfo, + pub(crate) calculations: Arc>, + pub(crate) calculate_depth: usize, + pub(crate) calculate_steps: usize, /// Bytes of text kept in alerts, console lines and requests, against [`xfa::MAX_PAYLOAD`]. pub(crate) kept: usize, /// Alerts, console lines and requests the limits left out. @@ -68,6 +72,7 @@ impl Host { let bytes = match &r { Request::Reset(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), Request::LaunchUrl(u) | Request::Submit(u) | Request::Focus(u) => u.len(), + Request::OpenAttachment { name, .. } => name.len(), _ => 0, }; let kept = self.room(bytes); diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 0bf4032efd..1c0fe24f7b 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -8,7 +8,7 @@ //! - `event`: `value`, `change`, `willCommit`, `rc`, `name`, `type`, `target`, `targetName`. //! - the document (`this`, also the global object): `getField`, `numFields`, //! `getNthFieldName`, `numPages`, `pageNum`, `documentFileName`, `info`, `resetForm`, -//! `calculateNow`, `print`, `submitForm`, `mailDoc`. +//! `calculateNow`, `getPageBox`, `print`, `submitForm`, `mailDoc`, `exportDataObject`. //! - Field objects: `name`, `type`, `value`, `valueAsString`, `defaultValue`, `readonly`, //! `required`, `display`, `hidden`, `numItems`, `getItemAt`, `currentValueIndices`, //! `isBoxChecked`, `checkThisBox`, `exportValues`, `charLimit`, `textColor`, `fillColor`, @@ -56,7 +56,7 @@ pub use model::{ use run::SCRIPT_STACK; use run::refuse; pub(crate) use run::{CheckedCodeFromStrings, MAX_SCRIPT_BYTES}; -pub use run::{run, run_shared}; +pub use run::{run, run_shared, run_shared_calculations, run_shared_calculations_shared}; pub mod formcalc; pub mod xfa; diff --git a/crates/js/src/model.rs b/crates/js/src/model.rs index d3195e6a5a..6314adbfa2 100644 --- a/crates/js/src/model.rs +++ b/crates/js/src/model.rs @@ -114,6 +114,10 @@ pub struct DocInfo { pub page: usize, /// Document information (`this.info.title` etc.): lower-case key, value. pub info: Vec<(String, String)>, + /// Effective page boxes in PDF user space, ordered media, crop, bleed, trim, art. + pub page_boxes: Vec<[[f64; 4]; 5]>, + /// Clockwise page rotations, normalized to 0, 90, 180 or 270 degrees. + pub page_rotations: Vec, /// Whether the viewer highlights form fields (`app.runtimeHighlight`). pub highlight: bool, /// The document has an XFA form, so scripts see `xfa_installed`. @@ -241,6 +245,12 @@ pub enum Request { Beep, /// `app.execMenuItem("SaveAs")` (XFA forms' Save buttons). SaveAs, + /// `this.exportDataObject(cName, …, nLaunch)`: the attached file `name`, to open (`nLaunch` + /// 1 or 2) or to save (0). + OpenAttachment { + name: String, + open: bool, + }, /// `app.runtimeHighlight = on`: highlight the form's fields, or stop. Highlight(bool), /// `app.execMenuItem("ShowHide…")`: show the side panel, or hide it when it is showing. @@ -287,6 +297,8 @@ pub struct Outcome { pub change: String, /// Fields whose value or properties the script changed, as they are now. pub changed: Vec, + /// Fields updated by Calculate scripts; their Format script must redraw the appearance. + pub formatted: std::collections::BTreeSet, /// The messages the script showed, in order. pub alerts: Vec, pub console: Vec, diff --git a/crates/js/src/run.rs b/crates/js/src/run.rs index 9435e7c8e4..8f760f8130 100644 --- a/crates/js/src/run.rs +++ b/crates/js/src/run.rs @@ -16,6 +16,9 @@ pub(crate) const MAX_PRINTF_OUTPUT_BYTES: usize = 1 << 20; /// length costs the script nothing but the host one string per item; real lists are tiny. pub(crate) const MAX_ARRAY_LENGTH: u64 = 1 << 20; +/// Maximum calculated field scripts evaluated by `calculateNow()` during one JavaScript run. +pub(crate) const MAX_CALCULATE_STEPS: usize = 4096; + /// Deepest nesting of `(`, `[` and `{`. const MAX_BRACKET_DEPTH: usize = 64; @@ -108,6 +111,33 @@ pub fn run(script: &str, event: &Event, doc: &DocInfo, fields: &[FieldState], do /// context; the first field write copies the snapshot, so neither the caller nor another /// script can observe uncommitted writes. Read-only calculations avoid copying the whole form. pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc>, doc_scripts: &[String], limits: Limits) -> Outcome { + run_shared_calculations(script, event, doc, fields, doc_scripts, &Arc::new(Vec::new()), limits) +} + +/// Like [`run_shared`], with the document's ordered calculated fields available to +/// `calculateNow()` while this context is running. +pub fn run_shared_calculations( + script: &str, + event: &Event, + doc: &DocInfo, + fields: Arc>, + doc_scripts: &[String], + calculations: &[(String, String)], + limits: Limits, +) -> Outcome { + run_shared_calculations_shared(script, event, doc, fields, doc_scripts, &Arc::new(calculations.to_vec()), limits) +} + +/// Like [`run_shared_calculations`], sharing its ordered calculation scripts with the context. +pub fn run_shared_calculations_shared( + script: &str, + event: &Event, + doc: &DocInfo, + fields: Arc>, + doc_scripts: &[String], + calculations: &Arc>, + limits: Limits, +) -> Outcome { let failed = |why: String| Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), error: Some(why), ..Default::default() }; if let Some(why) = refuse(script) { return failed(why); @@ -132,10 +162,11 @@ pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())), Err(e) => failed(format!("the script engine could not start: {e}")), @@ -143,7 +174,7 @@ pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc String { /// `doc_scripts` are the document scripts to run with their places among all of them; /// `skipped` the ones refused before running, with the reason. -fn run_here( - script: &str, - event: &Event, - doc: &DocInfo, - fields: Arc>, - doc_scripts: &[(usize, &str)], - skipped: &[(usize, String)], - limits: Limits, -) -> Outcome { +struct ScriptSetup<'a> { + doc_scripts: &'a [(usize, &'a str)], + skipped: &'a [(usize, String)], + calculations: &'a Arc>, +} + +fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc>, setup: ScriptSetup<'_>, limits: Limits) -> Outcome { let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(NoCodeFromStrings)).build() { Ok(ctx) => ctx, Err(e) => { @@ -206,15 +235,19 @@ fn run_here( ctx.insert_data(RefCell::new(Host { fields, changed: BTreeSet::new(), + formatted: BTreeSet::new(), alerts: Vec::new(), console: Vec::new(), requests: Vec::new(), doc: doc.clone(), + calculations: Arc::clone(setup.calculations), + calculate_depth: 0, + calculate_steps: 0, kept: 0, dropped: 0, })); let mut out = Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), ..Default::default() }; - let mut doc_errors: Vec<(usize, String)> = skipped.iter().map(|(i, why)| (*i, doc_error(*i, why))).collect(); + let mut doc_errors: Vec<(usize, String)> = setup.skipped.iter().map(|(i, why)| (*i, doc_error(*i, why))).collect(); let result = (|| -> JsResult<()> { install(&mut ctx)?; let (ev, value_store) = event_object(&mut ctx, event)?; @@ -222,7 +255,7 @@ fn run_here( // One document script that throws (calling a method PdfCraft lacks, say) must not keep // every field script in the form from running: what it defined before the error, and // the other scripts, stay available (#3940). - for &(i, d) in doc_scripts { + for &(i, d) in setup.doc_scripts { if let Err(e) = ctx.eval(Source::from_bytes(d)) { doc_errors.push((i, doc_error(i, &e))); } @@ -248,6 +281,7 @@ fn run_here( if let Some(h) = ctx.remove_data::() { let h = h.into_inner(); out.changed = h.changed.iter().map(|i| h.fields[*i].clone()).collect(); + out.formatted = h.formatted.iter().filter_map(|&i| h.fields.get(i).map(|f| f.name.clone())).collect(); out.alerts = h.alerts; out.console = h.console; out.requests = h.requests; diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 72a88ac9f3..fb49c06532 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -19,7 +19,7 @@ fn fields() -> Vec { /// The document these tests' scripts run in. fn doc() -> DocInfo { - DocInfo { file_name: "order.pdf".into(), num_pages: 3, page: 0, info: vec![("title".into(), "Order".into())], highlight: false, xfa: false } + DocInfo { file_name: "order.pdf".into(), num_pages: 3, page: 0, info: vec![("title".into(), "Order".into())], ..Default::default() } } /// Run AcroForm JavaScript `script` for `event` on these tests' document and fields. @@ -27,6 +27,59 @@ fn go(script: &str, event: &Event) -> Outcome { run(script, event, &doc(), &fields(), &[], Limits::default()) } +#[test] +fn calculate_now_reports_errors_and_continues() { + let calculations = + vec![("bad".into(), "getField('price').value = 'partial'; throw new Error('broken');".into()), ("good".into(), "event.value = 'ok';".into())]; + let state = Arc::new(vec![ + FieldState::new("bad", FieldType::Text, vec![]), + FieldState::new("good", FieldType::Text, vec![]), + FieldState::new("price", FieldType::Text, vec![]), + ]); + let outcome = run_shared_calculations( + "calculateNow(); console.println('continued');", + &Event::doc("Console"), + &doc(), + state, + &[], + &calculations, + Limits::default(), + ); + assert_eq!(outcome.error, None); + assert!(outcome.console.iter().any(|line| line.contains("calculation script for bad failed: Error: broken")), "{outcome:?}"); + assert!(outcome.console.iter().any(|line| line == "continued"), "{outcome:?}"); + assert_eq!(outcome.changed.iter().find(|field| field.name == "good").unwrap().value, ["ok"]); + assert!(outcome.changed.iter().all(|field| field.name != "price"), "{outcome:?}"); + assert_eq!(outcome.formatted, BTreeSet::from(["good".into()])); +} + +#[test] +fn calculate_now_sets_read_only_fields_and_handles_hundreds() { + let mut states = Vec::new(); + let mut calculations = Vec::new(); + for index in 0..300 { + let name = format!("field{index}"); + let mut state = FieldState::new(name.clone(), FieldType::Text, vec![]); + state.readonly = true; + states.push(state); + calculations.push((name, "event.value = 'calculated';".into())); + } + let state = Arc::new(states); + let outcome = + run_shared_calculations("calculateNow();", &Event::doc("Console"), &doc(), Arc::clone(&state), &[], &calculations, Limits::default()); + assert_eq!(outcome.error, None, "{outcome:?}"); + assert_eq!(outcome.changed.len(), 300); + assert!(outcome.changed.iter().all(|field| field.value == ["calculated"] && field.readonly)); +} + +#[test] +fn caught_calculate_now_recursion_has_a_step_limit() { + let calculations: Vec<_> = (0..600).map(|index| (format!("loop{index}"), "try { calculateNow(); } catch (e) {}".into())).collect(); + let state = Arc::new(calculations.iter().map(|(name, _)| FieldState::new(name.clone(), FieldType::Text, vec![])).collect()); + let outcome = run_shared_calculations("calculateNow();", &Event::doc("Console"), &doc(), state, &[], &calculations, Limits::default()); + assert!(outcome.error.as_deref().is_some_and(|error| error.contains("calculateNow evaluation limit exceeded")), "{outcome:?}"); +} + #[test] fn calculate_scripts_read_fields_and_set_the_value() { let o = go("event.value = this.getField('price').value * getField('qty').value;", &Event::field("Calculate", "total", "")); @@ -50,6 +103,49 @@ fn the_outcome_says_whether_the_script_assigned_event_value() { assert_eq!(o.result.as_deref(), Some("true"), "value stays an enumerable property of event"); } +#[test] +fn get_page_box_returns_supported_boxes_in_rotated_user_space() { + let mut document = doc(); + document.page_boxes = vec![ + [ + [10.0, 20.0, 210.0, 320.0], + [20.0, 30.0, 200.0, 310.0], + [25.0, 35.0, 195.0, 305.0], + [30.0, 40.0, 190.0, 300.0], + [35.0, 45.0, 185.0, 295.0] + ]; + 3 + ]; + document.page_rotations = vec![0, 90, 270]; + let out = run( + "JSON.stringify([this.getPageBox(), this.getPageBox('Media', 1), this.getPageBox('Bleed', 0), this.getPageBox('Trim', 0), this.getPageBox('Art', 2)])", + &Event::doc("Console"), + &document, + &[], + &[], + Limits::default(), + ); + assert_eq!(out.error, None); + assert_eq!(out.result.as_deref(), Some("[[0,280,180,0],[-10,190,290,-10],[5,275,175,5],[10,270,170,10],[15,165,265,15]]")); +} + +#[test] +fn get_page_box_rejects_unknown_boxes_and_invalid_pages() { + for script in [ + "this.getPageBox('BBox')", + "this.getPageBox(1)", + "this.getPageBox('Crop', -1)", + "this.getPageBox('Crop', 1.5)", + "this.getPageBox('Crop', 3)", + "this.getPageBox('Crop', null)", + "this.getPageBox('Crop', true)", + "this.getPageBox('Crop', '0')", + ] { + let out = go(script, &Event::doc("Console")); + assert!(out.error.is_some(), "{script}"); + } +} + #[test] fn numbers_with_leading_zeros_stay_text() { let o = go("event.value = typeof getField('zip').value + ':' + typeof getField('qty').value;", &Event::field("Calculate", "total", "")); @@ -392,10 +488,14 @@ fn app_alert_titles_are_clipped_and_charged() { let mut h = Host { fields: Arc::new(Vec::new()), changed: BTreeSet::new(), + formatted: BTreeSet::new(), alerts: Vec::new(), console: Vec::new(), requests: Vec::new(), doc: doc(), + calculations: Arc::new(Vec::new()), + calculate_depth: 0, + calculate_steps: 0, kept: 0, dropped: 0, }; @@ -1637,7 +1737,21 @@ $host.gotoURL("https://example.org")"#, Limits { loop_iterations: u64::MAX, recursion: 32 }, Some(std::time::Duration::ZERO), ); - assert!(o.error.unwrap().contains("too long")); + assert!(o.abandoned && o.error.as_deref().is_some_and(|e| e.contains("ran longer")), "{o:?}"); + // Stopped by its clock, a script keeps none of its changes, as when the caller stops + // waiting first: the outcome is the same whichever side notices. A loop adding rows + // until the clock stops it kept up to 1000 new rows when the script noticed first. + let o = crate::formcalc::run_formcalc_at( + "while (1) do table._row.addInstance(1) endwhile", + &ev, + &doc, + std::sync::Arc::new(form()), + false, + Limits { loop_iterations: u64::MAX, recursion: 32 }, + Some(std::time::Duration::from_millis(50)), + ); + assert!(o.abandoned && o.effects.is_empty() && o.notes.is_empty(), "{:?}, {} effects", o.error, o.effects.len()); + assert!(o.error.as_deref().is_some_and(|e| e.contains("ran longer")), "{:?}", o.error); assert!(run_hostile_formcalc(&format!("{}1{}", "-".repeat(5000), "")).error.is_some()); assert!(run_hostile_formcalc("var s = \"x\"\nfor i = 1 upto 100 do s = Concat(s, s) endfor\nLen(s)").error.unwrap().contains("too long")); assert!(run_hostile_formcalc("Space(1e12)").error.is_none()); @@ -1885,6 +1999,16 @@ mod session { slow.timeout = std::time::Duration::from_millis(200); let o = s.run(slow); assert!(o.abandoned || o.error.as_deref().is_some_and(|e| e.contains("too long")), "{o:?}"); + // Whether the script's clock or the session's wait notices first, a FormCalc loop + // adding rows until the time is up is abandoned with none of its rows, and the session + // is dead after it. + let mut s = XfaSession::new(XfaDoc::default()); + let mut slow = script("while (1) do table._row.addInstance(1) endwhile", true, "form1[0].page1[0].go[0]", "click"); + slow.limits = Limits { loop_iterations: u64::MAX, recursion: 32 }; + slow.timeout = std::time::Duration::from_millis(200); + let o = s.run(slow); + assert!(o.abandoned && o.effects.is_empty(), "{:?}, {} effects", o.error, o.effects.len()); + assert!(s.dead().is_some_and(|w| w.contains("abandoned"))); } } @@ -1924,6 +2048,61 @@ fn shared_field_snapshots_isolate_writes_errors_and_script_globals() { assert!(read.changed.is_empty()); } +mod data_objects { + use super::*; + use crate::xfa::{XfaDoc, XfaEffect, XfaEvent, run_xfa}; + + /// `exportDataObject`, as a document method or a bare global, asks to open the named attached + /// file when `nLaunch` is 1 or 2 and to save it otherwise; without `cName` it is an error. + #[test] + fn export_data_object_asks_to_open_or_save_an_attached_file() { + let o = go( + "this.exportDataObject({cName: 'Guide.pdf', nLaunch: 2}); exportDataObject('notes.txt'); \ + this.exportDataObject({cName: 'form.pdf', nLaunch: 1});", + &Event::field("Mouse Up", "total", ""), + ); + assert_eq!(o.error, None); + assert_eq!( + o.requests, + [ + Request::OpenAttachment { name: "Guide.pdf".into(), open: true }, + Request::OpenAttachment { name: "notes.txt".into(), open: false }, + Request::OpenAttachment { name: "form.pdf".into(), open: true }, + ] + ); + let o = go("this.exportDataObject({nLaunch: 2})", &Event::field("Mouse Up", "total", "")); + assert!(o.error.as_deref().is_some_and(|e| e.contains("cName")), "{:?}", o.error); + assert!(o.requests.is_empty()); + } + + /// Only `nLaunch` 1 and 2 open the file: other values (1.5, 3, a huge number) save it, as the + /// default 0 does. + #[test] + fn export_data_object_opens_only_for_n_launch_1_and_2() { + for launch in ["1.5", "3", "1e9", "-1"] { + let o = go(&format!("this.exportDataObject({{cName: 'Guide.pdf', nLaunch: {launch}}});"), &Event::field("Mouse Up", "total", "")); + assert_eq!(o.requests, [Request::OpenAttachment { name: "Guide.pdf".into(), open: false }], "nLaunch {launch}"); + } + } + + /// In an XFA script `event.target` is the document, so `exportDataObject` works on it, while + /// `xfa.event.target` is still the node the event happened on. + #[test] + fn in_xfa_scripts_event_target_is_the_document_and_xfa_event_target_the_node() { + let event = XfaEvent { activity: "click".into(), target: "form1[0].page1[0].go[0]".into(), ..Default::default() }; + let o = run_xfa( + "event.target.exportDataObject({cName: 'Guide.pdf', nLaunch: 2}); xfa.event.target.name", + &event, + &XfaDoc::default(), + &super::xfa_model::form(), + Limits::default(), + ); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.result.as_deref(), Some("go"), "xfa.event.target is still the button"); + assert!(o.effects.contains(&XfaEffect::OpenAttachment { name: "Guide.pdf".into(), open: true }), "{:?}", o.effects); + } +} + mod runtime_highlight { use super::*; use crate::xfa::{XfaDoc, XfaEffect, XfaEvent, XfaNode, run_xfa}; diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index 085262351e..ec8d7cc24d 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -15,6 +15,7 @@ use boa_engine::object::builtins::{JsArray, JsProxy}; use boa_engine::property::Attribute; use boa_engine::{Context, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string}; +use crate::globals::data_object; use crate::{Limits, Message, arg, as_number, error, function, platform, printd, printf, printx, refuse, s, scand, text}; mod globals; diff --git a/crates/js/src/xfa/globals.rs b/crates/js/src/xfa/globals.rs index 50647a3426..de57d7fd49 100644 --- a/crates/js/src/xfa/globals.rs +++ b/crates/js/src/xfa/globals.rs @@ -3,8 +3,8 @@ use super::instances::instance_manager; use super::model::{MAX_DEPTH, XfaDoc, XfaEffect, XfaEvent, clip, payload, reset_names}; use super::nodes::{node_index, node_object, noop, resolve_som, resolver, sink}; use super::{ - Attribute, Context, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, function, js_string, platform, printd, printf, printx, - s, scand, text, + Attribute, Context, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, data_object, function, js_string, platform, printd, + printf, printx, s, scand, text, }; // ── xfa.host, xfa.layout, xfa.event, app, console ─────────────────────────────────────────── @@ -15,6 +15,13 @@ fn effect(ctx: &mut Context, e: XfaEffect) -> JsResult<()> { Ok(()) } +/// `event.target.exportDataObject(…)`: the document asks to open or save an attached file. +fn export_data_object(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let (name, open) = data_object(args, ctx)?; + effect(ctx, XfaEffect::OpenAttachment { name, open })?; + Ok(JsValue::undefined()) +} + /// `app.runtimeHighlight`: whether the viewer highlights form fields. fn runtime_highlight(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { Ok(JsValue::from(host(ctx)?.borrow().doc.highlight)) @@ -67,13 +74,13 @@ fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { Ok(JsValue::undefined()) } -/// `xfa.host.beep()`: asks to beep. +/// `xfa.host.beep()` and `app.beep()`: ask to beep. fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { effect(ctx, XfaEffect::Beep)?; Ok(JsValue::undefined()) } -/// `xfa.host.gotoURL(url)`: asks to open the URL. +/// `xfa.host.gotoURL(url)` and `app.launchURL(url)`: ask to open the URL. fn goto_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let u = text(&arg(args, 0), ctx)?; effect(ctx, XfaEffect::LaunchUrl(u))?; @@ -114,50 +121,63 @@ fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult< Ok(JsValue::undefined()) } +/// `recalculate(…)` on a node or on `xfa`: records a request to recalculate the form. pub(super) fn recalculate(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { effect(ctx, XfaEffect::Recalculate)?; Ok(JsValue::undefined()) } +/// `xfa.layout.relayout()`, `relayoutPageArea()` and `remerge()`: record a request to lay the +/// form out again. pub(super) fn relayout(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { effect(ctx, XfaEffect::Relayout)?; Ok(JsValue::undefined()) } +/// `console.println(…)`: adds the line, cut to [`MAX_LINE`] characters, to the run's console +/// output. fn console_println(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let m = clip(text(&arg(args, 0), ctx)?); host(ctx)?.borrow_mut().print(m); Ok(JsValue::undefined()) } +/// `xfa.host.response(…)` and `app.response(…)`: always return `null`, as for a canceled dialog. fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { Ok(JsValue::null()) } +/// `xfa.layout.page(…)`: the current page, 1-based, whatever the argument. fn layout_page(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64 + 1.0)) } +/// `xfa.layout.absPage(…)`: the current page, 0-based, whatever the argument. fn layout_page0(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64)) } +/// `xfa.layout.pageCount()` and `absPageCount()`: the document's page count. fn layout_page_count(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { Ok(JsValue::from(host(ctx)?.borrow().doc.page_count as f64)) } +/// The `xfa.layout` measures `h`, `w`, `x`, `y` and `pageSpan`: always 0. fn zero(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { Ok(JsValue::from(0.0)) } +/// `xfa.resolveNode(som)`, or `xfa.resolveNodes(som)` when `many`, resolving from the node the +/// script runs on. fn xfa_resolve(ctx: &mut Context, many: bool) -> JsResult { let current = host(ctx)?.borrow().current; resolver(ctx, current, many) } /// Defines the globals for one script on node `current`: `xfa` (`form`, `host`, `layout`, `event`, -/// `resolveNode` and more), `event` as the same object as `xfa.event`, `app`, `console`, `util`, -/// and the nodes in scope by name, with `_name` for each repeatable one's instance manager. +/// `resolveNode` and more; `xfa.event`'s target is that node), a separate global `event` whose +/// target is a document offering `exportDataObject`, `app`, `console`, `util`, and the nodes in +/// scope by name, with `_name` for each repeatable one's instance manager. pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current: usize) -> JsResult<()> { let rw = Attribute::CONFIGURABLE; let form = node_object(ctx, 0)?; @@ -207,24 +227,33 @@ pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current .function(NativeFunction::from_fn_ptr(zero), js_string!("pageSpan"), 1) .property(js_string!("ready"), JsValue::from(true), Attribute::READONLY) .build(); - let ev = ObjectInitializer::new(ctx) - .property(js_string!("name"), s(&event.activity), Attribute::all()) - .property(js_string!("newText"), s(&event.new_text), Attribute::all()) - .property(js_string!("prevText"), s(&event.prev_text), Attribute::all()) - .property(js_string!("fullText"), s(&event.new_text), Attribute::all()) - .property(js_string!("change"), s(""), Attribute::all()) - .property(js_string!("target"), this_node.clone(), Attribute::all()) - .property(js_string!("selStart"), JsValue::from(0.0), Attribute::all()) - .property(js_string!("selEnd"), JsValue::from(0.0), Attribute::all()) - .property(js_string!("cancelAction"), JsValue::from(false), Attribute::all()) - .property(js_string!("reenter"), JsValue::from(false), Attribute::all()) - .property(js_string!("shift"), JsValue::from(false), Attribute::all()) - .property(js_string!("modifier"), JsValue::from(false), Attribute::all()) - .property(js_string!("keyDown"), JsValue::from(false), Attribute::all()) - .property(js_string!("commitKey"), JsValue::from(0.0), Attribute::all()) - .function(NativeFunction::from_fn_ptr(noop), js_string!("emit"), 0) - .function(NativeFunction::from_fn_ptr(noop), js_string!("reset"), 0) - .build(); + // Two event objects, as in Acrobat's XFA scripting: `xfa.event`, whose target is the form + // node the event happened on, and Acrobat's own `event`, whose target is the document + // (`event.target.exportDataObject(…)` is how forms open their attached files). + let acrobat_doc = + ObjectInitializer::new(ctx).function(NativeFunction::from_fn_ptr(export_data_object), js_string!("exportDataObject"), 4).build(); + let event_object = |ctx: &mut Context, target: JsValue| { + ObjectInitializer::new(ctx) + .property(js_string!("name"), s(&event.activity), Attribute::all()) + .property(js_string!("newText"), s(&event.new_text), Attribute::all()) + .property(js_string!("prevText"), s(&event.prev_text), Attribute::all()) + .property(js_string!("fullText"), s(&event.new_text), Attribute::all()) + .property(js_string!("change"), s(""), Attribute::all()) + .property(js_string!("target"), target, Attribute::all()) + .property(js_string!("selStart"), JsValue::from(0.0), Attribute::all()) + .property(js_string!("selEnd"), JsValue::from(0.0), Attribute::all()) + .property(js_string!("cancelAction"), JsValue::from(false), Attribute::all()) + .property(js_string!("reenter"), JsValue::from(false), Attribute::all()) + .property(js_string!("shift"), JsValue::from(false), Attribute::all()) + .property(js_string!("modifier"), JsValue::from(false), Attribute::all()) + .property(js_string!("keyDown"), JsValue::from(false), Attribute::all()) + .property(js_string!("commitKey"), JsValue::from(0.0), Attribute::all()) + .function(NativeFunction::from_fn_ptr(noop), js_string!("emit"), 0) + .function(NativeFunction::from_fn_ptr(noop), js_string!("reset"), 0) + .build() + }; + let ev = event_object(ctx, this_node.clone()); + let acrobat_ev = event_object(ctx, acrobat_doc.into()); let datasets = ObjectInitializer::new(ctx).property(js_string!("data"), form.clone(), rw).build(); let template = sink(ctx)?; let resolve_one = xfa_resolve(ctx, false)?; @@ -243,7 +272,7 @@ pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current .function(NativeFunction::from_fn_ptr(relayout), js_string!("remerge"), 0) .build(); ctx.register_global_property(js_string!("xfa"), xfa, rw)?; - ctx.register_global_property(js_string!("event"), ev, rw)?; + ctx.register_global_property(js_string!("event"), acrobat_ev, rw)?; ctx.register_global_property(js_string!("__xfa_this"), this_node, rw)?; let highlight_get = function(ctx, NativeFunction::from_fn_ptr(runtime_highlight)); let highlight_set = function(ctx, NativeFunction::from_fn_ptr(set_runtime_highlight)); diff --git a/crates/js/src/xfa/model.rs b/crates/js/src/xfa/model.rs index 11468c04b3..212c2dd38c 100644 --- a/crates/js/src/xfa/model.rs +++ b/crates/js/src/xfa/model.rs @@ -104,6 +104,11 @@ pub enum XfaEffect { MessageBox(Message), /// `xfa.host.resetData()`: the named fields, or every field when empty. ResetData(Vec), + /// `event.target.exportDataObject(…)`: the attached file `name`, to open or to save. + OpenAttachment { + name: String, + open: bool, + }, /// `app.execMenuItem("ShowHide…")`: show or hide a side panel. ShowPanel(crate::Panel), Print, @@ -217,6 +222,7 @@ pub(super) fn payload(e: &XfaEffect) -> usize { som.len().saturating_add(s.len()) } XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } => som.len(), + XfaEffect::OpenAttachment { name, .. } => name.len(), XfaEffect::MessageBox(m) => m.bytes(), XfaEffect::LaunchUrl(s) | XfaEffect::SetFocus(s) => s.len(), XfaEffect::ResetData(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), diff --git a/crates/js/src/xfa_session.rs b/crates/js/src/xfa_session.rs index 797f813332..88e0748625 100644 --- a/crates/js/src/xfa_session.rs +++ b/crates/js/src/xfa_session.rs @@ -179,7 +179,15 @@ mod native { loop { let left = deadline.map_or(limit, |d| d.saturating_duration_since(Instant::now())); match worker.from.recv_timeout(left) { - Ok(Reply::Outcome(o)) => return *o, + Ok(Reply::Outcome(o)) => { + // A FormCalc script its own clock stopped reports as abandoned: the + // session ends as it does when the wait runs out first. + if o.abandoned { + self.dead = o.error.clone(); + self.worker = None; + } + return *o; + } Ok(Reply::Started) => { // A second call changes nothing: the script's time has already begun. if !running { diff --git a/crates/ocr/Cargo.toml b/crates/ocr/Cargo.toml index 60e66d46c9..d3374fb41e 100644 --- a/crates/ocr/Cargo.toml +++ b/crates/ocr/Cargo.toml @@ -14,6 +14,7 @@ workspace = true pdfcraft-fonts = { workspace = true } ocrs = { version = "0.13.1", default-features = false, features = ["rten"] } rten = { version = "0.26.0", default-features = false, features = ["onnx_format", "rten_format"] } +rten-imageproc = "0.26.0" serde_json.workspace = true sha2 = "0.11" thiserror.workspace = true diff --git a/crates/ocr/src/ocrs.rs b/crates/ocr/src/ocrs.rs index 94f2abfa18..53070d90c5 100644 --- a/crates/ocr/src/ocrs.rs +++ b/crates/ocr/src/ocrs.rs @@ -4,6 +4,8 @@ use std::path::{Path, PathBuf}; +use rten_imageproc::{RectF, RotatedRect}; + use super::{Line, MAX_PIXELS, MAX_SIDE, OcrError, OcrImage, RecognizeOptions, Recognizer, Word}; /// The validated byte length of a `width` × `height` RGBA raster, with the same caps @@ -85,6 +87,47 @@ impl Models { } } +/// How far each found line is widened before its image is cut out for recognition, as fractions +/// of the line's median word height: above and below every word, and before its first and after +/// its last word. ocrs grows each word box by a fixed 3 pixels, a sliver of a letter on a page +/// scanned at 300 dpi or more: the line images clipped the first and last letters ("The" read as +/// "he", "and" as "an", closing full stops lost), and the more so the higher the resolution. +/// Chosen on synthetic pages at 150 to 400 dpi, clean and noisy, and on MDPBench's pages. More +/// padding above and below costs small, noisy text (the recogniser scales each line image to a +/// fixed height, so the margin leaves less height for the letters): at 0.15 the 8 pt "Telefon" +/// label of a noisy form scanned at 150 dpi read "Teleton". +const LINE_PAD_ACROSS: f32 = 0.12; +const LINE_PAD_ALONG: f32 = 0.1; + +/// Widen `line` (its word boxes, in reading order) as [`LINE_PAD_ACROSS`] and +/// [`LINE_PAD_ALONG`] say, after the lines were found, so finding them is unchanged. Returns the +/// box the words covered before, which the recognised words are clipped to: the text layer sizes +/// its text from them. +fn pad_line(line: &mut [RotatedRect]) -> Option { + let before = rten_imageproc::bounding_rect(line.iter())?; + // A box's height runs along its up axis and its width along the line, whichever way the + // line lies on the page. + let mut heights: Vec = line.iter().map(RotatedRect::height).filter(|h| h.is_finite() && *h > 0.0).collect(); + heights.sort_by(f32::total_cmp); + let height = heights.get(heights.len() / 2).copied().unwrap_or(0.0); + let last = line.len().saturating_sub(1); + for (i, r) in line.iter_mut().enumerate() { + let along = if i == 0 || i == last { 2.0 * LINE_PAD_ALONG * height } else { 0.0 }; + let across = 2.0 * LINE_PAD_ACROSS * height; + r.resize(r.width() + along, r.height() + across); + } + Some(before) +} + +/// `rect` ([left, top, right, bottom]) clipped to `to`; unchanged if that leaves nothing: a word +/// wholly in the padding is a letter the line found had cut off, and keeps its own box. +fn clip(rect: [f32; 4], to: Option) -> [f32; 4] { + let Some(to) = to else { return rect }; + let [l, t, r, b] = rect; + let clipped = [l.max(to.left()), t.max(to.top()), r.min(to.right()), b.min(to.bottom())]; + if clipped[0] < clipped[2] && clipped[1] < clipped[3] { clipped } else { rect } +} + /// A loaded recogniser. Loading takes a moment; keep one and reuse it. pub struct Ocr { engine: ocrs::OcrEngine, @@ -140,18 +183,20 @@ impl Ocr { let source = ocrs::ImageSource::from_bytes(&rgb, (width, height)).map_err(|e| err(&e))?; let input = self.engine.prepare_input(source).map_err(|e| err(&e))?; let found = self.engine.detect_words(&input).map_err(|e| err(&e))?; - let lines = self.engine.find_text_lines(&input, &found); + let mut lines = self.engine.find_text_lines(&input, &found); + let before: Vec> = lines.iter_mut().map(|l| pad_line(l)).collect(); let read = self.engine.recognize_text(&input, &lines).map_err(|e| err(&e))?; use ocrs::TextItem; Ok(read .into_iter() - .flatten() - .map(|line| Line { + .zip(before) + .filter_map(|(line, before)| line.map(|l| (l, before))) + .map(|(line, before)| Line { words: line .words() .map(|w| { let r = w.bounding_rect(); - Word::new(w.to_string(), [r.left() as f32, r.top() as f32, r.right() as f32, r.bottom() as f32], "ocrs") + Word::new(w.to_string(), clip([r.left() as f32, r.top() as f32, r.right() as f32, r.bottom() as f32], before), "ocrs") }) .filter(|w| !w.text.trim().is_empty()) .collect(), @@ -207,8 +252,39 @@ impl Recognizer for OcrsRecognizer { #[cfg(test)] mod tests { + use rten_imageproc::{PointF, Vec2}; + use super::*; + /// A found line is widened by fractions of its word height: every word above and below, the + /// first and the last word along the line; the box returned is the line's before. + #[test] + fn a_line_is_padded_by_its_word_height() { + let word = |l: f32, r: f32| RotatedRect::from_rect(RectF::from_tlbr(100.0, l, 140.0, r)); + let mut line = vec![word(10.0, 90.0), word(100.0, 200.0), word(210.0, 260.0)]; + let before = pad_line(&mut line).unwrap(); + assert_eq!([before.left(), before.top(), before.right(), before.bottom()], [10.0, 100.0, 260.0, 140.0]); + let (across, along) = (2.0 * 0.12 * 40.0, 2.0 * 0.1 * 40.0); + assert!(line.iter().all(|r| (r.height() - (40.0 + across)).abs() < 1e-3)); + assert!((line[0].width() - (80.0 + along)).abs() < 1e-3 && (line[2].width() - (50.0 + along)).abs() < 1e-3); + assert!((line[1].width() - 100.0).abs() < 1e-3, "a word inside the line keeps its width"); + // A box on its side (up axis horizontal) keeps its height across the line and its width + // along it. + let mut side = vec![RotatedRect::new(PointF::from_yx(0.0, 0.0), Vec2::from_yx(0.0, 1.0), 40.0, 80.0)]; + pad_line(&mut side).unwrap(); + assert!((side[0].height() - (80.0 + 2.0 * 0.12 * 80.0)).abs() < 1e-3 && (side[0].width() - (40.0 + 2.0 * 0.1 * 80.0)).abs() < 1e-3); + assert!(pad_line(&mut []).is_none()); + } + + #[test] + fn recognised_words_are_clipped_to_the_line_found() { + let line = Some(RectF::from_tlbr(100.0, 10.0, 140.0, 260.0)); + assert_eq!(clip([4.0, 94.0, 60.0, 146.0], line), [10.0, 100.0, 60.0, 140.0]); + // A word wholly in the padding keeps its own box rather than none. + assert_eq!(clip([262.0, 94.0, 270.0, 146.0], line), [262.0, 94.0, 270.0, 146.0]); + assert_eq!(clip([1.0, 2.0, 3.0, 4.0], None), [1.0, 2.0, 3.0, 4.0]); + } + /// Raw dimensions are untrusted (they come from callers, ultimately from documents): zero /// sides, sides or pixel totals over the caps, and products that would overflow the naive /// `width * height * 4` are errors, never panics, before anything is touched. diff --git a/crates/ocr/src/tesseract.rs b/crates/ocr/src/tesseract.rs index 9fbcc574d8..a4e1407331 100644 --- a/crates/ocr/src/tesseract.rs +++ b/crates/ocr/src/tesseract.rs @@ -343,8 +343,17 @@ impl TesseractCli { // Known limit: only the direct child is killed at the timeout or on an early cap. A // wrapper script's children (grandchildren of this process) may survive the kill; the // cap and the timeout still bound what this run reads and how long it waits. + let mut command = std::process::Command::new(&self.program); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + // `CREATE_NO_WINDOW`: tesseract is a console program and the app is not, so without + // it Windows opens a console window for every run, which flashes up during OCR. The + // output files above are unaffected. + command.creation_flags(0x0800_0000); + } let mut child = - std::process::Command::new(&self.program) + command .args(args) .stdin(std::process::Stdio::null()) .stdout(out_file) @@ -894,6 +903,28 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// The program starts without a console window (Windows): the app has no console, so a + /// console program that isn't told otherwise gets a window of its own, which flashes up on + /// every OCR run. PowerShell stands in for tesseract and reports, through the same output file + /// a reading comes back in, its console's window and the processes attached to that console. + /// Both are checked, because a program started without the flag by a process that has a + /// console shares it instead, and that console may itself have no window. + #[cfg(windows)] + #[test] + fn the_program_starts_without_a_console_window() { + const PROBE: &str = r#"Add-Type -Namespace PdfCraftOcrProbe -Name Console -MemberDefinition '[DllImport("kernel32.dll")] public static extern System.IntPtr GetConsoleWindow(); [DllImport("kernel32.dll")] public static extern uint GetConsoleProcessList(uint[] ids, uint count);' +$ids = New-Object uint32[] 256 +$n = [PdfCraftOcrProbe.Console]::GetConsoleProcessList($ids, 256) +"$([PdfCraftOcrProbe.Console]::GetConsoleWindow()) $($ids[0..($n-1)] -join ',')""#; + let root = std::env::var_os("SystemRoot").map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from); + let t = TesseractCli::new(root.join(r"System32\WindowsPowerShell\v1.0\powershell.exe")); + let text = t.run(&["-NoProfile", "-NonInteractive", "-Command", PROBE], t.timeout, t.max_output).unwrap(); + let (window, attached) = text.trim().split_once(' ').unwrap(); + assert_eq!(window, "0", "the program has a console window: {text}"); + let attached: Vec = attached.split(',').map(|id| id.parse().unwrap()).collect(); + assert!(!attached.is_empty() && !attached.contains(&std::process::id()), "the program shares this process's console: {text}"); + } + #[cfg(unix)] fn make_executable(p: &Path) { use std::os::unix::fs::PermissionsExt; diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 3207b69cab..08fb23d22b 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -147,6 +147,16 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going action; a bookmark keeps its title and children. Links within the document and web links (`URI`) stay. An outline of more than 100,000 bookmarks stops the optimization with an error. +Apart from optimizing, `tile_large_images` redraws large pictures for Windows printing (#5131). +Windows' PDF renderer draws an image of more than 2²² pixels from a copy shrunk to that size, so +in the copy of a print that goes to the spooler (`pdfcraft_engine::spooler::spooler_copy`) each +such image becomes a form XObject of the same object number drawing tiles, one every 2000 pixels. +Each overlaps the next by a pixel, against seams on drivers that soften image edges, so a tile has +at most 2001 pixels a side; except for an image with a soft mask, whose see-through pixels would +be blended twice there. A soft or stencil +mask of the image's size is cut the same way. JPEG 2000, JPEGs that aren't grey or RGB, masks of +another size and images over 80 million pixels are left whole. + The engine runs Remove Hidden Information (`pdfcraft-redact`) for the user-data categories, then this, then merges identical objects and writes a full, compressed save (`pdfcraft_engine::optimizer::OptimizeJob`, which the UI runs on a worker thread with a diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index 97296844a2..750f641a07 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -29,6 +29,9 @@ //! runs alongside. Links that open another document, and bookmark actions that do, are discarded //! here (Discard User Data ▸ Discard external cross-references, see //! [`Settings::discard_external_refs`]). +//! +//! Apart from optimizing, [`tile_large_images`] redraws large pictures as tiles in the copy of a +//! document printed on Windows, whose PDF renderer shrinks any image over 2²² pixels. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -40,6 +43,7 @@ mod images; mod links; mod pool; mod samples; +mod tiles; mod unused; pub use audit::{SpaceCategory, SpaceUse, audit_space}; @@ -50,6 +54,7 @@ use pdfcraft_filters::{Filter, Params}; pub use images::{ImageTarget, effective_resolutions, recolour_image}; pub use pool::give_way_to_other_programs; pub use samples::{NotDecoded, Samples, decode_samples, encode_samples}; +pub use tiles::{DOWNSAMPLED_ABOVE, TileReport, tile_large_images}; #[derive(Debug, thiserror::Error)] pub enum OptimizeError { diff --git a/crates/optimize/src/tiles.rs b/crates/optimize/src/tiles.rs new file mode 100644 index 0000000000..efe71f0276 --- /dev/null +++ b/crates/optimize/src/tiles.rs @@ -0,0 +1,357 @@ +//! Large pictures drawn as tiles, for Windows printing (#5131). +//! +//! Windows prints each page through its own PDF renderer (`Windows.Data.Pdf`), which draws an +//! image of more than 2²² pixels from a copy shrunk to that size, however finely the page is +//! printed: a 600 ppi scan of a page prints at about 200 ppi. Drawn as tiles under that size, the +//! same picture keeps every pixel, and tiles that meet leave no seam (measured in #5131). +//! [`tile_large_images`] does this to the copy of a document that goes to the spooler. + +use std::collections::HashSet; +use std::fmt::Write as _; + +use pdfcraft_cos::{Dict, Document, ImageBudget, Object, Stream}; +use pdfcraft_filters::Filter; + +use crate::images::{self, MAX_PIXELS}; + +/// Images of more pixels than this are printed from a smaller copy on Windows: every larger image +/// in #5131 came out at 4,194,304 pixels. +pub const DOWNSAMPLED_ABOVE: u64 = 1 << 22; + +/// A tile's side in pixels, before its overlap. With the overlap a tile has at most 2001 × 2001 +/// pixels, under [`DOWNSAMPLED_ABOVE`]. A multiple of 8, so that in every row a tile's samples +/// start on a byte whatever the image's bits per pixel. +const TILE: u32 = 2000; + +/// Pixels each tile reaches into the next one, so that rounding in the tiles' matrices, or a +/// printer driver that softens an image's edges, can't open a seam between them. +/// `Windows.Data.Pdf` itself leaves none either way, turned and scaled too (#5131). Not for an image +/// with a soft mask: where two tiles overlap, a pixel the mask makes see-through would be blended +/// twice and come out darker. The same goes for an opaque image drawn see-through (`/ca` below 1), +/// which this can't tell from the image: a faint line one pixel wide, against a seam on paper for +/// every tiled image on such a driver. +const OVERLAP: u32 = 1; + +/// What [`tile_large_images`] did. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct TileReport { + /// Images now drawn as tiles. + pub tiled: usize, + /// Images over [`DOWNSAMPLED_ABOVE`] left whole: JPEG 2000, a JPEG that isn't grey or RGB or + /// whose colour transform readers disagree about, a mask of another size than its image, more + /// than 80 million pixels, data shorter than the image, or past the decode allowance. + pub left_whole: usize, +} + +/// Replaces every image XObject of more than [`DOWNSAMPLED_ABOVE`] pixels with a form XObject of +/// the same object number that draws it as tiles of at most [`TILE`] pixels a side, overlapping +/// by [`OVERLAP`] unless it has a soft mask. Whatever draws the image (pages, forms, patterns, appearances) then draws the +/// tiles, unchanged. The samples are copied as they are, colour space, decode array and +/// interpolation included; a JPEG's are decoded and stored with Flate. A soft mask or stencil +/// mask of the image's size is cut into the same tiles. An image used as another's mask goes with +/// that image, and is otherwise left whole, as are the images [`TileReport::left_whole`] lists. +pub fn tile_large_images(doc: &mut Document) -> TileReport { + let mut budget = ImageBudget::new(doc); + let mut large = Vec::new(); + let mut masks = HashSet::new(); + for (r, object) in doc.scan_objects() { + let Object::Stream(s) = &*object else { continue }; + if s.dict.name(b"Subtype") != Some(b"Image") { + continue; + } + masks.extend([b"SMask".as_slice(), b"Mask"].into_iter().filter_map(|key| s.dict.reference(key))); + if size(s).is_some_and(|(w, h)| u64::from(w) * u64::from(h) > DOWNSAMPLED_ABOVE) { + large.push(r); + } + } + let mut report = TileReport::default(); + for r in large.into_iter().filter(|r| !masks.contains(r)) { + let object = doc.get(r); + let Object::Stream(image) = &*object else { continue }; + match tiles(doc, image, &mut budget) { + Some(tiles) => { + let form = tiles.store(doc, image); + doc.set(r, Object::Stream(form)); + report.tiled += 1; + } + None => report.left_whole += 1, + } + } + report +} + +/// An image's `/Width` and `/Height`, when both are positive. +fn size(s: &Stream) -> Option<(u32, u32)> { + let side = |key: &[u8]| s.dict.int(key).and_then(|v| u32::try_from(v).ok()).filter(|v| *v > 0); + Some((side(b"Width")?, side(b"Height")?)) +} + +/// An image's samples, decoded: rows of `bits_per_pixel` × width bits each, padded to a byte. +/// Kept as the image stores them, in its own colour space and depth (CMYK, indexed, 16-bit, a +/// stencil mask), so each tile redraws exactly the picture it replaces. That is why this is not +/// the crate's [`crate::Samples`], which turns an image into 8-bit grey or RGB for tools that +/// repaint it and refuses the rest. +struct StoredSamples { + data: Vec, + bits_per_pixel: usize, + /// Bits per colour component as stored: 8 for a decoded JPEG. + bits_per_component: i64, +} + +impl StoredSamples { + /// Image `s`, `w` × `h`, decoded. A JPEG gives eight-bit grey or RGB ([`images::pixels`]), + /// anything else its samples as they are stored ([`ImageBudget::decode`]). A soft mask is grey + /// whether or not it says so: ISO 32000-2 requires `/ColorSpace /DeviceGray` of one. + fn decode(doc: &Document, s: &Stream, (w, h): (u32, u32), soft_mask: bool, budget: &mut ImageBudget) -> Option { + if u64::from(w) * u64::from(h) > MAX_PIXELS { + return None; + } + let stencil = matches!(s.dict.get(b"ImageMask").map(|m| doc.resolve(m)).as_deref(), Some(Object::Bool(true))); + let components = |colour_space: fn(&Document, &Object) -> Option| match s.dict.get(b"ColorSpace") { + Some(cs) => colour_space(doc, cs), + None => soft_mask.then_some(1), + }; + match s.filters().last().map(|(f, _)| f.clone()) { + Some(Filter::Jpx) => None, + Some(Filter::Dct) if !stencil => { + let components = components(images::components)?; + let data = images::pixels(doc, s, components, budget)?; + Some(StoredSamples { data, bits_per_pixel: components.checked_mul(8)?, bits_per_component: 8 }) + } + Some(Filter::Dct) => None, + _ => { + let (components, bits_per_component) = + if stencil { (1, 1) } else { (components(sample_components)?, s.dict.int(b"BitsPerComponent")?) }; + if !matches!(bits_per_component, 1 | 2 | 4 | 8 | 16) { + return None; + } + let bits_per_pixel = components.checked_mul(usize::try_from(bits_per_component).ok()?)?; + let len = row_bytes(w, bits_per_pixel)?.checked_mul(usize::try_from(h).ok()?)?; + let mut data = budget.decode(doc, s, images::allowance(len, h)?).ok()?; + // Fewer bytes would leave rows unpainted; readers ignore what follows the last row. + if data.len() < len { + return None; + } + data.truncate(len); + Some(StoredSamples { data, bits_per_pixel, bits_per_component }) + } + } + } + + /// The samples of columns `x` and rows `y` (start and end), rows padded to a byte. `x.0` is a + /// multiple of [`TILE`], so of 8: each row of the tile starts on a byte of the image's row. + fn cut(&self, w: u32, x: (u32, u32), y: (u32, u32)) -> Option> { + let stride = row_bytes(w, self.bits_per_pixel)?; + let start = usize::try_from(x.0).ok()?.checked_mul(self.bits_per_pixel)? / 8; + let len = row_bytes(x.1.checked_sub(x.0)?, self.bits_per_pixel)?; + let mut out = Vec::with_capacity(len.checked_mul(usize::try_from(y.1.checked_sub(y.0)?).ok()?)?); + for row in y.0..y.1 { + let from = usize::try_from(row).ok()?.checked_mul(stride)?.checked_add(start)?; + out.extend_from_slice(self.data.get(from..from.checked_add(len)?)?); + } + Some(out) + } +} + +/// Bytes in a row of `w` pixels of `bits_per_pixel` bits, padded to a byte. +fn row_bytes(w: u32, bits_per_pixel: usize) -> Option { + Some(usize::try_from(w).ok()?.checked_mul(bits_per_pixel)?.div_ceil(8)) +} + +/// Colour components in a sample of colour space `cs` (ISO 32000-2 §8.6): `None` for a space an +/// image can't be in, or one that can't be read. +fn sample_components(doc: &Document, cs: &Object) -> Option { + match &*doc.resolve(cs) { + Object::Name(n) => match n.as_slice() { + b"DeviceGray" | b"G" => Some(1), + b"DeviceRGB" | b"RGB" => Some(3), + b"DeviceCMYK" | b"CMYK" => Some(4), + _ => None, + }, + Object::Array(a) => match doc.resolve(a.first()?).as_name()? { + b"CalGray" | b"Indexed" | b"I" | b"Separation" => Some(1), + b"CalRGB" | b"Lab" => Some(3), + b"ICCBased" => match &*doc.resolve(a.get(1)?) { + Object::Stream(icc) => usize::try_from(icc.dict.int(b"N")?).ok().filter(|n| (1..=32).contains(n)), + _ => None, + }, + b"DeviceN" => Some(doc.resolve(a.get(1)?).as_array()?.len()).filter(|n| (1..=32).contains(n)), + _ => None, + }, + _ => None, + } +} + +/// The spans, start and end, an image side of `len` pixels is cut into: one every [`TILE`], +/// each `overlap` longer where the side goes on. None lies wholly inside the one before's overlap. +fn spans(len: u32, overlap: u32) -> impl Iterator { + (0..len.saturating_sub(overlap).div_ceil(TILE).max(1)).map(move |i| { + let start = i.saturating_mul(TILE); + (start, start.saturating_add(TILE).saturating_add(overlap).min(len)) + }) +} + +/// One tile of an image: the columns and rows it covers (start and end), its samples, and its +/// mask's when the image has a mask stream. +struct Tile { + x: (u32, u32), + y: (u32, u32), + image: Stream, + mask: Option, +} + +/// An image cut into tiles, with its mask cut the same way, ready to be stored. +struct Tiles { + size: (u32, u32), + tiles: Vec, + /// The key the image names its mask by: `SMask` or `Mask`. + mask_key: &'static [u8], +} + +/// Image `image` cut into tiles, or `None` when it can't be read faithfully. +fn tiles(doc: &Document, image: &Stream, budget: &mut ImageBudget) -> Option { + let dims = size(image)?; + let samples = StoredSamples::decode(doc, image, dims, false, budget)?; + // A soft mask overrides `/Mask` (ISO 32000-2, the image dictionary's `/SMask`). A mask stream + // goes into the same tiles, so it has to have the image's size. + let soft = image.dict.contains(b"SMask"); + let mask_key: &'static [u8] = if soft { b"SMask" } else { b"Mask" }; + let mask_object = image.dict.get(mask_key).map(|m| doc.resolve(m)); + let mask = match mask_object.as_deref() { + Some(Object::Stream(m)) if size(m) == Some(dims) => Some((m, StoredSamples::decode(doc, m, dims, soft, budget)?)), + // A colour key mask goes on each tile as it is (`tile_dict`). + Some(Object::Array(_)) if !soft => None, + None | Some(Object::Null) => None, + Some(_) => return None, + }; + let mut tiles = Vec::new(); + // Overlapping tiles blend a see-through pixel twice ([`OVERLAP`]). + let overlap = if soft { 0 } else { OVERLAP }; + for y in spans(dims.1, overlap) { + for x in spans(dims.0, overlap) { + let tile = |s: &Stream, samples: &StoredSamples| -> Option { + let dict = tile_dict(doc, &s.dict, (x.1.saturating_sub(x.0), y.1.saturating_sub(y.0)), samples.bits_per_component); + Some(Stream::flate(dict, &samples.cut(dims.0, x, y)?)) + }; + let mask_tile = match &mask { + Some((m, samples)) => Some(tile(m, samples)?), + None => None, + }; + tiles.push(Tile { x, y, image: tile(image, &samples)?, mask: mask_tile }); + } + } + Some(Tiles { size: dims, tiles, mask_key }) +} + +/// The dictionary of a tile `w` × `h` of the image whose dictionary is `image`: what paints its +/// samples, and nothing that belongs to the image as a whole (its mask goes on separately; its +/// optional content, structure and metadata stay with the form that replaces it). +fn tile_dict(doc: &Document, image: &Dict, (w, h): (u32, u32), bits_per_component: i64) -> Dict { + let mut d = Dict::new(); + d.set("Type", Object::name("XObject")); + d.set("Subtype", Object::name("Image")); + d.set("Width", Object::Int(i64::from(w))); + d.set("Height", Object::Int(i64::from(h))); + d.set("BitsPerComponent", Object::Int(bits_per_component)); + for key in [b"ColorSpace".as_slice(), b"Decode", b"ImageMask", b"Interpolate", b"Intent", b"Matte"] { + if let Some(v) = image.get(key) { + d.set(key.to_vec(), v.clone()); + } + } + // A colour key mask lists sample values, which the tiles keep. + if let Some(Object::Array(key)) = image.get(b"Mask").map(|m| doc.resolve(m)).as_deref() { + d.set("Mask", Object::Array(key.clone())); + } + d +} + +impl Tiles { + /// Adds the tiles to `doc` and returns the form XObject that draws them where `image` was: the + /// unit square, each tile in its share of it, the image's top row at the top. + fn store(self, doc: &mut Document, image: &Stream) -> Stream { + let (w, h) = (f64::from(self.size.0), f64::from(self.size.1)); + let mut names = Dict::new(); + let mut content = String::new(); + for (i, Tile { x, y, image: mut tile, mask }) in self.tiles.into_iter().enumerate() { + if let Some(mask) = mask { + tile.dict.set(self.mask_key.to_vec(), Object::Ref(doc.add(Object::Stream(mask)))); + } + names.set(format!("T{i}").into_bytes(), Object::Ref(doc.add(Object::Stream(tile)))); + let [a, d] = [f64::from(x.1.saturating_sub(x.0)) / w, f64::from(y.1.saturating_sub(y.0)) / h]; + let [e, f] = [f64::from(x.0) / w, 1.0 - f64::from(y.1) / h]; + // Writing to a String can't fail. + let _ = writeln!(content, "q {} 0 0 {} {} {} cm /T{i} Do Q", number(a), number(d), number(e), number(f)); + } + let mut resources = Dict::new(); + resources.set("XObject", Object::Dict(names)); + let mut form = Dict::new(); + form.set("Type", Object::name("XObject")); + form.set("Subtype", Object::name("Form")); + form.set("BBox", Object::Array([0, 0, 1, 1].map(Object::Int).to_vec())); + form.set("Resources", Object::Dict(resources)); + if let Some(oc) = image.dict.get(b"OC") { + form.set("OC", oc.clone()); + } + Stream::flate(form, content.as_bytes()) + } +} + +/// `v` in a content stream: ten decimals, enough for a pixel of an image 80 million wide, trailing +/// zeros dropped. +fn number(v: f64) -> String { + let s = format!("{v:.10}"); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s.is_empty() || s == "-" { "0".to_string() } else { s.to_string() } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sides_are_cut_every_tile_with_an_overlap() { + let spans = |len| spans(len, OVERLAP).collect::>(); + assert_eq!(spans(1), [(0, 1)]); + assert_eq!(spans(2000), [(0, 2000)]); + // The 2001st pixel is the first tile's overlap: no tile of its own. + assert_eq!(spans(2001), [(0, 2001)]); + assert_eq!(spans(2002), [(0, 2001), (2000, 2002)]); + assert_eq!(spans(6000), [(0, 2001), (2000, 4001), (4000, 6000)]); + // Every pixel is in a tile, and no tile has more than its side and the overlap. + for len in [1, 1999, 2000, 2001, 2002, 4001, 4002, 6000, 123_457] { + let s = spans(len); + let zero: Vec<_> = super::spans(len, 0).collect(); + assert!(zero.windows(2).all(|w| w[1].0 == w[0].1), "{len}: tiles without an overlap meet exactly"); + assert_eq!(s.first().map(|t| t.0), Some(0)); + assert_eq!(s.last().map(|t| t.1), Some(len)); + assert!(s.windows(2).all(|w| w[1].0 < w[0].1), "{len}: a gap"); + assert!(s.iter().all(|(a, b)| b - a <= TILE + OVERLAP && a % 8 == 0), "{len}"); + } + } + + #[test] + fn a_tile_takes_whole_bytes_of_each_row_at_any_bit_depth() { + // Two rows of 4004 one-bit pixels: 501 bytes each, the last holding 4 pixels. + let row: Vec = (0..501).map(|i| i as u8).collect(); + let samples = StoredSamples { data: [row.clone(), row.iter().map(|b| !b).collect()].concat(), bits_per_pixel: 1, bits_per_component: 1 }; + let tile = samples.cut(4004, (4000, 4004), (0, 2)).unwrap(); + assert_eq!(tile, [row[500], !row[500]]); + let first = samples.cut(4004, (0, 2001), (1, 2)).unwrap(); + assert_eq!(first.len(), 251); + assert_eq!(first[0], !row[0]); + // Sixteen-bit RGB: six bytes a pixel. + let rgb16 = StoredSamples { data: (0..=255u8).cycle().take(6 * 2002).collect(), bits_per_pixel: 48, bits_per_component: 16 }; + assert_eq!(rgb16.cut(2002, (2000, 2002), (0, 1)).unwrap(), rgb16.data[12_000..12_012]); + // Rows past the data are no tile, not a panic. + assert_eq!(samples.cut(4004, (0, 8), (1, 3)), None); + } + + #[test] + fn numbers_are_written_plainly() { + assert_eq!(number(0.0), "0"); + assert_eq!(number(1.0), "1"); + assert_eq!(number(0.5), "0.5"); + assert_eq!(number(2001.0 / 35_000.0), "0.0571714286"); + assert_eq!(number(1.0 - 4001.0 / 6000.0), "0.3331666667"); + } +} diff --git a/crates/optimize/tests/tiles.rs b/crates/optimize/tests/tiles.rs new file mode 100644 index 0000000000..12e5f60352 --- /dev/null +++ b/crates/optimize/tests/tiles.rs @@ -0,0 +1,242 @@ +//! Large pictures drawn as tiles for Windows printing (#5131): the page looks the same, and no +//! image it draws is over the size Windows' renderer shrinks. + +use std::sync::Arc; + +use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; +use pdfcraft_optimize::{DOWNSAMPLED_ABOVE, TileReport, tile_large_images}; + +/// 4200 × 1000: just over [`DOWNSAMPLED_ABOVE`], three tiles wide. +const W: u32 = 4200; +const H: u32 = 1000; + +fn image_dict(w: u32, h: u32, entries: &[(&str, Object)]) -> Dict { + let mut d = Dict::new(); + d.set("Type", Object::name("XObject")); + d.set("Subtype", Object::name("Image")); + d.set("Width", Object::Int(i64::from(w))); + d.set("Height", Object::Int(i64::from(h))); + for (k, v) in entries { + d.set(*k, v.clone()); + } + d +} + +/// Samples that change at every pixel, so a tile out of place by one shows: `n` bytes a pixel. +fn texture(w: u32, h: u32, n: usize) -> Vec { + (0..h).flat_map(|y| (0..w).flat_map(move |x| (0..n).map(move |c| (x.wrapping_mul(7) ^ y.wrapping_mul(13) ^ (c as u32 * 85)) as u8))).collect() +} + +/// A document with one page `w` × `h` points that draws image `im` (added by `add`) over it +/// after `before` (a fill colour, say), so that at scale 1 each sample is a pixel. +fn page_drawing(w: u32, h: u32, before: &str, add: impl FnOnce(&mut Document) -> ObjRef) -> (Document, ObjRef) { + 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 im = add(&mut doc); + let mut xobjects = Dict::new(); + xobjects.set("Im", Object::Ref(im)); + let mut resources = Dict::new(); + resources.set("XObject", Object::Dict(xobjects)); + let content = format!("{before} q {w} 0 0 {h} 0 0 cm /Im Do Q"); + let c = doc.add(Object::Stream(Stream::from_raw(Dict::new(), content.into_bytes()))); + let mut p = Dict::new(); + p.set("Type", Object::name("Page")); + p.set("Parent", Object::Ref(pages)); + p.set("MediaBox", Object::Array(vec![0.into(), 0.into(), Object::Int(i64::from(w)), Object::Int(i64::from(h))])); + p.set("Resources", Object::Dict(resources)); + p.set("Contents", Object::Ref(c)); + let r = doc.add(Object::Dict(p)); + doc.update_dict(pages, |d| { + d.set("Count", Object::Int(1)); + d.set("Kids", Object::Array(vec![Object::Ref(r)])); + }) + .unwrap(); + (doc, im) +} + +fn render(doc: &Document) -> Vec { + let bytes = write_full(doc, &SaveOptions::default()).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(Arc::new(bytes), pdfcraft_render::RenderConfig::default()); + let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + out.rgba.to_vec() +} + +/// Tiles the image of `doc`, then checks that the image is now a form that draws `count` tiles under +/// the cap, and that the page renders as before, within `tolerance` a channel. +fn tiles_and_looks_the_same(mut doc: Document, im: ObjRef, count: usize, tolerance: u8) { + let before = render(&doc); + assert_eq!(tile_large_images(&mut doc), TileReport { tiled: 1, left_whole: 0 }); + let form = doc.get(im); + let Object::Stream(form) = &*form else { panic!("the image isn't a stream") }; + assert_eq!(form.dict.name(b"Subtype"), Some(b"Form".as_slice())); + let tiles = form.dict.get(b"Resources").and_then(Object::as_dict).and_then(|r| r.get(b"XObject")).and_then(Object::as_dict).unwrap(); + assert_eq!(tiles.len(), count); + for (_, tile) in tiles.iter() { + let tile = doc.get(tile.as_ref().unwrap()); + let Object::Stream(t) = &*tile else { panic!("a tile isn't a stream") }; + let pixels = t.dict.int(b"Width").unwrap() * t.dict.int(b"Height").unwrap(); + assert!(pixels as u64 <= DOWNSAMPLED_ABOVE, "a tile of {pixels} pixels"); + } + let after = render(&doc); + assert_eq!(before.len(), after.len()); + let worst = before.iter().zip(&after).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0); + assert!(worst <= tolerance, "the tiled page differs by {worst} in a channel"); +} + +#[test] +fn rgb_samples_are_cut_to_the_pixel() { + let (doc, im) = page_drawing(W, H, "", |doc| { + let d = image_dict(W, H, &[("ColorSpace", Object::name("DeviceRGB")), ("BitsPerComponent", Object::Int(8))]); + doc.add(Object::Stream(Stream::flate(d, &texture(W, H, 3)))) + }); + tiles_and_looks_the_same(doc, im, 3, 0); +} + +#[test] +fn one_bit_grey_and_four_bit_indexed_samples_are_cut_on_byte_boundaries() { + let bits: Vec = texture(W.div_ceil(8), H, 1); + let (doc, im) = page_drawing(W, H, "", |doc| { + let d = image_dict(W, H, &[("ColorSpace", Object::name("DeviceGray")), ("BitsPerComponent", Object::Int(1))]); + doc.add(Object::Stream(Stream::flate(d, &bits))) + }); + tiles_and_looks_the_same(doc, im, 3, 0); + let palette: Vec = (0..48u8).map(|i| i.wrapping_mul(37)).collect(); + let nibbles: Vec = texture(W.div_ceil(2), H, 1); + let (doc, im) = page_drawing(W, H, "", |doc| { + let indexed = Object::Array(vec![ + Object::name("Indexed"), + Object::name("DeviceRGB"), + Object::Int(15), + Object::String(pdfcraft_cos::PdfString::literal(palette.clone())), + ]); + let d = image_dict(W, H, &[("ColorSpace", indexed), ("BitsPerComponent", Object::Int(4))]); + doc.add(Object::Stream(Stream::flate(d, &nibbles))) + }); + tiles_and_looks_the_same(doc, im, 3, 0); +} + +#[test] +fn a_cmyk_image_with_a_decode_array_keeps_both() { + let (doc, im) = page_drawing(W, H, "", |doc| { + let decode = Object::Array([1, 0, 0, 1, 1, 0, 0, 1].map(Object::Int).to_vec()); + let d = image_dict(W, H, &[("ColorSpace", Object::name("DeviceCMYK")), ("BitsPerComponent", Object::Int(8)), ("Decode", decode)]); + doc.add(Object::Stream(Stream::flate(d, &texture(W, H, 4)))) + }); + tiles_and_looks_the_same(doc, im, 3, 0); +} + +#[test] +fn a_soft_mask_is_cut_into_the_same_tiles() { + let (doc, im) = page_drawing(W, H, "0 0 1 rg 0 0 4200 1000 re f", |doc| { + // Without `/ColorSpace`, as many writers leave a soft mask. + let mask = image_dict(W, H, &[("BitsPerComponent", Object::Int(8))]); + let mask = doc.add(Object::Stream(Stream::flate(mask, &texture(W, H, 1)))); + let d = image_dict(W, H, &[("ColorSpace", Object::name("DeviceRGB")), ("BitsPerComponent", Object::Int(8)), ("SMask", Object::Ref(mask))]); + doc.add(Object::Stream(Stream::flate(d, &texture(W, H, 3)))) + }); + tiles_and_looks_the_same(doc, im, 3, 0); +} + +#[test] +fn a_stencil_mask_paints_the_fill_colour_through_its_tiles() { + let bits: Vec = texture(W.div_ceil(8), H, 1); + let (doc, im) = page_drawing(W, H, "1 0 0 rg", |doc| { + let d = image_dict(W, H, &[("ImageMask", Object::Bool(true))]); + doc.add(Object::Stream(Stream::flate(d, &bits))) + }); + tiles_and_looks_the_same(doc, im, 3, 0); +} + +#[test] +fn a_jpeg_is_decoded_into_its_tiles() { + let px = texture(W, H, 3); + let mut jpeg = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 95).encode(&px, W, H, image::ExtendedColorType::Rgb8).unwrap(); + let (doc, im) = page_drawing(W, H, "", |doc| { + let d = image_dict( + W, + H, + &[("ColorSpace", Object::name("DeviceRGB")), ("BitsPerComponent", Object::Int(8)), ("Filter", Object::name("DCTDecode"))], + ); + doc.add(Object::Stream(Stream::from_raw(d, jpeg))) + }); + // The renderer's JPEG decoder and the one tiling uses may round a sample differently. + tiles_and_looks_the_same(doc, im, 3, 2); +} + +/// Adds an image to a document. +type AddImage = Box ObjRef>; + +#[test] +fn images_it_cannot_cut_faithfully_are_left_whole() { + fn rgb() -> Dict { + image_dict(W, H, &[("ColorSpace", Object::name("DeviceRGB")), ("BitsPerComponent", Object::Int(8))]) + } + let cases: Vec<(&str, AddImage)> = vec![ + ( + "JPEG 2000", + Box::new(|doc| { + let mut d = rgb(); + d.set("Filter", Object::name("JPXDecode")); + doc.add(Object::Stream(Stream::from_raw(d, b"not decoded here".to_vec()))) + }), + ), + ("data short of the image", Box::new(|doc| doc.add(Object::Stream(Stream::flate(rgb(), &texture(W, H - 1, 3)))))), + ( + "a soft mask of another size", + Box::new(|doc| { + let mask = doc.add(Object::Stream(Stream::flate(image_dict(10, 10, &[("BitsPerComponent", Object::Int(8))]), &[0; 100]))); + let mut d = rgb(); + d.set("SMask", Object::Ref(mask)); + doc.add(Object::Stream(Stream::flate(d, &texture(W, H, 3)))) + }), + ), + ( + "an unknown colour space", + Box::new(|doc| { + let mut d = rgb(); + d.set("ColorSpace", Object::name("Fancy")); + doc.add(Object::Stream(Stream::flate(d, &texture(W, H, 3)))) + }), + ), + ( + "12 bits a component", + Box::new(|doc| { + let mut d = rgb(); + d.set("BitsPerComponent", Object::Int(12)); + doc.add(Object::Stream(Stream::flate(d, &texture(W, H, 3)))) + }), + ), + ]; + for (what, add) in cases { + let (mut doc, im) = page_drawing(W, H, "", add); + let before = doc.get(im); + assert_eq!(tile_large_images(&mut doc), TileReport { tiled: 0, left_whole: 1 }, "{what}"); + assert!(Arc::ptr_eq(&before, &doc.get(im)) || *before == *doc.get(im), "{what}: changed"); + } +} + +#[test] +fn small_images_and_a_large_mask_of_a_small_image_are_not_touched() { + let (mut doc, im) = page_drawing(W, H, "", |doc| { + let mask = doc.add(Object::Stream(Stream::flate(image_dict(W, H, &[("BitsPerComponent", Object::Int(8))]), &texture(W, H, 1)))); + // Exactly the cap: drawn whole by Windows. + let d = + image_dict(2048, 2048, &[("ColorSpace", Object::name("DeviceGray")), ("BitsPerComponent", Object::Int(8)), ("SMask", Object::Ref(mask))]); + doc.add(Object::Stream(Stream::flate(d, &texture(2048, 2048, 1)))) + }); + let before = doc.get(im); + assert_eq!(tile_large_images(&mut doc), TileReport::default()); + assert_eq!(*before, *doc.get(im)); +} + +#[test] +fn a_grid_of_tiles_keeps_its_rows_and_columns_in_place() { + // 2100 × 2100: two tiles a side, the top row first in the samples and at the top of the page. + let (doc, im) = page_drawing(2100, 2100, "", |doc| { + let d = image_dict(2100, 2100, &[("ColorSpace", Object::name("DeviceRGB")), ("BitsPerComponent", Object::Int(8))]); + doc.add(Object::Stream(Stream::flate(d, &texture(2100, 2100, 3)))) + }); + tiles_and_looks_the_same(doc, im, 4, 0); +} diff --git a/crates/platform/Cargo.toml b/crates/platform/Cargo.toml index a552a43f81..1808c4128f 100644 --- a/crates/platform/Cargo.toml +++ b/crates/platform/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true workspace = true [dependencies] +log = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] # `write_atomically`: a volume that refuses F_FULLFSYNC gets a plain fsync. diff --git a/crates/platform/src/owner_only.rs b/crates/platform/src/owner_only.rs index f7f4c20b5d..1151aba5d7 100644 --- a/crates/platform/src/owner_only.rs +++ b/crates/platform/src/owner_only.rs @@ -132,10 +132,19 @@ const ICACLS: &str = "icacls.exe"; /// A tool Windows ships, started by its full path (review on #3956). Started by bare name, a /// `whoami.exe`, `powershell.exe` or `icacls.exe` found first (next to the app, or earlier on the /// search path) would answer instead, and `whoami`'s answer is what every check here trusts. +/// +/// Each tool is a console program and the app is not, so without `CREATE_NO_WINDOW` Windows gives +/// every one of them a console window of its own, which flashes up while the app writes its +/// `--control` file. The flag doesn't touch the pipes `output()` reads the answer through. #[cfg(windows)] fn system_tool(relative: &str) -> std::process::Command { + use std::os::windows::process::CommandExt as _; + /// `CREATE_NO_WINDOW`, from the Windows process creation flags. + const CREATE_NO_WINDOW: u32 = 0x0800_0000; let root = std::env::var_os("SystemRoot").map_or_else(|| std::path::PathBuf::from(r"C:\Windows"), std::path::PathBuf::from); - std::process::Command::new(root.join("System32").join(relative)) + let mut command = std::process::Command::new(root.join("System32").join(relative)); + command.creation_flags(CREATE_NO_WINDOW); + command } #[cfg(windows)] @@ -384,6 +393,28 @@ mod tests { } } + /// The tools start without a console window: the app has no console, so a tool that isn't + /// told otherwise gets a window of its own, which flashes up. The started PowerShell reports + /// its console's window and the processes attached to that console. Both are checked, because + /// a tool started without the flag by a process that has a console shares it instead, and that + /// console may itself have no window (a test run started by a service or an IDE). + #[cfg(windows)] + #[test] + fn the_tools_start_without_a_console_window() { + const PROBE: &str = r#"Add-Type -Namespace PdfCraftProbe -Name Console -MemberDefinition '[DllImport("kernel32.dll")] public static extern System.IntPtr GetConsoleWindow(); [DllImport("kernel32.dll")] public static extern uint GetConsoleProcessList(uint[] ids, uint count);' +$ids = New-Object uint32[] 256 +$n = [PdfCraftProbe.Console]::GetConsoleProcessList($ids, 256) +"$([PdfCraftProbe.Console]::GetConsoleWindow()) $($ids[0..($n-1)] -join ',')""#; + let out = system_tool(POWERSHELL).args(["-NoProfile", "-NonInteractive", "-Command", PROBE]).output().unwrap(); + // The answer still comes back through the pipe. + let text = String::from_utf8_lossy(&out.stdout); + assert!(out.status.success(), "{text} {}", String::from_utf8_lossy(&out.stderr)); + let (window, attached) = text.trim().split_once(' ').unwrap(); + assert_eq!(window, "0", "the tool has a console window: {text}"); + let attached: Vec = attached.split(',').map(|id| id.parse().unwrap()).collect(); + assert!(!attached.is_empty() && !attached.contains(&std::process::id()), "the tool shares this process's console: {text}"); + } + #[test] fn other_owners_readers_and_missing_lists_are_refused() { let other = "S-1-5-21-1-2-3-1002"; diff --git a/crates/platform/src/staging.rs b/crates/platform/src/staging.rs index 4316051352..b9b2a80443 100644 --- a/crates/platform/src/staging.rs +++ b/crates/platform/src/staging.rs @@ -132,7 +132,8 @@ pub fn staging_suffixes() -> impl Iterator { /// (`sync_all`); parent-directory durability after a power loss is not promised. On Unix a file /// that is replaced keeps its permissions and group, so a private file stays private; a new file gets the /// usual mode for a new file. On Windows a replaced file stays hidden or system if it was, and -/// plain if it was plain. On Windows and macOS a replaced file keeps its creation date, where +/// plain if it was plain, even where a server hides the dot-named staging file (#5001, #4593, #6029, #6119). +/// On Windows and macOS a replaced file keeps its creation date, where /// the volume stores one. /// On macOS, accessible extended attributes of an existing regular destination are copied /// before flushing; a copy failure refuses the save rather than losing metadata (#2658). @@ -185,7 +186,28 @@ pub fn write_atomically_with( keep_created(&file, created); sync_staged(&file, sync_all)?; } - std::fs::rename(&tmp, path) + let rename_result = std::fs::rename(&tmp, path); + #[cfg(windows)] + if rename_result.is_ok() + && let Some(keep) = &keep + { + // Restore attributes only if the saved file's hidden or system bits differ from the + // replaced file's (#5001, #4593, #6029, #6119). Samba/NAS servers mark dot-named + // staging files hidden and persist that flag through the rename; on local disks, the + // staging file already has the right KEPT_ATTRIBUTES. Restoring unconditionally would + // overwrite the newly written file's entire attribute word with the old file's, + // incorrectly wiping the archive bit (which backup tools need) or retaining offline/index + // flags. On servers where attributes differ, restoring permissions is a safe trade to keep + // the file visible. When saving a new file (`keep` is None), no baseline exists. + use std::os::windows::fs::MetadataExt as _; + let saved = std::fs::symlink_metadata(path).map(|m| m.file_attributes() & KEPT_ATTRIBUTES); + if saved.is_ok_and(|a| a != (keep.file_attributes() & KEPT_ATTRIBUTES)) + && let Err(e) = std::fs::set_permissions(path, keep.permissions()) + { + log::warn!("could not restore replaced file attributes on {}: {e}", path.display()); + } + } + rename_result })(); if result.is_err() { let _ = std::fs::remove_file(&tmp); diff --git a/crates/platform/src/staging/tests.rs b/crates/platform/src/staging/tests.rs index b5552a2c7b..da1e015e15 100644 --- a/crates/platform/src/staging/tests.rs +++ b/crates/platform/src/staging/tests.rs @@ -448,3 +448,76 @@ fn a_replaced_link_keeps_its_own_attributes_not_its_targets() { assert_ne!(std::fs::metadata(&referent).unwrap().file_attributes() & HIDDEN, 0, "and still hidden"); let _ = std::fs::remove_dir_all(&dir); } + +/// #5001, #4593: a save overwriting an existing file preserves its original attributes even if +/// the server (such as Samba/QNAP NAS hiding dot-files) marks the temporary staging file hidden. +#[cfg(windows)] +#[test] +fn a_replaced_file_restores_attributes_if_server_hides_staging_file() { + use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; + const HIDDEN: u32 = 0x2; + let dir = test_dir("server-hides-staging"); + let target = dir.join("plain.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + assert_eq!(std::fs::metadata(&target).unwrap().file_attributes() & HIDDEN, 0, "target starts visible"); + + // Create a temporary hidden reference file to obtain permissions with the HIDDEN attribute. + let hidden_ref = dir.join("hidden_ref"); + let _ = std::fs::OpenOptions::new().write(true).create_new(true).attributes(HIDDEN).open(&hidden_ref).unwrap(); + let hidden_perm = std::fs::metadata(&hidden_ref).unwrap().permissions(); + let _ = std::fs::remove_file(&hidden_ref); + + for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] { + write_atomically_with(&target, b"NEW", form, [77], |_file| { + // Find the staging file in dir (the file that is not target): + for entry in std::fs::read_dir(&dir)? { + let path = entry?.path(); + if path != target { + // Mark the staging file as HIDDEN on disk (simulating Samba hiding dot-files). + std::fs::set_permissions(&path, hidden_perm.clone())?; + assert_ne!(std::fs::metadata(&path)?.file_attributes() & HIDDEN, 0, "staging file was marked hidden"); + } + } + Ok(()) + }) + .unwrap(); + + assert_eq!( + std::fs::metadata(&target).unwrap().file_attributes() & HIDDEN, + 0, + "the saved file remains visible despite server hiding the staging file" + ); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + } + + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +#[cfg(windows)] +fn a_replaced_local_file_keeps_archive_bit_intact() { + use std::os::windows::fs::MetadataExt as _; + let mut dir = std::env::temp_dir(); + dir.push(format!("pdfcraft-platform-test-archive-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + let target = dir.join("plain.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + + // Clear the archive bit on the replaced file (simulating backup tools like robocopy /M or attrib -A). + const ARCHIVE: u32 = 0x20; + let status = std::process::Command::new("attrib").arg("-A").arg(&target).status().unwrap(); + assert!(status.success(), "attrib -A succeeded"); + let old_attrs = std::fs::metadata(&target).unwrap().file_attributes(); + assert_eq!(old_attrs & ARCHIVE, 0, "archive bit was cleared on the old file"); + + write_atomically(&target, b"NEW", StagingName::TagThenSuffix).unwrap(); + + // The newly written file must have the archive bit set by the system (#5001, #4593, #6029, #6119). + let new_attrs = std::fs::metadata(&target).unwrap().file_attributes(); + assert_ne!(new_attrs & ARCHIVE, 0, "the newly saved file has the archive bit set by the file system"); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index d5a6d80b04..54a8a9508f 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -521,6 +521,41 @@ fn content_colour(data: &[u8], used: &mut DeviceColour) -> usize { read } +/// Records any device colour space named directly or used as the base or alternate space of a +/// colour-space object (`Name` or `Array` such as `[/Indexed base …]`, `[/Separation _ alt …]`, +/// or `[/DeviceN _ alt …]`). +fn mark_cs_object(doc: &Document, cs: &Object, used: &mut DeviceColour, depth: u8) { + if depth > MAX_DIRECT_DEPTH { + return; + } + match cs { + Object::Ref(r) => mark_cs_object(doc, &doc.get(*r), used, depth + 1), + Object::Name(n) => used.mark(n, false), + Object::Array(a) => { + let mut kind = a.first().map(|o| doc.resolve(o)); + for _ in 0..MAX_DIRECT_DEPTH { + let Some(&Object::Ref(r)) = kind.as_deref() else { break }; + kind = Some(doc.get(r)); + } + match kind.as_deref().and_then(Object::as_name) { + Some(n) if a.len() == 1 => used.mark(n, false), + Some(b"I" | b"Indexed" | b"Pattern") => { + if let Some(base) = a.get(1) { + mark_cs_object(doc, base, used, depth + 1); + } + } + Some(b"Separation" | b"DeviceN") => { + if let Some(alt) = a.get(2) { + mark_cs_object(doc, alt, used, depth + 1); + } + } + _ => {} + } + } + _ => {} + } +} + /// Whether any content or object uses a device colour space. fn uses_device_colour(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) -> DeviceColour { let mut used = DeviceColour::default(); @@ -536,15 +571,15 @@ fn uses_device_colour(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) { used.default_rgb |= spaces.get(b"DefaultRGB").is_some(); used.default_cmyk |= spaces.get(b"DefaultCMYK").is_some(); + for (k, entry) in spaces.iter() { + if !matches!(k.as_slice(), b"DefaultGray" | b"DefaultRGB" | b"DefaultCMYK") { + mark_cs_object(doc, entry, &mut used, 0); + } + } } } - if let Some(cs) = d.get(b"ColorSpace").map(|c| doc.resolve(c)) { - match &*cs { - Object::Name(n) if n == b"DeviceGray" => used.gray = true, - Object::Name(n) if n == b"DeviceRGB" => used.rgb = true, - Object::Name(n) if n == b"DeviceCMYK" => used.cmyk = true, - _ => {} - } + if let Some(cs) = d.get(b"ColorSpace") { + mark_cs_object(doc, cs, &mut used, 0); } // Content streams only: a font program or an ICC profile is data, whatever bytes it holds. // Parsing is the expensive part, so once content has used all three spaces no more is read. @@ -947,8 +982,20 @@ fn check_objects( let Some(d) = dict_of(o) else { continue }; check_one_object(doc, level, d, o, drawing.contains(r), page_of(*r), issue); } + for (i, p) in pdfcraft_model::pages(doc).into_iter().enumerate() { + if let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a)) + && let Some(a) = a.as_array() + { + for entry in a { + if let Object::Dict(d) = entry { + check_one_object(doc, level, d, entry, false, Some(i), issue); + } + } + } + } } +/// Checks a single object against the PDF/A rules for `level`. fn check_one_object( doc: &Document, level: Level, @@ -961,13 +1008,14 @@ fn check_one_object( let ty = dict.name(b"Type"); let subtype = dict.name(b"Subtype"); check_actions(doc, dict, issue); - if is_annotation(dict, ty, subtype) && check_annotation(doc, dict, subtype, page, issue) { + let is_annot = matches!(object, Object::Dict(_)) && is_annotation(doc, dict, ty, subtype, page.is_some()); + if is_annot && check_annotation(doc, dict, subtype, page, issue) { // A forbidden annotation stops every later check for its object, as the scan did before // it was split into helpers: the rest of its own checks and the form field, font, stream // and embedded file below all came after that `continue`. return; } - if dict.get(b"FT").is_some() && dict.get(b"AA").is_some() && ty != Some(b"Annot") { + if dict.get(b"FT").is_some() && dict.get(b"AA").is_some() && !is_annot { issue("6.5.2", "A form field has additional actions", None, None, true); } // A Type 0 font's descendant is checked through it. @@ -992,8 +1040,11 @@ fn check_actions(doc: &Document, dict: &Dict, issue: &mut impl FnMut(&'static st } } -fn is_annotation(dict: &Dict, ty: Option<&[u8]>, subtype: Option<&[u8]>) -> bool { - ty == Some(b"Annot") || (subtype.is_some() && dict.get(b"Rect").is_some() && dict.get(b"P").is_some()) +/// Whether `dict` is an annotation dictionary (`/Type /Annot`, or a dictionary with the required +/// `/Subtype` and `/Rect` array entries that either sits in a page's `/Annots` list or carries `/P`). +fn is_annotation(doc: &Document, dict: &Dict, ty: Option<&[u8]>, subtype: Option<&[u8]>, on_page: bool) -> bool { + ty == Some(b"Annot") + || (subtype.is_some() && dict.get(b"Rect").is_some_and(|r| doc.resolve(r).as_array().is_some()) && (on_page || dict.get(b"P").is_some())) } fn check_annotation( @@ -1044,6 +1095,7 @@ fn check_annotation_appearance( } } +/// Checks a stream's compression filters, external file keys, and image/PostScript subtype rules. fn check_stream( doc: &Document, stream: &pdfcraft_cos::Stream, @@ -1053,12 +1105,12 @@ fn check_stream( let filters: Vec> = match stream.dict.get(b"Filter").map(|filter| doc.resolve(filter)) { Some(filter) => match &*filter { Object::Name(name) => vec![name.clone()], - Object::Array(filters) => filters.iter().filter_map(|filter| filter.as_name().map(<[u8]>::to_vec)).collect(), + Object::Array(filters) => filters.iter().filter_map(|filter| doc.resolve(filter).as_name().map(<[u8]>::to_vec)).collect(), _ => Vec::new(), }, None => Vec::new(), }; - if filters.iter().any(|filter| filter == b"LZWDecode") { + if filters.iter().any(|filter| filter == b"LZWDecode" || filter == b"LZW") { issue("6.1.7.2", "A stream uses LZW compression", None, None, false); } if stream.dict.get(b"F").is_some() || stream.dict.get(b"FFilter").is_some() { @@ -1223,6 +1275,86 @@ fn strip_actions(doc: &Document, d: &mut Dict) -> bool { changed } +/// Collects every indirect annotation reference listed in any page's `/Annots` array. +fn page_annot_refs(doc: &Document) -> std::collections::HashSet { + let mut refs = std::collections::HashSet::new(); + for p in pdfcraft_model::pages(doc) { + if let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a)) + && let Some(a) = a.as_array() + { + refs.extend(a.iter().filter_map(Object::as_ref)); + } + } + refs +} + +/// Removes annotation `r` from every page's `/Annots` list and replaces its indirect object with `Null`. +fn remove_annot_from_pages(doc: &mut Document, r: ObjRef) -> Result<(), pdfcraft_cos::CosError> { + for p in pdfcraft_model::pages(doc) { + let annots = p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); + if annots.iter().any(|a| a.as_ref() == Some(r)) { + let kept: Vec = annots.into_iter().filter(|a| a.as_ref() != Some(r)).collect(); + doc.update_dict(p.obj, |pd| pd.set(b"Annots".to_vec(), Object::Array(kept)))?; + } + } + doc.set(r, Object::Null); + Ok(()) +} + +fn fix_annot_or_field(doc: &Document, nd: &mut Dict, is_annot: bool, sub: Option<&[u8]>, actions: &mut usize, flags: &mut usize) -> bool { + let mut changed = strip_actions(doc, nd); + *actions += changed as usize; + if is_annot && sub != Some(b"Popup") { + let f = nd.int(b"F").unwrap_or(0); + let want = (f | 4) & !(1 | 2 | 32); + if want != f { + nd.set(b"F".to_vec(), Object::Int(want)); + *flags += 1; + changed = true; + } + } + changed +} + +fn convert_direct_page_annots( + doc: &mut Document, + page: &pdfcraft_model::Page, + actions: &mut usize, + flags: &mut usize, + removed: &mut usize, +) -> Result<(), pdfcraft_cos::CosError> { + let Some(annots) = page.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()) else { + return Ok(()); + }; + if !annots.iter().any(|a| a.as_dict().is_some()) { + return Ok(()); + } + let mut kept = Vec::with_capacity(annots.len()); + let mut changed = false; + for entry in annots { + let Object::Dict(mut nd) = entry else { + kept.push(entry); + continue; + }; + let ty = nd.name(b"Type").map(<[u8]>::to_vec); + let sub = nd.name(b"Subtype").map(<[u8]>::to_vec); + let is_annot = is_annotation(doc, &nd, ty.as_deref(), sub.as_deref(), true); + if is_annot && sub.as_deref().is_some_and(|s| FORBIDDEN_ANNOTS.contains(&s)) { + *removed += 1; + changed = true; + continue; + } + if is_annot || nd.get(b"FT").is_some() { + changed |= fix_annot_or_field(doc, &mut nd, is_annot, sub.as_deref(), actions, flags); + } + kept.push(Object::Dict(nd)); + } + if changed { + doc.update_dict(page.obj, |pd| pd.set(b"Annots".to_vec(), Object::Array(kept)))?; + } + Ok(()) +} + /// Make `doc` conform to `level` as far as possible, then re-check it. pub fn convert(doc: &mut Document, level: Level) -> Result { let mut fixed = Vec::new(); @@ -1293,45 +1425,26 @@ pub fn convert(doc: &mut Document, level: Level) -> Result::to_vec); let sub = d.name(b"Subtype").map(<[u8]>::to_vec); - let is_annot = ty.as_deref() == Some(b"Annot"); + let is_annot = matches!(&*o, Object::Dict(_)) && is_annotation(doc, &d, ty.as_deref(), sub.as_deref(), page_annots.contains(&r)); if is_annot && sub.as_deref().is_some_and(|s| FORBIDDEN_ANNOTS.contains(&s)) { - // Take it off its page. - for p in pdfcraft_model::pages(doc) { - let annots = p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); - if annots.iter().any(|a| a.as_ref() == Some(r)) { - let kept: Vec = annots.into_iter().filter(|a| a.as_ref() != Some(r)).collect(); - doc.update_dict(p.obj, |pd| pd.set(b"Annots".to_vec(), Object::Array(kept)))?; - } - } - // The object stays in the file unless it is replaced: verify scans every object, so a - // forbidden annotation left behind would still be reported (clause 6.3.1). - doc.set(r, Object::Null); + remove_annot_from_pages(doc, r)?; removed += 1; continue; } if matches!(&*o, Object::Dict(_)) && (is_annot || d.get(b"FT").is_some()) { let mut nd = d.clone(); - let mut changed = strip_actions(doc, &mut nd); - actions += changed as usize; - if is_annot && sub.as_deref() != Some(b"Popup") { - let f = nd.int(b"F").unwrap_or(0); - let want = (f | 4) & !(1 | 2 | 32); - if want != f { - nd.set(b"F".to_vec(), Object::Int(want)); - flags += 1; - changed = true; - } - } - if changed { + if fix_annot_or_field(doc, &mut nd, is_annot, sub.as_deref(), &mut actions, &mut flags) { doc.set_existing(r, Object::Dict(nd))?; } continue; diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index e0107d9f21..fca88769e0 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -495,6 +495,89 @@ fn an_output_intent_profile_header_is_read_within_a_size_cap() { } } +#[test] +fn typeless_annotations_indexed_cmyk_and_indirect_filter_elements_are_handled() { + // Annotations in /Annots that omit optional /Type /Annot and /P (while keeping required /Subtype + // and /Rect), whether indirect or direct dictionaries, are still verified and fixed by convert. + // A malformed /Annots dictionary without /Type /Annot and without /Rect is not treated as an + // annotation or destructively removed by convert. + let mut doc = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 8 0 R << /Subtype /Movie /Rect [60 10 70 30] >> << /Subtype /Sound >> << /Subtype /Sound /Rect null >> << /Subtype /Sound /Rect 9 0 R >>] >>", + "<< /Subtype /Text /Rect [10 10 30 30] /P 3 0 R /AP << /N 7 0 R >> >>", + "<< /Subtype /Sound /Rect [50 10 60 30] >>", + "<< /Subtype /Widget /FT /Tx /T (f) /Rect [70 10 90 30] /F 4 /AA << /E << /S /URI /URI (https://example.com) >> >> /AP << /N 7 0 R >> >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 20 20] /Length 0 >>\nstream\n\nendstream", + "<< /Subtype /Movie >>", + "null", + ], + "", + ); + let issues = verify(&doc, Level::A2b); + let msgs = messages(&issues); + assert!(msgs.contains(&"A Text annotation isn't set to print, or is hidden"), "{msgs:#?}"); + assert!(msgs.contains(&"Sound annotations aren't allowed"), "{msgs:#?}"); + assert!(msgs.contains(&"Movie annotations aren't allowed"), "{msgs:#?}"); + assert!(msgs.contains(&"A Widget annotation has additional actions"), "{msgs:#?}"); + assert!(!msgs.contains(&"A form field has additional actions"), "{msgs:#?}"); + let r = convert(&mut doc, Level::A2b).unwrap(); + assert!(r.remaining.is_empty(), "{:#?}", r.remaining); + // The two /Rect-bearing forbidden annotations (5 0 R and the inline /Movie with /Rect) were + // removed, while the /Rect-less or null-/Rect dictionaries (8 0 R and the inline /Sound + // entries) without /Type /Annot are left untouched rather than destructively stripped. + assert_eq!(&*doc.get(ObjRef::new(5, 0)), &Object::Null); + assert!(doc.get(ObjRef::new(8, 0)).as_dict().is_some()); + let page_annots = doc.get(ObjRef::new(3, 0)).as_dict().and_then(|d| d.get(b"Annots")).and_then(Object::as_array).cloned().unwrap(); + assert_eq!(page_annots.len(), 6, "{page_annots:#?}"); + assert!(page_annots.iter().any(|entry| entry.as_ref() == Some(ObjRef::new(8, 0)))); + assert_eq!(page_annots.iter().filter(|entry| entry.as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Sound"))).count(), 3); + + // Indexed and Separation colour spaces over DeviceCMYK on image XObjects or /ColorSpace resources count as CMYK. + let indexed_img = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >>", + "<< /Length 7 >>\nstream\n/Im0 Do\nendstream", + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace [/Indexed /DeviceCMYK 0 <00000000>] /BitsPerComponent 8 /Length 1 >>\nstream\n\u{0}\nendstream", + ], + "", + ); + assert!(uses_cmyk(&indexed_img)); + + let res_cmyk = drawing("/CS0 cs 0 sc 0 0 10 10 re f", "/ColorSpace << /CS0 [/Indexed /DeviceCMYK 0 <00000000>] >>", &[]); + assert!(uses_cmyk(&res_cmyk)); + let pattern_cmyk = drawing("0 0 10 10 re f", "/ColorSpace << /CS0 [/Pattern /DeviceCMYK] >>", &[]); + assert!(uses_cmyk(&pattern_cmyk)); + let chained_cs = drawing("0 0 10 10 re f", "/ColorSpace << /CS0 5 0 R >>", &["6 0 R", "[7 0 R 8 0 R]", "/Pattern", "/DeviceCMYK"]); + assert!(uses_cmyk(&chained_cs)); + + // An indirect filter name inside a /Filter array or the /LZW abbreviation is still checked for LZWDecode. + let indirect_lzw = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>", + "<< /Filter [5 0 R] /Length 0 >>\nstream\n\nendstream", + "/LZWDecode", + ], + "", + ); + assert!(messages(&verify(&indirect_lzw, Level::A2b)).contains(&"A stream uses LZW compression")); + let abbrev_lzw = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>", + "<< /Filter /LZW /Length 0 >>\nstream\n\nendstream", + ], + "", + ); + assert!(messages(&verify(&abbrev_lzw, Level::A2b)).contains(&"A stream uses LZW compression")); +} + /// PDF/A-2 6.1.13 holds a file to the implementation limits of ISO 32000-1 Annex C (Table C.1): /// an integer past 32 bits, a string over 32767 bytes or a name over 127 bytes (decoded: a name /// written with `#xx` escapes counts its bytes once read), in an object or in content; and a page diff --git a/crates/print/Cargo.toml b/crates/print/Cargo.toml index 6ffa60b813..9d349b6568 100644 --- a/crates/print/Cargo.toml +++ b/crates/print/Cargo.toml @@ -10,10 +10,16 @@ workspace = true [dependencies] craft-print = { workspace = true } +log = { workspace = true } pdfcraft-cos = { workspace = true } pdfcraft-content = { workspace = true } pdfcraft-model = { workspace = true } thiserror = { workspace = true } +[target.'cfg(windows)'.dependencies] +winprint = { version = "0.2.1", git = "https://github.com/storytold/winprint.rs", rev = "5f80b9baf6b58937c401656634e42d33f00ddf49", default-features = false } + [dev-dependencies] pdfcraft-render = { workspace = true } +# Reads the renamed programs back with a parser that isn't ours. +skrifa = "0.44" diff --git a/crates/print/README.md b/crates/print/README.md index 9866f788a3..6fa2dc9367 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -26,10 +26,14 @@ spool::submit(&pdf, &spool::Job { printer: None, copies: 2, ..Default::default() Headless: `doc_print` with `layout: "multiple"`, `order: "cut-stack"` and `per_sheet: 4` (or any supported grid size), plus `path` or `printer`. - **Booklet**: saddle-stitch imposition padded to a multiple of 4; both sides, front or back - only; left or right binding. + only; left or right binding; creep compensation, which moves each sheet's pages toward the + fold by its place from the outside times the creep (points per sheet, up to `MAX_CREEP`), + clipped at the fold. Headless: `doc_print` with `layout: "booklet"` and `creep`. - **Poster**: tile scale, overlap shared by neighbouring tiles, cut marks. - **Comments & forms**: document, + markups, + stamps, or form fields only; annotations print only with their Print flag, drawn from their appearance streams (Algorithm 8.1). + Existing Link appearances are document content in the first three modes; Link appearances + are not synthesized when absent, and form fields only excludes them. - **Properties… on CUPS** reads the queue's PPD and its current defaults (`lpoptions -l`) off the GUI thread, and gives `lpoptions` ten seconds: a CUPS that has stopped answering leaves the PPD's own defaults in force instead of holding the dialog (#1818; `spool::output_within` @@ -50,6 +54,16 @@ Each source page becomes a Form XObject (its content wrapped in q/Q, plus the pr annotations); sheets place them with a clip. The result is a fresh, unencrypted, garbage-collected file (callers check the print permission). Annotation appearances receive unused XObject names, preserving existing page resources even when they use the same naming prefix. +Vector printing also carries each annotation's optional-content membership onto its own appearance invocation. +Hidden groups, membership policies, visibility expressions and existing layer overrides therefore remain effective in the generated PDF, including when appearances are shared. +Malformed top-level membership or required resource containers produce a print error instead of an unguarded appearance. +Nested policy data is preserved without a new evaluator; readers remain responsible for optional-content semantics. +The separate on-screen and Print as image annotation-visibility gap is tracked in #3572 / #3624. +Appearance placement keeps the full PDF number precision, including small coefficients needed by large appearance bounding boxes or matrices. +Existing printable NoRotate appearances keep their orientation and upper-left rectangle anchor when the source page is rotated. +Text annotations use this upright placement even with the NoRotate flag clear (ISO 32000-1 section 12.5.6.4). +Crop origins, UserUnit, sheet scaling, and later imposition rotations still apply; NoZoom does not cancel printing scale. +Missing or unusable appearance streams still cannot be printed. The GUI Print dialog remembers its last selected paper size across application sessions, including when Print is cancelled. The preference stores a canonical name such as `A4`; @@ -70,6 +84,28 @@ orientation, zero added margins and hardware-edge clipping. Long-edge and short- use the corresponding Windows binding edges. Random, exclusively created Windows job files are removed on success and failure. Other targets offer the print-ready PDF instead. +On Windows, **Printer default** reads the selected driver's current `DEVMODEW` duplex +setting immediately before submission and carries that binding explicitly into the job +(#5130). This also applies to `doc_print` with `duplex: "printer"`; its omitted-argument +default remains `off`. Explicit Off/Long Edge/Short Edge choices, booklet binding and +single-sided cut-and-stack bypass this lookup. Drivers without `DM_DUPLEX` retain the +unspecified setting. Two-sided defaults also stay unspecified when the driver does not +confirm automatic two-sided support, preserving manual duplex preferences (#5772). +A failed lookup, settings read or invalid declared duplex value is +logged and falls back to leaving duplex unspecified, so the driver can still choose its default. +If the queue was resolved before the settings failure, the job remains pinned to that queue. CUPS still leaves the choice to the queue. The Windows backend +selects `JobDuplexAllDocumentsContiguously`, falling back to `DocumentDuplex` when +the driver exposes only document-level duplex, and validates the retained binding before submission +([craft-libs #411](https://github.com/storytold/craft-libs/pull/411)). + +Before a job reaches the Windows spooler, every embedded TrueType or OpenType program is +renamed `PdfCraftPrint` in its `name` table and in the PDF (`/BaseFont`, `/FontName`; a +subset keeps its tag). Windows otherwise draws a subset's renumbered glyphs with the installed +font of the same name, so ReportLab's Arial subsets printed as shifted letters (#3132). Glyphs, +encodings and `/ToUnicode` are unchanged, and CUPS and Save as PDF get the PDF as it was. A +program PdfCraft can't read and rewrite keeps its old name in the PDF too, so a viewer that +can't load it either still picks its substitute by that name. + The shared dependency requires Rust 1.95. Transport and native Windows regression tests live in craft-libs; local tests retain composition, facade, engine, automation and UI coverage. The desktop UI discovers printers and submits jobs on workers, so neither operation blocks @@ -80,7 +116,7 @@ In a browser there is no spooler: the app (`pdfcraft-ui-egui`'s `web_print`) dra PDF's sheets with PdfCraft's renderer and prints them through the browser's print dialog, which sets copies, two-sided printing and colour. This crate stays free of web APIs. -Not yet: print as image, poster labels, PostScript output, colour conversion for +Not yet: poster labels, PostScript output, colour conversion for grayscale. ## Architectural sheets and scale @@ -104,3 +140,10 @@ a matching page. On Windows craft-print sends the sheets as vectors with a Print for the sheet's exact paper and no scaling (#599's approach), validated by the driver: a job is refused, not shrunk, when the driver lacks the paper or would change it. Verify the driver's paper settings and physical calibration before relying on drawing scale. + +Vector printing resolves direct and indirect annotation rectangle coordinates before placing existing appearances. +Rectangles must contain exactly four finite numbers; malformed rectangles are skipped. +For an appearance-state dictionary, direct or indirect `AS` names select the existing artwork. +Rectangle arrays, individual coordinates and `AS` names follow up to 64 indirect references with cycle detection. +Missing, invalid, unresolved, cyclic, overly deep, or unknown state names leave that annotation unprinted; a single normal appearance stream does not require `AS`. +The print content mode still controls whether a field or comment prints, and printing preserves the source objects. diff --git a/crates/print/src/font_names.rs b/crates/print/src/font_names.rs new file mode 100644 index 0000000000..fc3b0e3a18 --- /dev/null +++ b/crates/print/src/font_names.rs @@ -0,0 +1,388 @@ +//! Embedded TrueType and OpenType programs renamed before a print-ready PDF goes to the Windows +//! spooler, so no installed font answers to their names (#3132). +//! +//! A subset program numbers its glyphs anew: ReportLab's Arial subsets draw a space with glyph 11 +//! (Arial Bold: glyph 8), where the installed Arial's space is glyph 3. Windows' print path +//! (Windows.Data.Pdf drawing into Direct2D's print control, through craft-print) keeps the +//! subset's glyph numbers but draws them with the installed font of the same name, so every letter +//! prints as the installed glyph with that number: `Revisión de clientes,` in the bold subset comes +//! out as `Wj{nxn s%ij%hqnjsyjx1`, its `z` as `Ä`. Both Brother queues and the Adobe PDF printer +//! show it; PdfCraft's renderer, CUPS and Chrome on Windows draw the embedded program and are right. +//! +//! [`distinct`] renames each program to `PdfCraftPrint`: the identity records of its `name` +//! table, `/FontName` of its descriptors and `/BaseFont` of the fonts drawn with it (a subset keeps +//! its tag, `AAAAAA+PdfCraftPrint1`). Glyphs, glyph numbers, cmaps, encodings, widths and +//! `/ToUnicode` are not touched, so pages draw and extract as before. A program that can't be +//! read and rewritten keeps its old name everywhere. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; + +use crate::PrintError; + +/// Whether [`crate::spool`] renames the programs before spooling: on Windows only. +pub const RENAME_FOR_SPOOLER: bool = cfg!(windows); + +/// Decoded size past which a program is left as it is (a whole CJK font is about 20 MB). +const MAX_PROGRAM: usize = 64 << 20; + +/// Decoded bytes the whole renaming may read, across every program. A document's fonts rarely +/// come to 100 MB; a file of many distinct compressed bombs is read only this far. +const MAX_DECODED_IN_ALL: usize = 512 << 20; + +/// More tables than any font has; a larger count is a damaged directory. +const MAX_TABLES: usize = 256; + +/// The `name` records that say which font this is: family, unique ID, full name, PostScript name, +/// typographic family, Mac compatible full name, PostScript CID name, WWS family and the +/// variations PostScript name prefix. +const IDENTITY: [u16; 9] = [1, 3, 4, 6, 16, 18, 20, 21, 25]; + +/// The font subtypes whose descriptor can hold a TrueType or OpenType program. +const DRAWN_WITH_A_PROGRAM: [&[u8]; 5] = [b"TrueType", b"Type1", b"MMType1", b"CIDFontType0", b"CIDFontType2"]; + +/// An embedded program and what it will be called. +struct Program { + name: String, + /// A `/FontFile2` program, whose `/Length1` is the decoded length. + true_type: bool, +} + +/// The fonts drawn with embedded TrueType or OpenType programs, found by scanning every object. +#[derive(Default)] +struct Found { + /// Each program, numbered in object order. + programs: BTreeMap, + /// Simple fonts and CIDFonts, with their program. + fonts: BTreeMap, + /// Indirect font descriptors, with their program. + descriptors: BTreeMap, + /// Type 0 fonts whose CIDFont is an object of its own, with that CIDFont. + type0: BTreeMap, + /// Type 0 fonts whose CIDFont is written inline, with the array object holding it when + /// `/DescendantFonts` names one, and the CIDFont's program. + inline: BTreeMap, ObjRef)>, +} + +impl Found { + /// Note the program `font` draws with, and its descriptor when that is an object of its own; + /// the program, if it has one to rename. + fn add(&mut self, doc: &Document, font: &Dict) -> Option { + if !font.name(b"Subtype").is_some_and(|s| DRAWN_WITH_A_PROGRAM.contains(&s)) { + return None; + } + let descriptor = font.get(b"FontDescriptor")?; + let (program, true_type) = doc.dict(descriptor).and_then(|d| program(doc, &d))?; + let number = self.programs.len() + 1; + self.programs.entry(program).or_insert_with(|| Program { name: format!("PdfCraftPrint{number}"), true_type }); + if let Object::Ref(descriptor) = descriptor { + self.descriptors.insert(*descriptor, program); + } + Some(program) + } +} + +/// `pdf` with every embedded TrueType or OpenType program renamed; `None` when it has none that +/// can be. +pub fn distinct(pdf: &[u8]) -> Result>, PrintError> { + distinct_within(pdf, &mut DecodeBudget::new(MAX_DECODED_IN_ALL, 0)) +} + +/// [`distinct`], decoding the programs against `budget`: once it is spent, the programs not yet +/// read keep their names, as one that can't be read does. +pub(crate) fn distinct_within(pdf: &[u8], budget: &mut DecodeBudget) -> Result>, PrintError> { + let mut doc = Document::open(Arc::new(pdf.to_vec()))?; + let mut found = find(&doc); + // A program that can't be rewritten keeps its names in the PDF too: a viewer that can't load + // it either then still picks its substitute by the original name. The lookups below skip it. + found.programs.retain(|program, about| rename_program(&mut doc, *program, about, budget)); + if found.programs.is_empty() { + return Ok(None); + } + for (descriptor, program) in &found.descriptors { + let Some(about) = found.programs.get(program) else { continue }; + doc.update_dict(*descriptor, |d| rename(d, b"FontName", &about.name))?; + } + for (font, program) in &found.fonts { + let Some(about) = found.programs.get(program) else { continue }; + doc.update_dict(*font, |d| rename_font(d, &about.name))?; + } + // A Type 0 font is named after its CIDFont; renaming only the CIDFont would leave the old + // name on the font the page uses. + for (font, cid) in &found.type0 { + let Some(about) = found.fonts.get(cid).and_then(|p| found.programs.get(p)) else { continue }; + doc.update_dict(*font, |d| rename(d, b"BaseFont", &about.name))?; + } + for (font, (array, program)) in &found.inline { + let Some(about) = found.programs.get(program) else { continue }; + doc.update_dict(*font, |d| { + rename(d, b"BaseFont", &about.name); + if let Some(Object::Array(descendants)) = d.get_mut(b"DescendantFonts") { + rename_first(descendants, &about.name); + } + })?; + if let Some(array) = array { + let Object::Array(mut descendants) = doc.get(*array).as_ref().clone() else { continue }; + rename_first(&mut descendants, &about.name); + doc.set(*array, Object::Array(descendants)); + } + } + Ok(Some(write_full(&doc, &SaveOptions::default())?)) +} + +fn find(doc: &Document) -> Found { + let mut found = Found::default(); + for (reference, object) in doc.scan_objects() { + let Object::Dict(font) = object.as_ref() else { continue }; + if font.name(b"Subtype") == Some(b"Type0") { + find_descendant(doc, reference, font, &mut found); + } else if let Some(program) = found.add(doc, font) { + found.fonts.insert(reference, program); + } + } + found +} + +/// Note the CIDFont a Type 0 font is named after: an object of its own, renamed when the scan +/// reaches it, or written inline, which no scan reaches, so its program is noted here. +fn find_descendant(doc: &Document, font: ObjRef, d: &Dict, found: &mut Found) { + let Some(descendants) = d.get(b"DescendantFonts") else { return }; + let array = match descendants { + Object::Ref(array) => Some(*array), + _ => None, + }; + let descendants = doc.resolve(descendants); + match descendants.as_array().and_then(|a| a.first()) { + Some(Object::Ref(cid)) => { + found.type0.insert(font, *cid); + } + Some(Object::Dict(cid)) => { + if let Some(program) = found.add(doc, cid) { + found.inline.insert(font, (array, program)); + } + } + _ => {} + } +} + +/// Rename a simple font or CIDFont, and its descriptor when that is written inline. +fn rename_font(d: &mut Dict, name: &str) { + rename(d, b"BaseFont", name); + if let Some(Object::Dict(descriptor)) = d.get_mut(b"FontDescriptor") { + rename(descriptor, b"FontName", name); + } +} + +/// Rename the CIDFont written inline at the head of `/DescendantFonts`. +fn rename_first(descendants: &mut [Object], name: &str) { + if let Some(Object::Dict(cid)) = descendants.first_mut() { + rename_font(cid, name); + } +} + +/// The descriptor's TrueType program (`/FontFile2`), else its OpenType one (`/FontFile3` with +/// `/Subtype /OpenType`), with whether it is TrueType. +fn program(doc: &Document, descriptor: &Dict) -> Option<(ObjRef, bool)> { + if let Some(Object::Ref(program)) = descriptor.get(b"FontFile2") { + return Some((*program, true)); + } + let Some(Object::Ref(program)) = descriptor.get(b"FontFile3") else { return None }; + let open_type = matches!(doc.get(*program).as_ref(), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"OpenType")); + open_type.then_some((*program, false)) +} + +/// Set `key` to `name`, keeping the subset tag the old name had. +fn rename(d: &mut Dict, key: &[u8], name: &str) { + let mut renamed = d.name(key).and_then(subset_tag).map(<[u8]>::to_vec).unwrap_or_default(); + renamed.extend_from_slice(name.as_bytes()); + d.set(key.to_vec(), Object::Name(renamed)); +} + +/// `ABCDEF+` at the start of a subset font's name (ISO 32000-2 §9.9.2). +fn subset_tag(name: &[u8]) -> Option<&[u8]> { + let tag = name.get(..7)?; + let (letters, plus) = tag.split_at(6); + (letters.iter().all(u8::is_ascii_uppercase) && plus == b"+").then_some(tag) +} + +/// Rewrite the program with its `name` table renamed; whether it was. A program that can't be +/// decoded within `budget` or isn't an sfnt with a readable `name` table stays as it is. +fn rename_program(doc: &mut Document, program: ObjRef, about: &Program, budget: &mut DecodeBudget) -> bool { + let object = doc.get(program); + let Object::Stream(stream) = object.as_ref() else { return false }; + let Ok(bytes) = budget.decode_within(stream, MAX_PROGRAM) else { return false }; + let Some(renamed) = renamed_sfnt(&bytes, &about.name) else { return false }; + let mut dict = stream.dict.clone(); + dict.remove(b"DL"); + if about.true_type || dict.get(b"Length1").is_some() { + dict.set(b"Length1".to_vec(), Object::Int(i64::try_from(renamed.len()).unwrap_or(i64::MAX))); + } + doc.set(program, Object::Stream(Stream::flate(dict, &renamed))); + true +} + +fn u16_at(bytes: &[u8], at: usize) -> Option { + Some(u16::from_be_bytes(bytes.get(at..at.checked_add(2)?)?.try_into().ok()?)) +} + +fn u32_at(bytes: &[u8], at: usize) -> Option { + Some(u32::from_be_bytes(bytes.get(at..at.checked_add(4)?)?.try_into().ok()?)) +} + +fn checksum(data: &[u8]) -> u32 { + data.chunks(4).fold(0_u32, |sum, c| { + let mut word = [0; 4]; + for (a, b) in word.iter_mut().zip(c) { + *a = *b; + } + sum.wrapping_add(u32::from_be_bytes(word)) + }) +} + +/// `program` with the identity records of its `name` table saying `name`. The new table goes +/// after the others, as `pdfcraft_fonts` writes its editor cmap, and the directory entry and +/// `head`'s checksum adjustment are brought up to date. `None` when `program` is not a single +/// sfnt font with a `name` table that can be read. +pub(crate) fn renamed_sfnt(program: &[u8], name: &str) -> Option> { + let version = program.get(..4)?; + if version != [0, 1, 0, 0] && version != b"true" && version != b"OTTO" { + return None; + } + let count = usize::from(u16_at(program, 4)?); + if count > MAX_TABLES { + return None; + } + let (mut entry, mut head) = (None, None); + for at in (0..count).map(|i| 12 + i * 16) { + match program.get(at..at + 4)? { + b"name" => entry = Some(at), + b"head" => head = Some(usize::try_from(u32_at(program, at + 8)?).ok()?), + _ => {} + } + } + let entry = entry?; + let offset = usize::try_from(u32_at(program, entry + 8)?).ok()?; + let length = usize::try_from(u32_at(program, entry + 12)?).ok()?; + let table = renamed_table(program.get(offset..offset.checked_add(length)?)?, name)?; + let mut out = program.to_vec(); + while !out.len().is_multiple_of(4) { + out.push(0); + } + let at = u32::try_from(out.len()).ok()?; + out.get_mut(entry + 4..entry + 8)?.copy_from_slice(&checksum(&table).to_be_bytes()); + out.get_mut(entry + 8..entry + 12)?.copy_from_slice(&at.to_be_bytes()); + out.get_mut(entry + 12..entry + 16)?.copy_from_slice(&u32::try_from(table.len()).ok()?.to_be_bytes()); + out.extend_from_slice(&table); + if let Some(head) = head { + let adjustment = head.checked_add(8)?..head.checked_add(12)?; + out.get_mut(adjustment.clone())?.fill(0); + let sum = checksum(&out); + out.get_mut(adjustment)?.copy_from_slice(&0xb1b0afba_u32.wrapping_sub(sum).to_be_bytes()); + } + Some(out) +} + +/// Where a record's string comes from: the old table's storage (offset and length), or the new +/// name in UTF-16BE or in single bytes. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Text { + Old(u16, u16), + Wide, + Narrow, +} + +/// A `name` table (format 0 or 1) with its identity records saying `name`. The records stay in +/// their order, which the format requires to be sorted. An identity record in an encoding that +/// can't spell `name`, or any record whose string lies outside the table, is left out; a broken +/// language tag fails the table, because records refer to the tags by position. +/// +/// Nothing is copied until the whole table has been read, and then each distinct string once: +/// records that shared a string still share it, and the strings stay within what 16-bit offsets +/// reach, so a small table can't make the copy large (#4098). +fn renamed_table(table: &[u8], name: &str) -> Option> { + let format = u16_at(table, 0)?; + if format > 1 { + return None; + } + let count = usize::from(u16_at(table, 2)?); + let storage = usize::from(u16_at(table, 4)?); + let old = |offset: u16, length: u16| table.get(storage.checked_add(usize::from(offset))?..)?.get(..usize::from(length)); + let tags_at = 6 + count * 12; + let tag_count = if format == 1 { usize::from(u16_at(table, tags_at)?) } else { 0 }; + // The records and tags come before the strings, whose offset is 16 bits: a table that claims + // more of them overlaps its own strings and is refused. + u16::try_from(tags_at + if format == 1 { 2 + tag_count * 4 } else { 0 }).ok()?; + let mut records: Vec<([u16; 4], Text)> = Vec::with_capacity(count); + for at in (0..count).map(|i| 6 + i * 12) { + let field = |i: usize| u16_at(table, at + 2 * i); + let key = [field(0)?, field(1)?, field(2)?, field(3)?]; + let [platform, encoding, _, id] = key; + let text = if IDENTITY.contains(&id) { + spelling(platform, encoding) + } else { + let (length, offset) = (field(4)?, field(5)?); + old(offset, length).map(|_| Text::Old(offset, length)) + }; + if let Some(text) = text { + records.push((key, text)); + } + } + let mut tags = Vec::with_capacity(tag_count); + for at in (0..tag_count).map(|i| tags_at + 2 + 4 * i) { + let (length, offset) = (u16_at(table, at)?, u16_at(table, at + 2)?); + old(offset, length)?; + tags.push(Text::Old(offset, length)); + } + let wide: Vec = name.encode_utf16().flat_map(u16::to_be_bytes).collect(); + let mut placed: BTreeMap = BTreeMap::new(); + let mut strings = Vec::new(); + // A string's length and offset in the new storage, storing it the first time it is asked for. + let mut place = |text: Text| -> Option<[u16; 2]> { + if let Some(at) = placed.get(&text) { + return Some(*at); + } + let bytes = match text { + Text::Old(offset, length) => old(offset, length)?, + Text::Wide => &wide, + Text::Narrow => name.as_bytes(), + }; + let at = [u16::try_from(bytes.len()).ok()?, u16::try_from(strings.len()).ok()?]; + strings.extend_from_slice(bytes); + placed.insert(text, at); + Some(at) + }; + let header = 6 + records.len() * 12 + if format == 1 { 2 + tags.len() * 4 } else { 0 }; + let mut out = Vec::with_capacity(header); + for v in [format, u16::try_from(records.len()).ok()?, u16::try_from(header).ok()?] { + out.extend_from_slice(&v.to_be_bytes()); + } + for (key, text) in &records { + for v in key.iter().chain(&place(*text)?) { + out.extend_from_slice(&v.to_be_bytes()); + } + } + if format == 1 { + out.extend_from_slice(&u16::try_from(tags.len()).ok()?.to_be_bytes()); + for tag in &tags { + for v in place(*tag)? { + out.extend_from_slice(&v.to_be_bytes()); + } + } + } + out.extend_from_slice(&strings); + Some(out) +} + +/// How `name` (ASCII) is written in a `name` record of this platform and encoding: UTF-16BE for +/// Unicode and the Windows Unicode and symbol encodings, single bytes for Macintosh (every Mac +/// script encoding starts with ASCII); `None` for the legacy Windows CJK encodings and the ISO and +/// custom platforms. +fn spelling(platform: u16, encoding: u16) -> Option { + match (platform, encoding) { + (0, _) | (3, 0 | 1 | 10) => Some(Text::Wide), + (1, _) => Some(Text::Narrow), + _ => None, + } +} diff --git a/crates/print/src/imposition.rs b/crates/print/src/imposition.rs index 19a75af32c..9a225a0db7 100644 --- a/crates/print/src/imposition.rs +++ b/crates/print/src/imposition.rs @@ -3,7 +3,7 @@ use super::settings::{Content, Settings}; use super::sheets::{Sheet, layout}; use super::{DecodeBudget, Dict, Document, Matrix, ObjRef, Object, SaveOptions, Stream, write_full}; -// Model-derived transforms and clipping bounds need their full PDF decimal precision. +// Transforms and clipping bounds need their full PDF decimal precision. fn geometry_number(value: f64) -> String { let mut bytes = Vec::new(); pdfcraft_cos::serialize(&Object::Real(value), &mut bytes); @@ -100,7 +100,9 @@ pub(crate) fn prints(doc: &Document, d: &Dict, content: Content) -> bool { let subtype = subtype_obj.as_deref().and_then(Object::as_name).unwrap_or(b""); match subtype { b"Widget" => true, - b"Link" | b"Popup" => false, + // Existing Link appearances are part of the document, not markup or fields. + b"Link" => content != Content::FormFieldsOnly, + b"Popup" => false, b"Stamp" => content != Content::Document && content != Content::FormFieldsOnly, // ISO 32000-2 Table 167: an Invisible annotation of a type that isn't standard (Table // 171) is not printed even with its Print flag set. @@ -109,15 +111,30 @@ pub(crate) fn prints(doc: &Document, d: &Dict, content: Content) -> bool { } } -/// The normal appearance stream to draw for an annotation (honouring `/AS`). +/// Read an annotation value through at most 64 indirect references. +/// Standalone indirect objects can themselves hold references (ISO 32000-2 §7.3.10). +/// Cycles and longer chains are unusable, like other invalid annotation values. +fn annotation_value(doc: &Document, value: &Object) -> Option> { + let mut value = std::sync::Arc::new(value.clone()); + let mut seen = std::collections::HashSet::new(); + while let Object::Ref(reference) = &*value { + if seen.len() == 64 || !seen.insert(*reference) { + return None; + } + value = doc.get(*reference); + } + Some(value) +} + +/// The normal appearance stream to draw, resolving the annotation's `/AS` state name. pub(crate) fn appearance(doc: &Document, d: &Dict) -> Option { let ap = doc.resolve(d.get(b"AP")?); let normal = ap.as_dict()?.get(b"N")?.clone(); match &*doc.resolve(&normal) { Object::Stream(_) => normal.as_ref(), Object::Dict(states) => { - let state = d.name(b"AS")?; - states.get(state)?.as_ref() + let state = annotation_value(doc, d.get(b"AS")?)?; + states.get(state.as_name()?)?.as_ref() } _ => None, } @@ -146,6 +163,70 @@ pub(crate) fn appearance_matrix(doc: &Document, form: &Dict, rect: [f64; 4]) -> Some(a) } +/// Resolve optional-content resources without turning a loading failure or reference cycle +/// into an absent entry. Keep the terminal object's identity for catalog OCG state. +fn optional_object(doc: &Document, value: &Object) -> Result<(Option, std::sync::Arc), PrintError> { + let mut object = std::sync::Arc::new(value.clone()); + let mut reference = None; + let mut seen = std::collections::HashSet::new(); + while let Object::Ref(r) = &*object { + if seen.len() >= 64 || !seen.insert(*r) { + return Err(PrintError::Invalid("an optional-content resource has a cyclic reference or more than 64 references".into())); + } + reference = Some(*r); + object = doc.try_get(r.num)?; + } + Ok((reference, object)) +} + +/// Preserve the complete membership rather than evaluating visibility here. The reader owns +/// policy/VE evaluation; the annotation and its appearance can have independent memberships. +fn annotation_membership(doc: &mut Document, annotation: &Dict) -> Result, PrintError> { + let Some(value) = annotation.get(b"OC") else { return Ok(None) }; + let (reference, object) = optional_object(doc, value)?; + let dict = match &*object { + Object::Null => return Ok(None), // Null and undefined references are absent PDF dictionary entries. + Object::Dict(dict) => dict, + _ => return Err(PrintError::Invalid("an annotation's optional-content entry is not an OCG or OCMD dictionary".into())), + }; + let kind = dict.get(b"Type").map(|value| optional_object(doc, value)).transpose()?; + match kind.as_ref().and_then(|(_, object)| object.as_name()) { + Some(b"OCG") => reference.map(Some).ok_or_else(|| PrintError::Invalid("an annotation's optional-content group must be indirect".into())), + Some(b"OCMD") => { + // Named Properties are indirect for interoperable OCG identity and for the renderer's + // resource path. An OCMD has no group identity; promoting its intact dict is harmless. + Ok(Some(reference.unwrap_or_else(|| doc.add(Object::Dict(dict.clone()))))) + } + _ => Err(PrintError::Invalid("an annotation's optional-content dictionary must have Type OCG or OCMD".into())), + } +} + +/// Clone existing marked-content properties, including unrelated entries. Never replace an +/// unreadable map with an empty one: doing so can expose the page's own optional content. +fn optional_properties(doc: &Document, resources: &Dict) -> Result { + let Some(value) = resources.get(b"Properties") else { return Ok(Dict::new()) }; + let (_, object) = optional_object(doc, value)?; + match &*object { + Object::Null => Ok(Dict::new()), + Object::Dict(dict) => Ok(dict.clone()), + _ => Err(PrintError::Invalid("cannot preserve annotation layers: page Properties is not a dictionary".into())), + } +} + +/// ISO 32000-1 section 12.5.3: counterrotate the fitted appearance about Rect's upper-left +/// corner in default user space. Crop, UserUnit and sheet transforms remain outside it. +fn no_rotate_placement(placement: Matrix, rect: [f64; 4], rotation: i64) -> Option { + let [x, _, _, y] = rect; + let counterrotation = match rotation { + 90 => Matrix([0.0, 1.0, -1.0, 0.0, x + y, y - x]), + 180 => Matrix([-1.0, 0.0, 0.0, -1.0, 2.0 * x, 2.0 * y]), + 270 => Matrix([0.0, -1.0, 1.0, 0.0, x - y, y + x]), + _ => Matrix::default(), + }; + let placement = placement.then(&counterrotation); + placement.0.iter().all(|value| value.is_finite()).then_some(placement) +} + /// A Form XObject holding page `index` as it prints (user space; BBox = crop box). /// `pages` are the document's pages, listed once by the caller: listing them for each page /// printed would make printing quadratic in the page count. @@ -158,7 +239,8 @@ pub(crate) fn page_form( ) -> Result { let page = pages.get(index).ok_or_else(|| PrintError::Invalid(format!("there is no page {}", index.saturating_add(1))))?; let crop = page.crop(doc); - let mut res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let resources = page.dict.get(b"Resources").map(|value| optional_object(doc, value)).transpose(); + let mut res = resources.as_ref().ok().and_then(|value| value.as_ref()).and_then(|(_, object)| object.as_dict()).cloned().unwrap_or_default(); let mut body = if content == Content::FormFieldsOnly { Vec::new() } else { @@ -174,21 +256,60 @@ pub(crate) fn page_form( } let annots = page.dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default(); let mut xobjects = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); + let mut properties = None; + let mut property_index = 0usize; let mut k = 0usize; for a in annots { let Some(d) = doc.resolve(&a).as_dict().cloned() else { continue }; if !prints(doc, &d, content) { continue; } - let rect: Vec = - d.get(b"Rect").and_then(|r| doc.resolve(r).as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default(); - if rect.len() != 4 { - continue; - } - let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; + // A rectangle has four numbers, and each can be an indirect object (ISO 32000-2 §7.3.10). + let rect = d.get(b"Rect").and_then(|r| { + let rect = annotation_value(doc, r)?; + let [x0, y0, x1, y1] = rect.as_array()?.as_slice() else { return None }; + let number = |v: &Object| annotation_value(doc, v)?.as_f64().filter(|v| v.is_finite()); + Some([number(x0)?, number(y0)?, number(x1)?, number(y1)?]) + }); + let Some([x0, y0, x1, y1]) = rect else { continue }; + let rect = [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)]; let Some(ap) = appearance(doc, &d) else { continue }; let form = doc.get(ap).as_dict().cloned().unwrap_or_default(); let Some(m) = appearance_matrix(doc, &form, rect) else { continue }; + let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); + // Text annotations have implicit NoRotate even when the flag is clear (ISO 32000-1 section 12.5.6.4). + let no_rotate = flags & 16 != 0 || d.get(b"Subtype").is_some_and(|subtype| doc.resolve(subtype).as_name() == Some(b"Text")); + let m = if no_rotate { + no_rotate_placement(m, rect, page.rotation(doc)) + .ok_or_else(|| PrintError::Invalid(format!("page {} has a non-finite NoRotate annotation placement", index.saturating_add(1))))? + } else { + m + }; + let membership = annotation_membership(doc, &d)?; + let property = if let Some(membership) = membership { + if properties.is_none() { + match &resources { + Err(error) => return Err(PrintError::Invalid(format!("cannot preserve annotation layers: {error}"))), + Ok(Some((_, object))) if !matches!(&**object, Object::Null | Object::Dict(_)) => { + return Err(PrintError::Invalid("cannot preserve annotation layers: page Resources is not a dictionary".into())); + } + _ => {} + } + properties = Some(optional_properties(doc, &res)?); + } + let properties = properties.get_or_insert_with(Dict::new); + let name = loop { + let name = format!("PCPrOC{property_index}"); + property_index = property_index.checked_add(1).ok_or_else(|| PrintError::Invalid("too many annotation layer resources".into()))?; + if !properties.contains(name.as_bytes()) { + break name; + } + }; + properties.set(name.as_bytes().to_vec(), Object::Ref(membership)); + Some(name) + } else { + None + }; let ap = if !form.contains(b"Subtype") && let Object::Stream(stream) = &*doc.get(ap) { @@ -216,17 +337,38 @@ pub(crate) fn page_form( } }; let [a0, b0, c0, d0, e0, f0] = m.0; - body.extend(format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(a0), n(b0), n(c0), n(d0), n(e0), n(f0)).bytes()); + if let Some(name) = &property { + body.extend(format!("/OC /{name} BDC\n").bytes()); + } + body.extend( + format!( + "q {} {} {} {} {} {} cm /{name} Do Q\n", + geometry_number(a0), + geometry_number(b0), + geometry_number(c0), + geometry_number(d0), + geometry_number(e0), + geometry_number(f0) + ) + .bytes(), + ); + if property.is_some() { + body.extend_from_slice(b"EMC\n"); + } xobjects.set(name.into_bytes(), Object::Ref(ap)); } // With no appearance added the form keeps the page's own Resources, shared by reference, - // rather than a deep copy per printed page. + // rather than a deep copy per printed page. An appearance in a layer (#5005) adds its + // membership to the copy's Properties, so a page with one is never shared. let resources = match page.dict.get(b"Resources") { - Some(r) if k == 0 && doc.resolve(r).as_dict().is_some() => r.clone(), + Some(r) if k == 0 && properties.is_none() && doc.resolve(r).as_dict().is_some() => r.clone(), _ => { if !xobjects.is_empty() { res.set(b"XObject".to_vec(), Object::Dict(xobjects)); } + if let Some(properties) = properties { + res.set(b"Properties".to_vec(), Object::Dict(properties)); + } Object::Dict(res) } }; diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 54ae81cbdc..5707a42012 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -20,6 +20,7 @@ use pdfcraft_content::Matrix; use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; +mod font_names; pub mod range; pub mod spool; #[cfg(test)] @@ -38,13 +39,14 @@ use imposition::{PAGES_LISTED, appearance, appearance_matrix, decoded_contents, pub use imposition::{impose, keep_printed, sheet_pages}; pub use settings::{ Binding, BookletSubset, Content, Crop, LABEL_PAPER, Layout, MAX_GRID, Orientation, PAPERS, PageOrder, Raster, Settings, SizeMode, booklet_duplex, - cups_media, media_option, + booklet_job_duplex, cups_media, custom_media, driver_needs_grayscale, media_option, paper_media, paper_media_option, }; #[cfg(test)] use sheets::text_direction; pub use sheets::{ - LABEL_MARGIN, MARGIN, Placement, Sheet, add_cups_orientation, cups_orientation, layout, matching_paper, text_reads_up, texts_read_up, + LABEL_MARGIN, MARGIN, MAX_CREEP, Placement, Sheet, add_cups_orientation, cups_orientation, layout, matching_paper, text_reads_up, texts_read_up, }; +pub use spool::{open_printer_preferences, printer_default_paper, printer_paper_info, printer_papers, show_printer_properties}; /// The names of the Print dialog's choices, shared by the `doc_print` tool and the settings /// file, so a saved preference reads like a tool argument and there is one vocabulary to keep. diff --git a/crates/print/src/settings.rs b/crates/print/src/settings.rs index ff860f5c65..50657b328a 100644 --- a/crates/print/src/settings.rs +++ b/crates/print/src/settings.rs @@ -1,4 +1,5 @@ use super::spool; +use crate::matching_paper; #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum Orientation { @@ -50,6 +51,20 @@ pub fn booklet_duplex(subset: BookletSubset) -> spool::Duplex { } } +/// What a booklet's job asks the printer for, given whether its driver takes two-sided settings +/// from other programs (`two_sided`, as the printer list reports it): [`booklet_duplex`], only as +/// Acrobat enables two-sided printing "if available". A driver that doesn't (`Some(false)`) would +/// refuse a two-sided job and print nothing, so both sides ask for nothing and its own setting +/// applies, such as manual two-sided printing chosen in its Properties (#5772). A driver of +/// unknown support is asked for two-sided as before; one side on its own is always one-sided. +pub fn booklet_job_duplex(subset: BookletSubset, two_sided: Option) -> Option { + match booklet_duplex(subset) { + spool::Duplex::Off => Some(spool::Duplex::Off), + _ if two_sided == Some(false) => None, + two_sided_duplex => Some(two_sided_duplex), + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum Binding { #[default] @@ -75,9 +90,12 @@ pub enum Layout { /// portrait sheet that turns to landscape when that suits it. custom: bool, }, + /// `creep` (points, per sheet) moves sheet k's pages toward the fold by `k × creep`, sheet 0 + /// being the outermost, so the margins match once the nested sheets are folded and trimmed. Booklet { subset: BookletSubset, binding: Binding, + creep: f64, }, /// Tile scale (percent), overlap (points), cut marks. Poster { @@ -161,6 +179,10 @@ pub struct Settings { /// resolution before it is placed on the paper. The engine does the rendering; `impose` is /// then given the image-only document. pub raster: Option, + /// Print a selected area (Acrobat's "print selected area"): only this rectangle of the one + /// page in `pages`, in that page's display space (points, origin at the bottom-left of the + /// page as shown, y up), sized like a page of its own by a [`Layout::Size`] choice. + pub area: Option<[f64; 4]>, } /// How pages are rendered when printing as image. @@ -178,6 +200,14 @@ impl Default for Raster { } } +/// Whether a print asked to be `grayscale` needs the printer's driver to print in grayscale. +/// Not when the pages go as grey pictures (`raster` with [`Raster::grayscale`]): they are grey +/// already, so a driver that keeps no grayscale setting (Microsoft Print to PDF, many inkjets) +/// still prints them instead of the job being refused (#5806). +pub fn driver_needs_grayscale(grayscale: bool, raster: Option) -> bool { + grayscale && !raster.is_some_and(|r| r.grayscale) +} + #[derive(Clone, Copy, Debug, PartialEq)] pub struct Crop { pub rect: [f64; 4], @@ -194,12 +224,13 @@ impl Default for Settings { content: Content::default(), raster: None, crops: Vec::new(), + area: None, } } } /// Common paper sizes (points, portrait). -pub const PAPERS: [(&str, (f64, f64)); 21] = [ +pub const PAPERS: [(&str, (f64, f64)); 23] = [ ("US Letter", (612.0, 792.0)), ("US Legal", (612.0, 1008.0)), ("Tabloid", (792.0, 1224.0)), @@ -221,6 +252,8 @@ pub const PAPERS: [(&str, (f64, f64)); 21] = [ ("A0", (2383.94, 3370.39)), ("4 x 6 in label (101.6 x 152.4 mm)", (288.0, 432.0)), ("US #10 Envelope (4.125 x 9.5 in)", (297.0, 684.0)), + ("Folio (8.5 x 13 in)", (612.0, 936.0)), + ("F4 (210 x 330 mm)", (595.28, 935.43)), ]; /// `PAPERS` index of the 4 × 6 in shipping label. @@ -255,6 +288,8 @@ pub fn cups_media(index: usize) -> Option<&'static str> { "A1" => "iso_a1_594x841mm", "A0" => "iso_a0_841x1189mm", "US #10 Envelope (4.125 x 9.5 in)" => "Env10", + "Folio (8.5 x 13 in)" => "na_foolscap_8.5x13in", + "F4 (210 x 330 mm)" => "om_folio_210x330mm", _ if index == LABEL_PAPER => "w288h432", // A size added to PAPERS without a name here: its tests fail, and meanwhile the queue's // paper stands, as it did for every size before #1583. @@ -267,3 +302,51 @@ pub fn cups_media(index: usize) -> Option<&'static str> { pub fn media_option(index: usize) -> Option<(String, String)> { cups_media(index).map(|m| ("media".to_string(), m.to_string())) } + +/// Format custom dimensions in points as a CUPS custom media string (e.g. `Custom.300x500`). +pub fn custom_media(size: (f64, f64)) -> String { + let format_dim = |d: f64| { + if (d.round() - d).abs() < 1e-4 { + format!("{:.0}", d.round()) + } else { + let s = format!("{d:.2}"); + s.trim_end_matches('0').trim_end_matches('.').to_string() + } + }; + format!("Custom.{}x{}", format_dim(size.0), format_dim(size.1)) +} + +/// The CUPS media name for a paper by its name and dimensions. +/// +/// Matches standard [`PAPERS`] presets by size first, then a clean driver-reported keyword, +/// falling back to custom media dimensions (`Custom.x`). +pub fn paper_media(name: Option<&str>, size: (f64, f64)) -> String { + if let Some(index) = matching_paper(size) + && let Some(media) = cups_media(index) + { + return media.to_string(); + } + if let Some(n) = name { + let is_dim = n.split_once(['x', 'X', '*']).is_some_and(|(w, h)| { + let parse_dim = |d: &str| { + let d = d.trim().trim_start_matches("Custom.").trim_start_matches("custom.").trim_end_matches("points").trim_end_matches("pt").trim(); + d.parse::().is_ok() + }; + parse_dim(w) && parse_dim(h) + }); + let is_clean = !n.is_empty() + && n.len() <= 64 + && !n.contains(' ') + && !is_dim + && n.chars().all(|c| c.is_ascii_graphic() && !matches!(c, '/' | ':' | '=' | '"' | '\'' | '\\' | '{')); + if is_clean { + return n.to_string(); + } + } + custom_media(size) +} + +/// The media job option (`("media", )`) for a paper by its optional name and dimensions. +pub fn paper_media_option(name: Option<&str>, size: (f64, f64)) -> (String, String) { + ("media".to_string(), paper_media(name, size)) +} diff --git a/crates/print/src/sheets.rs b/crates/print/src/sheets.rs index cca4bdc07e..24b1b5d5de 100644 --- a/crates/print/src/sheets.rs +++ b/crates/print/src/sheets.rs @@ -168,10 +168,75 @@ fn place_in(page: usize, (dw, dh): (f64, f64), cell: [f64; 4], s: f64, rotate: b Placement { page, matrix, clip: [0.0, 0.0, dw, dh] } } +/// The most creep per sheet, in points. Paper adds a fraction of a point per sheet; more is a mistake. +pub const MAX_CREEP: f64 = 10.0; + +/// `placed` moved `shift` points across the sheet (positive is rightward) and clipped at the fold +/// at `fold`, so a page moved toward it never prints on the facing page. +fn creep_toward_fold(mut placed: Placement, shift: f64, fold: f64, s: f64) -> Placement { + if shift == 0.0 || s <= 0.0 { + return placed; + } + placed.matrix.0[4] += shift; + let at_fold = (fold - placed.matrix.0[4]) / s; + if shift > 0.0 { + placed.clip[2] = placed.clip[2].min(at_fold); + } else { + placed.clip[0] = placed.clip[0].max(at_fold); + } + placed +} + fn fit_scale((dw, dh): (f64, f64), (cw, ch): (f64, f64)) -> f64 { if dw <= 0.0 || dh <= 0.0 { 1.0 } else { (cw / dw).min(ch / dh) } } +/// A selected area: one page, within it, printed as if it were a page of that size. +fn selected_area(sizes: &[(f64, f64)], pages: &[usize], settings: &Settings) -> Result, PrintError> { + let Some(a) = settings.area else { return Ok(None) }; + if !matches!(settings.layout, Layout::Size(_)) { + return Err(PrintError::Invalid("an area prints with Fit, Actual size, Shrink or a custom scale".into())); + } + let refused = || PrintError::Invalid("the selected area is empty or not on one page".into()); + let [p] = pages else { return Err(refused()) }; + let d = sizes.get(*p).copied().ok_or_else(refused)?; + // `min` and `max` pass over NaN, which would make an all-NaN area the whole page. + if a.iter().any(|v| !v.is_finite()) { + return Err(refused()); + } + // Cut to the page; what is left must be a point wide. + let [x0, y0, x1, y1] = [a[0].min(a[2]).max(0.0), a[1].min(a[3]).max(0.0), a[0].max(a[2]).min(d.0), a[1].max(a[3]).min(d.1)]; + if !(x1 - x0 >= 1.0 && y1 - y0 >= 1.0) { + return Err(refused()); + } + Ok(Some([x0, y0, x1, y1])) +} + +/// One sheet per page (or per selected area), scaled by `mode`. +fn layout_size(sizes: &[(f64, f64)], pages: &[usize], settings: &Settings, mode: SizeMode, area: Option<[f64; 4]>) -> Result, PrintError> { + let mut sheets = Vec::new(); + for &p in pages { + let d = area.map_or(sizes[p], |a| (a[2] - a[0], a[3] - a[1])); + let size = oriented(settings.paper, settings.orientation, d.0 > d.1); + let fit = fit_scale(d, (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN)); + let s = match mode { + SizeMode::Fit => fit, + SizeMode::Actual => 1.0, + SizeMode::Shrink => fit.min(1.0), + SizeMode::Custom(pct) if pct.is_finite() && pct > 0.0 => pct / 100.0, + SizeMode::Custom(_) => return Err(PrintError::Invalid("the scale must be more than 0%".into())), + }; + let sheet = [0.0, 0.0, size.0, size.1]; + // An area is placed as a page of its size would be, and clipped to it. + let placed = match area { + Some(a) => place_crop(p, a, sheet, s, 0), + None => place_in(p, d, sheet, s, false), + }; + sheets.push(Sheet { size, placed: vec![placed], ..Sheet::default() }); + } + Ok(sheets) +} + /// Lay out the sheets for `settings` given each source page's display size. pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, PrintError> { let pages: Vec = settings.pages.iter().copied().filter(|p| *p < sizes.len()).collect(); @@ -182,23 +247,10 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, P if !(paper.0 >= 72.0 && paper.1 >= 72.0 && paper.0.is_finite() && paper.1.is_finite()) { return Err(PrintError::Invalid("invalid paper size".into())); } + let area = selected_area(sizes, &pages, settings)?; let mut sheets = Vec::new(); match settings.layout { - Layout::Size(mode) => { - for &p in &pages { - let d = sizes[p]; - let size = oriented(paper, settings.orientation, d.0 > d.1); - let fit = fit_scale(d, (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN)); - let s = match mode { - SizeMode::Fit => fit, - SizeMode::Actual => 1.0, - SizeMode::Shrink => fit.min(1.0), - SizeMode::Custom(pct) if pct.is_finite() && pct > 0.0 => pct / 100.0, - SizeMode::Custom(_) => return Err(PrintError::Invalid("the scale must be more than 0%".into())), - }; - sheets.push(Sheet { size, placed: vec![place_in(p, d, [0.0, 0.0, size.0, size.1], s, false)], ..Sheet::default() }); - } - } + Layout::Size(mode) => sheets = layout_size(sizes, &pages, settings, mode, area)?, Layout::Label { flip } => { sheets.extend(layout_labels(sizes, &pages, paper, settings.orientation, settings, flip)); } @@ -288,13 +340,19 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, P sheets.push(sheet); } } - Layout::Booklet { subset, binding } => { + Layout::Booklet { subset, binding, creep } => { // Pad to a multiple of 4 with blank pages (None). let n4 = pages.len().div_ceil(4) * 4; let at = |i: usize| pages.get(i).copied(); let size = oriented(paper, Orientation::Landscape, true); let half = (size.0 - 2.0 * MARGIN) / 2.0; let cells = [[MARGIN, MARGIN, MARGIN + half, size.1 - MARGIN], [MARGIN + half, MARGIN, size.0 - MARGIN, size.1 - MARGIN]]; + // The innermost sheet moves furthest; it must stay within half its cell. `pages` is not + // empty (checked above), so there is at least one sheet. + let innermost = creep * (n4 / 4 - 1) as f64; + if !((0.0..=MAX_CREEP).contains(&creep) && innermost < half / 2.0) { + return Err(PrintError::Invalid("invalid booklet creep".into())); + } for k in 0..n4 / 4 { let front = (at(n4 - 1 - 2 * k), at(2 * k)); let back = (at(2 * k + 1), at(n4 - 2 - 2 * k)); @@ -308,11 +366,12 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, P for (l, r) in sides { let (l, r) = if binding == Binding::Right { (r, l) } else { (l, r) }; let mut sheet = Sheet { size, ..Sheet::default() }; - for (p, cell) in [(l, cells[0]), (r, cells[1])] { + for (p, cell, toward) in [(l, cells[0], 1.0), (r, cells[1], -1.0)] { if let Some(p) = p { let d = sizes[p]; let s = fit_scale(d, (cell[2] - cell[0], cell[3] - cell[1])); - sheet.placed.push(place_in(p, d, cell, s, false)); + let placed = place_in(p, d, cell, s, false); + sheet.placed.push(creep_toward_fold(placed, toward * creep * k as f64, MARGIN + half, s)); } } sheets.push(sheet); diff --git a/crates/print/src/spool.rs b/crates/print/src/spool.rs index b16f57026d..73a267daa5 100644 --- a/crates/print/src/spool.rs +++ b/crates/print/src/spool.rs @@ -2,10 +2,13 @@ //! stay in PdfCraft; these adapters retain the application's print-error surface. pub use craft_print::{ - Duplex, HANDLED_OPTIONS, HAS_PRINTER_PREFERENCES, Job, Printer, PrinterOption, Scaling, lp_args, lpstat_command, parse_lpoptions, parse_lpstat, - parse_lpstat_e, parse_ppd, parse_windows_printers, ppd_text, printers_parsed, + AUTO_SELECT, Duplex, HANDLED_OPTIONS, HAS_PRINTER_PREFERENCES, Job, Printer, PrinterOption, Scaling, apply_queue_defaults, lp_args, + lpstat_command, parse_lpoptions, parse_lpstat, parse_lpstat_e, parse_ppd, parse_windows_printers, ppd_text, printers_parsed, }; +#[cfg(any(windows, test))] +mod windows_duplex; + /// Windows: the in-box DLLs printing draws and describes jobs with. The executables delay-load /// them (`apps/pdfcraft{,-cli}/build.rs`, and `build.rs` for this crate's tests: the same list) /// so a Windows installation without them still starts (#2630); a call into a missing @@ -24,8 +27,9 @@ thread_local! { /// Tests: the folder the print DLLs are looked for in, instead of the system's, so the /// public entry points can be checked against a Windows that lacks them. pub(crate) static SYSTEM32_FOR_TEST: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; - /// Tests: how many times [`printers`] asked the system's printer backend, so a test can tell - /// the guard answered without relying on the machine having no printers. + /// Tests: how many times [`printers`], [`printer_queues`] or [`two_sided`] asked the system's + /// printer backend, so a test can tell the guard answered without relying on the machine + /// having no printers. pub(crate) static PRINTER_BACKEND_CALLS: std::cell::Cell = const { std::cell::Cell::new(0) }; } @@ -49,6 +53,19 @@ fn unavailable(dll: &str) -> crate::PrintError { crate::PrintError::Spool(format!("printing needs {dll}, which this Windows installation lacks; use Save as PDF instead")) } +/// Keeps two threads of this process out of the Windows PrintTicket provider (`PTOpenProvider` +/// and the calls on its handle) at the same time. When two threads make the process's first calls +/// together, winspool can keep a pointer to a device capabilities cache it has freed, and a later +/// call crashes the process. All entry points in this process that query driver capabilities +/// or submit jobs share this process-wide lock. +#[cfg(windows)] +static PRINT_TICKET_PROVIDER: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +#[cfg(windows)] +pub(crate) fn print_ticket_provider() -> std::sync::MutexGuard<'static, ()> { + PRINT_TICKET_PROVIDER.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + /// The system's printers; none on a Windows installation without the print DLLs. pub fn printers() -> Vec { if missing_print_dll().is_some() { @@ -56,9 +73,36 @@ pub fn printers() -> Vec { } #[cfg(all(windows, test))] PRINTER_BACKEND_CALLS.with(|c| c.set(c.get().saturating_add(1))); + #[cfg(windows)] + let _provider = print_ticket_provider(); craft_print::printers() } +/// The system's print queues without asking any driver, so a Print dialog's list never waits on +/// a printer (#2192); none on a Windows installation without the print DLLs, as [`printers`]. +pub fn printer_queues() -> Vec { + if missing_print_dll().is_some() { + return Vec::new(); + } + #[cfg(all(windows, test))] + PRINTER_BACKEND_CALLS.with(|c| c.set(c.get().saturating_add(1))); + craft_print::printer_queues() +} + +/// Whether `printer`'s driver takes two-sided settings, read for that one printer; it can wait +/// on the device, so call it off the UI thread. Unknown on a Windows installation without the +/// print DLLs: reading it goes through them. +pub fn two_sided(printer: &str) -> Option { + if missing_print_dll().is_some() { + return None; + } + #[cfg(all(windows, test))] + PRINTER_BACKEND_CALLS.with(|c| c.set(c.get().saturating_add(1))); + #[cfg(windows)] + let _provider = print_ticket_provider(); + craft_print::two_sided(printer) +} + /// The driver's job options for a printer (CUPS: its PPD, with the queue's current defaults); /// empty when it has none or on other platforms. Reads files and runs `lpoptions`, so call it /// when the user asks, not every frame. @@ -164,10 +208,14 @@ pub(crate) fn printer_options_via_within( ) -> Vec { let (queue, instance) = printer.split_once('/').map_or((printer, None), |(q, i)| (q, Some(i))); // Retain the conservative name checks, applied to each component. Neither component is - // a path: reject dot components, backslashes and any additional separator before I/O. + // a path: reject dot components, backslashes and any additional separator before I/O. A + // leading `-` is refused too: `lpoptions` parses its argv with getopt, so `-p -E -l` would + // read `-E` as an option rather than as the destination, and `--` cannot help because it + // ends option parsing before the `-p` that names the queue. let valid = |name: &str| { !name.is_empty() && name.len() <= 64 + && !name.starts_with('-') && !matches!(name, "." | "..") && name.bytes().all(|b| b.is_ascii_graphic() && !matches!(b, b'/' | b'\\' | b':' | b'=' | b'"')) }; @@ -181,17 +229,125 @@ pub(crate) fn printer_options_via_within( f.take(PPD_CAP).read_to_end(&mut bytes).ok().map(|_| bytes) }); let mut options = ppd.as_deref().map(|b| parse_ppd(&ppd_text(b))).unwrap_or_default(); + let lpoptions_out = output_within(lpoptions.args(["-p", printer, "-l"]), limit).ok(); + let current = lpoptions_out.as_deref().map(|out| parse_lpoptions(&String::from_utf8_lossy(out))).unwrap_or_default(); + if options.is_empty() { + // The PPD is missing or unreadable (#4699). The same `lpoptions -l` listing carries every + // option with its choices and default; build a fallback list from it. + if let Some(out) = &lpoptions_out { + options = parse_lpoptions_list(&String::from_utf8_lossy(out)); + } + } + // After the fallback: a readable PPD whose `*OpenUI` blocks yielded nothing still supplies the + // translations, and `localize` leaves options it cannot translate untouched. if let Some(b) = &ppd { localize(&mut options, b, lang); } - let current = - output_within(lpoptions.args(["-p", printer, "-l"]), limit).map(|out| parse_lpoptions(&String::from_utf8_lossy(&out))).unwrap_or_default(); - for (key, value) in current { - if let Some(o) = options.iter_mut().find(|o| o.key == key) - && o.choices.iter().any(|(c, _)| *c == value) - { - o.default = value; + // The queue's current choices become the defaults (names matched as CUPS matches them). On + // macOS the paper tray also starts on Auto Select, which is what a job naming no tray asks + // for, so the printer isn't held on an empty default tray (#3752, storytold/craft-libs#232). + apply_queue_defaults(&mut options, ¤t); + options +} + +/// Caps that match [`craft_print::parse_ppd`]: a malformed spooler answer is not allowed to +/// dominate memory or the dialog. +#[cfg(all(unix, not(target_arch = "wasm32")))] +const MAX_LPOPTIONS_OPTIONS: usize = 400; +#[cfg(all(unix, not(target_arch = "wasm32")))] +const MAX_LPOPTIONS_CHOICES: usize = 400; +#[cfg(all(unix, not(target_arch = "wasm32")))] +const MAX_LPOPTIONS_LABEL: usize = 256; + +/// A PPD keyword: printable ASCII without spaces, `/`, `:`, `=` or `"`. +#[cfg(all(unix, not(target_arch = "wasm32")))] +// The three name helpers below mirror `is_ppd_keyword`, `name_label` and `ppd_label` in craft-print, +// which are private there: keep them in step with that crate, or they will accept in one reader what +// the other refuses. +fn is_lpoptions_keyword(s: &str) -> bool { + !s.is_empty() && s.len() <= 64 && s.bytes().all(|b| b.is_ascii_graphic() && !matches!(b, b'/' | b':' | b'=' | b'"')) +} + +/// `Name/Label` → (`Name`, `Label`), the label falling back to the name. +#[cfg(all(unix, not(target_arch = "wasm32")))] +fn lpoptions_name_label(s: &str) -> (&str, &str) { + match s.split_once('/') { + Some((n, l)) if !l.trim().is_empty() => (n.trim(), l.trim()), + Some((n, _)) => (n.trim(), n.trim()), + None => (s.trim(), s.trim()), + } +} + +/// `label`, cut to [`MAX_LPOPTIONS_LABEL`] characters. +#[cfg(all(unix, not(target_arch = "wasm32")))] +fn lpoptions_label(label: &str) -> String { + label.chars().take(MAX_LPOPTIONS_LABEL).collect() +} + +/// Whether `choice` is a custom-value template. For a choice the user has to type, `lpoptions -l` +/// prints `Custom`, `Custom.` (`Custom.PASSCODE`, `Custom.STRING`, …) or `Custom.WIDTHxHEIGHT` +/// in place of the choices, and the value is not something Craft can collect: it would be sent to +/// the driver as the literal text. The prefix is reserved by CUPS, case-insensitively. +#[cfg(all(unix, not(target_arch = "wasm32")))] +fn is_lpoptions_custom_template(choice: &str) -> bool { + match choice.split_once('.') { + Some((name, _)) => name.eq_ignore_ascii_case("Custom"), + None => choice.eq_ignore_ascii_case("Custom"), + } +} + +/// Build [`PrinterOption`]s from the `lpoptions -p NAME -l` listing (`Key/Label: a *b c`). This is +/// the fallback when the PPD cannot be read, so there is no group information: every option lands in +/// one unnamed group, and the listing cannot say which options sit under `InstallableOptions`, so an +/// optional-hardware entry that [`parse_ppd`] drops may appear here. The starred choice is the +/// driver's default. Options Craft handles itself ([`HANDLED_OPTIONS`]) and custom-value templates +/// ([`is_lpoptions_custom_template`]) are skipped. +/// +/// The line is split at its **last** colon, not its first: `list_group()` prints the option as +/// `"%s/%s:"` from the PPD's keyword and translated text, then every choice as a bare `" %s"`, so +/// text such as `Supporto: Tipo` carries colons while a choice keyword cannot. Splitting at the +/// first colon would turn the rest of the label into invented choices. +#[cfg(all(unix, not(target_arch = "wasm32")))] +fn parse_lpoptions_list(out: &str) -> Vec { + let mut options = Vec::new(); + for line in out.lines() { + let line = line.trim_end_matches('\r'); + let Some((head, choices)) = line.rsplit_once(':') else { + continue; + }; + let (key, label) = lpoptions_name_label(head); + if !is_lpoptions_keyword(key) || HANDLED_OPTIONS.contains(&key) { + continue; } + let mut choice_list = Vec::new(); + let mut default = String::new(); + // Set when the choice CUPS marks is a template the dialog cannot collect. The printer keeps + // that value, so standing in the first retained choice would show a default the job does not + // use: only options changed away from the default are submitted. + let mut marked_template = false; + for token in choices.split_whitespace() { + let starred = token.starts_with('*'); + let (choice_key, choice_label) = lpoptions_name_label(token.strip_prefix('*').unwrap_or(token)); + if !is_lpoptions_keyword(choice_key) || is_lpoptions_custom_template(choice_key) { + marked_template |= starred && is_lpoptions_custom_template(choice_key); + continue; + } + if starred { + default = choice_key.to_string(); + } + if choice_list.len() < MAX_LPOPTIONS_CHOICES && choice_list.iter().all(|(c, _)| c != choice_key) { + choice_list.push((choice_key.to_string(), lpoptions_label(choice_label))); + } + } + if choice_list.is_empty() || options.len() >= MAX_LPOPTIONS_OPTIONS { + continue; + } + if default.is_empty() && !marked_template { + // No choice marked at all: the dialog needs something selected, and the listing has + // nothing better than the first one to offer. + default = choice_list.first().map(|(c, _)| c.clone()).unwrap_or_default(); + } + options.push(PrinterOption { key: key.to_string(), label: lpoptions_label(label), group: String::new(), choices: choice_list, default }); } options } @@ -244,11 +400,19 @@ fn spooled(job: &Job) -> Job { } } +/// Submit a PDF to the system print spooler for the configured `job`. pub fn submit(pdf: &[u8], job: &Job) -> Result { if let Some(dll) = missing_print_dll() { return Err(unavailable(dll)); } - craft_print::submit(pdf, &spooled(job)).map_err(|e| crate::PrintError::Spool(e.to_string())) + #[cfg(windows)] + let _provider = print_ticket_provider(); + #[cfg(windows)] + let resolved = windows_duplex::job(job); + #[cfg(windows)] + let job = resolved.as_ref(); + let pdf = spooled_pdf(pdf, crate::font_names::RENAME_FOR_SPOOLER); + craft_print::submit(&pdf, &spooled(job)).map_err(|e| crate::PrintError::Spool(e.to_string())) } /// [`submit`], told `(sent, sheets)` as each sheet reaches the spooler (Windows; CUPS takes the @@ -257,13 +421,158 @@ pub fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnMut(usiz if let Some(dll) = missing_print_dll() { return Err(unavailable(dll)); } - craft_print::submit_with_progress(pdf, &spooled(job), progress).map_err(|e| crate::PrintError::Spool(e.to_string())) + #[cfg(windows)] + let _provider = print_ticket_provider(); + #[cfg(windows)] + let resolved = windows_duplex::job(job); + #[cfg(windows)] + let job = resolved.as_ref(); + let pdf = spooled_pdf(pdf, crate::font_names::RENAME_FOR_SPOOLER); + craft_print::submit_with_progress(&pdf, &spooled(job), progress).map_err(|e| crate::PrintError::Spool(e.to_string())) } +/// The PDF as the system spooler takes it: with `rename` (Windows), its embedded TrueType and +/// OpenType programs renamed, so Windows can't draw a subset's glyph numbers with an installed +/// font of the same name (#3132, [`crate::font_names`]). A PDF that can't be read back goes as it +/// is: the renaming only protects the text from Windows, and the job shouldn't fail over it. +pub(crate) fn spooled_pdf(pdf: &[u8], rename: bool) -> std::borrow::Cow<'_, [u8]> { + if !rename { + return std::borrow::Cow::Borrowed(pdf); + } + match crate::font_names::distinct(pdf) { + Ok(Some(renamed)) => std::borrow::Cow::Owned(renamed), + Ok(None) | Err(_) => std::borrow::Cow::Borrowed(pdf), + } +} + +/// Opens the printer driver's native Printing Preferences window (Windows `printui.dll`). pub fn open_printer_preferences(printer: &str) -> Result<(), crate::PrintError> { craft_print::open_printer_preferences(printer).map_err(|e| crate::PrintError::Spool(e.to_string())) } +/// A named paper with dimensions in points `(width, height)`. +pub type Paper = (String, (f64, f64)); + +/// The paper sizes supported by `printer` as reported by the system / printer driver. +/// Empty when the printer reports none or on platforms where driver capabilities are not queried. +#[cfg(windows)] +pub fn printer_papers(printer: &str) -> Vec { + // A call into a missing delay-loaded PrintTicket DLL would terminate the process (#2630). + if missing_print_dll().is_some() { + return Vec::new(); + } + let _provider = print_ticket_provider(); + printer_papers_internal(printer) +} + +#[cfg(windows)] +fn printer_papers_internal(printer: &str) -> Vec { + #[cfg(test)] + PRINTER_BACKEND_CALLS.with(|c| c.set(c.get().saturating_add(1))); + use winprint::ticket::{FeatureOptionPack as _, FeatureOptionPackWithPredefined as _}; + let Ok(devices) = winprint::printer::PrinterDevice::all() else { return Vec::new() }; + let Some(device) = devices.into_iter().find(|d| d.name() == printer) else { return Vec::new() }; + let Ok(capabilities) = winprint::ticket::PrintCapabilities::fetch(&device) else { return Vec::new() }; + let mut papers = Vec::new(); + for media in capabilities.page_media_sizes() { + let size = media.size(); + let w_pts = (size.width_in_micron() as f64) * 72.0 / 25_400.0; + let h_pts = (size.height_in_micron() as f64) * 72.0 / 25_400.0; + if w_pts.is_finite() && h_pts.is_finite() && w_pts >= 72.0 && h_pts >= 72.0 && w_pts <= 14400.0 && h_pts <= 14400.0 { + let name = media.display_name().map(str::to_string).unwrap_or_else(|| { + if let Some(predefined) = media.as_predefined_name() { format!("{predefined:?}") } else { format!("{w_pts:.1} x {h_pts:.1} pt") } + }); + let w_pts = (w_pts * 100.0).round() / 100.0; + let h_pts = (h_pts * 100.0).round() / 100.0; + if !papers.iter().any(|(n, (w, h)): &(String, (f64, f64))| n == &name && (*w - w_pts).abs() < 0.1 && (*h - h_pts).abs() < 0.1) { + papers.push((name, (w_pts, h_pts))); + } + } + } + papers +} + +/// The paper sizes supported by `printer` (fallback for non-Windows platforms). +#[cfg(not(windows))] +pub fn printer_papers(_printer: &str) -> Vec { + Vec::new() +} + +/// Match `paper_pts` against detected driver `papers` within a 1.0 pt tolerance. +#[cfg(windows)] +fn resolve_paper_name(papers: &[Paper], paper_pts: (f64, f64)) -> Paper { + let w_pts = (paper_pts.0 * 100.0).round() / 100.0; + let h_pts = (paper_pts.1 * 100.0).round() / 100.0; + let name = papers + .iter() + .find(|(_, (w, h))| ((*w - w_pts).abs() <= 1.0 && (*h - h_pts).abs() <= 1.0) || ((*w - h_pts).abs() <= 1.0 && (*h - w_pts).abs() <= 1.0)) + .map(|(n, _)| n.clone()) + .or_else(|| crate::matching_paper(paper_pts).and_then(|idx| crate::PAPERS.get(idx).map(|(n, _)| n.to_string()))) + .unwrap_or_default(); + (name, (w_pts, h_pts)) +} + +/// The default paper size configured for `printer` in the printer properties / DEVMODE. +/// Returns `Some((name, (width, height)))` if readable. +pub fn printer_default_paper(printer: &str) -> Option { + printer_paper_info(printer).1 +} + +/// Reads the supported paper sizes and the default paper size configured for `printer`. +pub fn printer_paper_info(printer: &str) -> (Vec, Option) { + if missing_print_dll().is_some() { + return (Vec::new(), None); + } + #[cfg(windows)] + let _provider = print_ticket_provider(); + #[cfg(windows)] + let papers = printer_papers_internal(printer); + #[cfg(not(windows))] + let papers = printer_papers(printer); + #[cfg(windows)] + let default_paper = { + let settings = craft_print::rgb::default_settings(printer).ok(); + let page = settings.as_ref().and_then(|s| craft_print::rgb::page(printer, Some(s)).ok()); + page.map(|p| resolve_paper_name(&papers, p.paper)) + }; + #[cfg(not(windows))] + let default_paper = { + let _ = &papers; + None + }; + (papers, default_paper) +} + +/// Shows the printer driver's Page Setup / DocumentProperties dialog (Windows). +/// +/// Returns the paper size and orientation selected by the user. Other per-job driver +/// settings (such as tray or quality) are not captured; general driver settings should +/// be configured through [`open_printer_preferences`] (Properties…). +pub fn show_printer_properties(printer: &str, parent: Option) -> Result, crate::PrintError> { + if let Some(dll) = missing_print_dll() { + return Err(unavailable(dll)); + } + #[cfg(windows)] + { + let devmode = match craft_print::rgb::settings_dialog(printer, None, parent) { + Ok(Some(dm)) => dm, + Ok(None) => return Ok(None), + Err(_) => { + open_printer_preferences(printer)?; + return Ok(None); + } + }; + let page = craft_print::rgb::page(printer, Some(&devmode)).map_err(|e| crate::PrintError::Spool(e.to_string()))?; + let papers = printer_papers(printer); + Ok(Some(resolve_paper_name(&papers, page.paper))) + } + #[cfg(not(windows))] + { + let _ = (printer, parent); + Ok(None) + } +} + /// The PPD language codes (`*cupsLanguages`, the `ll` of `*ll.Translation`) to try for a UI /// language code, most specific first: `pt-br` → `pt_BR`, `pt`; `zh-hans` → `zh_CN`, `zh`. // Read only by the CUPS path; the tests exercise it everywhere. @@ -271,7 +580,9 @@ pub fn open_printer_preferences(printer: &str) -> Result<(), crate::PrintError> fn ppd_languages(lang: &str) -> Vec { let lang = lang.to_ascii_lowercase(); let mut parts = lang.split(['-', '_']).filter(|p| !p.is_empty()); - let Some(primary) = parts.next() else { return Vec::new() }; + let Some(primary) = parts.next() else { + return Vec::new(); + }; let region = match (primary, parts.next()) { ("zh", Some("hans")) => Some("CN".to_string()), ("zh", Some("hant")) => Some("TW".to_string()), @@ -303,7 +614,9 @@ pub(crate) fn localize(options: &mut [PrinterOption], ppd: &[u8], lang: &str) { let mut choices: HashMap<(String, String), String> = HashMap::new(); let mut groups: HashMap = HashMap::new(); for line in ppd.split(|b| *b == b'\n') { - let Some(text) = std::str::from_utf8(line).ok() else { continue }; + let Some(text) = std::str::from_utf8(line).ok() else { + continue; + }; let text = text.trim_end_matches('\r'); if let Some(rest) = text.strip_prefix("*OpenGroup:") { // Group label → keyword, since options carry their group's label. @@ -313,10 +626,18 @@ pub(crate) fn localize(options: &mut [PrinterOption], ppd: &[u8], lang: &str) { } continue; } - let Some(rest) = text.strip_prefix(prefix.as_str()) else { continue }; - let Some((head, _)) = rest.split_once(':') else { continue }; - let Some((main, spec)) = head.split_once(' ') else { continue }; - let Some((key, label)) = spec.split_once('/') else { continue }; + let Some(rest) = text.strip_prefix(prefix.as_str()) else { + continue; + }; + let Some((head, _)) = rest.split_once(':') else { + continue; + }; + let Some((main, spec)) = head.split_once(' ') else { + continue; + }; + let Some((key, label)) = spec.split_once('/') else { + continue; + }; let label = label.trim(); if label.is_empty() { continue; diff --git a/crates/print/src/spool/windows_duplex.rs b/crates/print/src/spool/windows_duplex.rs new file mode 100644 index 0000000000..eca09cf9a1 --- /dev/null +++ b/crates/print/src/spool/windows_duplex.rs @@ -0,0 +1,214 @@ +//! Resolve Printer default from the Windows driver's current DEVMODE. Omitting duplex +//! from a PrintTicket leaves its interpretation to the XPS driver (#5130). + +use std::borrow::Cow; + +use super::{Duplex, Job}; +use crate::PrintError; + +/// Preserve an explicit duplex choice by borrowing `job` without calling `read`. +/// Otherwise call `read` once with the selected printer (or `None` for the system default), +/// returning an owned copy with the resolved printer and duplex value. Log lookup failures +/// and leave duplex unspecified so the spooler can use its previous default behavior; +/// the original job remains unchanged even when the driver has no duplex setting. +fn resolve<'a>(job: &'a Job, read: impl FnOnce(Option<&str>) -> Result<(String, Option), PrintError>) -> Cow<'a, Job> { + if job.duplex.is_some() { + return Cow::Borrowed(job); + } + let (printer, duplex) = match read(job.printer.as_deref()) { + Ok(settings) => settings, + Err(error) => { + driver_default(Err(error)); + return Cow::Borrowed(job); + } + }; + let mut resolved = job.clone(); + resolved.printer = Some(printer); + resolved.duplex = duplex; + Cow::Owned(resolved) +} + +/// A driver default is best-effort: record the fallback and let the driver choose. +fn driver_default(result: Result, PrintError>) -> Option { + match result { + Ok(value) => value, + Err(error) => { + log::warn!("could not resolve the printer's two-sided default; leaving duplex unspecified: {error}"); + None + } + } +} + +/// Preserve manual two-sided defaults when PrintCapabilities say the driver cannot take +/// explicit two-sided settings (#5772). Unknown capabilities also keep the driver's default: +/// turning a best-effort preference into a strict request could otherwise refuse a working job. +fn supported_default(value: Option, support: impl FnOnce() -> Option) -> Option { + if matches!(value, Some(Duplex::LongEdge | Duplex::ShortEdge)) && support() != Some(true) { None } else { value } +} + +/// DEVMODEW's public dmFields (offset 72) marks dmDuplex (offset 94) as valid with +/// DM_DUPLEX. Values are DMDUP_SIMPLEX, DMDUP_VERTICAL and DMDUP_HORIZONTAL, respectively. +/// Return `None` when DM_DUPLEX is absent, or a spool error for a malformed field or an +/// unrecognized declared value. Layout: . +fn duplex(settings: &craft_print::rgb::DevMode) -> Result, PrintError> { + let bytes = settings.as_bytes(); + let invalid = || PrintError::Spool("the printer driver returned an invalid two-sided default; check its printing preferences".into()); + let fields = bytes.get(72..76).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(u32::from_le_bytes).ok_or_else(invalid)?; + if fields & 0x1000 == 0 { + return Ok(None); + } + match bytes.get(94..96).and_then(|b| <[u8; 2]>::try_from(b).ok()).map(i16::from_le_bytes) { + Some(1) => Ok(Some(Duplex::Off)), + Some(2) => Ok(Some(Duplex::LongEdge)), + Some(3) => Ok(Some(Duplex::ShortEdge)), + _ => Err(invalid()), + } +} + +#[cfg(windows)] +/// Resolve an unspecified duplex setting from the selected Windows printer immediately +/// before submission. An unnamed printer is resolved to the system default and pinned to +/// the returned job. Explicit choices bypass the driver lookup. Lookup failures are logged +/// and leave duplex unspecified; a settings failure retains the resolved queue. +pub(super) fn job(job: &Job) -> Cow<'_, Job> { + resolve(job, |selected| { + let failed = |e| PrintError::Spool(format!("could not read the printer's two-sided default: {e}")); + let printer = match selected { + Some(name) => name.to_owned(), + None => craft_print::rgb::default_printer() + .map_err(failed)? + .ok_or_else(|| PrintError::Spool("there is no default printer; choose a printer".into()))?, + }; + let value = craft_print::rgb::default_settings(&printer).map_err(failed).and_then(|settings| duplex(&settings)); + // Only this printer's driver is asked (craft-print `two_sided`), not every queue's, as + // `printers()` would; a networked printer in power save could hold the job (#2192). + let value = supported_default(driver_default(value), || craft_print::two_sided(&printer)); + Ok((printer, value)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn settings(fields: u32, value: i16) -> craft_print::rgb::DevMode { + let mut bytes = vec![0; 220]; + bytes[68..70].copy_from_slice(&220u16.to_le_bytes()); + bytes[72..76].copy_from_slice(&fields.to_le_bytes()); + bytes[94..96].copy_from_slice(&value.to_le_bytes()); + craft_print::rgb::DevMode::from_bytes(bytes).unwrap() + } + + #[test] + fn printer_default_reaches_the_windows_job_for_each_binding() { + for (value, expected) in [(1, Duplex::Off), (2, Duplex::LongEdge), (3, Duplex::ShortEdge)] { + let original = Job { + printer: Some("Office".into()), + copies: 3, + collate: false, + grayscale: true, + title: "report".into(), + options: vec![("media".into(), "A4".into())], + ..Job::default() + }; + let resolved = resolve(&original, |selected| { + assert_eq!(selected, Some("Office")); + Ok(("Office".into(), duplex(&settings(0x1000, value))?)) + }); + assert_eq!(resolved.duplex, Some(expected)); + assert_eq!(resolved.printer.as_deref(), Some("Office")); + assert_eq!(resolved.copies, 3); + assert_eq!(resolved.collate, original.collate); + assert_eq!(resolved.grayscale, original.grayscale); + assert_eq!(resolved.scaling, original.scaling); + assert_eq!(resolved.options, original.options); + assert_eq!(resolved.title, "report"); + assert_eq!(original.duplex, None, "Printer default remains selected for the next job"); + } + } + + #[test] + fn explicit_choices_do_not_read_or_change_the_printer_default() { + for choice in [Duplex::Off, Duplex::LongEdge, Duplex::ShortEdge] { + let original = Job { duplex: Some(choice), ..Job::default() }; + let resolved = resolve(&original, |_| panic!("explicit choice must skip driver settings")); + assert!(matches!(resolved, Cow::Borrowed(_))); + assert_eq!(resolved.duplex, Some(choice)); + } + } + + #[test] + fn system_default_queue_is_resolved_once_and_pinned_to_the_job() { + let original = Job::default(); + let resolved = resolve(&original, |selected| { + assert_eq!(selected, None); + Ok(("System default".into(), Some(Duplex::ShortEdge))) + }); + assert_eq!(resolved.printer.as_deref(), Some("System default")); + assert_eq!(resolved.duplex, Some(Duplex::ShortEdge)); + } + + #[test] + fn missing_duplex_field_is_left_to_the_driver_and_invalid_values_fail() { + assert_eq!(duplex(&settings(0, 99)).unwrap(), None); + for value in [0, -1, 4, i16::MAX] { + assert!(duplex(&settings(0x1000, value)).is_err()); + } + let original = Job::default(); + let resolved = resolve(&original, |_| Ok(("Simplex".into(), None))); + assert_eq!(resolved.duplex, None); + } + + #[test] + fn a_queue_lookup_failure_preserves_the_original_job() { + let original = Job::default(); + let resolved = resolve(&original, |_| Err(PrintError::Spool("driver unavailable".into()))); + assert!(matches!(resolved, Cow::Borrowed(_))); + assert_eq!(resolved.duplex, None); + assert_eq!(resolved.printer, original.printer); + } + + #[test] + fn failed_or_invalid_settings_fall_back_and_keep_the_resolved_queue() { + for value in [Err(PrintError::Spool("driver unavailable".into())), duplex(&settings(0x1000, 99))] { + let original = Job { copies: 3, ..Job::default() }; + let resolved = resolve(&original, |_| Ok(("Resolved queue".into(), driver_default(value)))); + assert_eq!(resolved.duplex, None); + assert_eq!(resolved.printer.as_deref(), Some("Resolved queue")); + assert_eq!(resolved.copies, 3); + assert_eq!(original.printer, None); + } + } + + #[test] + fn manual_duplex_defaults_remain_unspecified_without_automatic_support() { + for binding in [2, 3] { + for support in [Some(false), None, Some(true)] { + let original = Job { printer: Some("Manual duplex".into()), ..Job::default() }; + let value = duplex(&settings(0x1000, binding)).unwrap(); + let resolved = resolve(&original, |_| Ok(("Manual duplex".into(), supported_default(value, || support)))); + assert_eq!(resolved.duplex, if support == Some(true) { value } else { None }); + assert_eq!(resolved.printer.as_deref(), Some("Manual duplex")); + assert_eq!(original.duplex, None); + } + } + assert_eq!(supported_default(Some(Duplex::Off), || panic!("simplex needs no capability query")), Some(Duplex::Off)); + assert_eq!(supported_default(None, || panic!("unspecified needs no capability query")), None); + } + + /// Reads settings only: never submits a job or changes the printer's preferences. + #[cfg(windows)] + #[test] + #[ignore = "needs PDFCRAFT_PRINT_TEST_PRINTER naming an installed Windows printer; sends no job"] + fn installed_windows_printer_default_is_carried_explicitly() { + let printer = std::env::var("PDFCRAFT_PRINT_TEST_PRINTER").unwrap(); + let original = Job { printer: Some(printer.clone()), ..Job::default() }; + let expected = supported_default(duplex(&craft_print::rgb::default_settings(&printer).unwrap()).unwrap(), || { + super::super::printers().into_iter().find(|p| p.name == printer).and_then(|p| p.two_sided) + }); + let resolved = job(&original); + assert_eq!(resolved.printer.as_deref(), Some(printer.as_str())); + assert_eq!(resolved.duplex, expected); + eprintln!("native Windows default: {expected:?}; resolved job: {:?}", resolved.duplex); + } +} diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 0cd844a53d..98a37a98cc 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -3,6 +3,12 @@ use std::sync::Arc; use super::*; use crate::spool::{Duplex, Job, Printer, lp_args, parse_lpstat, parse_windows_printers}; +/// What `lp_args` adds on macOS for a job that names no paper tray: Auto Select, as the system +/// Print dialog asks (#3752, storytold/craft-libs#232). Nothing elsewhere. +fn macos_auto_tray() -> &'static str { + if cfg!(target_os = "macos") { " -o AP_D_InputSlot=" } else { "" } +} + /// `n` pages of 200×300 (page 3 is landscape 300×200 when n ≥ 3); page i shows "(Page i+1)". /// Page 1 carries a printable square comment, a non-printing note, and a stamp. fn fixture(n: usize) -> Document { @@ -288,15 +294,58 @@ fn imposing_lists_the_pages_once() { #[test] fn booklets_pair_pages_for_folding() { let sizes = vec![(200.0, 300.0); 6]; - let sheets = layout(&sizes, &settings((0..6).collect(), Layout::Booklet { subset: BookletSubset::BothSides, binding: Binding::Left })).unwrap(); + let sheets = + layout(&sizes, &settings((0..6).collect(), Layout::Booklet { subset: BookletSubset::BothSides, binding: Binding::Left, creep: 0.0 })) + .unwrap(); // 6 pages pad to 8: sheet 1 front [8,1] → [blank, 0], back [1, 6]; sheet 2 front [5, 2], back [3, 4]. assert_eq!(sheet_pages(&sheets), [vec![0], vec![1], vec![5, 2], vec![3, 4]]); assert!(sheets[0].placed[0].matrix.0[4] >= 395.9, "page 1 on the right half"); assert_eq!(sheets[0].size, (792.0, 612.0)); - let front = layout(&sizes, &settings((0..6).collect(), Layout::Booklet { subset: BookletSubset::FrontOnly, binding: Binding::Right })).unwrap(); + let front = + layout(&sizes, &settings((0..6).collect(), Layout::Booklet { subset: BookletSubset::FrontOnly, binding: Binding::Right, creep: 0.0 })) + .unwrap(); assert_eq!(sheet_pages(&front), [vec![0], vec![2, 5]], "right binding mirrors the spread"); } +/// #6777: creep moves each sheet's pages toward the fold by its place from the outside, and clips +/// them at the fold, for either binding. +#[test] +fn booklet_creep_moves_inner_sheets_toward_the_fold() { + let sizes = vec![(200.0, 300.0); 8]; + let booklet = + |binding, creep| layout(&sizes, &settings((0..8).collect(), Layout::Booklet { subset: BookletSubset::BothSides, binding, creep })).unwrap(); + for binding in [Binding::Left, Binding::Right] { + let (flat, crept) = (booklet(binding, 0.0), booklet(binding, 2.0)); + assert_eq!(sheet_pages(&flat), sheet_pages(&crept)); + let fold = 792.0 / 2.0; + for (side, (a, b)) in flat.iter().zip(&crept).enumerate() { + let shift = if side < 2 { 0.0 } else { 2.0 }; + let (left, right) = ((&a.placed[0], &b.placed[0]), (&a.placed[1], &b.placed[1])); + assert!((left.1.matrix.0[4] - left.0.matrix.0[4] - shift).abs() < 1e-9, "{binding:?} side {side}: the left page moves right"); + assert!((right.1.matrix.0[4] - right.0.matrix.0[4] + shift).abs() < 1e-9, "{binding:?} side {side}: the right page moves left"); + let s = left.1.matrix.0[0]; + assert!(left.1.matrix.0[4] + left.1.clip[2] * s <= fold + 1e-9, "{binding:?} side {side}: the left page stops at the fold"); + assert!(right.1.matrix.0[4] + right.1.clip[0] * s >= fold - 1e-9, "{binding:?} side {side}: the right page starts at the fold"); + } + } +} + +#[test] +fn booklet_creep_is_refused_when_it_is_not_a_small_positive_amount() { + let sizes = vec![(200.0, 300.0); 8]; + let booklet = |sizes: &[(f64, f64)], creep| { + layout(sizes, &settings((0..sizes.len()).collect(), Layout::Booklet { subset: BookletSubset::BothSides, binding: Binding::Left, creep })) + }; + for creep in [f64::NAN, f64::INFINITY, -1.0, MAX_CREEP + 0.1] { + assert!(matches!(booklet(&sizes, creep), Err(PrintError::Invalid(_))), "{creep}"); + } + assert!(booklet(&sizes, MAX_CREEP).is_ok()); + // 400 pages fold into 100 sheets: 5 pt each would move the innermost past half its cell. + let thick = vec![(200.0, 300.0); 400]; + assert!(booklet(&thick, 1.0).is_ok()); + assert!(matches!(booklet(&thick, 5.0), Err(PrintError::Invalid(_))), "the innermost sheet moves too far"); +} + #[test] fn posters_tile_with_overlap() { // A 200×300 page at 400% = 800×1200 on Letter with 18 pt margins (576×756 tiles), overlap 36. @@ -380,7 +429,10 @@ fn spooler_arguments_and_printer_list() { }; assert_eq!( lp_args(&job).join(" "), - "-d Office_Laser -n 3 -t memo.pdf -o collate=false -o sides=two-sided-long-edge -o print-color-mode=monochrome -o fit-to-page=false" + format!( + "-d Office_Laser -n 3 -t memo.pdf -o collate=false -o sides=two-sided-long-edge -o print-color-mode=monochrome -o fit-to-page=false{}", + macos_auto_tray() + ) ); assert_eq!(lp_args(&Job::default())[0], "-n", "no -d: the default printer"); } @@ -709,9 +761,11 @@ fn landscape_sheets_ask_cups_to_turn_the_page() { // A landscape Size sheet, and a booklet (always landscape sheets), are both turned. let page = layout(&[(300.0, 200.0)], &settings(vec![0], Layout::Size(SizeMode::Fit))).unwrap(); assert_eq!(cups_orientation(&page), ask); - let booklet = - layout(&[(200.0, 300.0); 4], &settings(vec![0, 1, 2, 3], Layout::Booklet { subset: BookletSubset::BothSides, binding: Binding::Left })) - .unwrap(); + let booklet = layout( + &[(200.0, 300.0); 4], + &settings(vec![0, 1, 2, 3], Layout::Booklet { subset: BookletSubset::BothSides, binding: Binding::Left, creep: 0.0 }), + ) + .unwrap(); assert_eq!(cups_orientation(&booklet), ask); // Portrait sheets, mixed jobs and nothing at all say nothing. let portrait = layout(&[(200.0, 300.0)], &settings(vec![0], Layout::Size(SizeMode::Fit))).unwrap(); @@ -721,7 +775,7 @@ fn landscape_sheets_ask_cups_to_turn_the_page() { assert_eq!(cups_orientation(&[]), None); // `lp` is given it, among the job's other options. let job = Job { options: ask.into_iter().collect(), ..Job::default() }; - assert!(lp_args(&job).join(" ").ends_with("-o fit-to-page=false -o orientation-requested=4")); + assert!(lp_args(&job).join(" ").ends_with(&format!("-o fit-to-page=false -o orientation-requested=4{}", macos_auto_tray()))); } /// Both submission paths add the orientation through `add_cups_orientation`: after the job's @@ -751,6 +805,13 @@ fn a_job_that_names_its_orientation_keeps_it() { mod cups_options { use crate::spool; + /// The paper tray a CUPS option list shows as its default: on macOS, a queue left on the driver's + /// own default tray (`driver_default`) shows Auto Select, which is what a job naming no tray asks + /// for (#3752, storytold/craft-libs#232); elsewhere, and for any other tray, `expected` as it is. + fn macos_tray<'a>(expected: &'a str, driver_default: &str) -> &'a str { + if cfg!(target_os = "macos") && expected == driver_default { spool::AUTO_SELECT } else { expected } + } + const RECORD: &str = "PDFCRAFT_LPOPTIONS_TEST_RECORD"; const ANSWER: &str = "PDFCRAFT_LPOPTIONS_TEST_ANSWER"; /// Generous: these tests are about what is read, not how long it takes. @@ -759,7 +820,9 @@ mod cups_options { /// The child process stands in for lpoptions, recording its real arguments. #[test] fn stand_in_for_lpoptions() { - let Some(record) = std::env::var_os(RECORD) else { return }; + let Some(record) = std::env::var_os(RECORD) else { + return; + }; let args: Vec = std::env::args().skip_while(|a| a != "--").skip(1).collect(); std::fs::write(std::path::Path::new(&record).join("args"), args.join("\n")).unwrap(); let answer = std::env::var(ANSWER).unwrap(); @@ -785,7 +848,8 @@ mod cups_options { let options = spool::printer_options_via_within("Office", &dir, command(&dir, "HANG"), std::time::Duration::from_millis(500), "en"); assert!(started.elapsed() < std::time::Duration::from_secs(20), "given up at the deadline, not when the command does"); assert_eq!(options.len(), 1, "the PPD is still read"); - assert_eq!(options[0].default, "Auto", "the PPD default stands when the queue does not answer"); + // On macOS the driver's default tray is shown as Auto Select, what the job then sends (#3752). + assert_eq!(options[0].default, macos_tray("Auto", "Auto"), "the PPD default stands when the queue does not answer"); std::fs::remove_dir_all(dir).unwrap(); } @@ -839,8 +903,13 @@ mod cups_options { let options = spool::printer_options_via_within(destination, &dir, command(&dir, &format!("InputSlot/Paper tray: *{current}")), LIMIT, "en"); assert_eq!(options.len(), 1, "{destination}: the queue's PPD must be read"); - assert_eq!(options[0].default, expected, "{destination}: only valid instance choices override the PPD default"); - assert_eq!(options[0].choices, [("Auto".into(), "Automatic".into()), ("Tray2".into(), "Tray 2".into())]); + assert_eq!(options[0].default, macos_tray(expected, "Auto"), "{destination}: only valid instance choices override the PPD default"); + // On macOS, a queue left on the driver's default tray also offers Auto Select first (#3752). + let mut choices: Vec<(String, String)> = vec![("Auto".into(), "Automatic".into()), ("Tray2".into(), "Tray 2".into())]; + if macos_tray(expected, "Auto") != expected { + choices.insert(0, (spool::AUTO_SELECT.into(), "Auto Select".into())); + } + assert_eq!(options[0].choices, choices); assert_eq!(std::fs::read_to_string(dir.join("args")).unwrap(), format!("-p\n{destination}\n-l")); } let job = spool::Job { printer: Some("Office/duplex".into()), ..Default::default() }; @@ -877,6 +946,13 @@ mod cups_options { long_queue.as_str(), long_queue_instance.as_str(), long_instance.as_str(), + // A destination cannot be an argument: `lpoptions` would take these as options. + "-Office", + "-E", + "-", + "--", + "Office/-l", + "Office/--", ] { assert!( spool::printer_options_via_within(destination, &dir, command(&dir, "InputSlot: *Auto"), LIMIT, "en").is_empty(), @@ -886,6 +962,120 @@ mod cups_options { } std::fs::remove_dir_all(dir).unwrap(); } + + /// #4699: when the queue's PPD is not readable, build the option list from `lpoptions -l` + /// instead of returning an empty dialog. + #[test] + fn unreadable_ppd_falls_back_to_lpoptions_list() { + let dir = folder(); + // No PPD file: the PPD path is unreadable, just as on Arch where cupsd keeps it at + // 0640 root:cups. + let lpoptions_out = "MediaType/Media Type: *Stationery PhotographicGlossy StationeryInkjet Com.brotherBp71\ncupsPrintQuality/cupsPrintQuality: Draft *Normal High\nPageSize/Page Size: A4 Letter\nDuplex/Duplex: *None DuplexNoTumble\nno colon on this line\nEmpty/Empty:\n"; + let options = spool::printer_options_via_within("Office", &dir, command(&dir, lpoptions_out), LIMIT, "en"); + let keys: Vec<&str> = options.iter().map(|o| o.key.as_str()).collect(); + assert_eq!(keys, ["MediaType", "cupsPrintQuality"], "PageSize is handled by the dialog and must be skipped"); + + let media_type = options.iter().find(|o| o.key == "MediaType").unwrap(); + assert_eq!(media_type.label, "Media Type"); + assert_eq!(media_type.default, "Stationery"); + assert_eq!( + media_type.choices, + [ + ("Stationery".into(), "Stationery".into()), + ("PhotographicGlossy".into(), "PhotographicGlossy".into()), + ("StationeryInkjet".into(), "StationeryInkjet".into()), + ("Com.brotherBp71".into(), "Com.brotherBp71".into()), + ] + ); + + let quality = options.iter().find(|o| o.key == "cupsPrintQuality").unwrap(); + assert_eq!(quality.label, "cupsPrintQuality"); + assert_eq!(quality.default, "Normal"); + assert_eq!(quality.choices, [("Draft".into(), "Draft".into()), ("Normal".into(), "Normal".into()), ("High".into(), "High".into())]); + + // The starring decides the fallback default, wherever the starred choice sits. + let lpoptions_out = "MediaType/Media Type: Plain *Glossy Matte\n"; + let options = spool::printer_options_via_within("Office", &dir, command(&dir, lpoptions_out), LIMIT, "en"); + assert_eq!(options.len(), 1); + assert_eq!(options[0].default, "Glossy", "the starred choice is the fallback default"); + assert_eq!(options[0].choices, [("Plain".into(), "Plain".into()), ("Glossy".into(), "Glossy".into()), ("Matte".into(), "Matte".into())]); + + // A PPD path that exists but cannot be opened at all, for a reason other than NotFound: + // ENOTDIR fails for root as well, where a 0o000 file would still be readable. + let blocker = dir.join("not-a-directory"); + std::fs::write(&blocker, b"x").unwrap(); + let options = spool::printer_options_via_within("Office", &blocker, command(&dir, lpoptions_out), LIMIT, "en"); + assert_eq!(options.len(), 1, "an unopenable PPD falls back as an unreadable one does"); + + // A readable PPD whose *OpenUI blocks yield nothing (driverless queues): the fallback still + // fills the list, and the PPD's translations apply to it. + std::fs::write(dir.join("Empty.ppd"), b"*PPD-Adobe: \"4.3\"\n*cupsLanguages: \"it\"\n*it.Translation MediaType/Tipo di supporto: \"\"\n") + .unwrap(); + let options = spool::printer_options_via_within("Empty", &dir, command(&dir, lpoptions_out), LIMIT, "it"); + assert_eq!(options.len(), 1, "a readable PPD without options falls back too"); + assert_eq!(options[0].label, "Tipo di supporto", "the readable PPD still translates the fallback list"); + + // A queue instance reaches lpoptions as the destination, and falls back the same way. + let options = spool::printer_options_via_within("Office/Instance", &dir, command(&dir, lpoptions_out), LIMIT, "en"); + assert_eq!(options.len(), 1); + let recorded = std::fs::read_to_string(dir.join("args")).unwrap(); + assert!(recorded.contains("Office/Instance"), "the instance is the destination: {recorded}"); + + std::fs::remove_dir_all(dir).unwrap(); + } + + /// CUPS prints a custom option's template instead of its choices — `Custom`, `Custom.PASSCODE`, + /// `Custom.STRING`, `Custom.WIDTHxHEIGHT`: the dialog has no field to collect the value, so + /// offering the template would send that literal text to the driver. + #[test] + fn custom_value_templates_are_not_offered() { + let dir = folder(); + let lpoptions_out = "Pin/Pin: *Custom.PASSCODE\nJobPassword/Job Password: 1234 *5678 Custom.PASSWORD\nScale/Scale: *Custom.REAL\nSolo/Solo: *Custom\nMediaType/Media Type: *Plain Custom.Glossy Matte\n"; + let options = spool::printer_options_via_within("Office", &dir, command(&dir, lpoptions_out), LIMIT, "en"); + let keys: Vec<&str> = options.iter().map(|o| o.key.as_str()).collect(); + assert_eq!(keys, ["JobPassword", "MediaType"], "an option whose choices are all templates is dropped"); + + let password = options.iter().find(|o| o.key == "JobPassword").unwrap(); + assert_eq!(password.choices, [("1234".into(), "1234".into()), ("5678".into(), "5678".into())]); + assert_eq!(password.default, "5678", "a template is never a choice"); + + let media_type = options.iter().find(|o| o.key == "MediaType").unwrap(); + assert_eq!(media_type.choices, [("Plain".into(), "Plain".into()), ("Matte".into(), "Matte".into())]); + assert_eq!(media_type.default, "Plain"); + + // A starred template is never offered as a choice, and never replaced by an invented + // default: the printer keeps the template, so standing the first real choice in its + // place would show a value the job does not use. + let options = spool::printer_options_via_within("Office", &dir, command(&dir, "Pin/Pin: *Custom.PASSCODE 1234\n"), LIMIT, "en"); + assert_eq!(options.len(), 1); + assert_eq!(options[0].choices, [("1234".into(), "1234".into())]); + assert_eq!(options[0].default, "", "an uncollectable default is not a choice"); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// `list_group()` prints `Keyword/Text:` and then bare choice keywords, so the text may carry a + /// colon while the choices cannot: the split belongs at the last one. + #[test] + fn an_option_text_may_contain_a_colon() { + let dir = folder(); + let lpoptions_out = "Media/Supporto: Tipo: *A B\n"; + let options = spool::printer_options_via_within("Office", &dir, command(&dir, lpoptions_out), LIMIT, "en"); + assert_eq!(options.len(), 1); + assert_eq!(options[0].key, "Media"); + assert_eq!(options[0].label, "Supporto: Tipo", "the text keeps its colon"); + assert_eq!(options[0].choices, [("A".into(), "A".into()), ("B".into(), "B".into())], "no choice is invented from the text"); + assert_eq!(options[0].default, "A"); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn the_listing_is_capped_the_way_the_ppd_is() { + let dir = folder(); + let listing: String = (0..500).map(|i| format!("Option{i}/Option {i}: *A B\n")).collect(); + let options = spool::printer_options_via_within("Office", &dir, command(&dir, &listing), LIMIT, "en"); + assert_eq!(options.len(), 400, "the fallback honours the same cap as parse_ppd"); + std::fs::remove_dir_all(dir).unwrap(); + } } #[test] @@ -985,10 +1175,48 @@ fn every_paper_reaches_the_spooler_by_a_name_of_its_size() { ("A0", "iso_a0_841x1189mm"), ("4 x 6 in label (101.6 x 152.4 mm)", "w288h432"), ("US #10 Envelope (4.125 x 9.5 in)", "Env10"), + ("Folio (8.5 x 13 in)", "na_foolscap_8.5x13in"), + ("F4 (210 x 330 mm)", "om_folio_210x330mm"), ] ); } +#[test] +fn paper_media_and_custom_dimensions() { + assert_eq!(custom_media((300.0, 500.0)), "Custom.300x500"); + assert_eq!(custom_media((300.5, 500.25)), "Custom.300.5x500.25"); + + // Standard presets match by size regardless of name: + assert_eq!(paper_media(None, (612.0, 792.0)), "Letter"); + assert_eq!(paper_media(Some("Driver Letter"), (612.0, 792.0)), "Letter"); + assert_eq!(paper_media(Some("Shipping Label"), (288.0, 432.0)), "w288h432"); + + // Clean driver keyword that does not match PAPERS presets: + assert_eq!(paper_media(Some("Roll80mm"), (226.77, 800.0)), "Roll80mm"); + assert_eq!(paper_media(Some("Photo5x7"), (360.0, 504.0)), "Photo5x7"); + + // Driver paper with spaces or raw dimensions falls back to Custom: + assert_eq!(paper_media(Some("Custom 5x7"), (360.0, 504.0)), "Custom.360x504"); + assert_eq!(paper_media(Some("300x500"), (300.0, 500.0)), "Custom.300x500"); + assert_eq!(paper_media(None, (300.0, 500.0)), "Custom.300x500"); + + assert_eq!(paper_media_option(Some("300x500"), (300.0, 500.0)), ("media".to_string(), "Custom.300x500".to_string())); +} + +/// #4777: Folio (8.5 × 13 in) and F4 (210 × 330 mm) presets are offered, fed by their +/// PWG 5101.1 media names (`na_foolscap_8.5x13in` and `om_folio_210x330mm`) and laid out at their +/// proper dimensions. +#[test] +fn folio_and_f4_paper_sizes_are_available() { + let folio = PAPERS.iter().position(|(name, _)| *name == "Folio (8.5 x 13 in)").expect("Folio present"); + assert_eq!(cups_media(folio), Some("na_foolscap_8.5x13in")); + assert_eq!(PAPERS[folio].1, (612.0, 936.0)); + + let f4 = PAPERS.iter().position(|(name, _)| *name == "F4 (210 x 330 mm)").expect("F4 present"); + assert_eq!(cups_media(f4), Some("om_folio_210x330mm")); + assert_eq!(PAPERS[f4].1, (595.28, 935.43)); +} + /// #2734: the US #10 envelope (4.125 × 9.5 in) is offered, fed by its PPD name and laid out at /// its own size. #[test] @@ -1101,6 +1329,58 @@ fn imposed_pages_preserve_large_user_unit_matrices() { } } +#[test] +fn a_selected_area_prints_alone_fitted_or_at_actual_size() { + // Page 1 is 200×300; the area is its 100×50 block whose corner is at (40, 200). + let area = [40.0, 200.0, 140.0, 250.0]; + let fit = layout(&[(200.0, 300.0), (200.0, 300.0)], &Settings { area: Some(area), ..settings(vec![0], Layout::Size(SizeMode::Fit)) }).unwrap(); + assert_eq!(fit.len(), 1); + let pl = fit[0].placed[0]; + assert_eq!(pl.clip, area, "clipped to the area"); + // A landscape area on Auto takes a landscape sheet and fills its width inside the margin. + assert_eq!(fit[0].size, (792.0, 612.0)); + let s = pl.matrix.0[0]; + assert!(close(s, (792.0 - 2.0 * MARGIN) / 100.0), "{s}"); + // The area's own corner lands where a page of its size would: centred on the sheet. + let (x, y) = (pl.matrix.0[0] * area[0] + pl.matrix.0[4], pl.matrix.0[3] * area[1] + pl.matrix.0[5]); + assert!(close(x, (792.0 - 100.0 * s) / 2.0) && close(y, (612.0 - 50.0 * s) / 2.0), "{x} {y}"); + let actual = layout(&[(200.0, 300.0)], &Settings { area: Some(area), ..settings(vec![0], Layout::Size(SizeMode::Actual)) }).unwrap(); + assert_eq!(actual[0].placed[0].matrix.0[0], 1.0, "100%"); + // An area outside the page is cut to it; an empty one, several pages or another layout are refused. + let cut = layout(&[(200.0, 300.0)], &Settings { area: Some([150.0, 250.0, 400.0, 900.0]), ..settings(vec![0], Layout::Size(SizeMode::Actual)) }) + .unwrap(); + assert_eq!(cut[0].placed[0].clip, [150.0, 250.0, 200.0, 300.0]); + for (pages, area, layout_) in [ + (vec![0], [10.0, 10.0, 10.5, 40.0], Layout::Size(SizeMode::Fit)), + (vec![0, 1], area, Layout::Size(SizeMode::Fit)), + (vec![0], [10.0, 10.0, 10.0, 40.0], Layout::Size(SizeMode::Fit)), + (vec![0], [250.0, 10.0, 400.0, 40.0], Layout::Size(SizeMode::Fit)), + (vec![0], [f64::NAN, 0.0, 50.0, 50.0], Layout::Size(SizeMode::Fit)), + (vec![0], [f64::NAN; 4], Layout::Size(SizeMode::Fit)), + (vec![0], [0.0, 0.0, f64::INFINITY, 50.0], Layout::Size(SizeMode::Fit)), + ( + vec![0], + area, + Layout::Multiple { cols: 2, rows: 1, per_sheet: 2, order: PageOrder::Horizontal, border: false, auto_rotate: true, custom: false }, + ), + (vec![0], area, Layout::Booklet { subset: BookletSubset::BothSides, binding: Binding::Left, creep: 0.0 }), + (vec![0], area, Layout::Poster { scale: 200.0, overlap: 18.0, cut_marks: true }), + (vec![0], area, Layout::Label { flip: false }), + ] { + assert!(matches!( + layout(&[(200.0, 300.0), (200.0, 300.0)], &Settings { area: Some(area), ..settings(pages, layout_) }), + Err(PrintError::Invalid(_)) + )); + } + // A page the document doesn't have is refused before the area is looked at. + let missing = Settings { area: Some(area), ..settings(vec![5], Layout::Size(SizeMode::Fit)) }; + assert!(matches!(layout(&[(200.0, 300.0)], &missing), Err(PrintError::NoPages))); + // The print-ready PDF has one sheet with only that area. + let bytes = impose(&fixture(1), &Settings { area: Some(area), ..settings(vec![0], Layout::Size(SizeMode::Actual)) }).unwrap(); + let out = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(pdfcraft_model::pages(&out).len(), 1); +} + /// `output_within` (#1818): the bounded wait every platform's `lpoptions` call goes through. #[cfg(not(target_arch = "wasm32"))] mod bounded_commands { @@ -1193,6 +1473,8 @@ fn driver_options_take_the_ppds_own_names_in_the_ui_language() { } mod appearances; +mod font_names; +mod optional_content; #[test] fn repeated_content_references_share_one_decode_budget() { @@ -1301,6 +1583,19 @@ fn a_booklet_prints_both_sides_on_the_short_edge() { assert_eq!(booklet_duplex(BookletSubset::BackOnly), spool::Duplex::Off); } +/// #5772: both sides ask a driver that takes two-sided settings for the short edge, and one that +/// doesn't for nothing, so its own setting (manual two-sided, say) prints the booklet rather than +/// the job being refused. One side on its own asks every driver for one side. +#[test] +fn a_booklet_leaves_two_sided_to_a_driver_that_keeps_its_own() { + use BookletSubset::{BackOnly, BothSides, FrontOnly}; + for (two_sided, both) in [(Some(true), Some(spool::Duplex::ShortEdge)), (None, Some(spool::Duplex::ShortEdge)), (Some(false), None)] { + assert_eq!(booklet_job_duplex(BothSides, two_sided), both, "{two_sided:?}"); + assert_eq!(booklet_job_duplex(FrontOnly, two_sided), Some(spool::Duplex::Off), "{two_sided:?}"); + assert_eq!(booklet_job_duplex(BackOnly, two_sided), Some(spool::Duplex::Off), "{two_sided:?}"); + } +} + /// #3502: `/Contents` array pieces are joined with a space, matching the renderer, so a `%` /// comment at the end of a piece without a line break continues through the first line of the /// next piece in both `impose` and `text_reads_up`. @@ -1407,7 +1702,7 @@ fn missing_print_dlls_are_named() { #[cfg(windows)] #[test] fn printing_without_the_print_dlls_is_refused_before_the_spooler() { - use crate::spool::{PRINT_DLLS, PRINTER_BACKEND_CALLS, SYSTEM32_FOR_TEST, printers, submit, submit_with_progress}; + use crate::spool::{PRINT_DLLS, PRINTER_BACKEND_CALLS, SYSTEM32_FOR_TEST, printer_queues, printers, submit, submit_with_progress, two_sided}; let dir = std::env::temp_dir().join(format!("pdfcraft-print-no-dlls-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); @@ -1424,11 +1719,104 @@ fn printing_without_the_print_dlls_is_refused_before_the_spooler() { // Not only an empty list, which a machine without printers gives anyway: the backend wasn't asked. PRINTER_BACKEND_CALLS.with(|c| c.set(0)); assert!(printers().is_empty(), "no printers are listed"); + assert!(printer_queues().is_empty(), "no queues are listed"); + assert_eq!(two_sided("PdfCraft test: no such printer"), None, "two-sided support is unknown"); assert_eq!(PRINTER_BACKEND_CALLS.with(|c| c.get()), 0, "the printer backend wasn't asked"); SYSTEM32_FOR_TEST.with(|d| *d.borrow_mut() = None); let _ = std::fs::remove_dir_all(&dir); } +/// #2630 and #6100: paper discovery must respect the same delay-load guard as submission. +/// The nonexistent queue makes a missing guard observable without requiring printer hardware. +#[cfg(windows)] +#[test] +fn paper_detection_without_print_dlls_never_reaches_the_driver() { + use crate::spool::{PRINT_DLLS, PRINTER_BACKEND_CALLS, SYSTEM32_FOR_TEST}; + let dir = std::env::temp_dir().join(format!("pdfcraft-paper-no-dlls-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + for missing in PRINT_DLLS { + SYSTEM32_FOR_TEST.with(|d| *d.borrow_mut() = Some(dir.clone())); + PRINTER_BACKEND_CALLS.with(|c| c.set(0)); + let name = "PdfCraft test: no such printer"; + let papers = crate::spool::printer_papers(name); + let info = crate::spool::printer_paper_info(name); + let default = crate::spool::printer_default_paper(name); + let calls = PRINTER_BACKEND_CALLS.with(|c| c.get()); + // A broken guard must not open a real dialog while this regression fails. + let properties = (calls == 0).then(|| crate::spool::show_printer_properties(name, None)); + SYSTEM32_FOR_TEST.with(|d| *d.borrow_mut() = None); + assert!(papers.is_empty()); + assert_eq!(info, (Vec::new(), None)); + assert_eq!(default, None); + assert_eq!(calls, 0, "paper queries must not reach the backend without {missing}"); + assert!(matches!(properties, Some(Err(crate::PrintError::Spool(m))) if m.contains(missing))); + std::fs::write(dir.join(missing), b"").unwrap(); + } + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn printer_papers_and_default_paper_api() { + // Nonexistent printer returns empty + assert!(crate::spool::printer_papers("Nonexistent Printer 12345").is_empty()); + assert_eq!(crate::spool::printer_default_paper("Nonexistent Printer 12345"), None); + + #[cfg(windows)] + { + // On Windows with installed printers, test detection if any installed + for p in crate::spool::printers() { + let papers = crate::spool::printer_papers(&p.name); + if !papers.is_empty() { + // Every paper has a valid positive size and a non-empty name + for (name, (w, h)) in &papers { + assert!(!name.is_empty()); + assert!(*w > 0.0 && *h > 0.0); + } + } + } + } +} + +/// Overlapping driver discovery, queue queries, and paper detection run concurrently across +/// worker threads without deadlocking or racing the PrintTicket provider. +#[test] +fn overlapping_paper_discovery_two_sided_and_queue_queries() { + let queues = crate::spool::printer_queues(); + let name = queues.first().map(|p| p.name.clone()).unwrap_or_else(|| "Microsoft Print to PDF".to_string()); + + let threads: Vec<_> = (0..8) + .map(|i| { + let printer = name.clone(); + std::thread::spawn(move || { + for _ in 0..5 { + match i % 5 { + 0 => { + let _ = crate::spool::printer_papers(&printer); + } + 1 => { + let _ = crate::spool::printer_default_paper(&printer); + } + 2 => { + let _ = crate::spool::two_sided(&printer); + } + 3 => { + let _ = crate::spool::printers(); + } + _ => { + let _ = crate::spool::printer_paper_info(&printer); + } + } + } + }) + }) + .collect(); + + for handle in threads { + handle.join().unwrap(); + } +} + #[test] fn a_page_form_without_annotations_shares_the_pages_indirect_resources() { let mut doc = fixture(2); diff --git a/crates/print/src/tests/appearances.rs b/crates/print/src/tests/appearances.rs index 0d3107d1d7..90fd861e3f 100644 --- a/crates/print/src/tests/appearances.rs +++ b/crates/print/src/tests/appearances.rs @@ -56,6 +56,11 @@ fn render(bytes: Vec) -> RenderedPage { } fn render_page(bytes: Vec, index: usize) -> RenderedPage { + render_page_at_size(bytes, index, (100, 100)) +} + +/// Explicit raster dimensions expose `/UserUnit` and sheet scaling without a default-size render hiding them. +fn render_page_at_size(bytes: Vec, index: usize, size: (u32, u32)) -> RenderedPage { let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { page: index, kind: RequestKind::Pixels, @@ -66,7 +71,7 @@ fn render_page(bytes: Vec, index: usize) -> RenderedPage { }); assert_eq!(page.error, None); assert!(page.warnings.is_empty(), "{:?}", page.warnings); - assert_eq!((page.width, page.height), (100, 100)); + assert_eq!((page.width, page.height), size); page } @@ -79,12 +84,58 @@ fn saved(doc: &Document) -> Vec { write_full(doc, &SaveOptions::default()).unwrap() } +/// Wrap a synthetic value in exactly `depth` standalone indirect objects. +fn reference_chain(doc: &mut Document, mut value: Object, depth: usize) -> Object { + for _ in 0..depth { + value = Object::Ref(doc.add(value)); + } + value +} + fn xobject(doc: &Document, owner: &Dict, name: &[u8]) -> ObjRef { let resources = doc.resolve(owner.get(b"Resources").unwrap()); let objects = doc.resolve(resources.as_dict().unwrap().get(b"XObject").unwrap()); objects.as_dict().unwrap().get(name).unwrap().as_ref().unwrap() } +/// Existing Link artwork prints as document content only when its flags allow printing. +#[test] +fn printable_link_appearance_survives_vector_print() { + for flags in [0, 2, 4, 6, 5] { + for content in [Content::Document, Content::DocumentAndMarkups, Content::DocumentAndStamps, Content::FormFieldsOnly] { + let (mut doc, _, annot) = widget_fixture(Some(Object::name("Form")), None); + doc.update_dict(annot, |d| { + d.set(b"Subtype".to_vec(), Object::name("Link")); + d.set(b"F".to_vec(), Object::Int(flags)); + }) + .unwrap(); + let before = saved(&doc); + let expected = flags & 4 != 0 && flags & 2 == 0 && content != Content::FormFieldsOnly; + let printed = render(impose(&doc, &print_settings(content)).unwrap()); + let color = if expected { [0, 255, 0, 255] } else { [255, 255, 255, 255] }; + assert_eq!(pixel(&printed, 30, 70), color, "flags={flags}, content={content:?}"); + let mut raster_source = doc.clone(); + keep_printed(&mut raster_source, content); + let page = &pdfcraft_model::pages(&raster_source)[0]; + let annots = raster_source.resolve(page.dict.get(b"Annots").unwrap()); + assert_eq!(annots.as_array().unwrap().len(), usize::from(expected), "raster flags={flags}, content={content:?}"); + assert_eq!(saved(&doc), before, "source remains unchanged"); + } + } + for subtype in ["Link", "Popup"] { + let (mut doc, _, annot) = widget_fixture(Some(Object::name("Form")), None); + doc.update_dict(annot, |d| { + d.set(b"Subtype".to_vec(), Object::name(subtype)); + if subtype == "Link" { + d.remove(b"AP"); + } + }) + .unwrap(); + let printed = render(impose(&doc, &print_settings(Content::DocumentAndMarkups)).unwrap()); + assert_eq!(pixel(&printed, 30, 70), [255, 255, 255, 255], "{subtype} must not acquire an appearance"); + } +} + /// Printing keeps occupied page resource names and distinct annotation appearances intact, /// whether the page resources are direct or inherited from the page tree. #[test] @@ -147,6 +198,291 @@ fn annotation_appearances_do_not_replace_page_xobjects() { } } +/// Existing field/comment artwork must survive vector printing when any Rect coordinate is indirect. +#[test] +fn indirect_annotation_rectangles_keep_their_printed_appearances() { + for (subtype, content) in [("Widget", Content::Document), ("Square", Content::DocumentAndMarkups)] { + for outer_indirect in [false, true] { + for reversed in [false, true] { + for mask in [0usize, 1, 0b1010, 0b1111] { + let (mut doc, _, annot) = widget_fixture(Some(Object::name("Form")), None); + let values = if reversed { [50.0, 40.25, 10.0, 20.25] } else { [10.0, 20.25, 50.0, 40.25] }; + let coordinates = values.into_iter().enumerate().map(|(i, value)| { + let value = if i % 2 == 0 { Object::Int(value as i64) } else { Object::Real(value) }; + if mask & (1 << i) != 0 { Object::Ref(doc.add(value)) } else { value } + }); + let rect = Object::Array(coordinates.collect()); + let rect = if outer_indirect { Object::Ref(doc.add(rect)) } else { rect }; + doc.update_dict(annot, |d| { + d.set(b"Subtype".to_vec(), Object::name(subtype)); + d.set(b"Rect".to_vec(), rect); + }) + .unwrap(); + let before = saved(&doc); + let source = render(before.clone()); + assert_eq!(pixel(&source, 30, 70), [0, 255, 0, 255], "source appearance"); + let printed = render(impose(&doc, &print_settings(content)).unwrap()); + assert_eq!( + pixel(&printed, 30, 70), + [0, 255, 0, 255], + "{subtype}, outer_indirect={outer_indirect}, reversed={reversed}, mask={mask:04b}" + ); + assert_eq!(printed.rgba, source.rgba, "reopened printed pixels match the source"); + assert_eq!(saved(&doc), before, "printing leaves the source objects unchanged"); + } + } + } + } +} + +/// State dictionaries select the same artwork for direct and indirect AS names, for both +/// widgets and comments, without changing the document or the selected print content mode. +#[test] +fn indirect_annotation_states_keep_their_printed_appearances() { + for subtype in ["Widget", "Square"] { + for (state_name, color) in [("Yes", [0, 255, 0, 255]), ("Off", [255, 0, 0, 255])] { + for indirect_state in [false, true] { + for indirect_states in [false, true] { + let (mut doc, on, annot) = widget_fixture(Some(Object::name("Form")), None); + let off = doc.add(Object::Stream(Stream::flate(doc.get(on).as_dict().unwrap().clone(), b"/Ink cs 1 0 0 scn 0 0 20 10 re f"))); + let states = Object::Dict(Dict::from_iter([(b"Yes".to_vec(), Object::Ref(on)), (b"Off".to_vec(), Object::Ref(off))])); + let states = if indirect_states { Object::Ref(doc.add(states)) } else { states }; + let state = Object::name(state_name); + let state = if indirect_state { Object::Ref(doc.add(state)) } else { state }; + doc.update_dict(annot, |d| { + d.set(b"Subtype".to_vec(), Object::name(subtype)); + if subtype == "Widget" { + d.set(b"FT".to_vec(), Object::name("Btn")); + d.set(b"V".to_vec(), Object::name(state_name)); + } else { + d.remove(b"FT"); + } + d.set(b"AS".to_vec(), state); + d.set(b"AP".to_vec(), Object::Dict(Dict::from_iter([(b"N".to_vec(), states)]))); + }) + .unwrap(); + let doc = Document::open(Arc::new(saved(&doc))).unwrap(); + let before = saved(&doc); + let source = render(before.clone()); + assert_eq!(pixel(&source, 30, 70), color, "the source selects its stated appearance"); + for content in [Content::Document, Content::DocumentAndMarkups, Content::DocumentAndStamps, Content::FormFieldsOnly] { + let shown = subtype == "Widget" || content == Content::DocumentAndMarkups; + let printed = render(impose(&doc, &print_settings(content)).unwrap()); + assert_eq!( + pixel(&printed, 30, 70), + if shown { color } else { [255; 4] }, + "{subtype}, indirect_state={indirect_state}, indirect_states={indirect_states}, {content:?}" + ); + assert_eq!(pixel(&printed, 4, 96), if content == Content::FormFieldsOnly { [255; 4] } else { [0, 0, 255, 255] }); + if shown && content != Content::FormFieldsOnly { + assert_eq!(printed.rgba, source.rgba, "the saved/reopened print keeps all page and annotation pixels"); + } + } + assert_eq!(saved(&doc), before, "printing does not rewrite the source annotation or appearance state"); + } + } + } + } +} + +/// Standalone indirect objects can contain further references, including rectangle arrays, +/// individual coordinates and AS names. Printing must match the equivalent direct values. +#[test] +fn chained_annotation_values_keep_their_printed_appearances() { + for subtype in ["Widget", "Square"] { + for (state_name, color) in [("Yes", [0, 255, 0, 255]), ("Off", [255, 0, 0, 255])] { + for (rect_depth, coordinate_depth, state_depth) in [(2, 0, 0), (0, 2, 0), (0, 0, 2), (2, 2, 2), (64, 64, 64)] { + let (mut doc, on, annot) = widget_fixture(Some(Object::name("Form")), None); + let off = doc.add(Object::Stream(Stream::flate(doc.get(on).as_dict().unwrap().clone(), b"/Ink cs 1 0 0 scn 0 0 20 10 re f"))); + let values = [Object::Int(50), Object::Real(40.25), Object::Int(10), Object::Real(20.25)]; + doc.update_dict(annot, |d| { + d.set(b"Subtype".to_vec(), Object::name(subtype)); + d.set(b"Rect".to_vec(), Object::Array(values.to_vec())); + d.set(b"AS".to_vec(), Object::name(state_name)); + d.set( + b"AP".to_vec(), + Object::Dict(Dict::from_iter([( + b"N".to_vec(), + Object::Dict(Dict::from_iter([(b"Yes".to_vec(), Object::Ref(on)), (b"Off".to_vec(), Object::Ref(off))])), + )])), + ); + }) + .unwrap(); + let modes = [Content::Document, Content::DocumentAndMarkups, Content::DocumentAndStamps, Content::FormFieldsOnly]; + let controls: Vec<_> = modes.iter().map(|&content| render(impose(&doc, &print_settings(content)).unwrap())).collect(); + // Mix direct, one-hop and chained integer/real coordinates in the same rectangle. + let coordinates = values + .into_iter() + .enumerate() + .map(|(i, value)| { + reference_chain( + &mut doc, + value, + if coordinate_depth == 0 || i == 0 { + 0 + } else if i == 2 { + 1 + } else { + coordinate_depth + }, + ) + }) + .collect(); + let rect = reference_chain(&mut doc, Object::Array(coordinates), rect_depth); + let state = reference_chain(&mut doc, Object::name(state_name), state_depth); + doc.update_dict(annot, |d| { + d.set(b"Rect".to_vec(), rect); + d.set(b"AS".to_vec(), state); + }) + .unwrap(); + // ISO 32000-2 permits reference-to-reference objects outside object streams. + let options = SaveOptions { object_streams: false, ..SaveOptions::default() }; + let doc = Document::open(Arc::new(write_full(&doc, &options).unwrap())).unwrap(); + let before = write_full(&doc, &options).unwrap(); + let state = doc.read_state(); + let graph: Vec<_> = doc.scan_objects().collect(); + for (&content, control) in modes.iter().zip(controls) { + let printed = render(impose(&doc, &print_settings(content)).unwrap()); + let shown = subtype == "Widget" || content == Content::DocumentAndMarkups; + assert_eq!( + pixel(&printed, 30, 70), + if shown { color } else { [255; 4] }, + "{subtype}, {state_name}, depths={rect_depth}/{coordinate_depth}/{state_depth}, {content:?}" + ); + assert_eq!(printed.rgba, control.rgba, "chained values preserve the complete direct-control output"); + } + assert!(state.matches(&doc), "printing preserves the source read state"); + assert_eq!(doc.scan_objects().collect::>(), graph, "printing preserves the source object graph"); + assert_eq!(write_full(&doc, &options).unwrap(), before, "printing preserves standalone reference chains"); + } + } + } +} + +/// Cycles, missing targets and reference chains beyond the print reader's limit skip only +/// the unusable annotation, retaining page content and leaving source values unchanged. +#[test] +fn invalid_annotation_reference_chains_are_skipped_when_printing() { + for property in ["rectangle", "coordinate", "state"] { + for kind in ["cycle", "unresolved", "too-deep"] { + let (mut doc, ap, annot) = widget_fixture(Some(Object::name("Form")), None); + let value = match kind { + "cycle" => { + let first = doc.add(Object::Null); + let second = doc.add(Object::Ref(first)); + doc.set(first, Object::Ref(second)); + Object::Ref(first) + } + "unresolved" => reference_chain(&mut doc, Object::Ref(ObjRef::new(999_999, 0)), 2), + "too-deep" => { + let value = match property { + "rectangle" => numbers(&[10.0, 20.0, 50.0, 40.0]), + "coordinate" => Object::Int(40), + "state" => Object::name("Yes"), + _ => panic!("unknown property {property}"), + }; + reference_chain(&mut doc, value, 65) + } + _ => panic!("unknown kind {kind}"), + }; + doc.update_dict(annot, |d| match property { + "rectangle" => d.set(b"Rect".to_vec(), value), + "coordinate" => d.set(b"Rect".to_vec(), Object::Array(vec![10.into(), 20.into(), 50.into(), value])), + "state" => { + d.set(b"AS".to_vec(), value); + d.set( + b"AP".to_vec(), + Object::Dict(Dict::from_iter([(b"N".to_vec(), Object::Dict(Dict::from_iter([(b"Yes".to_vec(), Object::Ref(ap))])))])), + ); + } + _ => panic!("unknown property {property}"), + }) + .unwrap(); + let before = saved(&doc); + let printed = render(impose(&doc, &print_settings(Content::Document)).unwrap()); + assert_eq!(pixel(&printed, 30, 70), [255; 4], "invalid {property} chain ({kind}) does not select artwork"); + assert_eq!(pixel(&printed, 4, 96), [0, 0, 255, 255], "page content survives invalid {property} chain ({kind})"); + assert_eq!(saved(&doc), before, "invalid {property} chain ({kind}) is not rewritten"); + } + } +} + +/// Malformed, missing and cyclic AS values do not select arbitrary appearance states, while +/// a single normal appearance stream continues to work without a usable state name. +#[test] +fn invalid_annotation_states_do_not_select_an_appearance() { + for kind in ["absent", "null", "integer", "string", "missing-name", "unresolved", "cyclic"] { + let (mut doc, ap, annot) = widget_fixture(Some(Object::name("Form")), None); + let state = match kind { + "absent" => None, + "null" => Some(Object::Null), + "integer" => Some(Object::Int(1)), + "string" => Some(Object::String(pdfcraft_cos::PdfString::literal(b"Yes".to_vec()))), + "missing-name" => Some(Object::name("Missing")), + "unresolved" => Some(Object::Ref(ObjRef::new(999_999, 0))), + "cyclic" => { + let r = doc.add(Object::Null); + doc.set(r, Object::Ref(r)); + Some(Object::Ref(r)) + } + _ => panic!("unknown fixture kind {kind}"), + }; + doc.update_dict(annot, |d| { + if let Some(state) = state { + d.set(b"AS".to_vec(), state); + } + }) + .unwrap(); + assert_eq!(appearance(&doc, doc.get(annot).as_dict().unwrap()), Some(ap), "a normal stream does not need AS: {kind}"); + let printed = render(impose(&doc, &print_settings(Content::Document)).unwrap()); + assert_eq!(pixel(&printed, 30, 70), [0, 255, 0, 255], "the normal stream still prints: {kind}"); + doc.update_dict(annot, |d| { + d.set( + b"AP".to_vec(), + Object::Dict(Dict::from_iter([(b"N".to_vec(), Object::Dict(Dict::from_iter([(b"Yes".to_vec(), Object::Ref(ap))])))])), + ); + }) + .unwrap(); + let before = saved(&doc); + assert!(appearance(&doc, doc.get(annot).as_dict().unwrap()).is_none(), "invalid state must not choose Yes: {kind}"); + let printed = render(impose(&doc, &print_settings(Content::Document)).unwrap()); + assert_eq!(pixel(&printed, 30, 70), [255; 4], "the invalid state is skipped: {kind}"); + assert_eq!(pixel(&printed, 4, 96), [0, 0, 255, 255], "page artwork is retained: {kind}"); + assert_eq!(saved(&doc), before, "invalid source data is not rewritten: {kind}"); + } +} + +/// Malformed Rect values must not become printable rectangles by dropping invalid entries. +#[test] +fn invalid_annotation_rectangles_are_skipped_when_printing() { + for invalid in [ + Object::Null, + numbers(&[10.0, 20.0, 50.0]), + numbers(&[10.0, 20.0, 50.0, 40.0, 60.0]), + Object::Array(vec![10.into(), 20.into(), 50.into(), Object::Null]), + Object::Array(vec![10.into(), 20.into(), 50.into(), 40.into(), Object::Null]), + Object::Array(vec![10.into(), 20.into(), 50.into(), Object::Ref(ObjRef::new(999_999, 0))]), + numbers(&[f64::NAN, 20.0, 50.0, 40.0]), + numbers(&[10.0, f64::INFINITY, 50.0, 40.0]), + numbers(&[10.0, 20.0, f64::NEG_INFINITY, 40.0]), + ] { + for outer_indirect in [false, true] { + let (mut doc, _, annot) = widget_fixture(Some(Object::name("Form")), None); + let rect = if outer_indirect { Object::Ref(doc.add(invalid.clone())) } else { invalid.clone() }; + doc.update_dict(annot, |d| d.set(b"Rect".to_vec(), rect)).unwrap(); + let printed = render(impose(&doc, &print_settings(Content::Document)).unwrap()); + assert_eq!(pixel(&printed, 30, 70), [255, 255, 255, 255], "invalid={invalid:?}, outer_indirect={outer_indirect}"); + assert_eq!(pixel(&printed, 4, 96), [0, 0, 255, 255], "ordinary page content still prints"); + } + } + let (mut doc, _, annot) = widget_fixture(Some(Object::name("Form")), None); + let cycle = doc.add(Object::Null); + doc.set(cycle, Object::Ref(cycle)); + doc.update_dict(annot, |d| d.set(b"Rect".to_vec(), Object::Array(vec![10.into(), 20.into(), 50.into(), Object::Ref(cycle)]))).unwrap(); + let printed = render(impose(&doc, &print_settings(Content::Document)).unwrap()); + assert_eq!(pixel(&printed, 30, 70), [255, 255, 255, 255], "cyclic coordinate references are skipped"); +} + #[test] fn missing_subtype_appearance_survives_vector_print() { let (control, _, _) = widget_fixture(Some(Object::name("Form")), None); @@ -204,6 +540,98 @@ fn missing_subtype_appearance_survives_vector_print() { assert_eq!(actual_bytes, expected_bytes); } +/// Appearance coordinates are independent of page units: a large BBox or Matrix can require +/// placement coefficients far below four decimal places even on an ordinary 100-point page. +#[test] +fn annotation_placement_preserves_small_transform_coefficients() { + let (control, _, _) = widget_fixture(Some(Object::name("Form")), None); + let control = render(saved(&control)); + for scale in [1.0, 4096.0, 75000.0] { + for scaled_matrix in [false, true] { + let (mut doc, ap, widget) = widget_fixture(Some(Object::name("Form")), None); + let mut form = doc.get(ap).as_dict().unwrap().clone(); + let coordinates = if scaled_matrix { 1.0 } else { scale }; + form.set(b"BBox".to_vec(), numbers(&[0.0, 0.0, 20.0 * coordinates, 10.0 * coordinates])); + if scaled_matrix { + form.set(b"Matrix".to_vec(), numbers(&[scale, 0.0, 0.0, scale, 7.0, -13.0])); + } + doc.set( + ap, + Object::Stream(Stream::flate(form, format!("/Ink cs 0 1 0 scn 0 0 {} {} re f", 20.0 * coordinates, 10.0 * coordinates).as_bytes())), + ); + for (subtype, content) in [ + ("Widget", Content::Document), + ("Widget", Content::FormFieldsOnly), + ("Square", Content::DocumentAndMarkups), + ("Stamp", Content::DocumentAndStamps), + ] { + doc.update_dict(widget, |d| d.set(b"Subtype".to_vec(), Object::name(subtype))).unwrap(); + let before = saved(&doc); + let source = render(before.clone()); + assert!(source.rgba == control.rgba, "equivalent source, scale={scale}, matrix={scaled_matrix}, {subtype}"); + let printed = impose(&doc, &print_settings(content)).unwrap(); + let reopened = Document::open(Arc::new(printed.clone())).unwrap(); + assert_eq!(pdfcraft_model::pages(&reopened).len(), 1); + let printed = render(printed); + assert_eq!(pixel(&printed, 30, 70), [0, 255, 0, 255], "scale={scale}, matrix={scaled_matrix}, {subtype}"); + if content != Content::FormFieldsOnly { + assert!(printed.rgba == source.rgba, "scale={scale}, matrix={scaled_matrix}, {subtype}"); + } + // Check the serialized placement too: sub-pixel translation rounding may be + // invisible at 72 dpi while still moving the appearance's physical corners. + let page = &pdfcraft_model::pages(&reopened)[0]; + let page_form = reopened.get(xobject(&reopened, &page.dict, b"P0")); + let Object::Stream(page_form) = &*page_form else { panic!("page form") }; + let parsed = pdfcraft_content::parse(&page_form.decoded().unwrap()); + assert_eq!(parsed.skipped, 0); + let matrices: Vec<_> = parsed.ops.iter().filter(|op| op.is("cm")).collect(); + assert_eq!(matrices.len(), 1); + let placement = Matrix(matrices[0].nums::<6>().unwrap()); + let (x, y) = if scaled_matrix { (7.0, -13.0) } else { (0.0, 0.0) }; + for ((x, y), expected) in [(placement.apply(x, y), (10.0, 20.0)), (placement.apply(x + 20.0 * scale, y + 10.0 * scale), (50.0, 40.0))] + { + assert!((x - expected.0).abs() < 1e-10 && (y - expected.1).abs() < 1e-10, "corner ({x}, {y}) != {expected:?}"); + } + assert_eq!(saved(&doc), before, "printing preserves the source"); + } + } + } +} + +/// Compare bad-geometry output with a no-annotation control to ensure page artwork remains visible. +#[test] +fn unusable_appearance_geometry_keeps_page_artwork() { + let (mut control, _, _) = widget_fixture(Some(Object::name("Form")), None); + let page = pdfcraft_model::pages(&control)[0].obj; + control + .update_dict(page, |d| { + d.remove(b"Annots"); + }) + .unwrap(); + let control = render(saved(&control)); + for (bbox, matrix) in [ + (None, None), + (Some(numbers(&[0.0, 0.0, 20.0])), None), + (Some(numbers(&[0.0, 0.0, 0.0, 10.0])), None), + (Some(numbers(&[0.0, 0.0, 20.0, 0.0])), None), + (Some(numbers(&[0.0, 0.0, 20.0, 10.0])), Some([0.0; 6])), + ] { + let (mut doc, ap, _) = widget_fixture(Some(Object::name("Form")), matrix); + let mut form = doc.get(ap).as_dict().unwrap().clone(); + match bbox { + Some(bbox) => form.set(b"BBox".to_vec(), bbox), + None => { + form.remove(b"BBox"); + } + } + doc.set(ap, Object::Stream(Stream::flate(form, b"/Ink cs 0 1 0 scn 0 0 20 10 re f"))); + let before = saved(&doc); + let printed = render(impose(&doc, &print_settings(Content::Document)).unwrap()); + assert!(printed.rgba == control.rgba, "unusable appearance is omitted without losing page artwork"); + assert_eq!(saved(&doc), before); + } +} + #[test] fn promotion_preserves_streams_and_explicit_subtypes() { for subtype in [None, Some(Object::name("Form")), Some(Object::name("Image")), Some(Object::Null), Some(Object::Int(7))] { @@ -393,3 +821,261 @@ fn shared_promotions_span_pages_without_merging_distinct_sources() { } } } + +const CORNER_COLORS: [[u8; 4]; 4] = [[255, 0, 0, 255], [0, 0, 255, 255], [0, 255, 0, 255], [255, 255, 0, 255]]; + +// Contributor-original, asymmetric 20 x 10 artwork: red/blue above green/yellow. +/// Shift `/CropBox` and `/Rect` together so the colored, asymmetric artwork exposes origin and rotation errors. +fn no_rotate_fixture(rotation: Object, origin: [f64; 2], subtype: &str, flags: i64) -> (Document, ObjRef, ObjRef) { + let (mut doc, ap, annotation) = widget_fixture(Some(Object::name("Form")), None); + let form = doc.get(ap).as_dict().unwrap().clone(); + doc.set( + ap, + Object::Stream(Stream::flate(form, b"0 1 0 rg 0 0 20 10 re f 1 0 0 rg 0 5 10 5 re f 0 0 1 rg 10 5 10 5 re f 1 1 0 rg 10 0 10 5 re f")), + ); + let [x, y] = origin; + doc.update_dict(annotation, |d| { + d.set(b"Subtype".to_vec(), Object::name(subtype)); + d.set(b"F".to_vec(), Object::Int(flags)); + d.set(b"Rect".to_vec(), numbers(&[x + 40.0, y + 40.0, x + 60.0, y + 50.0])); + }) + .unwrap(); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| { + d.set(b"MediaBox".to_vec(), numbers(&[x - 20.0, y - 20.0, x + 120.0, y + 120.0])); + d.set(b"CropBox".to_vec(), numbers(&[x, y, x + 100.0, y + 100.0])); + d.set(b"Rotate".to_vec(), rotation); + d.remove(b"Contents"); + }) + .unwrap(); + (doc, ap, annotation) +} + +/// Colored quadrant samples distinguish every quarter-turn; white margins expose extent or anchor errors. +fn assert_upright_corners(page: &RenderedPage, anchor: (usize, usize), scale: usize) { + let (x, y) = anchor; + for ((dx, dy), color) in [(5, 2), (15, 2), (5, 7), (15, 7)].into_iter().zip(CORNER_COLORS) { + assert_eq!(pixel(page, x + dx * scale, y + dy * scale), color, "upright corner at {anchor:?}, scale={scale}"); + } + for (px, py) in [(x - 2, y + 5 * scale), (x + 22 * scale, y + 5 * scale), (x + 10 * scale, y - 2), (x + 10 * scale, y + 12 * scale)] { + assert_eq!(pixel(page, px, py), [255, 255, 255, 255], "outside the upright appearance at ({px},{py})"); + } +} + +/// Literal anchors and colors test pivot, orientation, and size without copying production math. +/// Direct and indirect `Text` subtypes with `/F 4` exercise implicit `NoRotate` without `/F 16`. +/// Unflagged non-`Text` cases retain the rotating control. +#[test] +fn vector_print_preserves_no_rotate_appearance_anchors() { + for (rotation, anchor, control) in [ + (0, (40, 50), [(45, 52), (55, 52), (45, 57), (55, 57)]), + (90, (50, 40), [(47, 45), (47, 55), (42, 45), (42, 55)]), + (180, (60, 50), [(55, 47), (45, 47), (55, 42), (45, 42)]), + (270, (50, 60), [(52, 55), (52, 45), (57, 55), (57, 45)]), + ] { + for (subtype, reversed, indirect_subtype) in [ + ("Widget", false, false), + ("Square", false, false), + ("Square", true, false), + ("Stamp", false, false), + ("Text", false, false), + ("Text", false, true), + ] { + for flags in [4, 20] { + let (mut doc, ap, annotation) = no_rotate_fixture(Object::Int(rotation), [0.0, 0.0], subtype, flags); + if indirect_subtype { + let name = doc.add(Object::name(subtype)); + doc.update_dict(annotation, |d| d.set(b"Subtype".to_vec(), Object::Ref(name))).unwrap(); + } + if reversed { + doc.update_dict(annotation, |d| d.set(b"Rect".to_vec(), numbers(&[60.0, 50.0, 40.0, 40.0]))).unwrap(); + } + let before = saved(&doc); + let original = doc.get(ap); + let settings = print_settings(Content::DocumentAndMarkups); + let first = impose(&doc, &settings).unwrap(); + let reopened = Document::open(Arc::new(first.clone())).unwrap(); + for bytes in [first, impose(&reopened, &settings).unwrap()] { + let page = render(bytes); + if flags == 20 || subtype == "Text" { + assert_upright_corners(&page, anchor, 1); + } else { + for ((x, y), color) in control.into_iter().zip(CORNER_COLORS) { + assert_eq!(pixel(&page, x, y), color, "control: rotation={rotation}, subtype={subtype}"); + } + } + } + assert_eq!(doc.get(ap), original, "shared appearance bytes/dictionary stay unchanged"); + assert_eq!(saved(&doc), before, "rotation={rotation}, subtype={subtype}, flags={flags}"); + } + } + } +} + +/// Compare output pixel-for-pixel with the source shifted to its `/Rect` anchor. +/// This also verifies that the exposed page area remains white. +fn assert_translated_upright_source(actual: &RenderedPage, source: &RenderedPage, anchor: (i32, i32)) { + let (dx, dy) = (anchor.0 - 40, anchor.1 - 50); + for y in 0..100i32 { + for x in 0..100i32 { + let (sx, sy) = (x - dx, y - dy); + let expected = if (0..100).contains(&sx) && (0..100).contains(&sy) { pixel(source, sx as usize, sy as usize) } else { &[255; 4] }; + let actual = pixel(actual, x as usize, y as usize); + assert_eq!(actual, expected, "translated source at ({x},{y})"); + } + } +} + +/// Compare print output with a rotation-zero source under shifted `/BBox` and `/Matrix` transforms. +/// This also covers inherited and normalized `/Rotate` values. +#[test] +fn no_rotate_keeps_appearance_matrix_and_shifted_inherited_page_geometry() { + for (rotation, inherited, origin, anchor) in + [(450, true, [-20.0, 30.0], (50, 40)), (-180, false, [15.0, -25.0], (60, 50)), (-90, true, [-20.0, 30.0], (50, 60))] + { + for (matrix, shifted_bbox) in + [([2.0, 0.0, 0.0, 3.0, 7.0, -11.0], true), ([1.0, 0.0, 1.0, 1.0, 7.0, -11.0], false), ([-1.0, 0.0, 0.0, 1.0, 27.0, -11.0], false)] + { + let (mut doc, ap, _) = no_rotate_fixture(Object::Int(rotation), origin, "Widget", 20); + doc.update_dict(ap, |d| { + d.set(b"Matrix".to_vec(), numbers(&matrix)); + if shifted_bbox { + d.set(b"BBox".to_vec(), numbers(&[5.0, -4.0, 25.0, 6.0])); + } + }) + .unwrap(); + if shifted_bbox { + let form = doc.get(ap).as_dict().unwrap().clone(); + doc.set( + ap, + Object::Stream(Stream::flate( + form, + b"q 1 0 0 1 5 -4 cm 0 1 0 rg 0 0 20 10 re f 1 0 0 rg 0 5 10 5 re f 0 0 1 rg 10 5 10 5 re f 1 1 0 rg 10 0 10 5 re f Q", + )), + ); + } + let page = pdfcraft_model::pages(&doc)[0].obj; + if inherited { + let parent = doc.get(page).as_dict().unwrap().reference(b"Parent").unwrap(); + let rotation = Object::Ref(doc.add(Object::Int(rotation))); + doc.update_dict(parent, |d| d.set(b"Rotate".to_vec(), rotation)).unwrap(); + doc.update_dict(page, |d| { + d.remove(b"Rotate"); + }) + .unwrap(); + } + let mut upright = doc.clone(); + upright.update_dict(page, |d| d.set(b"Rotate".to_vec(), Object::Int(0))).unwrap(); + let expected = render(saved(&upright)); + // Establish that the reference contains real asymmetric artwork, rather than + // accepting two blank or uniformly colored renderings as equivalent. + for color in CORNER_COLORS { + assert!(expected.rgba.as_chunks::<4>().0.contains(&color)); + } + let before = saved(&doc); + let original = doc.get(ap); + let settings = print_settings(Content::Document); + let printed = impose(&doc, &settings).unwrap(); + let reopened = Document::open(Arc::new(printed.clone())).unwrap(); + assert_translated_upright_source(&render(printed), &expected, anchor); + assert_translated_upright_source(&render(impose(&reopened, &settings).unwrap()), &expected, anchor); + assert_eq!(doc.get(ap), original); + assert_eq!(saved(&doc), before, "rotation={rotation}, inherited={inherited}, origin={origin:?}, matrix={matrix:?}"); + } + } +} + +/// Invalid `/Rotate` values normalize to zero while the eligible appearance remains upright. +#[test] +fn malformed_page_rotation_keeps_no_rotate_appearance_upright() { + for rotation in [Object::Int(135), Object::Int(-45), Object::Real(90.5), Object::name("QuarterTurn")] { + let (doc, _, _) = no_rotate_fixture(rotation.clone(), [-20.0, 30.0], "Widget", 20); + assert_eq!(pdfcraft_model::pages(&doc)[0].rotation(&doc), 0); + let before = saved(&doc); + let printed = render(impose(&doc, &print_settings(Content::Document)).unwrap()); + assert_upright_corners(&printed, (40, 50), 1); + assert_eq!(saved(&doc), before, "malformed Rotate={rotation:?}"); + } +} + +/// `NoZoom` does not cancel print layout scaling across `UserUnit` and custom sheet sizes. +#[test] +fn no_rotate_keeps_user_units_and_sheet_scaling() { + let (mut doc, _, annotation) = no_rotate_fixture(Object::Int(90), [-20.0, 30.0], "Widget", 28); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"UserUnit".to_vec(), Object::Int(2))).unwrap(); + // Resolve the same flags as the existing print path; NoZoom does not cancel print scale. + let flags = doc.add(Object::Int(28)); + doc.update_dict(annotation, |d| d.set(b"F".to_vec(), Object::Ref(flags))).unwrap(); + let before = saved(&doc); + for (paper, layout, size, anchor, scale) in [ + ((200.0, 200.0), Layout::Size(SizeMode::Actual), (200, 200), (100, 80), 2), + ((100.0, 100.0), Layout::Size(SizeMode::Custom(50.0)), (100, 100), (50, 40), 1), + ] { + let settings = Settings { paper, layout, ..print_settings(Content::Document) }; + let printed = render_page_at_size(impose(&doc, &settings).unwrap(), 0, size); + assert_upright_corners(&printed, anchor, scale); + } + assert_eq!(saved(&doc), before); +} + +/// Counterrotating one use must not alter the shared stream or rotate its later unflagged use. +#[test] +fn no_rotate_is_per_invocation_when_appearances_are_shared() { + let (mut doc, ap, annotation) = no_rotate_fixture(Object::Int(90), [0.0, 0.0], "Widget", 20); + let mut control = doc.get(annotation).as_dict().unwrap().clone(); + control.set(b"F".to_vec(), Object::Int(4)); + control.set(b"Rect".to_vec(), numbers(&[10.0, 10.0, 30.0, 20.0])); + let control = doc.add(Object::Dict(control)); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(annotation), Object::Ref(control)]))).unwrap(); + let before = saved(&doc); + let original = doc.get(ap); + let output = impose(&doc, &print_settings(Content::Document)).unwrap(); + let reopened = Document::open(Arc::new(output.clone())).unwrap(); + let rendered = render(output); + assert_upright_corners(&rendered, (50, 40), 1); + for ((x, y), color) in [(17, 15), (17, 25), (12, 15), (12, 25)].into_iter().zip(CORNER_COLORS) { + assert_eq!(pixel(&rendered, x, y), color, "unflagged use of the shared appearance"); + } + let sheet = &pdfcraft_model::pages(&reopened)[0]; + let form = reopened.get(xobject(&reopened, &sheet.dict, b"P0")); + assert_eq!(xobject(&reopened, form.as_dict().unwrap(), b"PCPrA0"), xobject(&reopened, form.as_dict().unwrap(), b"PCPrA1")); + assert_eq!(doc.get(ap), original); + assert_eq!(saved(&doc), before); +} + +/// Refuse non-finite `NoRotate` placement without changing the source. +/// Excluded and degenerate appearances retain their omission behavior. +#[test] +fn no_rotate_refuses_nonfinite_placement_and_still_skips_degenerate_appearances() { + for (rotation, rect) in [(90, [1e308, 0.0, 1.7e308, 1e308]), (180, [1e308, 0.0, 1.7e308, 1e308]), (270, [-1e308, 0.0, -0.3e308, 1e308])] { + for (subtype, flags) in [("Widget", 20), ("Text", 4)] { + let (mut doc, ap, annotation) = no_rotate_fixture(Object::Int(rotation), [0.0, 0.0], subtype, flags); + doc.update_dict(annotation, |d| d.set(b"Rect".to_vec(), numbers(&rect))).unwrap(); + let before = saved(&doc); + let original = doc.get(ap); + assert!( + matches!(impose(&doc, &print_settings(Content::DocumentAndMarkups)), Err(PrintError::Invalid(_))), + "rotation={rotation}, subtype={subtype}, flags={flags}: unrepresentable placement" + ); + assert_eq!(doc.get(ap), original); + assert_eq!(saved(&doc), before); + let mut excluded = doc.clone(); + for flags in [16, 22] { + excluded.update_dict(annotation, |d| d.set(b"F".to_vec(), Object::Int(flags))).unwrap(); + let printed = render(impose(&excluded, &print_settings(Content::DocumentAndMarkups)).unwrap()); + assert!(printed.rgba.as_chunks::<4>().0.iter().all(|pixel| *pixel == [255, 255, 255, 255]), "excluded malformed appearance"); + } + } + } + let (mut doc, ap, _) = no_rotate_fixture(Object::Int(90), [0.0, 0.0], "Widget", 20); + doc.update_dict(ap, |d| d.set(b"BBox".to_vec(), numbers(&[0.0, 0.0, 0.0, 10.0]))).unwrap(); + let before = saved(&doc); + let printed = render(impose(&doc, &print_settings(Content::Document)).unwrap()); + assert!( + printed.rgba.as_chunks::<4>().0.iter().all(|pixel| *pixel == [255, 255, 255, 255]), + "degenerate appearance keeps its existing omission behavior" + ); + assert_eq!(saved(&doc), before); +} diff --git a/crates/print/src/tests/font_names.rs b/crates/print/src/tests/font_names.rs new file mode 100644 index 0000000000..200d35ebee --- /dev/null +++ b/crates/print/src/tests/font_names.rs @@ -0,0 +1,507 @@ +//! #3132: embedded TrueType programs go to the Windows spooler under names no installed font has. + +use super::*; +use crate::font_names::{distinct, distinct_within, renamed_sfnt}; +use crate::spool::spooled_pdf; +use pdfcraft_cos::DecodeBudget; +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; +use skrifa::raw::TableProvider as _; +use skrifa::string::StringId; +use skrifa::{FontRef, MetadataProvider as _}; + +/// Inter (OFL, `ATTRIBUTION.toml`) stands in for the subset ReportLab embeds as `AAAAAA+ArialMT`. +const INTER: &[u8] = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); + +fn name_of(program: &[u8], id: StringId) -> Option { + FontRef::new(program).unwrap().localized_strings(id).english_or_first().map(|s| s.to_string()) +} + +/// A TrueType font over `program`, named `base_font`, as ReportLab writes one; its descriptor is +/// indirect. +fn true_type_font(doc: &mut Document, base_font: &str, program: ObjRef) -> ObjRef { + let mut descriptor = Dict::new(); + descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); + descriptor.set(b"FontName".to_vec(), Object::name(base_font)); + descriptor.set(b"Flags".to_vec(), Object::Int(32)); + descriptor.set(b"FontBBox".to_vec(), Object::Array([-500, -300, 1500, 1000].into_iter().map(Object::Int).collect())); + for (key, value) in [(b"ItalicAngle".as_slice(), 0), (b"Ascent", 900), (b"Descent", -200), (b"CapHeight", 700), (b"StemV", 80)] { + descriptor.set(key.to_vec(), Object::Int(value)); + } + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let descriptor = doc.add(Object::Dict(descriptor)); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("TrueType")); + font.set(b"BaseFont".to_vec(), Object::name(base_font)); + font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + font.set(b"FontDescriptor".to_vec(), Object::Ref(descriptor)); + doc.add(Object::Dict(font)) +} + +fn program_stream(doc: &mut Document) -> ObjRef { + let mut d = Dict::new(); + d.set(b"Length1".to_vec(), Object::Int(INTER.len() as i64)); + doc.add(Object::Stream(Stream::flate(d, INTER))) +} + +/// One 200×300 page writing "Hola" in a regular and a bold Arial subset, the way the report's +/// proposal is set, and its print-ready PDF. +fn print_ready() -> Vec { + let mut doc = fixture(1); + let (regular, bold) = (program_stream(&mut doc), program_stream(&mut doc)); + let regular = true_type_font(&mut doc, "AAAAAA+ArialMT", regular); + let bold = true_type_font(&mut doc, "AAAAAA+Arial-BoldMT", bold); + let mut fonts = Dict::new(); + fonts.set(b"F1".to_vec(), Object::Ref(regular)); + fonts.set(b"F2".to_vec(), Object::Ref(bold)); + let mut resources = Dict::new(); + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), b"BT /F1 40 Tf 10 200 Td (Hola) Tj ET BT /F2 40 Tf 10 100 Td (Hola) Tj ET"))); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| { + d.remove(b"Annots"); + d.set(b"Resources".to_vec(), Object::Dict(resources)); + d.set(b"Contents".to_vec(), Object::Ref(contents)); + }) + .unwrap(); + let settings = Settings { pages: vec![0], paper: (200.0, 300.0), layout: Layout::Size(SizeMode::Actual), ..Settings::default() }; + impose(&doc, &settings).unwrap() +} + +fn pixels(pdf: &[u8]) -> Vec { + let page = PageRenderer::new(Arc::new(pdf.to_vec()), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + assert_eq!(page.error, None); + page.rgba[..].to_vec() +} + +/// Each TrueType font in `pdf`: its `/BaseFont`, its descriptor's `/FontName`, its decoded +/// program and that program's `/Length1`. +fn true_type_fonts(pdf: &[u8]) -> Vec<(String, String, Vec, i64)> { + let doc = Document::open(Arc::new(pdf.to_vec())).unwrap(); + let mut fonts = Vec::new(); + for (_, object) in doc.scan_objects() { + let Object::Dict(font) = object.as_ref() else { continue }; + if font.name(b"Subtype") != Some(b"TrueType") { + continue; + } + let descriptor = doc.dict(font.get(b"FontDescriptor").unwrap()).unwrap(); + let program = doc.resolve(descriptor.get(b"FontFile2").unwrap()); + let Object::Stream(program) = program.as_ref() else { panic!("no program") }; + let name = |d: &Dict, key: &[u8]| String::from_utf8(d.name(key).unwrap().to_vec()).unwrap(); + fonts.push((name(font, b"BaseFont"), name(&descriptor, b"FontName"), program.decoded().unwrap(), program.dict.int(b"Length1").unwrap())); + } + fonts.sort(); + fonts +} + +#[test] +fn windows_gets_embedded_subsets_under_names_no_installed_font_has() { + let pdf = print_ready(); + let before = true_type_fonts(&pdf); + assert_eq!(before.iter().map(|f| f.0.as_str()).collect::>(), ["AAAAAA+Arial-BoldMT", "AAAAAA+ArialMT"]); + + let spooled = spooled_pdf(&pdf, true); + let after = true_type_fonts(&spooled); + + // Each program has a name of its own, in the PDF and in its `name` table; the subset tag stays. + let names: Vec<_> = after.iter().map(|f| (f.0.as_str(), f.1.as_str())).collect(); + assert_eq!(names, [("AAAAAA+PdfCraftPrint1", "AAAAAA+PdfCraftPrint1"), ("AAAAAA+PdfCraftPrint2", "AAAAAA+PdfCraftPrint2")]); + for (n, (_, _, program, length1)) in after.iter().enumerate() { + let name = format!("PdfCraftPrint{}", n + 1); + for id in [StringId::FAMILY_NAME, StringId::FULL_NAME, StringId::POSTSCRIPT_NAME, StringId::UNIQUE_ID] { + assert_eq!(name_of(program, id).as_deref(), Some(name.as_str()), "{id:?}"); + } + assert_eq!(name_of(program, StringId::SUBFAMILY_NAME).as_deref(), Some("Regular")); + assert_eq!(*length1, program.len() as i64); + // Nothing that draws a glyph changed: the same glyph for every character, and the same + // tables but `name` and `head`'s checksum adjustment. + let (old, new) = (FontRef::new(INTER).unwrap(), FontRef::new(program).unwrap()); + assert_eq!(old.charmap().mappings().collect::>(), new.charmap().mappings().collect::>()); + let table = |font: &FontRef, tag| { + let mut data = font.table_data(tag).unwrap().as_bytes().to_vec(); + if &tag == b"head" { + data[8..12].fill(0); + } + data + }; + for tag in old.table_directory.table_records().iter().map(|r| r.tag()).filter(|t| t != b"name") { + assert_eq!(table(&old, tag), table(&new, tag), "{tag}"); + } + // `head`'s checksum adjustment holds for the new file. + let sum = program.chunks(4).fold(0_u32, |s, c| s.wrapping_add(u32::from_be_bytes([0, 1, 2, 3].map(|i| c.get(i).copied().unwrap_or(0))))); + assert_eq!(sum, 0xb1b0afba); + } + assert!(before.iter().all(|f| name_of(&f.2, StringId::FAMILY_NAME).as_deref() == Some("Inter"))); + + // The page draws exactly as before, and draws something. + let (old, new) = (pixels(&pdf), pixels(&spooled)); + assert!(old.chunks(4).any(|p| p[0] < 128), "the text is drawn"); + assert!(old == new, "renaming changed what prints"); +} + +#[test] +fn other_platforms_and_pdfs_without_embedded_programs_spool_as_they_are() { + let pdf = print_ready(); + assert!(matches!(spooled_pdf(&pdf, false), std::borrow::Cow::Borrowed(p) if p == pdf.as_slice())); + let helvetica = impose(&fixture(1), &Settings { pages: vec![0], paper: (200.0, 300.0), ..Settings::default() }).unwrap(); + assert_eq!(distinct(&helvetica).unwrap(), None); + assert!(matches!(spooled_pdf(&helvetica, true), std::borrow::Cow::Borrowed(_))); + // Not a PDF: it goes as it is, and the spooler reports what it makes of it. + assert!(matches!(spooled_pdf(b"not a pdf", true), std::borrow::Cow::Borrowed(b"not a pdf"))); +} + +#[test] +fn a_program_that_cannot_be_rewritten_keeps_its_name_in_the_pdf_too() { + // A viewer that can't load the program either picks a substitute by the PDF's name, so that + // name must still say Arial. + let mut doc = fixture(1); + let good = program_stream(&mut doc); + let mut d = Dict::new(); + d.set(b"Length1".to_vec(), Object::Int(10)); + let broken = doc.add(Object::Stream(Stream::flate(d, b"not a font"))); + let good = true_type_font(&mut doc, "AAAAAA+ArialMT", good); + let broken = true_type_font(&mut doc, "BAAAAA+Arial-BoldMT", broken); + let mut fonts = Dict::new(); + fonts.set(b"F1".to_vec(), Object::Ref(good)); + fonts.set(b"F2".to_vec(), Object::Ref(broken)); + let mut resources = Dict::new(); + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + let pdf = write_full(&doc, &SaveOptions::default()).unwrap(); + + let names: Vec<_> = true_type_fonts(&spooled_pdf(&pdf, true)).into_iter().map(|f| (f.0, f.1, f.2 == b"not a font")).collect(); + assert_eq!( + names, + [ + ("AAAAAA+PdfCraftPrint1".to_owned(), "AAAAAA+PdfCraftPrint1".to_owned(), false), + ("BAAAAA+Arial-BoldMT".to_owned(), "BAAAAA+Arial-BoldMT".to_owned(), true), + ] + ); + // With nothing it can rewrite, the PDF goes as it is. + let mut fonts = Dict::new(); + fonts.set(b"F2".to_vec(), Object::Ref(broken)); + let mut resources = Dict::new(); + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + doc.update_dict(page, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + assert_eq!(distinct(&write_full(&doc, &SaveOptions::default()).unwrap()).unwrap(), None); +} + +#[test] +fn programs_past_the_shared_decode_budget_keep_their_names() { + // Each program decodes to Inter's whole length; the budget covers one and a half. + let pdf = print_ready(); + let mut budget = DecodeBudget::new(INTER.len() * 3 / 2, 0); + let renamed = distinct_within(&pdf, &mut budget).unwrap().unwrap(); + let names: Vec<_> = true_type_fonts(&renamed).into_iter().map(|f| (f.0, name_of(&f.2, StringId::FAMILY_NAME).unwrap())).collect(); + let (new, old): (Vec<_>, Vec<_>) = names.iter().partition(|(base_font, _)| base_font.contains("PdfCraftPrint")); + assert_eq!((new.len(), old.len()), (1, 1), "{names:?}"); + assert!(new[0].0.starts_with("AAAAAA+PdfCraftPrint") && new[0].1.starts_with("PdfCraftPrint"), "{names:?}"); + assert!(old[0].0.starts_with("AAAAAA+Arial") && old[0].1 == "Inter", "{names:?}"); + // With the default allowance both are renamed. + assert_eq!(true_type_fonts(&distinct(&pdf).unwrap().unwrap()).iter().filter(|f| f.0.contains("PdfCraftPrint")).count(), 2); +} + +/// A CIDFontType2 named `base_font` over `program`, written as a dictionary with its descriptor +/// inline, as some producers put it straight into `/DescendantFonts`. +fn inline_cid_font(base_font: &str, program: ObjRef) -> Dict { + let mut descriptor = Dict::new(); + descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); + descriptor.set(b"FontName".to_vec(), Object::name(base_font)); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let mut cid = Dict::new(); + cid.set(b"Type".to_vec(), Object::name("Font")); + cid.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + cid.set(b"BaseFont".to_vec(), Object::name(base_font)); + cid.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + cid +} + +fn type0_font(doc: &mut Document, base_font: &str, descendants: Object) -> ObjRef { + let mut type0 = Dict::new(); + type0.set(b"Type".to_vec(), Object::name("Font")); + type0.set(b"Subtype".to_vec(), Object::name("Type0")); + type0.set(b"BaseFont".to_vec(), Object::name(base_font)); + type0.set(b"Encoding".to_vec(), Object::name("Identity-H")); + type0.set(b"DescendantFonts".to_vec(), descendants); + doc.add(Object::Dict(type0)) +} + +#[test] +fn a_cidfont_written_inside_its_type0_font_is_renamed_with_it() { + let mut doc = fixture(1); + let (shared, own) = (program_stream(&mut doc), program_stream(&mut doc)); + let simple = true_type_font(&mut doc, "AAAAAA+ArialMT", shared); + // In `/DescendantFonts` itself, sharing the simple font's program; and in an array object of + // its own, over a program nothing else uses. + let direct = type0_font(&mut doc, "BAAAAA+ArialMT-Identity-H", Object::Array(vec![Object::Dict(inline_cid_font("BAAAAA+ArialMT", shared))])); + let array = doc.add(Object::Array(vec![Object::Dict(inline_cid_font("CAAAAA+ArialMT", own))])); + let in_array = type0_font(&mut doc, "CAAAAA+ArialMT-Identity-H", Object::Ref(array)); + let mut fonts = Dict::new(); + for (key, font) in [(b"F1", simple), (b"F2", direct), (b"F3", in_array)] { + fonts.set(key.to_vec(), Object::Ref(font)); + } + let mut resources = Dict::new(); + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + let pdf = write_full(&doc, &SaveOptions::default()).unwrap(); + + let renamed = Document::open(Arc::new(distinct(&pdf).unwrap().unwrap())).unwrap(); + let page = renamed.dict(&Object::Ref(pdfcraft_model::pages(&renamed)[0].obj)).unwrap(); + let fonts = renamed.dict(renamed.dict(page.get(b"Resources").unwrap()).unwrap().get(b"Font").unwrap()).unwrap(); + let name = |d: &Dict, key: &[u8]| String::from_utf8(d.name(key).unwrap().to_vec()).unwrap(); + let simple = renamed.dict(fonts.get(b"F1").unwrap()).unwrap(); + for (key, tag) in [(b"F2", "BAAAAA+"), (b"F3", "CAAAAA+")] { + let type0 = renamed.dict(fonts.get(key).unwrap()).unwrap(); + let descendants = renamed.resolve(type0.get(b"DescendantFonts").unwrap()); + let Some(Object::Dict(cid)) = descendants.as_array().and_then(|a| a.first()) else { panic!("inline CIDFont") }; + let Some(Object::Dict(descriptor)) = cid.get(b"FontDescriptor") else { panic!("inline descriptor") }; + let program = renamed.resolve(descriptor.get(b"FontFile2").unwrap()); + let Object::Stream(program) = program.as_ref() else { panic!("program") }; + let family = name_of(&program.decoded().unwrap(), StringId::FAMILY_NAME).unwrap(); + // The Type 0 font, its CIDFont and the descriptor all say the program's new name. + let expected = format!("{tag}{family}"); + assert!(family.starts_with("PdfCraftPrint"), "{family}"); + assert_eq!( + (name(&type0, b"BaseFont"), name(cid, b"BaseFont"), name(descriptor, b"FontName")), + (expected.clone(), expected.clone(), expected) + ); + } + // The shared program has one name, whichever font led to it. + let shared_name = name(&simple, b"BaseFont").replace("AAAAAA+", ""); + assert_eq!(name(&renamed.dict(fonts.get(b"F2").unwrap()).unwrap(), b"BaseFont"), format!("BAAAAA+{shared_name}")); +} + +#[test] +fn fonts_sharing_a_program_share_its_name_and_type0_fonts_follow_their_cidfont() { + let mut doc = fixture(1); + let program = program_stream(&mut doc); + let simple = true_type_font(&mut doc, "BCDEFG+Calibri", program); + let again = true_type_font(&mut doc, "Calibri", program); + // A Type 0 font over a CIDFontType2 whose descriptor is written inline. + let mut descriptor = Dict::new(); + descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); + descriptor.set(b"FontName".to_vec(), Object::name("HIJKLM+Calibri")); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let mut cid = Dict::new(); + cid.set(b"Type".to_vec(), Object::name("Font")); + cid.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + cid.set(b"BaseFont".to_vec(), Object::name("HIJKLM+Calibri")); + cid.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + let cid = doc.add(Object::Dict(cid)); + let mut type0 = Dict::new(); + type0.set(b"Type".to_vec(), Object::name("Font")); + type0.set(b"Subtype".to_vec(), Object::name("Type0")); + type0.set(b"BaseFont".to_vec(), Object::name("HIJKLM+Calibri-Identity-H")); + type0.set(b"Encoding".to_vec(), Object::name("Identity-H")); + type0.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Ref(cid)])); + let type0 = doc.add(Object::Dict(type0)); + let mut fonts = Dict::new(); + for (key, font) in [(b"F1", simple), (b"F2", again), (b"F3", type0)] { + fonts.set(key.to_vec(), Object::Ref(font)); + } + let mut resources = Dict::new(); + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + let pdf = write_full(&doc, &SaveOptions::default()).unwrap(); + + // The file is written anew, so the fonts are found again through the page. + let renamed = Document::open(Arc::new(distinct(&pdf).unwrap().unwrap())).unwrap(); + let page = renamed.dict(&Object::Ref(pdfcraft_model::pages(&renamed)[0].obj)).unwrap(); + let fonts = renamed.dict(renamed.dict(page.get(b"Resources").unwrap()).unwrap().get(b"Font").unwrap()).unwrap(); + let font = |key: &[u8]| renamed.dict(fonts.get(key).unwrap()).unwrap(); + let base_font = |d: &Dict| String::from_utf8(d.name(b"BaseFont").unwrap().to_vec()).unwrap(); + assert_eq!(base_font(&font(b"F1")), "BCDEFG+PdfCraftPrint1"); + assert_eq!(base_font(&font(b"F2")), "PdfCraftPrint1"); + assert_eq!(base_font(&font(b"F3")), "HIJKLM+PdfCraftPrint1"); + let Some(Object::Array(descendants)) = font(b"F3").get(b"DescendantFonts").cloned() else { panic!("descendants") }; + let cid = renamed.dict(&descendants[0]).unwrap(); + assert_eq!(base_font(&cid), "HIJKLM+PdfCraftPrint1"); + let Some(Object::Dict(inline)) = cid.get(b"FontDescriptor") else { panic!("inline descriptor") }; + assert_eq!(inline.name(b"FontName"), Some(b"HIJKLM+PdfCraftPrint1".as_slice())); + // Renamed once, not once per font. + let program = renamed.resolve(inline.get(b"FontFile2").unwrap()); + let Object::Stream(program) = program.as_ref() else { panic!("program") }; + assert_eq!(name_of(&program.decoded().unwrap(), StringId::FAMILY_NAME).as_deref(), Some("PdfCraftPrint1")); +} + +/// A `name` table of `format` with the given records `(platform, encoding, language, id, text)` +/// and language tags, inside a minimal sfnt with only that table. +fn sfnt_with_name(format: u16, records: &[(u16, u16, u16, u16, &[u8])], tags: &[&[u8]]) -> Vec { + let mut table = Vec::new(); + let mut strings = Vec::new(); + table.extend_from_slice(&format.to_be_bytes()); + table.extend_from_slice(&(records.len() as u16).to_be_bytes()); + let header = 6 + records.len() * 12 + if format == 1 { 2 + tags.len() * 4 } else { 0 }; + table.extend_from_slice(&(header as u16).to_be_bytes()); + for (platform, encoding, language, id, text) in records { + for v in [*platform, *encoding, *language, *id, text.len() as u16, strings.len() as u16] { + table.extend_from_slice(&v.to_be_bytes()); + } + strings.extend_from_slice(text); + } + if format == 1 { + table.extend_from_slice(&(tags.len() as u16).to_be_bytes()); + for tag in tags { + table.extend_from_slice(&(tag.len() as u16).to_be_bytes()); + table.extend_from_slice(&(strings.len() as u16).to_be_bytes()); + strings.extend_from_slice(tag); + } + } + table.extend_from_slice(&strings); + sfnt_with_table(&table) +} + +/// A minimal sfnt with `table` as its only table, `name`. +fn sfnt_with_table(table: &[u8]) -> Vec { + let mut sfnt = vec![0, 1, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0]; + sfnt.extend_from_slice(b"name"); + sfnt.extend_from_slice(&0_u32.to_be_bytes()); + sfnt.extend_from_slice(&28_u32.to_be_bytes()); + sfnt.extend_from_slice(&(table.len() as u32).to_be_bytes()); + sfnt.extend_from_slice(table); + sfnt +} + +/// A format-0 `name` table whose `copies` copyright records all point at one `length`-byte +/// string, then a family record after it; `storage` overrides where the table says its strings +/// start. +fn shared_string_table(copies: usize, length: usize, storage: Option) -> Vec { + let count = copies + 1; + let mut table = Vec::new(); + for v in [0, count as u16, storage.unwrap_or((6 + count * 12) as u16)] { + table.extend_from_slice(&v.to_be_bytes()); + } + let copyright = [3, 1, 0x409, 0, length as u16, 0]; + for v in (0..copies).flat_map(|_| copyright).chain([3, 1, 0x409, 1, 2, length as u16]) { + table.extend_from_slice(&v.to_be_bytes()); + } + table.extend(std::iter::repeat_n(b'c', length)); + table.extend_from_slice(&[0, b'A']); + table +} + +#[test] +fn records_that_share_one_string_keep_sharing_it() { + // A thousand records over one 60,000-byte string. Each given its own copy they would take + // 60 MB and lie past what 16-bit offsets reach; stored once, the table is as small as before. + let table = shared_string_table(1024, 60_000, None); + let renamed = renamed_sfnt(&sfnt_with_table(&table), "PdfCraftPrint1").unwrap(); + let font = FontRef::new(&renamed).unwrap(); + let name = font.name().unwrap(); + let copyright: std::collections::BTreeSet<_> = + name.name_record().iter().filter(|r| r.name_id().to_u16() == 0).map(|r| (r.string_offset().to_u32(), r.length())).collect(); + assert_eq!(copyright.len(), 1, "every copyright record points at the one string"); + assert!(font.table_data(skrifa::raw::types::Tag::new(b"name")).unwrap().len() <= table.len() + 64); + assert_eq!(name_of(&renamed, StringId::FAMILY_NAME).as_deref(), Some("PdfCraftPrint1")); + // 60,000 bytes of UTF-16BE: 30,000 characters. + assert_eq!(name_of(&renamed, StringId::COPYRIGHT_NOTICE).map(|c| c.chars().count()), Some(60_000 / 2)); +} + +#[test] +fn a_name_table_claiming_more_records_than_fit_before_its_strings_is_refused() { + // The case reported on #4098: 65,535 records over one 65,535-byte string, the strings said to + // start inside the records. Copying each record's string before checking took about 4 GiB. + let table = shared_string_table(65_534, 65_535, Some(6)); + assert_eq!(renamed_sfnt(&sfnt_with_table(&table), "X"), None); +} + +/// A `name` record: platform, encoding, language, name ID and the string's bytes. +type Record = (u16, u16, u16, u16, Vec); + +/// The records and language tags of the renamed font's `name` table. +fn records_of(sfnt: &[u8]) -> (Vec, Vec>) { + let font = FontRef::new(sfnt).unwrap(); + let name = font.name().unwrap(); + let data = name.string_data(); + let text = |length: u16, offset: u16| data.as_bytes()[offset as usize..][..length as usize].to_vec(); + let records = name + .name_record() + .iter() + .map(|r| (r.platform_id(), r.encoding_id(), r.language_id(), r.name_id().to_u16(), text(r.length(), r.string_offset().to_u32() as u16))) + .collect(); + let tags = name.lang_tag_record().unwrap_or_default().iter().map(|t| text(t.length(), t.lang_tag_offset().to_u32() as u16)).collect(); + (records, tags) +} + +#[test] +fn identity_records_are_respelled_per_platform_and_the_rest_are_kept() { + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + let arial = utf16("Arial"); + let copyright = utf16("© The Monotype Corporation"); + let sjis_family: &[u8] = &[0x82, 0xa0]; + let sfnt = sfnt_with_name( + 1, + &[ + (1, 0, 0, 1, b"Arial"), + (1, 0, 0, 2, b"Bold"), + (3, 1, 0x409, 0, ©right), + (3, 1, 0x409, 1, &arial), + (3, 1, 0x409, 6, &utf16("Arial-BoldMT")), + (3, 2, 0x411, 1, sjis_family), + (3, 1, 0x8000, 4, &utf16("Arial Bold")), + ], + &[b"\0e\0n"], + ); + let renamed = renamed_sfnt(&sfnt, "PdfCraftPrint7").unwrap(); + let (records, tags) = records_of(&renamed); + assert_eq!( + records, + [ + (1, 0, 0, 1, b"PdfCraftPrint7".to_vec()), + (1, 0, 0, 2, b"Bold".to_vec()), + (3, 1, 0x409, 0, copyright), + (3, 1, 0x409, 1, utf16("PdfCraftPrint7")), + (3, 1, 0x409, 6, utf16("PdfCraftPrint7")), + // The Shift-JIS family can't be respelled in ASCII here, so it goes. + (3, 1, 0x8000, 4, utf16("PdfCraftPrint7")), + ] + ); + assert_eq!(tags, [b"\0e\0n".to_vec()]); +} + +#[test] +fn damaged_programs_are_left_alone_and_never_panic() { + let good = sfnt_with_name(0, &[(3, 1, 0x409, 1, &[0, b'A'])], &[]); + assert!(renamed_sfnt(&good, "X").is_some()); + // Every truncation, of the small font and of the start of a real one. + for n in 0..good.len() { + let _ = renamed_sfnt(&good[..n], "X"); + } + for n in (0..2048).chain([INTER.len() - 1]) { + let _ = renamed_sfnt(&INTER[..n], "X"); + } + // A collection, a CFF-in-name-only header, an absurd table count, a `name` entry past the + // end, a format-2 `name` table and a string past the table's end. + let mut collection = good.clone(); + collection[..4].copy_from_slice(b"ttcf"); + assert_eq!(renamed_sfnt(&collection, "X"), None); + let mut many = good.clone(); + many[4..6].copy_from_slice(&u16::MAX.to_be_bytes()); + assert_eq!(renamed_sfnt(&many, "X"), None); + let mut past = good.clone(); + past[20..24].copy_from_slice(&u32::MAX.to_be_bytes()); + assert_eq!(renamed_sfnt(&past, "X"), None); + let mut format2 = good.clone(); + format2[28..30].copy_from_slice(&2_u16.to_be_bytes()); + assert_eq!(renamed_sfnt(&format2, "X"), None); + let mut long = sfnt_with_name(0, &[(3, 1, 0x409, 0, &[0, b'C']), (3, 1, 0x409, 1, &[0, b'A'])], &[]); + let first_length = 28 + 6 + 8; + long[first_length..first_length + 2].copy_from_slice(&u16::MAX.to_be_bytes()); + let (records, _) = records_of(&renamed_sfnt(&long, "X").unwrap()); + assert_eq!(records, [(3, 1, 0x409, 1, vec![0, b'X'])], "the broken copyright record is left out"); + // A broken language tag fails the table, since records point at tags by position. + let mut tagged = sfnt_with_name(1, &[(3, 1, 0x409, 1, &[0, b'A'])], &[b"\0e"]); + let tag_length = 28 + 6 + 12 + 2; + tagged[tag_length..tag_length + 2].copy_from_slice(&u16::MAX.to_be_bytes()); + assert_eq!(renamed_sfnt(&tagged, "X"), None); +} diff --git a/crates/print/src/tests/optional_content.rs b/crates/print/src/tests/optional_content.rs new file mode 100644 index 0000000000..b8c6caee15 --- /dev/null +++ b/crates/print/src/tests/optional_content.rs @@ -0,0 +1,502 @@ +use super::*; +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RenderedPage, RequestKind}; + +fn numbers(values: &[f64]) -> Object { + Object::Array(values.iter().copied().map(Object::Real).collect()) +} + +fn layer_fixture(off: bool) -> (Document, ObjRef, ObjRef, ObjRef) { + let mut doc = fixture(1); + let page = pdfcraft_model::pages(&doc)[0].obj; + let group = doc.add(Object::Dict(Dict::from_iter([ + (b"Type".to_vec(), Object::name("OCG")), + (b"Name".to_vec(), Object::String(pdfcraft_cos::PdfString::text("Annotation Layer"))), + ]))); + let properties = Dict::from_iter([ + (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(group)])), + ( + b"D".to_vec(), + Object::Dict(Dict::from_iter([ + (b"BaseState".to_vec(), Object::name("ON")), + (b"OFF".to_vec(), Object::Array(if off { vec![Object::Ref(group)] } else { vec![] })), + ])), + ), + ]); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"OCProperties".to_vec(), Object::Dict(properties))).unwrap(); + let form = Dict::from_iter([ + (b"Subtype".to_vec(), Object::name("Form")), + (b"BBox".to_vec(), numbers(&[0.0, 0.0, 20.0, 10.0])), + (b"Resources".to_vec(), Object::Dict(Dict::new())), + ]); + let appearance = doc.add(Object::Stream(Stream::flate(form, b"0 1 0 rg 0 0 20 10 re f"))); + let annotation = doc.add(Object::Dict(Dict::from_iter([ + (b"Subtype".to_vec(), Object::name("Square")), + (b"F".to_vec(), Object::Int(4)), + (b"Rect".to_vec(), numbers(&[10.0, 20.0, 50.0, 40.0])), + (b"OC".to_vec(), Object::Ref(group)), + (b"AP".to_vec(), Object::Dict(Dict::from_iter([(b"N".to_vec(), Object::Ref(appearance))]))), + ]))); + let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), b"0 0 1 rg 0 0 8 8 re f"))); + doc.update_dict(page, |d| { + d.set(b"MediaBox".to_vec(), numbers(&[0.0, 0.0, 100.0, 100.0])); + d.set(b"Resources".to_vec(), Object::Dict(Dict::new())); + d.set(b"Contents".to_vec(), Object::Ref(contents)); + d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(annotation)])); + }) + .unwrap(); + (doc, group, annotation, appearance) +} + +fn render(bytes: Vec) -> RenderedPage { + let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + assert_eq!(page.error, None); + assert!(page.warnings.is_empty(), "{:?}", page.warnings); + assert_eq!((page.width, page.height), (100, 100)); + page +} + +fn print(doc: &Document) -> Result, PrintError> { + impose(doc, &Settings { pages: vec![0], paper: (100.0, 100.0), layout: Layout::Size(SizeMode::Actual), ..Settings::default() }) +} + +#[test] +fn vector_print_preserves_annotation_layer_membership() { + for off in [false, true] { + let (doc, _, _, _) = layer_fixture(off); + let before = write_full(&doc, &SaveOptions::default()).unwrap(); + let printed = print(&doc).unwrap(); + let reopened = Document::open(Arc::new(printed.clone())).unwrap(); + assert_eq!(pdfcraft_model::pages(&reopened).len(), 1); + let page = render(printed); + let pixel = |x: usize, y: usize| &page.rgba[(y * 100 + x) * 4..(y * 100 + x + 1) * 4]; + assert_eq!(pixel(30, 70), if off { [255, 255, 255, 255] } else { [0, 255, 0, 255] }, "layer off={off}"); + assert_eq!(pixel(4, 96), [0, 0, 255, 255], "ordinary page artwork"); + assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), before); + } +} + +fn saved(doc: &Document) -> Vec { + write_full(doc, &SaveOptions::default()).unwrap() +} + +fn pixel(page: &RenderedPage, x: usize, y: usize) -> &[u8] { + let offset = (y * page.width as usize + x) * 4; + &page.rgba[offset..offset + 4] +} + +fn assert_appearance(page: &RenderedPage, visible: bool) { + assert_eq!(pixel(page, 30, 70), if visible { [0, 255, 0, 255] } else { [255, 255, 255, 255] }); + assert_eq!(pixel(page, 4, 96), [0, 0, 255, 255], "ordinary page artwork"); +} + +fn add_group(doc: &mut Document, name: &str, off: bool) -> ObjRef { + let group = doc.add(Object::Dict(Dict::from_iter([ + (b"Type".to_vec(), Object::name("OCG")), + (b"Name".to_vec(), Object::String(pdfcraft_cos::PdfString::text(name))), + ]))); + let root = doc.root().unwrap(); + let mut properties = doc.resolve(doc.get(root).as_dict().unwrap().get(b"OCProperties").unwrap()).as_dict().unwrap().clone(); + let mut groups = properties.get(b"OCGs").unwrap().as_array().unwrap().clone(); + groups.push(Object::Ref(group)); + properties.set(b"OCGs".to_vec(), Object::Array(groups)); + if off { + let mut config = properties.get(b"D").unwrap().as_dict().unwrap().clone(); + let mut hidden = config.get(b"OFF").unwrap().as_array().unwrap().clone(); + hidden.push(Object::Ref(group)); + config.set(b"OFF".to_vec(), Object::Array(hidden)); + properties.set(b"D".to_vec(), Object::Dict(config)); + } + doc.update_dict(root, |d| d.set(b"OCProperties".to_vec(), Object::Dict(properties))).unwrap(); + group +} + +fn membership(groups: Vec, policy: Option<&str>, expression: Option>) -> Dict { + let mut dict = Dict::from_iter([ + (b"Type".to_vec(), Object::name("OCMD")), + (b"OCGs".to_vec(), Object::Array(groups)), + (b"PrivatePolicy".to_vec(), Object::Array(vec![Object::Int(19), Object::name("KeepMe")])), + ]); + if let Some(policy) = policy { + dict.set(b"P".to_vec(), Object::name(policy)); + } + if let Some(expression) = expression { + dict.set(b"VE".to_vec(), Object::Array(expression)); + } + dict +} + +fn printed_form(doc: &Document) -> Dict { + let page = pdfcraft_model::pages(doc)[0].dict.clone(); + let resources = doc.resolve(page.get(b"Resources").unwrap()); + let xobjects = doc.resolve(resources.as_dict().unwrap().get(b"XObject").unwrap()); + doc.resolve(xobjects.as_dict().unwrap().get(b"P0").unwrap()).as_dict().unwrap().clone() +} + +fn form_properties(doc: &Document) -> Dict { + let form = printed_form(doc); + let resources = doc.resolve(form.get(b"Resources").unwrap()); + doc.resolve(resources.as_dict().unwrap().get(b"Properties").unwrap()).as_dict().unwrap().clone() +} + +#[test] +fn vector_print_preserves_ocmd_policies_and_visibility_expressions() { + for indirect in [false, true] { + for group_shape in 0..3 { + let single_group = group_shape != 0; + for (policy, visible) in [("AllOn", false), ("AnyOn", !single_group), ("AnyOff", true), ("AllOff", single_group)] { + let (mut doc, off, annotation, appearance) = layer_fixture(true); + let on = add_group(&mut doc, "Visible", false); + let groups = if single_group { vec![Object::Ref(off)] } else { vec![Object::Ref(off), Object::Ref(on)] }; + let mut dict = membership(groups, Some(policy), None); + if group_shape == 2 { + dict.set(b"OCGs".to_vec(), Object::Ref(off)); + } + let private = dict.get(b"PrivatePolicy").unwrap().clone(); + let oc = if indirect { Object::Ref(doc.add(Object::Dict(dict))) } else { Object::Dict(dict) }; + doc.update_dict(annotation, |d| d.set(b"OC".to_vec(), oc)).unwrap(); + let before = saved(&doc); + let original_appearance = doc.get(appearance); + let printed = print(&doc).unwrap(); + assert_appearance(&render(printed.clone()), visible); + let reopened = Document::open(Arc::new(printed)).unwrap(); + let props = form_properties(&reopened); + let membership = props.get(b"PCPrOC0").unwrap(); + assert!(membership.as_ref().is_some(), "direct OCMD must be promoted for named-resource rendering"); + assert_eq!(reopened.resolve(membership).as_dict().unwrap().get(b"PrivatePolicy"), Some(&private)); + assert_appearance(&render(print(&reopened).unwrap()), visible); + assert_eq!(doc.get(appearance), original_appearance); + assert_eq!(saved(&doc), before, "policy={policy}, indirect={indirect}, single={single_group}"); + } + } + for case in 0..6 { + let (mut doc, off, annotation, _) = layer_fixture(true); + let on = add_group(&mut doc, "Visible", false); + let mut dict = membership(vec![Object::Ref(off), Object::Ref(on)], None, None); + let visible = match case { + 0 => true, // Missing P defaults to AnyOn. + 1 => { + dict.set(b"P".to_vec(), Object::name("AllOn")); + dict.set(b"VE".to_vec(), Object::Array(vec![Object::name("Not"), Object::Ref(off)])); + true // VE overrides the false AllOn policy. + } + 2 => { + dict.set(b"P".to_vec(), Object::name("AnyOn")); + dict.set(b"VE".to_vec(), Object::Array(vec![Object::name("Not"), Object::Ref(on)])); + false // VE also overrides a true policy. + } + 3 => { + dict.remove(b"OCGs"); + true // Missing OCGs is a valid no-op. + } + 4 => { + dict.set(b"OCGs".to_vec(), Object::Array(vec![])); + true + } + _ => { + dict.remove(b"OCGs"); + dict.set(b"VE".to_vec(), Object::Array(vec![Object::name("Not"), Object::Ref(on)])); + false // VE-only membership is valid. + } + }; + let oc = if indirect { Object::Ref(doc.add(Object::Dict(dict))) } else { Object::Dict(dict) }; + doc.update_dict(annotation, |d| d.set(b"OC".to_vec(), oc)).unwrap(); + let before = saved(&doc); + assert_appearance(&render(print(&doc).unwrap()), visible); + assert_eq!(saved(&doc), before, "OCMD case={case}, indirect={indirect}"); + } + } +} + +#[test] +fn annotation_and_appearance_layers_are_independent_and_shared_appearances_stay_shared() { + for annotation_off in [false, true] { + for appearance_off in [false, true] { + let (mut doc, _, annotation, appearance) = layer_fixture(annotation_off); + let appearance_group = add_group(&mut doc, "Appearance", appearance_off); + doc.update_dict(appearance, |d| d.set(b"OC".to_vec(), Object::Ref(appearance_group))).unwrap(); + let before = saved(&doc); + let first = print(&doc).unwrap(); + assert_appearance(&render(first.clone()), !annotation_off && !appearance_off); + let reopened = Document::open(Arc::new(first)).unwrap(); + assert_appearance(&render(print(&reopened).unwrap()), !annotation_off && !appearance_off); + assert_eq!(saved(&doc), before); + assert_eq!(doc.get(annotation).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().reference(b"N"), Some(appearance)); + } + } + let (mut doc, _, annotation, appearance) = layer_fixture(true); + let visible = add_group(&mut doc, "Visible", false); + let mut other = doc.get(annotation).as_dict().unwrap().clone(); + other.set(b"OC".to_vec(), Object::Ref(visible)); + other.set(b"Rect".to_vec(), numbers(&[10.0, 50.0, 50.0, 70.0])); + let other = doc.add(Object::Dict(other)); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(annotation), Object::Ref(other)]))).unwrap(); + let before = saved(&doc); + let original = doc.get(appearance); + let first = print(&doc).unwrap(); + let reopened = Document::open(Arc::new(first.clone())).unwrap(); + for bytes in [first, print(&reopened).unwrap()] { + let image = render(bytes); + assert_appearance(&image, false); + assert_eq!(pixel(&image, 30, 40), [0, 255, 0, 255], "the other annotation uses the same appearance in a visible layer"); + } + let form = printed_form(&reopened); + let resources = reopened.resolve(form.get(b"Resources").unwrap()); + let objects = reopened.resolve(resources.as_dict().unwrap().get(b"XObject").unwrap()); + let objects = objects.as_dict().unwrap(); + assert_eq!(objects.get(b"PCPrA0"), objects.get(b"PCPrA1"), "membership belongs to each invocation, not an appearance copy"); + assert_eq!(doc.get(appearance), original); + assert_eq!(saved(&doc), before); +} + +#[test] +fn annotation_layers_keep_colliding_inherited_and_shared_page_properties() { + for indirect_resources in [false, true] { + for indirect_properties in [false, true] { + for inherited in [false, true] { + let (mut doc, hidden, annotation, appearance) = layer_fixture(true); + let visible = add_group(&mut doc, "Visible", false); + let unrelated = Object::Dict(Dict::from_iter([(b"PrivateKey".to_vec(), Object::name("KeepMe"))])); + let properties = Dict::from_iter([ + (b"PCPrOC0".to_vec(), Object::Ref(hidden)), + (b"PCPrOC1".to_vec(), Object::Ref(visible)), + (b"PCPrOC3".to_vec(), Object::Null), + (b"Unrelated".to_vec(), unrelated.clone()), + ]); + let properties = if indirect_properties { Object::Ref(doc.add(Object::Dict(properties))) } else { Object::Dict(properties) }; + let resources = Dict::from_iter([(b"Properties".to_vec(), properties)]); + let resources = if indirect_resources { Object::Ref(doc.add(Object::Dict(resources))) } else { Object::Dict(resources) }; + // The appearance shares the page's resource owner, so in-place extension would change both. + doc.update_dict(appearance, |d| d.set(b"Resources".to_vec(), resources.clone())).unwrap(); + let mut other = doc.get(annotation).as_dict().unwrap().clone(); + other.set(b"OC".to_vec(), Object::Ref(visible)); + other.set(b"Rect".to_vec(), numbers(&[10.0, 50.0, 50.0, 70.0])); + let other = doc.add(Object::Dict(other)); + let contents = doc.add(Object::Stream(Stream::flate( + Dict::new(), + b"0 0 1 rg 0 0 8 8 re f\n/OC /PCPrOC0 BDC 1 0 0 rg 60 60 10 10 re f EMC\n/OC /PCPrOC1 BDC 0 0 1 rg 70 60 10 10 re f EMC\n/Span /Unrelated BDC 1 0 1 rg 80 60 10 10 re f EMC", + ))); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| { + d.set(b"Contents".to_vec(), Object::Ref(contents)); + d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(annotation), Object::Ref(other)])); + }) + .unwrap(); + if inherited { + let parent = doc.get(page).as_dict().unwrap().reference(b"Parent").unwrap(); + doc.update_dict(parent, |d| d.set(b"Resources".to_vec(), resources)).unwrap(); + doc.update_dict(page, |d| { + d.remove(b"Resources"); + }) + .unwrap(); + } else { + doc.update_dict(page, |d| d.set(b"Resources".to_vec(), resources)).unwrap(); + } + let before = saved(&doc); + let original_appearance = doc.get(appearance); + let first = print(&doc).unwrap(); + let reopened = Document::open(Arc::new(first.clone())).unwrap(); + let properties = form_properties(&reopened); + assert_eq!(properties.get(b"PCPrOC3"), Some(&Object::Null)); + assert_eq!(properties.get(b"Unrelated"), Some(&unrelated)); + assert_eq!(properties.get(b"PCPrOC2"), properties.get(b"PCPrOC0")); + assert_eq!(properties.get(b"PCPrOC4"), properties.get(b"PCPrOC1")); + for bytes in [first, print(&reopened).unwrap()] { + let image = render(bytes); + assert_appearance(&image, false); + assert_eq!(pixel(&image, 30, 40), [0, 255, 0, 255]); + assert_eq!(pixel(&image, 65, 35), [255, 255, 255, 255], "existing hidden page content"); + assert_eq!(pixel(&image, 75, 35), [0, 0, 255, 255], "existing visible page content"); + assert_eq!(pixel(&image, 85, 35), [255, 0, 255, 255], "unrelated marked-content property"); + } + assert_eq!(doc.get(appearance), original_appearance); + assert_eq!(saved(&doc), before, "resources={indirect_resources}, properties={indirect_properties}, inherited={inherited}"); + } + } + } +} + +fn reference_chain(doc: &mut Document, mut value: Object, references: usize) -> Object { + for _ in 0..references { + value = Object::Ref(doc.add(value)); + } + value +} + +fn cycle(doc: &mut Document, two_nodes: bool) -> Object { + let first = doc.add(Object::Null); + let second = if two_nodes { doc.add(Object::Ref(first)) } else { first }; + doc.set(first, Object::Ref(second)); + Object::Ref(first) +} + +#[test] +fn absent_annotation_membership_and_null_resource_maps_remain_valid() { + for case in 0..6 { + let (mut doc, _, annotation, _) = layer_fixture(true); + match case { + 0 => { + doc.update_dict(annotation, |d| { + d.remove(b"OC"); + }) + .unwrap(); + } + 1 => doc.update_dict(annotation, |d| d.set(b"OC".to_vec(), Object::Null)).unwrap(), + 2 => doc.update_dict(annotation, |d| d.set(b"OC".to_vec(), Object::Ref(ObjRef::new(900_000, 0)))).unwrap(), + _ => { + let page = pdfcraft_model::pages(&doc)[0].obj; + let resources = match case { + 3 => Object::Null, + 4 => Object::Dict(Dict::from_iter([(b"Properties".to_vec(), Object::Null)])), + _ => Object::Dict(Dict::from_iter([(b"Properties".to_vec(), Object::Ref(ObjRef::new(900_000, 0)))])), + }; + doc.update_dict(page, |d| d.set(b"Resources".to_vec(), resources)).unwrap(); + } + } + let before = saved(&doc); + assert_appearance(&render(print(&doc).unwrap()), case < 3); + assert_eq!(saved(&doc), before, "null/absent case={case}"); + } +} + +#[test] +fn invalid_annotation_membership_is_refused_without_mutation() { + for case in 0..9 { + let (mut doc, _, annotation, _) = layer_fixture(false); + let value = match case { + 0 => Object::Int(7), + 1 => Object::name("Layer"), + 2 => Object::Array(vec![]), + 3 => Object::Dict(Dict::new()), + 4 => Object::Dict(Dict::from_iter([(b"Type".to_vec(), Object::name("Page"))])), + 5 => Object::Dict(Dict::from_iter([(b"Type".to_vec(), Object::name("OCG"))])), + 6 => Object::Ref(doc.add(Object::Stream(Stream::flate(membership(vec![], None, None), b"")))), + 7 => cycle(&mut doc, false), + _ => cycle(&mut doc, true), + }; + doc.update_dict(annotation, |d| d.set(b"OC".to_vec(), value)).unwrap(); + let before = saved(&doc); + assert!(matches!(print(&doc), Err(PrintError::Invalid(_))), "invalid membership case={case}"); + assert_eq!(saved(&doc), before, "invalid membership case={case}"); + } +} + +#[test] +fn optional_content_reference_resolution_is_bounded_and_keeps_terminal_group_identity() { + for references in [64, 65] { + for target in ["membership", "type", "properties", "resources"] { + let (mut doc, group, annotation, _) = layer_fixture(true); + match target { + "membership" => { + // The final OCG is already indirect and counts as one hop. + let value = reference_chain(&mut doc, Object::Ref(group), references - 1); + doc.update_dict(annotation, |d| d.set(b"OC".to_vec(), value)).unwrap(); + } + "type" => { + let value = reference_chain(&mut doc, Object::name("OCG"), references); + doc.update_dict(group, |d| d.set(b"Type".to_vec(), value)).unwrap(); + } + _ => { + let value = reference_chain(&mut doc, Object::Dict(Dict::new()), references); + let resources = if target == "properties" { Object::Dict(Dict::from_iter([(b"Properties".to_vec(), value)])) } else { value }; + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"Resources".to_vec(), resources)).unwrap(); + } + } + let before = saved(&doc); + let result = print(&doc); + if references == 64 { + let bytes = result.unwrap(); + // Check Type aliases structurally: rendering them would also test the + // renderer's separate reference-resolution behavior. + if target == "type" { + let reopened = Document::open(Arc::new(bytes)).unwrap(); + let root = reopened.get(reopened.root().unwrap()); + let catalog = reopened.resolve(root.as_dict().unwrap().get(b"OCProperties").unwrap()); + let groups = catalog.as_dict().unwrap().get(b"OCGs").unwrap().as_array().unwrap(); + assert_eq!(form_properties(&reopened).get(b"PCPrOC0"), groups.first()); + } else { + assert_appearance(&render(bytes), false); + } + } else { + assert!(matches!(result, Err(PrintError::Invalid(_))), "target={target}"); + } + assert_eq!(saved(&doc), before, "target={target}, references={references}"); + } + } +} + +#[test] +fn malformed_page_resource_maps_cannot_remove_optional_content_guards() { + for target in ["Resources", "Properties"] { + for case in 0..4 { + let (mut doc, _, _, _) = layer_fixture(true); + let value = match case { + 0 => Object::Int(7), + 1 => Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), b"")))), + 2 => cycle(&mut doc, false), + _ => cycle(&mut doc, true), + }; + let resources = if target == "Properties" { Object::Dict(Dict::from_iter([(b"Properties".to_vec(), value)])) } else { value }; + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"Resources".to_vec(), resources)).unwrap(); + let before = saved(&doc); + assert!(matches!(print(&doc), Err(PrintError::Invalid(_))), "target={target}, case={case}"); + assert_eq!(saved(&doc), before, "target={target}, case={case}"); + } + } +} + +#[test] +fn malformed_membership_on_annotations_that_do_not_draw_does_not_block_printing() { + for case in 0..6 { + let (mut doc, _, annotation, _) = layer_fixture(false); + doc.update_dict(annotation, |d| { + d.set(b"OC".to_vec(), Object::Int(7)); + match case { + 0 => d.set(b"F".to_vec(), Object::Int(0)), + 1 => d.set(b"F".to_vec(), Object::Int(6)), + // A Popup never prints. (A Link did not either, until #5028 printed existing + // Link appearances.) + 2 => d.set(b"Subtype".to_vec(), Object::name("Popup")), + 3 => { + d.remove(b"AP"); + } + 4 => d.set(b"Rect".to_vec(), Object::Array(vec![])), + _ => {} + } + }) + .unwrap(); + // Resource validation is also lazy: these maps are irrelevant when no guarded + // annotation invocation will be appended. + let page = pdfcraft_model::pages(&doc)[0].obj; + let resources = + if case % 2 == 0 { Object::Dict(Dict::from_iter([(b"Properties".to_vec(), Object::Int(7))])) } else { cycle(&mut doc, false) }; + doc.update_dict(page, |d| d.set(b"Resources".to_vec(), resources)).unwrap(); + let before = saved(&doc); + let bytes = if case == 5 { + impose( + &doc, + &Settings { + pages: vec![0], + paper: (100.0, 100.0), + layout: Layout::Size(SizeMode::Actual), + content: Content::Document, + ..Settings::default() + }, + ) + .unwrap() + } else { + print(&doc).unwrap() + }; + assert_appearance(&render(bytes), false); + assert_eq!(saved(&doc), before, "excluded annotation case={case}"); + } +} diff --git a/crates/redact/README.md b/crates/redact/README.md index 51ac11f086..e47df98f17 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -19,15 +19,35 @@ clear_marks(&mut doc, None)?; // remove marks without app inside; - **images**: fully covered → removed; partly covered → a copy with the covered pixels cleared (8/16-bit and 1/2/4-bit images, image masks; Flate/LZW/RL/A85/AHx, and CCITT fax or JBIG2 as - the last filter, decoded through `Document::decoded_image_within`, a JBIG2 image with its + the last filter, decoded through `ImageBudget::decode`, a JBIG2 image with its `/JBIG2Globals`; `/DecodeParms` given by reference, numbers inside included, count). The copy is Flate, or for a one-bit image of one component (a scan as a rule) CCITT Group 4, or JBIG2 when it was JBIG2, whichever is smallest as stored, coded from the cleared samples; it refers to no globals. Globals hold the shapes of every image that shares them (a scan's pages, often), - those of marks under the area included, so every other image that uses the globals of a removed - or replaced image is decoded and stored again in place without them, as a copy is (Flate, - Group 4 or JBIG2, whichever is smallest; `Report::images_restored`), and they leave the file on the full rewrite; one that can't be - decoded for certain refuses the redaction (`RedactError::SharedJbig2Globals`). Codecs redaction doesn't decode (DCT, JPX) are + those of marks under the area included, so whatever left the document, by any route, takes its + globals with it on the full rewrite: anything else the save would keep that names them, or a + copy of them (the same bytes once decoded), is stored again without them, as a copy is (Flate, Group 4 or JBIG2, + whichever is smallest; `Report::images_restored`). When one can't be (it isn't JBIG2, its colour + space is known to have more than one component, it has no size or one past what redaction + clears, its data is in another file, or it can't be decoded), or something else refers to them, `apply` + refuses and changes nothing (`RedactError::SharedJbig2Globals`). `remove_hidden` takes them out + the same way, but it has changed the document by the time it refuses: a caller passes a copy it + can drop. Another dictionary holding the same shapes in other bytes isn't found, nor is a copy + nothing names, and an image that hasn't left keeps its globals with its pixels: one another page + still draws, or one something that doesn't draw it still refers to. What an image names as + globals is read by object number, whatever generation a reference gives, as a reader that doesn't + compare generations draws it (cos reads such a reference as null); what one is stored again with, + its size, filters and parameters, is read as cos reads it. Finding what to store again + reads each object at most once for each way it is read (a `/JBIG2Globals` chain named part way, + a ring of references or a chain of more than 33, or a value read through a reference of another + generation, may be read again), and what it fetches, remembers and decodes (a decode that + fails counting as much as it was allowed, and an image the dictionaries it names) counts against + one budget for the run; past it the redaction is refused (`RedactError::SharedJbig2Budget`). The + images stored again, and the dictionaries read to know copies of them, are decoded within the + run's image allowance, every filter stage and the JBIG2 decoder's own work included, after what + clearing spent: past it `apply` is refused as one clearing too many images is + (`RedactError::ImagesPastAllowance`), and `remove_hidden`, which chose no pages, as one past the + run's budget. Codecs redaction doesn't decode (DCT, JPX) are removed whole (fail-closed), and so is a JBIG2 image whose page isn't its size (the renderer draws the whole page into the image, so the rows cleared wouldn't be the ones shown) or of more than one bit or component, and Group 3 on bytes (`/K` 0 or more, `/EncodedByteAlign`): PdfCraft's @@ -148,8 +168,10 @@ used by surviving pages or forms are kept. A redacted page that inherits `/Resources` from the page tree gets them pinned (by reference; a direct inherited dictionary is made one shared object first) before it is rewritten. The parent's `/Resources` is then retired unless another page still inherits it, so it cannot keep a removed -XObject reachable. Surviving form XObjects lose the resource entries for removed XObjects their own -content doesn't draw, for the same reason. Pages that aren't redacted are left untouched. +XObject reachable. Surviving form XObjects lose the resource entries for removed XObjects that +neither their own content nor a form without resources of its own drawn through them still draws, +for the same reason, and a redacted page keeps a name under that rule too. Pages that aren't +redacted are left untouched. ## Remove hidden information / Sanitize diff --git a/crates/redact/src/globals.rs b/crates/redact/src/globals.rs new file mode 100644 index 0000000000..f12378a2fa --- /dev/null +++ b/crates/redact/src/globals.rs @@ -0,0 +1,637 @@ +//! A `/JBIG2Globals` stream is a dictionary of symbols that JBIG2 images can share, and scanners +//! and `jbig2enc -s` share one across a scan's pages. It holds the shape of every distinct mark of +//! those pages, including a mark found only under a redaction, so taking the image out isn't enough: +//! while something else uses the dictionary, a full save keeps it. [`unshare`] stores what uses it +//! again without it. + +use std::collections::hash_map::DefaultHasher; +use std::collections::{HashMap, HashSet}; +use std::hash::{Hash, Hasher}; +use std::rc::Rc; +use std::sync::Arc; + +use pdfcraft_cos::{CosError, DECODE_PARMS_KEYS, Dict, Document, ImageBudget, MAX_JBIG2_GLOBALS, Object, Stream, reachable_objects}; + +/// The references followed from `/JBIG2Globals` towards a stream, so that a ring of them ends. +const MAX_HOPS: usize = 32; + +/// The objects a full save would keep, by number, as [`reachable_objects`] finds them: a reference +/// whose generation isn't its object's reads as null there (#1143), so it keeps nothing. Fails only +/// when a full save couldn't number them all. +pub(crate) fn kept(doc: &Document) -> Result, CosError> { + reachable_objects(doc) +} + +/// `o`, or what references lead to from it. A ring of them ends as a reference, which is no dictionary. +pub(crate) fn followed(doc: &Document, o: &Object) -> Arc { + let mut at = doc.resolve(o); + for _ in 0..MAX_HOPS { + let next = match &*at { + Object::Ref(next) => *next, + _ => break, + }; + at = doc.get(next); + } + at +} + +/// The most one run may spend finding what shares a dictionary with what left: objects fetched, +/// names remembered, and decoded data, a unit and one for each whole 4 KiB. Past it the run is refused +/// ([`RedactError::SharedJbig2Budget`](crate::RedactError::SharedJbig2Budget)). A scan reads a few +/// objects a page, and its pages stored again a unit for each 4 KiB of their samples and of the +/// dictionary they name, so a book of thousands of pages sharing a dictionary of a MiB or two stays +/// within it. The work of decoding, every filter stage's and the JBIG2 decoder's, is paid besides +/// from the run's image allowance (`ImageBudget`), which a unit of decoded data says nothing of. +pub(crate) const RUN_BUDGET: usize = 1 << 22; + +/// What a parameters value names as `/JBIG2Globals`, and the key it is remembered by: a value the +/// file refers to, by the number of the object the references end at and whether it is read as a +/// list of entries. +pub(crate) type Listed = (Rc<[u32]>, Option); + +/// A value the file refers to: the number of an object, and whether it is read as a list of entries. +pub(crate) type Key = (u32, bool); + +/// How a value is read, and so what is kept of it: what a reference leads to is remembered for each. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum How { + /// A size: a number, or null. + Number, + /// One filter: a name, or null. + Name, + /// `/Filter`: a name, or an array of names, or null for a chain longer than any decoder takes. + Filter, + /// `/DecodeParms` for so many filters. + Parms(usize), + /// One parameters entry: the numbers and flags decoders read, and `/JBIG2Globals` by reference. + Entry, + /// A number or a flag. + Scalar, + /// `/F`: whether it names anything (true) or is null. + Present, + /// `/ColorSpace`: whether it is known to have more than one component. + Components, + /// An ICC profile: its `/N`, or null. + Profile, +} + +/// What one redaction or Remove Hidden Information run reads, to find what shares a dictionary with +/// what left, against [`RUN_BUDGET`]. What a stream names is read by object number, whatever +/// generation a reference gives ([`Run::fetch_numbered`]); what one is stored again with is read as +/// cos reads it, by number and generation, so a reference of another generation reads its own way. +/// Each object is fetched at most once a run for each way it is read, and kept only as what is read +/// of it: a value many streams share, by reference, through a +/// chain of them or through an entry, costs it once however many there are. The exceptions are a +/// `/JBIG2Globals` chain named part way, which is followed again from there, and a ring of +/// references or a chain past the limit, read again (up to 33 fetches) wherever it is met; every +/// fetch counts. +pub(crate) struct Run { + budget: usize, + /// What was spent of the budget. + pub(crate) spent: usize, + /// What a reference leads to, as it is read, under every reference on the way, by number and + /// generation: one whose generation isn't its object's reads as null (#1143), and must not give + /// another reference to that number what it read, nor take what that one read. + reduced: HashMap<(pdfcraft_cos::ObjRef, How), Rc>, + /// What a reference leads to names, under every reference on the way, with the key of the one + /// that names what it ends at: aliases and chains find it, and are counted as that one. + names: HashMap, Key)>, + /// What a `/JBIG2Globals` reference leads to: it and each reference after it, by its number. + chains: HashMap>, + /// What each dictionary was found to decode to, or its limit when it couldn't be: an image + /// naming it may undo its filters again, which costs as much. + sizes: HashMap, + /// How many values were read for their names rather than remembered. + pub(crate) read: usize, + /// How many objects were fetched. + pub(crate) fetched: usize, +} + +impl Run { + pub(crate) fn new(budget: usize) -> Self { + Run { budget, spent: 0, reduced: HashMap::new(), names: HashMap::new(), chains: HashMap::new(), sizes: HashMap::new(), read: 0, fetched: 0 } + } + + /// Counts `units` against the budget. + fn spend(&mut self, units: usize) -> Result<(), crate::RedactError> { + self.spent = self.spent.saturating_add(units); + if self.spent > self.budget { Err(crate::RedactError::SharedJbig2Budget) } else { Ok(()) } + } + + /// Fetches the object `r` names, counted. + fn fetch(&mut self, doc: &Document, r: pdfcraft_cos::ObjRef) -> Result, crate::RedactError> { + self.spend(1)?; + self.fetched += 1; + Ok(doc.get(r)) + } + + /// Fetches the object of `r`'s number, whatever generation `r` gives, counted. Cos reads a + /// reference whose generation isn't its object's as null (#1143), but a reader that doesn't + /// compare generations draws with the object: what an image that left names this way may hold + /// its shapes, so it is named, as what names it this way is a user of it. + fn fetch_numbered(&mut self, doc: &Document, r: pdfcraft_cos::ObjRef) -> Result, crate::RedactError> { + self.fetch(doc, pdfcraft_cos::ObjRef::new(r.num, doc.generation(r.num))) + } + + /// What `v` leads to through references, as `followed` goes, kept as `read` makes it. Each + /// reference is looked up before it is fetched, and what it leads to is remembered under every + /// one on the way, so one remembered costs nothing more, and a chain only the references before + /// it. A reference of another generation is looked up as itself, and read as cos reads it. A + /// ring, or a chain past the limit, is read as the reference it ends at. + fn follow( + &mut self, + doc: &Document, + v: &Object, + how: How, + read: impl FnOnce(&mut Self, &Document, &Object) -> Result, + ) -> Result, crate::RedactError> { + let mut keys: Vec<(pdfcraft_cos::ObjRef, How)> = Vec::new(); + let mut fetched: Option> = None; + for _ in 0..=MAX_HOPS { + let Some(r) = fetched.as_deref().unwrap_or(v).as_ref() else { break }; + if let Some(hit) = self.reduced.get(&(r, how)).cloned() { + for k in keys { + self.reduced.insert(k, hit.clone()); + } + return Ok(hit); + } + keys.push((r, how)); + fetched = Some(self.fetch(doc, r)?); + } + let made = Rc::new(read(self, doc, fetched.as_deref().unwrap_or(v))?); + for k in keys { + self.reduced.insert(k, made.clone()); + } + Ok(made) + } + + /// What `s` names as `/JBIG2Globals`, and each reference on the way to one: in any entry of its + /// parameters, whichever filter that entry belongs to and however the filters are given, one + /// list for each key that gives it parameters. A reader may use less of this, but leaving a + /// dictionary behind is the failure that matters here. + pub(crate) fn names_of(&mut self, doc: &Document, s: &Stream) -> Result, crate::RedactError> { + // `/FDecodeParms` too: those of data in another file, which a reader may draw instead. + [b"DecodeParms".as_slice(), b"DP", b"FDecodeParms"] + .into_iter() + .filter_map(|key| s.dict.get(key)) + .map(|v| self.names_in(doc, v, true)) + .collect() + } + + /// What `v` names: one entry, or (`list`) an array of them or one. + fn names_in(&mut self, doc: &Document, v: &Object, list: bool) -> Result { + let mut keys: Vec = Vec::new(); + let mut fetched: Option> = None; + for _ in 0..=MAX_HOPS { + let Some(r) = fetched.as_deref().unwrap_or(v).as_ref() else { break }; + let key = (r.num, list); + if let Some((hit, at)) = self.names.get(&key).cloned() { + for k in keys { + self.names.insert(k, (hit.clone(), at)); + } + return Ok((hit, Some(at))); + } + keys.push(key); + fetched = Some(self.fetch_numbered(doc, r)?); + } + self.read += 1; + let held = fetched.as_deref().unwrap_or(v); + let names: Rc<[u32]> = match (held, list) { + (Object::Array(entries), true) => { + // A set as it is read, so many entries naming the same holds them once. + let mut all = std::collections::BTreeSet::new(); + for e in entries { + all.extend(self.names_in(doc, e, false)?.0.iter()); + } + all.into_iter().collect() + } + (entry, _) => self.entry_names(doc, entry)?.into(), + }; + // Remembered when the references end at an object, under the last of them, and counted + // against the budget as held; a ring, or a chain past the limit, is read again where met. + let at = keys.last().copied().filter(|_| !matches!(held, Object::Ref(_))); + if let Some(at) = at { + self.spend(names.len())?; + for k in keys { + self.names.insert(k, (names.clone(), at)); + } + } + Ok((names, at)) + } + + /// What one parameters entry names as `/JBIG2Globals`, and each reference on the way to it. + fn entry_names(&mut self, doc: &Document, entry: &Object) -> Result, crate::RedactError> { + match entry.as_dict().and_then(|d| d.get(b"JBIG2Globals")).and_then(Object::as_ref) { + Some(start) => Ok(self.chain(doc, start)?.to_vec()), + None => Ok(Vec::new()), + } + } + + /// `start` and each reference after it, up to the limit, remembered under `start` for the run, + /// so entries naming it, directly or not, fetch it once however many there are; a chain that + /// reaches the start of one already remembered takes what is known from there. + fn chain(&mut self, doc: &Document, start: pdfcraft_cos::ObjRef) -> Result, crate::RedactError> { + if let Some(hit) = self.chains.get(&start.num) { + return Ok(hit.clone()); + } + let mut found = Vec::new(); + let mut at = start; + for _ in 0..=MAX_HOPS { + if let Some(rest) = self.chains.get(&at.num) { + found.extend(rest.iter()); + break; + } + found.push(at.num); + match &*self.fetch_numbered(doc, at)? { + Object::Ref(next) => at = *next, + _ => break, + } + } + found.truncate(MAX_HOPS + 1); + let found: Rc<[u32]> = found.into(); + self.spend(found.len())?; + self.chains.insert(start.num, found.clone()); + Ok(found) + } + + /// A size through any references: a number, or null. + fn number(&mut self, doc: &Document, v: &Object) -> Result { + let read = |_: &mut Self, _: &Document, o: &Object| Ok(if matches!(o, Object::Int(_) | Object::Real(_)) { o.clone() } else { Object::Null }); + Ok((*self.follow(doc, v, How::Number, read)?).clone()) + } + + /// A `/Filter` through any references, as a decoder reads it: a name a decoder knows, or an array + /// of them, each entry through references too. Anything else is null, a chain longer than any + /// decoder takes (`MAX_CHAIN`, and a `/Crypt`) included, so a stream that names it is read as one + /// with no filter. + fn filter(&mut self, doc: &Document, v: &Object) -> Result { + // A name a decoder knows; any other is no filter, and isn't copied, however long. + fn name(_: &mut Run, _: &Document, o: &Object) -> Result { + let known = |n: &[u8]| n.len() <= 16 && !matches!(pdfcraft_filters::Filter::from_name(n), pdfcraft_filters::Filter::Unknown(_)); + Ok(match o { + Object::Name(n) if known(n) => o.clone(), + _ => Object::Null, + }) + } + let read = |run: &mut Self, doc: &Document, o: &Object| match o { + Object::Array(a) if a.len() > pdfcraft_filters::MAX_CHAIN + 1 => Ok(Object::Null), + Object::Array(a) => { + let mut names = Vec::with_capacity(a.len()); + for e in a { + names.push((*run.follow(doc, e, How::Name, name)?).clone()); + } + Ok(Object::Array(names)) + } + other => name(run, doc, other), + }; + Ok((*self.follow(doc, v, How::Filter, read)?).clone()) + } + + /// `/DecodeParms` as a reader takes them, through any references, for `filters` filters: of each + /// entry, the numbers and flags the decoders read ([`DECODE_PARMS_KEYS`]), and `/JBIG2Globals` when + /// it is a reference (a direct value is no stream, so no decoder reads it, and it may be large). + /// An entry that isn't a dictionary is null, as `Document::with_direct_parms` reads them. + fn parms(&mut self, doc: &Document, v: &Object, filters: usize) -> Result { + fn scalar(_: &mut Run, _: &Document, o: &Object) -> Result { + Ok(if matches!(o, Object::Int(_) | Object::Real(_) | Object::Bool(_)) { o.clone() } else { Object::Null }) + } + fn entry(run: &mut Run, doc: &Document, o: &Object) -> Result { + let Object::Dict(d) = o else { return Ok(Object::Null) }; + let mut made = Dict::new(); + for key in DECODE_PARMS_KEYS { + let Some(value) = d.get(key) else { continue }; + let value = run.follow(doc, value, How::Scalar, scalar)?; + if !matches!(*value, Object::Null) { + made.set(key.to_vec(), (*value).clone()); + } + } + if let Some(globals @ Object::Ref(_)) = d.get(b"JBIG2Globals") { + made.set(b"JBIG2Globals".to_vec(), globals.clone()); + } + Ok(Object::Dict(made)) + } + let read = move |run: &mut Self, doc: &Document, o: &Object| match o { + Object::Array(a) => { + let mut entries = Vec::new(); + for e in a.iter().take(filters) { + entries.push((*run.follow(doc, e, How::Entry, entry)?).clone()); + } + Ok(Object::Array(entries)) + } + other => entry(run, doc, other), + }; + Ok((*self.follow(doc, v, How::Parms(filters), read)?).clone()) + } + + /// Whether `s`'s data is in another file, which a reader may draw instead of what is here: a `/F` + /// that isn't null, through any references (a null entry is as if absent, and a reference to no + /// object is null, ISO 32000-2 §7.3.7 and §7.3.10). A ring of references counts as a file. + fn elsewhere(&mut self, doc: &Document, s: &Stream) -> Result { + let Some(f) = s.dict.get(b"F") else { return Ok(false) }; + let read = |_: &mut Self, _: &Document, o: &Object| Ok(Object::Bool(!matches!(o, Object::Null))); + Ok(matches!(*self.follow(doc, f, How::Present, read)?, Object::Bool(true))) + } + + /// Whether `s`'s colour space is known to have more than one component, through any references: + /// a JBIG2 image is drawn as one whatever its dictionary says, so such an image read as one bit + /// would show otherwise. An unknown colour space isn't known to have more. + fn several_components(&mut self, doc: &Document, s: &Stream) -> Result { + let Some(cs) = s.dict.get(b"ColorSpace") else { return Ok(false) }; + fn profile(_: &mut Run, _: &Document, o: &Object) -> Result { + // /N lives in the ICC profile stream, and shall be 1, 3 or 4 (ISO 32000-2 §8.6.5.5). + let n = match o { + Object::Stream(icc) => icc.dict.int(b"N").filter(|n| matches!(n, 1 | 3 | 4)), + _ => None, + }; + Ok(n.map_or(Object::Null, Object::Int)) + } + let read = |run: &mut Self, doc: &Document, o: &Object| { + let name = match o { + Object::Name(n) => n.as_slice(), + Object::Array(a) => match a.first().and_then(Object::as_name) { + Some(n) => n, + None => return Ok(Object::Bool(false)), + }, + _ => return Ok(Object::Bool(false)), + }; + Ok(Object::Bool(match name { + b"DeviceRGB" | b"RGB" | b"CalRGB" | b"Lab" | b"DeviceCMYK" | b"CMYK" => true, + b"ICCBased" => match o.as_array().and_then(|a| a.get(1)) { + Some(entry) => run.follow(doc, entry, How::Profile, profile)?.as_int().is_some_and(|n| n > 1), + None => false, + }, + _ => false, + })) + }; + Ok(matches!(*self.follow(doc, cs, How::Components, read)?, Object::Bool(true))) + } + + /// `s` with what `decoded_image` and `decoded_within` read as given made direct: its size, + /// filters and their parameters, each kept small, however often the file refers to a large object. + pub(crate) fn readable(&mut self, doc: &Document, s: &Stream) -> Result { + let mut readable = s.clone(); + for key in [b"Width".as_slice(), b"Height"] { + if let Some(v) = s.dict.get(key) { + let number = self.number(doc, v)?; + readable.dict.set(key.to_vec(), number); + } + } + if let Some(v) = s.dict.get(b"Filter") { + let filter = self.filter(doc, v)?; + readable.dict.set(b"Filter".to_vec(), filter); + } + if let Some(v) = s.dict.get(b"DecodeParms") { + let filters = match readable.dict.get(b"Filter") { + Some(Object::Array(a)) => a.len(), + Some(Object::Name(_)) => 1, + _ => 0, + }; + let parms = self.parms(doc, v, filters)?; + readable.dict.set(b"DecodeParms".to_vec(), parms); + } + Ok(readable) + } + + /// Counts what was decoded: a unit, and one more for each whole 4 KiB. + pub(crate) fn decoded(&mut self, bytes: usize) -> Result<(), crate::RedactError> { + self.spend(bytes / 4096 + 1) + } +} + +/// What dictionary `num` holds, to know a copy of it by: a file put together from pages split apart +/// has one per page, each byte for byte the others. +/// +/// Decoded within `images`, the run's image allowance, which pays for what every filter stage reads +/// and makes: what the last one makes says nothing of the work (a predictor over what three Flates +/// inflate, which hex then skips as white space, ends at nothing). Past it the run is refused +/// ([`RedactError::ImagesPastAllowance`](crate::RedactError::ImagesPastAllowance)). +pub(crate) fn content(run: &mut Run, doc: &Document, num: u32, images: &mut ImageBudget) -> Result, crate::RedactError> { + let Ok(object) = doc.try_get(num) else { return Ok(None) }; + let Object::Stream(s) = &*object else { return Ok(None) }; + let readable = run.readable(doc, s)?; + let refused = images.refused(); + let data = images.decode_filters(doc, &readable, MAX_JBIG2_GLOBALS); + if data.is_err() && images.refused() > refused { + return Err(crate::RedactError::ImagesPastAllowance); + } + let mut hash = DefaultHasher::new(); + match &data { + Ok(data) => { + run.decoded(data.len())?; + run.sizes.insert(num, data.len()); + data.hash(&mut hash); + } + // What it took to find out it can't be read: as much as it was allowed to decode. + Err(_) => { + run.decoded(MAX_JBIG2_GLOBALS)?; + run.sizes.insert(num, MAX_JBIG2_GLOBALS); + s.raw[..].hash(&mut hash); + } + } + Ok(Some(hash.finish())) +} + +/// JBIG2 stream `s` stored alone, as a cleared copy is: what it shows unchanged and no dictionary +/// referred to. `None` for a stream that isn't JBIG2, whose decoder took no dictionary and whose +/// samples another filter may give otherwise; for one past the size of an image redaction clears, +/// or known to have more than one component, which JBIG2 is drawn as one of whatever the dictionary +/// says; for one whose data is in another file (`/F`), which readers draw instead of what is here; +/// and for one that can't be decoded. +pub(crate) fn without_dictionary(run: &mut Run, doc: &Document, s: &Stream, images: &mut ImageBudget) -> Result, crate::RedactError> { + if run.elsewhere(doc, s)? { + return Ok(None); + } + let readable = run.readable(doc, s)?; + let jbig2 = match readable.dict.get(b"Filter") { + Some(Object::Array(a)) => a.iter().any(|f| f.as_name() == Some(b"JBIG2Decode".as_slice())), + Some(f) => f.as_name() == Some(b"JBIG2Decode".as_slice()), + None => false, + }; + if !jbig2 || run.several_components(doc, s)? { + return Ok(None); + } + let side = |key: &[u8]| readable.dict.int(key).and_then(|v| usize::try_from(v).ok()); + let (Some(w), Some(h)) = (side(b"Width"), side(b"Height")) else { return Ok(None) }; + if (w as u64).saturating_mul(h as u64) > crate::image::MAX_PIXELS { + return Ok(None); + } + // As for `clear`: the filters before the JBIG2 decoder may give up to three bytes for each one + // of the raster and a byte a row. + let Some(allowance) = + w.div_ceil(8).checked_mul(h).and_then(|n| n.checked_add(h)).and_then(|n| n.checked_mul(3)).and_then(|n| n.checked_add(4096)) + else { + return Ok(None); + }; + // Decoding it undoes the filters of each dictionary it names again when cos doesn't hold their + // bytes: charged as much as each was found to decode to. + let mut named = std::collections::BTreeSet::new(); + for (list, _) in run.names_of(doc, s)? { + named.extend(list.iter().copied()); + } + let undone = named.iter().filter_map(|n| run.sizes.get(n)).fold(0usize, |sum, size| sum.saturating_add(*size)); + if undone > 0 { + run.decoded(undone)?; + } + // Through the run's image allowance, which the decoder's own work, the dictionary's filters + // when they are undone again and the pixels are paid from. Past it the run is refused, as one + // clearing too many images is. + let refused = images.refused(); + let data = match images.decode(doc, &readable, allowance) { + Ok(data) => data, + Err(_) if images.refused() > refused => return Err(crate::RedactError::ImagesPastAllowance), + Err(_) => { + // What it took to find out it can't be read: as much as it was allowed to decode. + run.decoded(allowance)?; + return Ok(None); + } + }; + run.decoded(data.len())?; + let mut dict = readable.dict.clone(); + // `/FFilter` and `/FDecodeParms` too: with no data in another file they mean nothing, but + // `names_of` reads them. + for key in [b"Length".as_slice(), b"DecodeParms", b"DP", b"FFilter", b"FDecodeParms"] { + dict.remove(key); + } + dict.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + Ok(Some(crate::image::smallest(dict, &data, w, h, true, true))) +} + +/// Whether dictionary `n` is one that left (`left`), or a copy of one (`contents`); what each +/// object holds is found once a run (`read`). +fn matches_left( + run: &mut Run, + doc: &Document, + n: u32, + left: &HashSet, + contents: &HashSet, + read: &mut HashMap>, + images: &mut ImageBudget, +) -> Result { + if left.contains(&n) { + return Ok(true); + } + let held = match read.get(&n) { + Some(held) => *held, + None => { + let held = content(run, doc, n, images)?; + read.insert(n, held); + held + } + }; + Ok(held.is_some_and(|c| contents.contains(&c))) +} + +/// Takes the `/JBIG2Globals` dictionaries of what left the document since `before` ([`kept`]) out of +/// the file: everything a full save would keep that names one, or a copy of one, is stored again +/// without it, in place, so what refers to that stream stays as it is. Gives the number stored +/// again. A copy is found byte for byte; another dictionary that happens to hold the same shapes isn't. +/// +/// What left, not what a run says it removed: an image goes with a stamp, a widget, a Type 3 glyph, +/// an `/Alternates` entry or a form that can't be read, none of which it was drawn through directly. +/// An image another page still draws hasn't left: it keeps its pixels, so its dictionary keeps nothing +/// more. +/// +/// What it reads and decodes is counted against [`RUN_BUDGET`] for the whole run, and each +/// dictionary and image is decoded within `images`, the run's image allowance. +/// +/// Errors: [`RedactError::SharedJbig2Globals`](crate::RedactError::SharedJbig2Globals) when a dictionary would +/// still be kept, as what names it can't be stored without it (counted) or something else refers to +/// it (0 when that is all); [`RedactError::SharedJbig2Budget`](crate::RedactError::SharedJbig2Budget) when +/// finding that would spend more than the budget; [`RedactError::ImagesPastAllowance`](crate::RedactError::ImagesPastAllowance) +/// when decoding the dictionaries and images would spend more than `images` has left. Such an image may still be drawn, on a page no mark +/// reached, so it isn't removed, and `doc` is part changed by then: a caller works on a copy it can drop. +pub(crate) fn unshare(doc: &mut Document, before: &HashSet, images: &mut ImageBudget) -> Result { + let now = kept(doc)?; + let mut run = Run::new(RUN_BUDGET); + let mut dictionaries: HashSet = HashSet::new(); + // A list the file refers to is added once, however many streams share it. + let mut added: HashSet = HashSet::new(); + for &num in before.difference(&now) { + if let Ok(object) = doc.try_get(num) + && let Object::Stream(s) = &*object + { + for (list, key) in run.names_of(doc, s)? { + if key.is_none_or(|k| added.insert(k)) { + dictionaries.extend(list.iter()); + } + } + } + } + if dictionaries.is_empty() { + return Ok(0); + } + let mut contents: HashSet = HashSet::new(); + for &n in &dictionaries { + if let Some(c) = content(&mut run, doc, n, images)? { + contents.insert(c); + } + } + let mut read: HashMap> = HashMap::new(); + // Whether a name matches doesn't depend on order: it is a dictionary that left, or a copy of one + // (what is added below is only ever such a copy). So what a shared list matches is found once. + let left = dictionaries.clone(); + let mut matches: HashMap> = HashMap::new(); + added.clear(); + let mut users = Vec::new(); + for (r, object) in doc.scan_objects() { + let Object::Stream(s) = &*object else { continue }; + if !now.contains(&r.num) { + continue; + } + // Only what matched has to leave, a copy found here included. Another dictionary this also + // names was shared with nothing that left: what else uses it may keep it, whichever comes + // first in the file. + let mut uses = false; + for (list, key) in run.names_of(doc, s)? { + let matched = match key.and_then(|k| matches.get(&k)) { + Some(hit) => hit.clone(), + None => { + let mut found = Vec::new(); + for &n in list.iter() { + if matches_left(&mut run, doc, n, &left, &contents, &mut read, images)? { + found.push(n); + } + } + let found: Rc<[u32]> = found.into(); + if let Some(k) = key { + run.spend(found.len())?; + matches.insert(k, found.clone()); + } + found + } + }; + if !matched.is_empty() { + uses = true; + if key.is_none_or(|k| added.insert(k)) { + dictionaries.extend(matched.iter()); + } + } + } + if uses { + users.push(r); + } + } + let (mut stored_again, mut stuck) = (0, 0); + for &r in &users { + let object = doc.get(r); + let Object::Stream(s) = &*object else { continue }; + match without_dictionary(&mut run, doc, s, images)? { + Some(stored) => { + // In place, with both generations of a slot whose cross-reference row and object + // header disagree, which cos reads either way (#1143): `set` would keep only `r`'s. + doc.set_existing(r, Object::Stream(stored))?; + stored_again += 1; + } + // A dictionary that names itself isn't something that uses one. + None if dictionaries.contains(&r.num) => {} + None => stuck += 1, + } + } + let after = kept(doc)?; + if dictionaries.iter().any(|n| after.contains(n)) { + return Err(crate::RedactError::SharedJbig2Globals(stuck)); + } + // What couldn't be stored has left with the dictionaries, a dictionary that names itself say: + // it isn't counted. + Ok(stored_again) +} diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs index a99e179895..8578ca32cd 100644 --- a/crates/redact/src/image.rs +++ b/crates/redact/src/image.rs @@ -11,11 +11,12 @@ use pdfcraft_content::{Matrix, overlaps}; use pdfcraft_cos::{Dict, Document, ImageBudget, Object, Stream}; /// Most pixels an image may have to be cleared in place (larger ones are removed), or to be stored -/// again without shared `/JBIG2Globals` (see [`without_globals`]). +/// again without shared `/JBIG2Globals` (see [`crate::globals::without_dictionary`]). pub(crate) const MAX_PIXELS: u64 = pdfcraft_cos::MAX_REDACTED_IMAGE_PIXELS; -fn components(doc: &Document, s: &Stream) -> Option { - let cs = doc.resolve(s.dict.get(b"ColorSpace")?); +/// The components of image `s`'s colour space, through any references (`None` when unknown). +pub(crate) fn components(doc: &Document, s: &Stream) -> Option { + let cs = crate::globals::followed(doc, s.dict.get(b"ColorSpace")?); let cs = &*cs; let name = match cs { Object::Name(n) => n.as_slice(), @@ -28,7 +29,7 @@ fn components(doc: &Document, s: &Stream) -> Option { b"DeviceCMYK" | b"CMYK" => 4, b"ICCBased" => { // /N lives in the ICC profile stream, and shall be 1, 3 or 4 (ISO 32000-2 §8.6.5.5). - match &*doc.resolve(cs.as_array()?.get(1)?) { + match &*crate::globals::followed(doc, cs.as_array()?.get(1)?) { Object::Stream(icc) => usize::try_from(icc.dict.int(b"N")?).ok().filter(|n| matches!(n, 1 | 3 | 4))?, _ => return None, } @@ -126,9 +127,9 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] data.truncate(bytes); // Image masks: a sample of 0 paints unless /Decode is [1 0]; cleared samples must not paint. let clear_bit = if mask { - // The array, and its first number, given by reference too. - let decode = s.dict.get(b"Decode").map(|d| doc.resolve(d)); - let first = decode.as_deref().and_then(Object::as_array).and_then(|d| d.first()).map(|v| doc.resolve(v)); + // The array, and its first number, given by reference too, through any references. + let decode = s.dict.get(b"Decode").map(|d| crate::globals::followed(doc, d)); + let first = decode.as_deref().and_then(Object::as_array).and_then(|d| d.first()).map(|v| crate::globals::followed(doc, v)); let inverted = first.as_deref().and_then(Object::as_f64) == Some(1.0); !inverted } else { @@ -170,19 +171,31 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] return Some(None); } let mut dict = s.dict.clone(); - dict.remove(b"Length"); - dict.remove(b"DecodeParms"); - dict.remove(b"Alternates"); + for key in [b"Length".as_slice(), b"DecodeParms", b"DP", b"Alternates"] { + dict.remove(key); + } + if !data_elsewhere(doc, s) { + // Parameters for data in another file mean nothing without `/F`, but may name a dictionary. + dict.remove(b"FFilter"); + dict.remove(b"FDecodeParms"); + } Some(Some(smallest(dict, &data, w, h, ncomp == 1 && bpc == 1, is_jbig2))) } +/// Whether `s`'s data is in another file, which a reader may draw instead of what is here: a `/F` +/// that isn't null, through any references (a null entry is as if absent, and a reference to no +/// object is null, ISO 32000-2 §7.3.7 and §7.3.10). A ring of references counts as a file. +pub(crate) fn data_elsewhere(doc: &Document, s: &Stream) -> bool { + s.dict.get(b"F").is_some_and(|f| !matches!(&*crate::globals::followed(doc, f), Object::Null)) +} + /// The cleared samples `data` of a `w` × `h` image as a stream with `dict`: Flate, or, for one bit /// of one component (`bilevel`, a scan as a rule, which Flate stores several times larger), CCITT /// Group 4, or JBIG2 when the image was JBIG2 (`jbig2`; JBIG2 is PDF 1.4, which such a file already /// is), whichever is smallest as stored, dictionary included (a tiny image can lose by Group 4's /// parameters). Both decode to `data`, cleared before it is coded, so nothing of the original's /// code is carried over; Group 4 or JBIG2 that can't code the rows (too wide) is left out. -fn smallest(dict: Dict, data: &[u8], w: usize, h: usize, bilevel: bool, jbig2: bool) -> Stream { +pub(crate) fn smallest(dict: Dict, data: &[u8], w: usize, h: usize, bilevel: bool, jbig2: bool) -> Stream { let flate = Stream::flate(dict.clone(), data); let Some((columns, rows)) = u32::try_from(w).ok().zip(u32::try_from(h).ok()).filter(|_| bilevel) else { return flate }; let g4 = pdfcraft_filters::encode_g4(data, columns, rows).ok().map(|coded| { @@ -209,38 +222,3 @@ fn stored_len(s: &Stream) -> usize { pdfcraft_cos::serialize(&Object::Dict(s.dict.clone()), &mut dict); dict.len().saturating_add(s.raw.len()) } - -/// JBIG2 image `s` stored again without its `/JBIG2Globals`: decoded through them, as it is drawn, -/// and written as the same samples, Flate, Group 4 or JBIG2 coded alone, whichever is smallest -/// (`smallest`), its other entries kept. `None` when it can't be decoded for certain (too large, -/// past what is left of `budget`, data that doesn't decode, a JBIG2 page of another size than the -/// image, or not a one-bit image of one component). Redaction does this to every image that -/// shares a redacted image's globals, since their symbol dictionary holds the shapes of marks drawn -/// under the area too. -pub(crate) fn without_globals(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Option { - let (w, h) = (s.dict.int(b"Width")?, s.dict.int(b"Height")?); - if w <= 0 || h <= 0 || (w as u64) * (h as u64) > MAX_PIXELS { - return None; - } - let (w, h) = (w as usize, h as usize); - let mask = matches!(s.dict.get(b"ImageMask"), Some(Object::Bool(true))); - // JBIG2 codes one-bit images of one component (ISO 32000-2 §7.4.7). - if !mask && (components(doc, s)?, s.dict.int(b"BitsPerComponent")?) != (1, 1) { - return None; - } - let bytes = w.div_ceil(8).checked_mul(h)?; - // As for `clear`: the filters before the JBIG2 decoder may give up to three bytes for each one - // of the raster and a byte a row. - let allowance = bytes.checked_add(h)?.checked_mul(3)?.checked_add(4096)?; - let mut data = budget.decode(doc, s, allowance).ok()?; - if data.len() < bytes { - return None; - } - data.truncate(bytes); - let mut dict = s.dict.clone(); - dict.remove(b"Length"); - // The globals are named in the parameters; the copy is coded alone, as `clear`'s are. - dict.remove(b"DecodeParms"); - dict.remove(b"Alternates"); - Some(smallest(dict, &data, w, h, true, true)) -} diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 40f07f1f42..1e69cb32e2 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -228,6 +228,12 @@ impl<'a> Scope<'a> { } } + /// What is left of the page's allowance once the content is walked, for the cleanups after it + /// that read the forms drawing through the page's resources ([`prune`]) to go on spending. + pub(crate) fn into_budget(self) -> DecodeBudget { + self.budget + } + /// Keep at most `room` bytes of removed text: what earlier pages left of the document's /// [`MAX_REMOVED_BYTES`]. pub fn with_removed_room(mut self, room: usize) -> Self { @@ -1096,7 +1102,9 @@ fn xobject( out.doomed.push(r); return Some(None); } - // Strict, like page content: a Form read only in part counts as undecodable. + // Strict, like page content: a Form read only in part counts as undecodable. With its + // `/DecodeParms` given by reference too, as the cleanups after the walk read it: read + // without them, it looks empty and is left as it was, drawing whatever lies under a mark. let data = if scope.budget.enter_form() { scope.budget.decode_strict(doc, s) } else { @@ -1173,11 +1181,11 @@ fn xobject( let mut cleaned_res = res.clone(); // Install rewritten nested forms before finding live property names: a new form may // still inherit its caller's resources, and its new Do name must resolve here. - if !inner.xobjects.is_empty() || !inner.replaced.is_empty() { + // `prune_form_resources` below then drops the XObjects this form no longer draws, the + // ones it replaced among them, keeping those a form drawing through these resources + // still draws. + if !inner.xobjects.is_empty() { let mut xo = res_dict(doc, &cleaned_res, b"XObject"); - if let Some(data) = &new_data { - prune(&mut xo, &inner.replaced, std::slice::from_ref(data)); - } for (name, reference) in &inner.xobjects { xo.set(name.clone(), Object::Ref(*reference)); } @@ -1185,20 +1193,20 @@ fn xobject( } if let Some(data) = &new_data { let content = std::slice::from_ref(data); - match prune_properties(doc, &mut cleaned_res, content) { + match prune_properties(doc, &mut scope.budget, &mut cleaned_res, content) { Ok(retired) => out.retired_properties.extend(retired), Err(error) => { out.error = Some(error); return None; } } - if let Err(error) = prune_form_resources(doc, &mut cleaned_res, content) { + if let Err(error) = prune_form_resources(doc, &mut scope.budget, &mut cleaned_res, content) { out.error = Some(error); return None; } // No page: annotation appearances draw through a page's resources, never through a // form's. The form's own soft masks are still counted. - if let Err(error) = crate::type3::prune_type3_from_resources(doc, &mut cleaned_res, content, None) { + if let Err(error) = crate::type3::prune_type3_from_resources(doc, &mut scope.budget, &mut cleaned_res, content, None) { out.error = Some(error); return None; } diff --git a/crates/redact/src/interp/prune.rs b/crates/redact/src/interp/prune.rs index 2bfae6391f..39089601ec 100644 --- a/crates/redact/src/interp/prune.rs +++ b/crates/redact/src/interp/prune.rs @@ -3,49 +3,81 @@ use super::{ is_vertical, num, overlaps, page_streams, res_dict, }; -/// Drop every `replaced` resource name whose `Do` no longer appears in the rewritten `content`, -/// so a full-rewrite save stops reaching the original image or form it named. A name that some -/// surviving `Do` still draws (the same image shown again outside a mark) is kept. -pub(crate) fn prune(xo: &mut Dict, replaced: &[Vec], content: &[Vec]) { +/// Drop every `replaced` name of the XObject resources `xo` that the rewritten `content` no +/// longer draws, so a full-rewrite save stops reaching the original image or form it named. A +/// name still drawn is kept: by a surviving `Do` (the same image shown again outside a mark), or +/// by a form without /Resources of its own, which draws through `xo` too. Such forms are read +/// within `budget`, what is left of the page's allowance ([`resource_ops`]). Fails, as the cleanup +/// of the fonts and properties these forms draw with does, when that can't be told: such a form +/// doesn't decode (an image codec or a /Filter it can't run, say) or runs past `budget`, the walk +/// passes 2,000,000 operators, or the parser can't read all of such a form or of `content`. +/// Corrupt data in such a form is read, tolerantly, up to the damage, which may be nothing. +pub(crate) fn prune(doc: &Document, budget: &mut DecodeBudget, xo: &mut Dict, replaced: &[Vec], content: &[Vec]) -> Result<(), CosError> { if replaced.is_empty() { - return; + return Ok(()); } - let joined = Pieces::join(content); - let ops = joined.parse().ops; - let still_drawn = |n: &[u8]| ops.iter().any(|op| op.is("Do") && op.last_name() == Some(n)); + let res = Dict::from_iter([(b"XObject".to_vec(), Object::Dict(xo.clone()))]); + let ops = resource_ops(doc, budget, &res, content)?; + let drawn: HashSet<&[u8]> = ops.iter().filter(|op| op.is("Do")).filter_map(Op::last_name).collect(); for n in replaced { - if !still_drawn(n) { + if !drawn.contains(n.as_slice()) { xo.remove(n); } } + Ok(()) } /// The XObject names of the resource dictionary `xo` that `content` draws: directly, or through /// a form that has no /Resources of its own (such a form draws through its caller's). Each such -/// form is read once, however many names are asked about. The flag says whether some of what -/// `content` draws could not be read (a form that will not decode, or nesting past the limit), so -/// a name missing from the set may still be drawn. +/// form is read once, however many names are asked about, with its `/DecodeParms` given by +/// reference. The flag says whether some of what `content` draws could not be read (a form that +/// will not decode, or nesting past the limit), so a name missing from the set may still be drawn. +/// Operators are read one at a time, since only the `Do`s matter: no stream's are held whole, and +/// each form is decoded only in its turn. fn names_content_draws(doc: &Document, content: &[Vec], xo: &Dict) -> (HashSet>, bool) { let mut drawn = HashSet::new(); let mut seen = HashSet::new(); - let mut pending = vec![(Pieces::join(content).parse().ops, 0)]; + let joined = Pieces::join(content); + // What is still to read, each with its depth: `content` itself (`None`), then the forms it draws. + let mut pending: Vec<(Option, usize)> = vec![(None, 0)]; // A form that will not decode makes the answer uncertain, but it does not stop the forms // already queued from being read: a name drawn only inside one of those need not be unknown. let mut unknown = false; - while let Some((ops, depth)) = pending.pop() { - for n in ops.iter().filter(|op| op.is("Do")).filter_map(Op::last_name) { + while let Some((form, depth)) = pending.pop() { + let data = match form { + None => std::borrow::Cow::Borrowed(joined.data()), + Some(r) => { + let obj = doc.get(r); + let Object::Stream(s) = &*obj else { continue }; + match doc.with_direct_parms(s).decoded() { + Ok(data) => std::borrow::Cow::Owned(data), + Err(_) => { + unknown = true; + continue; + } + } + } + }; + for op in pdfcraft_content::ops(&data).filter(|op| op.is("Do")) { + let Some(n) = op.last_name() else { continue }; if !drawn.insert(n.to_vec()) { continue; } let Some(r) = xo.get(n).and_then(Object::as_ref) else { continue }; let obj = doc.get(r); let Object::Stream(s) = &*obj else { continue }; - if s.dict.name(b"Subtype") != Some(b"Form") || s.dict.contains(b"Resources") || !seen.insert(r) { + // A /Resources that isn't a dictionary (null, or a missing object) is no resources of + // its own, as for `resource_ops` and the interpreter. + if s.dict.name(b"Subtype") != Some(b"Form") + || s.dict.get(b"Resources").is_some_and(|r| doc.resolve(r).as_dict().is_some()) + || !seen.insert(r) + { continue; } - match s.decoded() { - Ok(data) if depth < MAX_DEPTH => pending.push((pdfcraft_content::parse(&data).ops, depth + 1)), - _ => unknown = true, + if depth < MAX_DEPTH { + pending.push((Some(r), depth + 1)); + } else { + unknown = true; } } } @@ -322,7 +354,8 @@ fn prune_retired_properties(doc: &Document, res: &mut Dict, names: &[Vec], c } let mut used = HashSet::new(); for content in contents { - for op in resource_ops(doc, res, content)? { + // Each is one page's content or one form's, with the allowance of a page. + for op in resource_ops(doc, &mut DecodeBudget::page(), res, content)? { if matches!(op.op.as_slice(), b"BDC" | b"DP") && let Some(name) = op.name(1) { @@ -383,7 +416,7 @@ fn unlink_from_forms(doc: &mut Document, holders: &[ObjRef], doomed: &HashSet Op Some(ops) } -pub(crate) fn resource_ops(doc: &Document, res: &Dict, content: &[Vec]) -> Result, CosError> { - let parsed = Pieces::join(content).parse(); - if parsed.skipped > 0 || parsed.ops.len() > 2_000_000 { - return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect property resources".into() }); +/// The operators of `content`, and of every form without /Resources of its own that it draws +/// through `res`, nested ones too: what draws with these resources. Each such form is read once, +/// with its `/DecodeParms` given by reference, its decoded bytes charged to `budget`, so the +/// cleanups of one page that walk the same forms spend one allowance between them. An error +/// when that can't all be read: the parser can't read all of `content` or of such a form, the walk +/// passes 2,000,000 operators, or such a form doesn't decode or runs past `budget`. Operators are +/// read one at a time, so the walk stops at one past the limit rather than holding them all. +pub(crate) fn resource_ops(doc: &Document, budget: &mut DecodeBudget, res: &Dict, content: &[Vec]) -> Result, CosError> { + const MAX_OPS: usize = 2_000_000; + let joined = Pieces::join(content); + let mut read = pdfcraft_content::ops(joined.data()); + let mut ops: Vec = read.by_ref().take(MAX_OPS + 1).collect(); + // Under the limit the reader ran to the end, so `skipped` counts all of it. + if read.skipped() > 0 || ops.len() > MAX_OPS { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully read the content that uses these resources".into() }); } - let mut ops = parsed.ops; let xobjects = res_dict(doc, res, b"XObject"); let mut seen = HashSet::new(); let mut cursor = 0; @@ -455,24 +498,26 @@ pub(crate) fn resource_ops(doc: &Document, res: &Dict, content: &[Vec]) -> R && s.dict.name(b"Subtype") == Some(b"Form") && s.dict.get(b"Resources").is_none_or(|r| doc.resolve(r).as_dict().is_none()) { - let parsed = pdfcraft_content::parse(&s.decoded()?); - if seen.len() > 2_000_000 || ops.len().saturating_add(parsed.ops.len()) > 2_000_000 || parsed.skipped > 0 { + let data = budget.decode(&doc.with_direct_parms(s))?; + let mut read = pdfcraft_content::ops(&data); + ops.extend(read.by_ref().take((MAX_OPS + 1).saturating_sub(ops.len()))); + // `seen` gains at most one form for each `Do` in `ops`, so this bounds it too. + if ops.len() > MAX_OPS || read.skipped() > 0 { return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect inherited form resources".into() }); } - ops.extend(parsed.ops); } cursor += 1; } Ok(ops) } -pub(crate) fn prune_properties(doc: &Document, res: &mut Dict, content: &[Vec]) -> Result, CosError> { +pub(crate) fn prune_properties(doc: &Document, budget: &mut DecodeBudget, res: &mut Dict, content: &[Vec]) -> Result, CosError> { let mut properties = res_dict(doc, res, b"Properties"); if properties.is_empty() { return Ok(Vec::new()); } let mut retired = Vec::new(); - let ops = resource_ops(doc, res, content)?; + let ops = resource_ops(doc, budget, res, content)?; let names: Vec> = properties.iter().map(|(n, _)| n.to_vec()).collect(); for name in names { if !ops.iter().any(|op| matches!(op.op.as_slice(), b"BDC" | b"DP") && op.name(1) == Some(&name)) @@ -489,8 +534,8 @@ pub(crate) fn prune_properties(doc: &Document, res: &mut Dict, content: &[Vec]) -> Result<(), CosError> { - let ops = resource_ops(doc, res, content)?; +pub(super) fn prune_form_resources(doc: &Document, budget: &mut DecodeBudget, res: &mut Dict, content: &[Vec]) -> Result<(), CosError> { + let ops = resource_ops(doc, budget, res, content)?; let mut xo = res_dict(doc, res, b"XObject"); let names: Vec> = xo.iter().map(|(n, _)| n.to_vec()).collect(); for name in names { diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 8846b7a501..7c1a65f55a 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -13,7 +13,9 @@ //! under a region the whole operation fails (callers keep the previous document: fail-closed). //! Finally, document metadata (`/Info` entries, XMP) that repeats the removed text is removed. //! Places it leaves as they are (bookmark titles, attachments, remaining comments, other XMP) -//! are only counted when they still hold that text ([`Report::still_holding`]). +//! are only counted when they still hold that text ([`Report::still_holding`]). Last, a shared +//! JBIG2 symbol dictionary (`/JBIG2Globals`) that anything gone by then used leaves the file too: +//! what still names it is stored again without it, or the redaction is refused. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -23,6 +25,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; pub mod codes; mod elsewhere; +mod globals; mod identification; mod image; mod interp; @@ -96,12 +99,22 @@ pub enum RedactError { /// take what no mark covers with it, unreported. #[error("the images under these marks are more than one redaction can decode, so nothing was changed; redact fewer pages at a time")] ImagesPastAllowance, - /// A JBIG2 image shares its `/JBIG2Globals` with a redacted one and can't be stored again - /// without them, so the shapes they hold, those of marks under the area included, would stay. - #[error( - "{0} JBIG2 image(s) share their symbol data with a redacted image and can't be decoded to be stored without it, so that data, which can show what is under the marks, would stay in the file; nothing was redacted" - )] + /// `/JBIG2Globals` used by something removed would stay: something that names them can't be + /// stored again without them, or something else refers to them. Either way the shapes they + /// hold, those of marks under the area included, would stay. Counts what can't be stored without + /// them: 0 when only something else keeps them. + #[error("{}", shared_jbig2_text(*.0))] SharedJbig2Globals(usize), + /// Finding what shares JBIG2 symbol data with what is being removed would read more of the file + /// than one run allows (`globals::RUN_BUDGET`): its images and parameters refer to the same + /// objects in more ways than are checked, or hold more than is decoded. Removing hidden + /// information says so too when the dictionaries it reads to know copies, or the images it + /// stores again, would decode more than its image allowance, which no choice of pages makes + /// smaller. + #[error( + "finding and storing again what shares JBIG2 symbol data with something being removed would read more of this file than one run allows, so it can't be shown that the data leaves with it; this was refused" + )] + SharedJbig2Budget, #[error(transparent)] Edit(#[from] pdfcraft_edit::EditError), #[error(transparent)] @@ -110,6 +123,15 @@ pub enum RedactError { Cos(#[from] pdfcraft_cos::CosError), } +/// [`RedactError::SharedJbig2Globals`]'s text, which says what keeps the data. +fn shared_jbig2_text(stuck: usize) -> String { + let why = match stuck { + 0 => "something else in the file refers to JBIG2 symbol data shared with something being removed".to_string(), + n => format!("{n} item(s) share JBIG2 symbol data with something being removed and can't be stored without it"), + }; + format!("{why}, so that data, which can show what was removed, would stay in the file; this was refused") +} + /// A redaction mark on a page. #[derive(Clone, Debug, PartialEq)] pub struct Mark { @@ -167,7 +189,7 @@ pub struct Report { pub images_removed: usize, pub images_cleared: usize, /// JBIG2 images, anywhere in the document, stored again (as a cleared copy is) without the - /// `/JBIG2Globals` they shared with a redacted image, so those globals leave the file. + /// `/JBIG2Globals` they shared with something this removed, so those globals leave the file. pub images_restored: usize, pub paths_removed: usize, pub paths_clipped: usize, @@ -484,6 +506,7 @@ fn apply_within(doc: &mut Document, pages: Option<&[usize]>, images: pdfcraft_co pdfcraft_fonts::undrawable_message(&format!("the overlay text of a redaction mark on page {}", m.page + 1), c) ))); } + let kept_before = globals::kept(doc)?; let mut report = Report { marks: chosen.len(), ..Report::default() }; // `marks` visits pages in order. Keep slices of that order for appearance generation, // content rewriting and verification instead of scanning all marks for every page. @@ -645,7 +668,6 @@ fn apply_within(doc: &mut Document, pages: Option<&[usize]>, images: pdfcraft_co interp::unlink_doomed(doc, &doomed_xobjects, &retired_properties)?; // Likewise a pruned pattern: a /Pages node above the page can still name its cell. interp::unlink_pruned_patterns(doc, &pruned_patterns)?; - report.images_restored = restore_shared_jbig2(doc, &doomed_xobjects, &mut images)?; // 5. Verify: nothing readable may remain under a region. let mut residue = 0; @@ -694,6 +716,10 @@ fn apply_within(doc: &mut Document, pages: Option<&[usize]>, images: pdfcraft_co let scrubbed = metadata::scrub(doc, &removed_text, !removed_cut)?; report.metadata = scrubbed.removed; report.identification = scrubbed.identification; + // Last of what leaves: an image that left with any of the above, the metadata removed just + // now included, is a user of a dictionary too. Storing images again changes no page's content + // or pattern cells, so the checks above hold for what follows. + report.images_restored = globals::unshare(doc, &kept_before, &mut images)?; // What it doesn't scrub is counted, never changed. report.still_holding = elsewhere::find(doc, &removed_text, !removed_cut); // The previous revision still holds the removed content: the next save must rewrite, and the @@ -702,76 +728,6 @@ fn apply_within(doc: &mut Document, pages: Option<&[usize]>, images: pdfcraft_co Ok(report) } -/// Most XObjects followed down from the removed and replaced ones to find their JBIG2 images. -const MAX_JBIG2_WALK: usize = 100_000; - -/// The `/JBIG2Globals` of a redacted JBIG2 image hold the symbol dictionary of every image that -/// shares them (`jbig2enc -s` and most scanners give one to all the pages of a scan): the bitmaps -/// of marks drawn only under the area too. The image itself is replaced or removed, but the globals -/// would stay for as long as another image uses them. So every image that uses the globals of a -/// removed or replaced image (`doomed`), or of one drawn in a removed or replaced form, is decoded -/// and stored again in place without them, as a cleared copy is, and a full save then leaves them -/// out. An image that can't be stored again refuses the redaction (nothing changes, as `apply` -/// works on a copy), but for a removed or replaced one, which is either unlinked or still drawn -/// whole on another page. They are decoded within what is left of the run's allowance (`images`); -/// past it the redaction is refused as one clearing too many images is. Returns how many images -/// that weren't removed or replaced were stored again. -fn restore_shared_jbig2(doc: &mut Document, doomed: &[ObjRef], images: &mut pdfcraft_cos::ImageBudget) -> Result { - let mut globals = HashSet::new(); - let mut seen = HashSet::new(); - let mut stack: Vec = doomed.to_vec(); - while let Some(r) = stack.pop() { - if !seen.insert(r) || seen.len() > MAX_JBIG2_WALK { - continue; - } - let object = doc.get(r); - let Object::Stream(s) = &*object else { continue }; - if let Some(g) = doc.jbig2_globals_ref(s) { - globals.insert(g); - } - // A form's images, and its forms' (an image's /SMask is a stream of its own, found below). - let resources = s.dict.get(b"Resources").map(|r| doc.resolve(r)); - let xobjects = resources.as_deref().and_then(Object::as_dict).and_then(|d| d.get(b"XObject")).map(|x| doc.resolve(x)); - if let Some(x) = xobjects.as_deref().and_then(Object::as_dict) { - stack.extend(x.iter().filter_map(|(_, v)| v.as_ref())); - } - if let Some(m) = s.dict.get(b"SMask").and_then(Object::as_ref) { - stack.push(m); - } - } - if globals.is_empty() { - return Ok(0); - } - let sharing: Vec = doc - .scan_objects() - .filter(|(_, o)| matches!(&**o, Object::Stream(s) if doc.jbig2_globals_ref(s).is_some_and(|g| globals.contains(&g)))) - .map(|(r, _)| r) - .collect(); - let doomed: HashSet = doomed.iter().copied().collect(); - let (mut restored, mut stuck, mut past_allowance) = (0, 0, false); - for r in sharing { - let object = doc.get(r); - let Object::Stream(s) = &*object else { continue }; - let refused = images.refused(); - match image::without_globals(doc, s, images) { - Some(new) => { - doc.set(r, Object::Stream(new)); - restored += usize::from(!doomed.contains(&r)); - } - None if doomed.contains(&r) => {} - None if images.refused() > refused => past_allowance = true, - None => stuck += 1, - } - } - if past_allowance { - return Err(RedactError::ImagesPastAllowance); - } - if stuck > 0 { - return Err(RedactError::SharedJbig2Globals(stuck)); - } - Ok(restored) -} - /// What applying the marks collects across pages. struct Applying { report: Report, @@ -812,14 +768,15 @@ fn redact_page_content( let mut budget = pdfcraft_cos::DecodeBudget::page(); let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let out = { + // What the walk leaves of the page's allowance, the cleanups below spend. + let (out, mut budget) = { let mut scope = Scope::new(rects, Mode::Apply, report, budget, images).with_removed_room(*removed_room); let out = process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY); scope.flush(); removed_text.append(&mut scope.removed_text); *removed_cut |= scope.removed_cut; *removed_room = scope.removed_room; - out + (out, scope.into_budget()) }; // Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot // tell whether those glyphs lie under a mark. Rather than report success and possibly @@ -861,6 +818,11 @@ fn redact_page_content( let mut res = resources.clone(); if !out.xobjects.is_empty() || !out.replaced.is_empty() { let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); + // The rewritten XObjects first, as for a rewritten form: one left without resources of + // its own draws through these, and `prune` follows it only under a name it can find. + for (n, r) in &out.xobjects { + xo.set(n.clone(), Object::Ref(*r)); + } // A full save keeps whatever is still referenced: unlink the originals this page no // longer draws so the removed image or form bytes cannot survive in the saved file. let page_content: Vec> = new_list @@ -875,10 +837,7 @@ fn redact_page_content( _ => Vec::new(), }) .collect(); - interp::prune(&mut xo, &out.replaced, &page_content); - for (n, r) in &out.xobjects { - xo.set(n.clone(), Object::Ref(*r)); - } + interp::prune(doc, &mut budget, &mut xo, &out.replaced, &page_content)?; res.set(b"XObject".to_vec(), Object::Dict(xo)); } let (_, cleaned_data) = page_streams( @@ -895,8 +854,8 @@ fn redact_page_content( // A Type 3 glyph without resources paints with patterns nobody tracked: keep them all. let painted = if out.keeps_all_patterns { &out.patterns_seen } else { &out.patterns_painted }; report.patterns_removed += interp::prune_patterns(doc, &mut res, &out.patterns_seen, painted, pruned_patterns); - retired_properties.extend(interp::prune_properties(doc, &mut res, &cleaned_data)?); - type3::prune_type3_from_resources(doc, &mut res, &cleaned_data, Some(&page.dict))?; + retired_properties.extend(interp::prune_properties(doc, &mut budget, &mut res, &cleaned_data)?); + type3::prune_type3_from_resources(doc, &mut budget, &mut res, &cleaned_data, Some(&page.dict))?; doc.update_dict(page.obj, |d| { d.set(b"Contents".to_vec(), Object::Array(new_list)); d.set(b"Resources".to_vec(), Object::Dict(res)); diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 2734dcd7ee..0d4ee2e18a 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -662,7 +662,8 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re let blocks = scope.layer_blocks; let glyphs = scope.report.glyphs; let retained_for_page = std::mem::take(&mut scope.retained_off_layers); - drop(scope); + // What the walk leaves of the page's allowance, the cleanups below spend. + let mut budget = scope.into_budget(); total += out.residue + blocks + glyphs; retained_off_layers.extend(retained_for_page); if !write { @@ -694,16 +695,18 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re let mut res = resources.clone(); if !out.xobjects.is_empty() || !out.replaced.is_empty() { let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); - // A full save keeps whatever is still referenced: unlink the originals this page no - // longer draws so the removed form or image bytes cannot survive in the saved file. - crate::interp::prune(&mut xo, &out.replaced, &page_content); + // The rewritten XObjects first, as on Apply Redactions' pages: `prune` follows a + // rewritten form left without resources of its own only under a name it can find. for (n, r) in &out.xobjects { xo.set(n.clone(), Object::Ref(*r)); } + // A full save keeps whatever is still referenced: unlink the originals this page no + // longer draws so the removed form or image bytes cannot survive in the saved file. + crate::interp::prune(doc, &mut budget, &mut xo, &out.replaced, &page_content)?; res.set(b"XObject".to_vec(), Object::Dict(xo)); } - retired_properties.extend(crate::interp::prune_properties(doc, &mut res, &page_content)?); - crate::type3::prune_type3_from_resources(doc, &mut res, &page_content, Some(&page.dict))?; + retired_properties.extend(crate::interp::prune_properties(doc, &mut budget, &mut res, &page_content)?); + crate::type3::prune_type3_from_resources(doc, &mut budget, &mut res, &page_content, Some(&page.dict))?; doc.update_dict(page.obj, |d| { d.set(b"Contents".to_vec(), Object::Array(new_list)); d.set(b"Resources".to_vec(), Object::Dict(res)); @@ -811,6 +814,20 @@ pub fn scan_only(doc: &Document, which: &[Hidden]) -> Vec<(Hidden, usize)> { .collect() } +/// [`crate::globals::unshare`] for what left without marks: past `images` it is refused as finding +/// what shares the dictionaries is, since the error redaction gives (redact fewer pages at a time) +/// means nothing when no page was chosen. +pub(crate) fn unshare_unmarked( + doc: &mut Document, + before: &std::collections::HashSet, + images: &mut pdfcraft_cos::ImageBudget, +) -> Result { + crate::globals::unshare(doc, before, images).map_err(|e| match e { + RedactError::ImagesPastAllowance => RedactError::SharedJbig2Budget, + e => e, + }) +} + /// Take from the outline of catalog `cat` every action that does more than go to a place in this /// document (see [`outline_without_actions`]): bookmarks that stay keep where they go, not what /// they run. An outline too large or nested too deep to follow is refused, not left half cleaned: @@ -920,6 +937,9 @@ pub(crate) fn remove_counted( let count = |h: Hidden| counts.iter().find(|c| c.0 == h).map_or(0, |c| c.1); let (root, _) = catalog(doc).ok_or(RedactError::NothingToApply)?; let mut done = Vec::new(); + // What a full save keeps before anything is removed: an image that leaves, by whatever route + // below, takes a shared JBIG2 dictionary with it (`unshare_unmarked`, last). + let kept_before = crate::globals::kept(doc)?; let has_acroform = doc.get(root).as_dict().is_some_and(|d| d.contains(b"AcroForm")); let fields = which.contains(&Hidden::FormFields) && (count(Hidden::FormFields) > 0 || has_acroform); // Pages' annotation lists change only for these; otherwise every page is left as it is. @@ -929,11 +949,7 @@ pub(crate) fn remove_counted( let listed_before = if annotations { listed_annotations(doc) } else { std::collections::HashSet::new() }; if fields { // Fields without appearances get one first, so their values stay visible. - for f in pdfcraft_forms::fields(doc) { - if f.widgets.iter().any(|w| !doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"AP"))) { - pdfcraft_forms::redraw_field(doc, &f.name)?; - } - } + pdfcraft_forms::redraw_fields(doc, |doc, f| f.widgets.iter().any(|w| !doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"AP"))))?; let n = pdfcraft_model::pages(doc).len(); pdfcraft_edit::flatten(doc, &(0..n).collect::>(), false, true)?; doc.update_dict(root, |d| { @@ -1042,6 +1058,9 @@ pub(crate) fn remove_counted( if done.is_empty() && unlinked == 0 { return Err(RedactError::NothingToApply); } + // Images stored again without a dictionary, and the dictionaries read to know copies, are + // decoded within an allowance of their own. + unshare_unmarked(doc, &kept_before, &mut pdfcraft_cos::ImageBudget::new(doc))?; // The rewrite drops the revisions that held the removed content, so it is a different file from // its source: it gets a new identifier, whoever asked for the removal (the remove-hidden edit // and the optimizer's discard option call this function directly, not through [`sanitize`]). diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 031296a01e..46ac131450 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -1368,6 +1368,25 @@ fn a_fax_image_has_its_covered_pixels_cleared_instead_of_being_removed() { vec![b"1".to_vec()], [0x00, 0xFF, 0xFF, 0xFF], ), + // Through a reference to a reference: the array, its first number, and a colour space. + ( + format!("{image} /ImageMask true /Decode 8 0 R /Filter /CCITTFaxDecode /DecodeParms {parms}"), + g4.clone(), + vec![b"9 0 R".to_vec(), b"[1 0]".to_vec()], + [0x00, 0xFF, 0xFF, 0xFF], + ), + ( + format!("{image} /ImageMask true /Decode [8 0 R 0] /Filter /CCITTFaxDecode /DecodeParms {parms}"), + g4.clone(), + vec![b"9 0 R".to_vec(), b"1".to_vec()], + [0x00, 0xFF, 0xFF, 0xFF], + ), + ( + format!("{image} /ColorSpace 8 0 R /Filter /CCITTFaxDecode /DecodeParms {parms}"), + g4.clone(), + vec![b"9 0 R".to_vec(), b"/DeviceGray".to_vec()], + [0x00, 0xFF, 0xFF, 0xFF], + ), ] { let mut objs = vec![stream(&dict, data)]; objs.extend(extra); @@ -2051,17 +2070,1078 @@ fn globals_a_redacted_jbig2_image_shares_with_another_page_leave_the_file() { let mut doc = build(&image.replace(&format!("/Height {h}"), &format!("/Height {}", h + 1))); mark(&mut doc, 0, &[[0.25, 113.75, 49.75, 120.0]], ""); assert!(matches!(apply(&mut doc, None), Err(RedactError::SharedJbig2Globals(1)))); - // Storing the second page's image again decodes it, within what is left of the run's - // allowance: with nothing left the redaction is refused as one past the allowance, not - // reported as an image that can't be read. + // Reading the dictionary to know copies of it, and storing the second page's image again, + // decode within what is left of the run's allowance: past it the redaction is refused as one + // past the allowance, not reported as an image that can't be read. The first page's image + // leaves (its page no longer lists it). let mut doc = build(&image); - let first_image = [ObjRef::new(7, 0)]; - let refused = crate::restore_shared_jbig2(&mut doc, &first_image, &mut pdfcraft_cos::ImageBudget::with_work(0)); + let before = crate::globals::kept(&doc).unwrap(); + let first = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(first, |d| { + d.remove(b"Resources"); + }) + .unwrap(); + let refused = crate::globals::unshare(&mut doc.clone(), &before, &mut pdfcraft_cos::ImageBudget::with_work(0)); assert!(matches!(refused, Err(RedactError::ImagesPastAllowance)), "{refused:?}"); - let restored = crate::restore_shared_jbig2(&mut doc, &first_image, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX)); + // Enough to read the dictionary, and nothing left for the image. + let mut reading = pdfcraft_cos::ImageBudget::with_work(u64::MAX); + crate::globals::content(&mut crate::globals::Run::new(crate::globals::RUN_BUDGET), &doc, 9, &mut reading).unwrap(); + let read = u64::MAX - reading.work_left(); + let refused = crate::globals::unshare(&mut doc.clone(), &before, &mut pdfcraft_cos::ImageBudget::with_work(read)); + assert!(matches!(refused, Err(RedactError::ImagesPastAllowance)), "the image: {refused:?}"); + // Removing hidden information chose no pages, so fewer of them is no way out: it is refused as + // finding what shares the dictionary is. + let refused = crate::sanitize::unshare_unmarked(&mut doc.clone(), &before, &mut pdfcraft_cos::ImageBudget::with_work(0)); + assert!(matches!(refused, Err(RedactError::SharedJbig2Budget)), "{refused:?}"); + let restored = crate::globals::unshare(&mut doc, &before, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX)); assert_eq!(restored.ok(), Some(1)); } +/// A scan's parts when it keeps its symbols in `/JBIG2Globals`: the dictionary, moved out as segment +/// data belonging to no page, and the page information and regions that are left. +struct SharedScan { + bits: Vec, + symbols: Vec, + page: Vec, +} + +fn shared_scan() -> SharedScan { + const LETTERS: [[&str; 7]; 3] = [ + ["#####", "#....", "####.", "#....", "#....", "#....", "#####"], + [".###.", "#...#", "#....", "#....", "#....", "#...#", ".###."], + ["#...#", "#...#", "#####", "#...#", "#...#", "#...#", "#...#"], + ]; + let (w, h) = (400usize, 40usize); + let stride = w / 8; + let mut bits = vec![0xFFu8; stride * h]; + for (line, top) in [2usize, 12, 22, 32].into_iter().enumerate() { + for (k, x) in (3..w - 6).step_by(7).enumerate() { + for (dy, row) in LETTERS[(k + line) % 3].iter().enumerate() { + for (dx, _) in row.bytes().enumerate().filter(|(_, c)| *c == b'#') { + bits[(top + dy) * stride + (x + dx) / 8] &= !(0x80 >> ((x + dx) % 8)); + } + } + } + } + let coded = pdfcraft_filters::encode_jbig2(&bits, w as u32, h as u32).unwrap(); + // The end of the segment at `at`, and where its page association is (7.2.2 to 7.2.7). + let end = |at: usize| { + let page = at + 6 + usize::from(coded[at + 5] >> 5); + let length = u32::from_be_bytes(coded[page + 1..page + 5].try_into().unwrap()); + (page + 5 + length as usize, page) + }; + let (dictionary, _) = end(0); + assert_eq!(coded[dictionary + 4], 0, "the second segment is the symbol dictionary"); + let (rest, page) = end(dictionary); + let mut symbols = coded[dictionary..rest].to_vec(); + symbols[page - dictionary] = 0; + SharedScan { page: [&coded[..dictionary], &coded[rest..]].concat(), symbols, bits } +} + +/// An image of [`SharedScan`]'s size that uses the dictionary, object 9, by `parms`. +fn shared_image(parms: &str) -> String { + format!("/Type /XObject /Subtype /Image /Width 400 /Height 40 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode {parms}") +} + +/// Two pages of a scan sharing object 9: its symbols, and `LEAK_GLOBALS`, for the shape of a mark found +/// only under the area. The first page draws image 7 unless told otherwise; the second draws image 8. +/// `extra` are objects 10 on. +struct SharedPages { + /// What the first page draws, its `/Resources` entries and anything else in its dictionary. + content: Vec, + resources: String, + page: String, + /// Image 7's dictionary and data; image 8's. + first: String, + first_data: Vec, + other: String, + other_data: Vec, + extra: Vec>, +} + +const DRAWS: &[u8] = b"q 200 0 0 20 0 100 cm /Im Do Q"; + +impl SharedPages { + fn new(scan: &SharedScan) -> Self { + let image = shared_image(SHARED); + Self { + content: DRAWS.to_vec(), + resources: "/XObject << /Im 7 0 R >>".into(), + page: String::new(), + first: image.clone(), + first_data: scan.page.clone(), + other: image, + other_data: scan.page.clone(), + extra: vec![], + } + } + + fn build(self, scan: &SharedScan) -> Document { + let Self { content, resources, page, first, first_data, other, other_data, extra } = self; + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>".to_vec(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 5 0 R /Resources << {resources} >> {page} >>").into_bytes(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 6 0 R /Resources << /XObject << /Im 8 0 R >> >> >>".to_vec(), + stream("", content), + stream("", DRAWS), + stream(&first, first_data), + stream(&other, other_data), + stream("", [jbig2_globals(b"LEAK_GLOBALS"), scan.symbols.clone()].concat()), + ]; + objs.extend(extra); + pdf(objs) + } +} + +/// The samples of the image the page draws as `/Im`. +fn drawn_samples(doc: &Document, page: usize) -> Vec { + let p = pdfcraft_model::pages(doc).swap_remove(page); + let res = doc.resolve(p.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + match &*doc.get(xo.get(b"Im").unwrap().as_ref().unwrap()) { + Object::Stream(s) => decoded_image(doc, s).unwrap(), + other => panic!("not an image: {other:?}"), + } +} + +const SHARED: &str = "/DecodeParms << /JBIG2Globals 9 0 R >>"; +const CLEARS: [f64; 4] = [0.25, 113.75, 49.75, 120.0]; +const COVERS: [f64; 4] = [0.0, 100.0, 200.0, 120.0]; + +/// The ways image 7 can leave the document while taking the dictionary's only reason to stay with it. +fn ways_out(scan: &SharedScan) -> Vec<(&'static str, [f64; 4], SharedPages)> { + let base = || SharedPages::new(scan); + let in_form = |filter: &str, data: &[u8]| { + stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /XObject << /Im 7 0 R >> >> {filter}"), data) + }; + let plain = "/Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceGray"; + let through_form = |filter: &str, data: &[u8]| SharedPages { + content: b"/Fm Do".to_vec(), + resources: "/XObject << /Fm 10 0 R >>".into(), + extra: vec![in_form(filter, data)], + ..base() + }; + let annotated = |subtype: &str| SharedPages { + content: Vec::new(), + resources: String::new(), + page: "/Annots [10 0 R]".into(), + extra: vec![ + format!("<< /Type /Annot /Subtype /{subtype} /T (Field) /FT /Tx /Rect [0 100 200 120] /AP << /N 11 0 R >> >>").into_bytes(), + in_form("", DRAWS), + ], + ..base() + }; + let glyph = SharedPages { + content: b"BT /T3 200 Tf 0 100 Td (a) Tj ET".to_vec(), + resources: "/Font << /T3 10 0 R >>".into(), + extra: vec![ + b"<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1000 1000] /FontMatrix [0.001 0 0 0.001 0 0] /CharProcs << /a 11 0 R >> \ + /Encoding << /Differences [97 /a] >> /FirstChar 97 /LastChar 97 /Widths [1000] /Resources << /XObject << /Im 7 0 R >> >> >>" + .to_vec(), + stream("", b"1000 0 d0 q 1000 0 0 100 0 0 cm /Im Do Q"), + ], + ..base() + }; + vec![ + ("cleared", CLEARS, base()), + ("removed", COVERS, base()), + ("in a form", COVERS, through_form("", DRAWS)), + ("in a form that can't be read", COVERS, through_form("/Filter /FlateDecode", b"not Flate")), + ("a stamp's appearance", COVERS, annotated("Stamp")), + ("a widget's appearance", COVERS, annotated("Widget")), + ("a Type 3 glyph", COVERS, glyph), + ( + "a mask", + COVERS, + SharedPages { first: format!("{plain} /SMask 10 0 R"), extra: vec![stream(&shared_image(SHARED), scan.page.clone())], ..base() }, + ), + ( + "a stencil mask", + COVERS, + SharedPages { + first: format!("{plain} /Mask 10 0 R"), + extra: vec![stream( + &shared_image(SHARED).replace("/BitsPerComponent 1 /ColorSpace /DeviceGray", "/ImageMask true"), + scan.page.clone(), + )], + ..base() + }, + ), + ( + "parameters for data in another file", + COVERS, + SharedPages { first: shared_image("/F (scan.jb2) /FFilter /JBIG2Decode /FDecodeParms << /JBIG2Globals 9 0 R >>"), ..base() }, + ), + ( + "an alternate", + COVERS, + SharedPages { + first: format!("{plain} /Alternates [<< /Image 10 0 R >>]"), + extra: vec![stream(&shared_image(SHARED), scan.page.clone())], + ..base() + }, + ), + ( + "/Filter by reference", + COVERS, + SharedPages { + first: shared_image("/DecodeParms [null << /JBIG2Globals 9 0 R >>]").replace("/Filter /JBIG2Decode", "/Filter 10 0 R"), + extra: vec![b"[/ASCIIHexDecode /JBIG2Decode]".to_vec()], + ..base() + }, + ), + ( + "a reference to it", + COVERS, + SharedPages { first: shared_image("/DecodeParms << /JBIG2Globals 10 0 R >>"), extra: vec![b"9 0 R".to_vec()], ..base() }, + ), + ( + "parameters through a reference to them", + COVERS, + SharedPages { + first: shared_image("/DecodeParms 10 0 R"), + extra: vec![b"11 0 R".to_vec(), b"<< /JBIG2Globals 9 0 R >>".to_vec()], + ..base() + }, + ), + ( + "an entry through a reference to it", + COVERS, + SharedPages { + first: shared_image("/DecodeParms [10 0 R]"), + extra: vec![b"11 0 R".to_vec(), b"<< /JBIG2Globals 9 0 R >>".to_vec()], + ..base() + }, + ), + ( + "an array of them through a reference to it", + COVERS, + SharedPages { + first: shared_image("/DecodeParms 10 0 R"), + extra: vec![b"11 0 R".to_vec(), b"[<< /JBIG2Globals 9 0 R >>]".to_vec()], + ..base() + }, + ), + ("/DP", COVERS, SharedPages { first: shared_image("/DP << /JBIG2Globals 9 0 R >>"), ..base() }), + // Cleared, the copy keeps no parameters of the old data, so none names the dictionary. + ("/DP, cleared", CLEARS, SharedPages { first: format!("{} /DP << /JBIG2Globals 9 0 R >>", shared_image(SHARED)), ..base() }), + ( + "parameters for data in another file, with none, cleared", + CLEARS, + SharedPages { first: format!("{} /FFilter /JBIG2Decode /FDecodeParms << /JBIG2Globals 9 0 R >>", shared_image(SHARED)), ..base() }, + ), + ("another generation", COVERS, SharedPages { first: shared_image("/DecodeParms << /JBIG2Globals 9 1 R >>"), ..base() }), + // Cos reads a reference of another generation as null (#1143); a reader that doesn't compare + // generations draws with what it names. Read first, it mustn't hide what the other names. + ( + "parameters through another generation, then their own", + COVERS, + SharedPages { + first: shared_image("/DecodeParms [10 1 R 10 0 R]").replace("/Filter /JBIG2Decode", "/Filter [/ASCIIHexDecode /JBIG2Decode]"), + extra: vec![b"<< /JBIG2Globals 9 0 R >>".to_vec()], + ..base() + }, + ), + ( + "parameters through another generation", + COVERS, + SharedPages { first: shared_image("/DecodeParms 10 1 R"), extra: vec![b"<< /JBIG2Globals 9 0 R >>".to_vec()], ..base() }, + ), + ( + "a reference to it of another generation", + COVERS, + SharedPages { first: shared_image("/DecodeParms << /JBIG2Globals 10 1 R >>"), extra: vec![b"9 0 R".to_vec()], ..base() }, + ), + ] +} + +/// Scanners and `jbig2enc -s` share one symbol dictionary across a scan's pages, and it holds the +/// shape of every distinct mark, one found only under a redaction included: a signature, a stamp. +/// The dictionary stayed in the file while any other image used it. Now the image left on the second +/// page is stored again without it, whichever way the first one left the document. +#[test] +fn a_redacted_scans_shared_jbig2_dictionary_leaves_the_file() { + let scan = shared_scan(); + for (way, rect, pages) in ways_out(&scan) { + let mut doc = pages.build(&scan); + assert_eq!(drawn_samples(&doc, 1), scan.bits, "{way}: the second page reads through the dictionary"); + mark(&mut doc, 0, &[rect], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!(report.images_restored, 1, "{way}"); + assert_gone(&doc, b"LEAK_GLOBALS"); + assert_gone(&doc, b"JBIG2Globals"); + assert_eq!(drawn_samples(&reopen(&doc), 1), scan.bits, "{way}: its pixels are unchanged"); + } +} + +/// A size given through a reference of another generation is null, as cos reads it (#1143), whatever +/// another image read through the reference of that number's own: the image can't be stored again, so +/// the redaction refuses, rather than storing it with the size the other reference read. +#[test] +fn a_size_through_another_generation_isnt_the_one_its_own_reference_reads() { + let scan = shared_scan(); + let stale = shared_image(SHARED).replace("/Width 400", "/Width 11 1 R"); + let mut doc = SharedPages { + content: [DRAWS, b" q 200 0 0 20 0 200 cm /Stale Do Q".as_slice()].concat(), + resources: "/XObject << /Im 7 0 R /Stale 12 0 R >>".into(), + other: shared_image(SHARED).replace("/Width 400", "/Width 11 0 R"), + extra: vec![b"null".to_vec(), b"400".to_vec(), stream(&stale, scan.page.clone())], + ..SharedPages::new(&scan) + } + .build(&scan); + mark(&mut doc, 0, &[COVERS], ""); + let result = apply(&mut doc, None); + assert!(matches!(result, Err(RedactError::SharedJbig2Globals(1))), "{result:?}"); +} + +/// An image whose cross-reference row and object header give different generations is read through +/// either (#1143), and page 2 names it by the header's. Stored again without the dictionary, it keeps +/// both, so that page still draws it, in the document and in a full save. +#[test] +fn an_image_stored_again_keeps_the_generation_its_header_gives() { + let scan = shared_scan(); + let bytes = SharedPages::new(&scan).build(&scan).bytes().to_vec(); + let patch = |bytes: Vec, from: &[u8], to: &[u8]| { + let at = bytes.windows(from.len()).position(|w| w == from).unwrap(); + assert_eq!(bytes.windows(from.len()).filter(|w| *w == from).count(), 1); + [&bytes[..at], to, &bytes[at + from.len()..]].concat() + }; + let bytes = patch(patch(bytes, b"\n8 0 obj", b"\n8 1 obj"), b"/Im 8 0 R", b"/Im 8 1 R"); + let mut doc = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(drawn_samples(&doc, 1), scan.bits, "the second page reads its image through the header's generation"); + mark(&mut doc, 0, &[COVERS], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_restored, 1); + assert_eq!(drawn_samples(&doc, 1), scan.bits, "the image stored again is still drawn"); + let full = write_full(&doc, &SaveOptions::default()).unwrap(); + assert!(!full.windows(12).any(|w| w == b"LEAK_GLOBALS"), "the dictionary left the full save"); + assert_eq!(drawn_samples(&Document::open(Arc::new(full)).unwrap(), 1), scan.bits, "and is drawn after it"); + assert_gone(&doc, b"LEAK_GLOBALS"); +} + +/// However the image that stays names the dictionary, it comes out without it: its parameters by +/// reference, behind another filter, or a copy of the dictionary, as a file put back together from +/// pages split apart has, each page with its own. It is also read when its size or filter is given +/// by reference, which `decoded_image` alone refuses. +#[test] +fn what_stays_is_stored_without_the_dictionary_however_it_names_it() { + let scan = shared_scan(); + let hex: Vec = scan.page.iter().map(|b| format!("{b:02x}")).collect::().bytes().chain(*b">").collect(); + let copy = stream("", [jbig2_globals(b"LEAK_GLOBALS"), scan.symbols.clone()].concat()); + let image = shared_image(SHARED); + for (other, data, extra) in [ + (shared_image("/DecodeParms 10 0 R"), scan.page.clone(), vec![b"<< /JBIG2Globals 9 0 R >>".to_vec()]), + ( + shared_image("/DecodeParms [null << /JBIG2Globals 9 0 R >>]").replace("/Filter /JBIG2Decode", "/Filter [/ASCIIHexDecode /JBIG2Decode]"), + hex.clone(), + vec![], + ), + (shared_image("/DecodeParms << /JBIG2Globals 10 0 R >>"), scan.page.clone(), vec![copy]), + (image.replace("/Width 400", "/Width 10 0 R"), scan.page.clone(), vec![b"400".to_vec()]), + (image.replace("/Height 40", "/Height 10 0 R"), scan.page.clone(), vec![b"40".to_vec()]), + (image.replace("/Filter /JBIG2Decode", "/Filter 10 0 R"), scan.page.clone(), vec![b"/JBIG2Decode".to_vec()]), + ( + shared_image("/DecodeParms [null << /JBIG2Globals 9 0 R >>]").replace("/Filter /JBIG2Decode", "/Filter [/ASCIIHexDecode 10 0 R]"), + hex.clone(), + vec![b"/JBIG2Decode".to_vec()], + ), + (image.replace("/ColorSpace /DeviceGray ", ""), scan.page.clone(), vec![]), + ( + image.replace("/ColorSpace /DeviceGray", "/ColorSpace [/DeviceN [/Black] /DeviceGray 10 0 R]"), + scan.page.clone(), + vec![b"<< /FunctionType 2 /Domain [0 1] /N 1 >>".to_vec()], + ), + // Parameters no filter reads, on a picture that needs no dictionary. + (shared_image("/DP << /JBIG2Globals 9 0 R >>"), pdfcraft_filters::encode_jbig2_generic(&scan.bits, 400, 40).unwrap(), vec![]), + // Those for data in another file, with none: beside the parameters it reads, and alone. + (format!("{image} /FFilter /JBIG2Decode /FDecodeParms << /JBIG2Globals 9 0 R >>"), scan.page.clone(), vec![]), + ( + shared_image("/FFilter /JBIG2Decode /FDecodeParms << /JBIG2Globals 9 0 R >>"), + pdfcraft_filters::encode_jbig2_generic(&scan.bits, 400, 40).unwrap(), + vec![], + ), + // A `/F` that is null, directly, by reference, or as a reference to no object, is none. + (format!("{image} /F null"), scan.page.clone(), vec![]), + (format!("{image} /F 10 0 R"), scan.page.clone(), vec![b"null".to_vec()]), + (format!("{image} /F 99 0 R"), scan.page.clone(), vec![]), + // Through a reference to a reference: /F to null, the size, the filter, the colour space. + (format!("{image} /F 10 0 R"), scan.page.clone(), vec![b"11 0 R".to_vec(), b"null".to_vec()]), + (shared_image("/DecodeParms 10 0 R"), scan.page.clone(), vec![b"11 0 R".to_vec(), b"<< /JBIG2Globals 9 0 R >>".to_vec()]), + (image.replace("/Width 400", "/Width 10 0 R"), scan.page.clone(), vec![b"11 0 R".to_vec(), b"400".to_vec()]), + (image.replace("/Height 40", "/Height 10 0 R"), scan.page.clone(), vec![b"11 0 R".to_vec(), b"40".to_vec()]), + (image.replace("/Filter /JBIG2Decode", "/Filter 10 0 R"), scan.page.clone(), vec![b"11 0 R".to_vec(), b"/JBIG2Decode".to_vec()]), + ( + shared_image("/DecodeParms [null << /JBIG2Globals 9 0 R >>]").replace("/Filter /JBIG2Decode", "/Filter [/ASCIIHexDecode 10 0 R]"), + hex.clone(), + vec![b"11 0 R".to_vec(), b"/JBIG2Decode".to_vec()], + ), + (image.replace("/ColorSpace /DeviceGray", "/ColorSpace 10 0 R"), scan.page.clone(), vec![b"11 0 R".to_vec(), b"/DeviceGray".to_vec()]), + // An ICC profile of one component is one. + (format!("{} /ColorSpace [/ICCBased 10 0 R]", image.replace("/ColorSpace /DeviceGray ", "")), scan.page.clone(), vec![stream("/N 1", b"")]), + ] { + let mut doc = SharedPages { other: other.clone(), other_data: data, extra, ..SharedPages::new(&scan) }.build(&scan); + mark(&mut doc, 0, &[CLEARS], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!(report.images_restored, 1, "{other}"); + assert_gone(&doc, b"LEAK_GLOBALS"); + assert_eq!(drawn_samples(&reopen(&doc), 1), scan.bits, "{other}: its pixels are unchanged"); + } +} + +/// JBIG2 is drawn as one bit of one component whatever the dictionary says, so the Flate copy has to +/// say so too: a stream with no `/BitsPerComponent`, or 8, shows the same picture stored again. +#[test] +fn a_stored_jbig2_image_is_one_bit_whatever_its_dictionary_said() { + let scan = shared_scan(); + let hex: Vec = scan.page.iter().map(|b| format!("{b:02x}")).collect::().bytes().chain(*b">").collect(); + let chained = + shared_image("/DecodeParms [null << /JBIG2Globals 9 0 R >>]").replace("/Filter /JBIG2Decode", "/Filter [/ASCIIHexDecode /JBIG2Decode]"); + let lone = shared_image(SHARED).replace("/Filter /JBIG2Decode", "/Filter [/JBIG2Decode]"); + for (other, other_data, bits) in [ + (shared_image(SHARED), scan.page.clone(), ""), + (shared_image(SHARED), scan.page.clone(), "/BitsPerComponent 8"), + (chained, hex, ""), + (lone, scan.page.clone(), ""), + ] { + let other = other.replace("/BitsPerComponent 1", bits); + let mut doc = SharedPages { other, other_data, ..SharedPages::new(&scan) }.build(&scan); + mark(&mut doc, 0, &[CLEARS], ""); + apply(&mut doc, None).unwrap(); + let saved = reopen(&doc); + assert_eq!(drawn_samples(&saved, 1), scan.bits, "{bits}"); + let p = pdfcraft_model::pages(&saved).swap_remove(1); + let res = saved.resolve(p.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = saved.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + let image = saved.get(xo.get(b"Im").unwrap().as_ref().unwrap()); + let Object::Stream(s) = &*image else { panic!("not an image") }; + assert_eq!(s.dict.int(b"BitsPerComponent"), Some(1), "{bits}"); + } +} + +/// A mark that takes nothing using the dictionary leaves it, and what uses it, alone: here it covers +/// text in a form that also draws the scan, away from the scan. A stream nothing reaches isn't decoded +/// for nothing either: this one can't be, and it uses the dictionary. +#[test] +fn a_dictionary_stays_when_nothing_using_it_left() { + let scan = shared_scan(); + let text = b"BT /F1 10 Tf 10 250 Td (SECRET) Tj ET q 200 0 0 20 0 100 cm /Im Do Q"; + let pages = SharedPages { + content: b"/Fm Do".to_vec(), + resources: "/XObject << /Fm 10 0 R >>".into(), + extra: vec![ + stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /XObject << /Im 7 0 R >> >>", + text, + ), + stream(&shared_image(SHARED), b"not JBIG2"), + ], + ..SharedPages::new(&scan) + }; + let mut doc = pages.build(&scan); + mark(&mut doc, 0, &[[0.0, 240.0, 200.0, 270.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.images_removed, report.images_cleared, report.images_restored), (0, 0, 0)); + assert_eq!(drawn_samples(&reopen(&doc), 1), scan.bits); +} + +/// A dictionary of other bytes is another dictionary: the scan using it keeps it and isn't stored again. +#[test] +fn another_jbig2_dictionary_is_left_alone() { + let scan = shared_scan(); + let own = stream("", [jbig2_globals(b"OWN_GLOBALS"), scan.symbols.clone()].concat()); + let other = shared_image("/DecodeParms << /JBIG2Globals 10 0 R >>"); + let mut doc = SharedPages { other, extra: vec![own], ..SharedPages::new(&scan) }.build(&scan); + mark(&mut doc, 0, &[CLEARS], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_restored, 0); + let saved = reopen(&doc); + let holds = |secret: &[u8]| { + saved.scan_objects().any(|(_, o)| matches!(&*o, Object::Stream(s) if s.decoded().is_ok_and(|d| d.windows(secret.len()).any(|w| w == secret)))) + }; + assert!(!holds(b"LEAK_GLOBALS") && holds(b"OWN_GLOBALS")); + assert_eq!(drawn_samples(&saved, 1), scan.bits); +} + +/// An image that can't be stored without the dictionary may still be drawn: the renderer draws a +/// JBIG2 page of another size than its image, which `decoded_image` refuses, and draws three components +/// of one bit as grey. Removing it would lose a page no mark reached, and keeping the dictionary would +/// keep what the mark covered, so the redaction refuses and the document stays as it was. So it does +/// for anything else that refers to the dictionary, a reference that holds a reference to it among them. +#[test] +fn a_shared_jbig2_dictionary_that_cant_leave_refuses_the_redaction() { + let scan = shared_scan(); + let image = shared_image(SHARED); + let elsewhere = "/Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 1 /ColorSpace /DeviceGray /Note 9 0 R"; + let not_jbig2 = image.replace("/Filter /JBIG2Decode", "/Filter /FlateDecode"); + for (other, data, extra) in [ + (image.replace("/Height 40", "/Height 41"), scan.page.clone(), vec![]), + (image.replace("/DeviceGray", "/DeviceRGB"), scan.page.clone(), vec![]), + (image.replace("/DeviceGray", "/DeviceCMYK"), scan.page.clone(), vec![]), + (image.replace("/ColorSpace /DeviceGray", "/ColorSpace 10 0 R"), scan.page.clone(), vec![b"11 0 R".to_vec(), b"/DeviceRGB".to_vec()]), + ( + format!("{image} /ColorSpace [/ICCBased 10 0 R]").replace("/ColorSpace /DeviceGray ", ""), + scan.page.clone(), + vec![b"11 0 R".to_vec(), stream("/N 3", b"")], + ), + // Through a reference to a reference, which now reads as what it names (#5548): stored again + // without the dictionary, or refused (see below). + (shared_image("/DecodeParms << /JBIG2Globals 10 0 R >>"), scan.page.clone(), vec![b"9 0 R".to_vec()]), + (elsewhere.to_string(), vec![0], vec![]), + (not_jbig2, pdfcraft_filters::encode_flate(&scan.bits), vec![]), + // A copy with its filter by reference is known for a copy, and the decoder doesn't read it. + ( + shared_image("/DecodeParms << /JBIG2Globals 10 0 R >>"), + scan.page.clone(), + vec![ + stream("/Filter 11 0 R", pdfcraft_filters::encode_flate(&[jbig2_globals(b"LEAK_GLOBALS"), scan.symbols.clone()].concat())), + b"/FlateDecode".to_vec(), + ], + ), + ] { + let through_reference = other.contains("/JBIG2Globals 10 0 R"); + let mut doc = SharedPages { other: other.clone(), other_data: data, extra, ..SharedPages::new(&scan) }.build(&scan); + mark(&mut doc, 0, &[CLEARS], ""); + let before = doc.clone(); + match apply(&mut doc, None) { + Err(RedactError::SharedJbig2Globals(_)) => assert_eq!(drawn_samples(&doc, 0), drawn_samples(&before, 0), "{other}: nothing was redacted"), + Ok(_) if through_reference => { + // Object 10 too, the copy or the reference to the dictionary: `assert_gone` can't + // decode the copy, its `/Filter` given by reference, nor see its bytes, which are Flate. + let kept = pdfcraft_cos::reachable_objects(&doc).unwrap(); + assert!(!kept.contains(&9) && !kept.contains(&10), "{other}: the dictionary or its copy stays"); + assert_gone(&doc, b"LEAK_GLOBALS"); + } + result => panic!("{other}: {result:?}"), + } + } +} + +/// What is stored again is bounded as what is cleared is: an image of [`image::MAX_PIXELS`] is, and one +/// past it, which decodes, refuses the redaction. +#[test] +fn an_image_past_the_size_redaction_clears_refuses_it() { + let scan = shared_scan(); + let h = 8192u32; + for (w, stored) in [(8192u32, true), (8200, false)] { + let blank = vec![0xFFu8; (w as usize).div_ceil(8) * h as usize]; + let other = shared_image(SHARED).replace("/Width 400", &format!("/Width {w}")).replace("/Height 40", &format!("/Height {h}")); + let data = pdfcraft_filters::encode_jbig2_generic(&blank, w, h).unwrap(); + let mut doc = SharedPages { other, other_data: data, ..SharedPages::new(&scan) }.build(&scan); + mark(&mut doc, 0, &[CLEARS], ""); + let run = apply(&mut doc, None); + assert_eq!(run.as_ref().is_ok_and(|r| r.images_restored == 1), stored, "{w}"); + assert_eq!(matches!(run, Err(RedactError::SharedJbig2Globals(1))), !stored, "{w}"); + } +} + +/// An alternate of an image stored again stays with it, stored without the dictionary in turn: a page +/// no mark reached keeps what it had. +#[test] +fn an_alternate_of_what_stays_stays() { + let scan = shared_scan(); + let other = format!("{} /Alternates [<< /Image 10 0 R >>]", shared_image(SHARED)); + let mut doc = SharedPages { other, extra: vec![stream(&shared_image(SHARED), scan.page.clone())], ..SharedPages::new(&scan) }.build(&scan); + mark(&mut doc, 0, &[CLEARS], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_restored, 2); + assert_gone(&doc, b"LEAK_GLOBALS"); + let saved = reopen(&doc); + let kept = saved.scan_objects().filter(|(_, o)| matches!(&**o, Object::Stream(s) if s.dict.contains(b"Alternates"))).count(); + assert_eq!(kept, 1); +} + +/// The refusal counts the images that can't be stored, and says so; when only something else refers +/// to the dictionary, it counts none and says that instead, however many such references there are. +#[test] +fn a_refused_redaction_counts_what_cant_be_stored() { + let scan = shared_scan(); + let wrong = shared_image(SHARED).replace("/Height 40", "/Height 41"); + let other = format!("{wrong} /SMask 10 0 R"); + let mut doc = SharedPages { other, extra: vec![stream(&wrong, scan.page.clone())], ..SharedPages::new(&scan) }.build(&scan); + mark(&mut doc, 0, &[CLEARS], ""); + let refused = apply(&mut doc, None); + assert!(matches!(refused, Err(RedactError::SharedJbig2Globals(2)))); + assert!(refused.unwrap_err().to_string().starts_with("2 item(s) share JBIG2 symbol data with something being removed and can't be stored")); + let note = "/Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 1 /ColorSpace /DeviceGray /Note 9 0 R"; + for (other, extra, stuck) in [ + (note.to_string(), vec![], 0), + (format!("{note} /SMask 10 0 R"), vec![stream(note, vec![0])], 0), + (format!("{note} /SMask 10 0 R"), vec![stream(&wrong, scan.page.clone())], 1), + ] { + let mut doc = SharedPages { other: other.clone(), other_data: vec![0], extra, ..SharedPages::new(&scan) }.build(&scan); + mark(&mut doc, 0, &[CLEARS], ""); + let refused = apply(&mut doc, None); + assert!(matches!(refused, Err(RedactError::SharedJbig2Globals(n)) if n == stuck), "{other}"); + let text = refused.unwrap_err().to_string(); + assert_eq!(text.starts_with("something else in the file refers to JBIG2 symbol data"), stuck == 0, "{other}: {text}"); + assert_eq!(text.contains("can't be stored"), stuck > 0, "{other}: {text}"); + } + // An image whose data is in another file can't be stored here, though what is here decodes: readers + // draw the other file, with the dictionary its own parameters name. + let elsewhere = format!("{} /F (page2.jb2) /FFilter /JBIG2Decode /FDecodeParms << /JBIG2Globals 9 0 R >>", shared_image(SHARED)); + let mut doc = SharedPages { other: elsewhere, ..SharedPages::new(&scan) }.build(&scan); + mark(&mut doc, 0, &[CLEARS], ""); + assert!(matches!(apply(&mut doc, None), Err(RedactError::SharedJbig2Globals(1)))); +} + +/// A dictionary that names itself as its own globals (malformed) can't be stored without them, but +/// leaves with what used it, so the run goes on; it isn't counted as stored, nor, when the run is +/// refused, as something that uses the dictionary. +#[test] +fn a_dictionary_that_names_itself_isnt_counted_as_stored() { + let scan = shared_scan(); + let names_itself = |pages: SharedPages| { + let mut doc = pages.build(&scan); + let dictionary = pdfcraft_cos::ObjRef::new(9, 0); + let Object::Stream(mut s) = (*doc.get(dictionary)).clone() else { panic!("object 9 is the dictionary") }; + let mut parms = pdfcraft_cos::Dict::new(); + parms.set(b"JBIG2Globals".to_vec(), Object::Ref(dictionary)); + s.dict.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + doc.set(dictionary, Object::Stream(s)); + mark(&mut doc, 0, &[CLEARS], ""); + doc + }; + let mut doc = names_itself(SharedPages::new(&scan)); + assert_eq!(apply(&mut doc, None).unwrap().images_restored, 1); + assert_gone(&doc, b"LEAK_GLOBALS"); + assert_eq!(drawn_samples(&reopen(&doc), 1), scan.bits); + // The second page's image has its data in another file, so it can't be stored: one item. + let mut doc = names_itself(SharedPages { other: format!("{} /F (p.jb2)", shared_image(SHARED)), ..SharedPages::new(&scan) }); + assert!(matches!(apply(&mut doc, None), Err(RedactError::SharedJbig2Globals(1)))); +} + +/// What a stream's size, filters and parameters are made direct to is small, however often they +/// refer to a large object: only numbers, names and the parameters decoders read are copied. +#[test] +fn what_is_made_direct_is_small_however_often_a_large_object_is_named() { + let big = format!("[{}]", "1 ".repeat(100_000)); + let keys: String = (0..50).map(|k| format!(" /K{k} 2 0 R")).collect(); + // Three filters, and parameters for 2,001: the entries past the filters are dropped. Two name + // object 6, whose `/JBIG2Globals` is the large array given directly, which no decoder reads. + let (filters, entries) = (" 2 0 R".repeat(2), format!(" 6 0 R 6 0 R{}", " 2 0 R".repeat(1998))); + let dict = + format!("/Width 2 0 R /Height 2 0 R /Filter [/FlateDecode{filters}] /DecodeParms [<< /Predictor 3 0 R /Columns 2 0 R{keys} >>{entries}]"); + let globals = format!("<< /JBIG2Globals {big} >>"); + // A chain of more filters than any decoder takes is no filter, and its parameters aren't read: + // ten here, against eight and a `/Crypt`. + let long = format!("/Filter [{}] /DecodeParms 2 0 R", "/FlateDecode ".repeat(10)); + let doc = pdf(vec![ + b"<< /Type /Catalog >>".to_vec(), + big.into_bytes(), + b"4 0 R".to_vec(), + b"12".to_vec(), + stream(&dict, b""), + globals.into_bytes(), + stream(&long, b""), + stream(&format!("/Filter [{}]", "/FlateDecode ".repeat(9)), b""), + // A name no decoder knows, long or short, is no filter, and isn't copied however often. + format!("/{}", "A".repeat(2000)).into_bytes(), + format!("[{} /Foo]", " 9 0 R".repeat(8)).into_bytes(), + stream("/Filter 10 0 R", b""), + ]); + let Object::Stream(s) = &*doc.get(ObjRef::new(5, 0)) else { panic!("object 5 is the stream") }; + let made = crate::globals::Run::new(crate::globals::RUN_BUDGET).readable(&doc, s).unwrap(); + let mut bytes = Vec::new(); + pdfcraft_cos::serialize(&Object::Dict(made.dict.clone()), &mut bytes); + assert!(bytes.len() < 4096, "{} bytes made direct", bytes.len()); + let parms = made.dict.get(b"DecodeParms").and_then(Object::as_array).unwrap(); + assert_eq!(parms.first().and_then(Object::as_dict).and_then(|d| d.get(b"Predictor")), Some(&Object::Int(12))); + assert_eq!(parms.len(), 3, "an entry for each filter"); + assert!(parms[1].as_dict().is_some_and(|d| !d.contains(b"JBIG2Globals")), "{:?}", parms[1]); + let Object::Stream(s) = &*doc.get(ObjRef::new(7, 0)) else { panic!("object 7 is the stream") }; + let made = crate::globals::Run::new(crate::globals::RUN_BUDGET).readable(&doc, s).unwrap(); + assert_eq!(made.dict.get(b"Filter"), Some(&Object::Null)); + assert_eq!(made.dict.get(b"DecodeParms").and_then(Object::as_array).map(Vec::len), Some(0)); + // Eight filters and a `/Crypt` are as many as a decoder takes: those are read. + let Object::Stream(s) = &*doc.get(ObjRef::new(8, 0)) else { panic!("object 8 is the stream") }; + let made = crate::globals::Run::new(crate::globals::RUN_BUDGET).readable(&doc, s).unwrap(); + assert_eq!(made.dict.get(b"Filter").and_then(Object::as_array).map(Vec::len), Some(9)); + let Object::Stream(s) = &*doc.get(ObjRef::new(11, 0)) else { panic!("object 11 is the stream") }; + let made = crate::globals::Run::new(crate::globals::RUN_BUDGET).readable(&doc, s).unwrap(); + assert_eq!(made.dict.get(b"Filter"), Some(&Object::Array(vec![Object::Null; 9]))); +} + +/// A `/JBIG2Globals` reference in parameters given directly is fetched once a run, however many +/// entries and streams name it, and what it leads to is held once. +#[test] +fn a_dictionary_named_in_direct_entries_is_fetched_once() { + let entries = " << /JBIG2Globals 2 0 R >>".repeat(9); + let mut objects = vec![b"<< /Type /Catalog >>".to_vec(), stream("", b"symbols")]; + objects.extend((0..50).map(|_| stream(&format!("/Filter /JBIG2Decode /DecodeParms [{entries}]"), b""))); + let doc = pdf(objects); + let mut run = crate::globals::Run::new(crate::globals::RUN_BUDGET); + for n in 3..53 { + let Object::Stream(s) = &*doc.get(ObjRef::new(n, 0)) else { panic!("object {n} is a stream") }; + let lists = run.names_of(&doc, s).unwrap(); + assert_eq!(lists.iter().map(|(list, _)| list.to_vec()).collect::>(), [vec![2]]); + } + assert_eq!((run.fetched, run.spent), (1, 2), "one fetch, and the one number held"); +} + +/// A `/JBIG2Globals` reference leading through a chain names each reference on the way, up to the +/// limit of 33, whether the chain is met first part way or from its start. +#[test] +fn a_chain_of_references_is_named_to_the_limit() { + // Objects 2 to 41 each refer to the next, and 42 is the dictionary; 43 names the chain from 20, + // and 44 from its start. + let mut objects = vec![b"<< /Type /Catalog >>".to_vec()]; + objects.extend((3..=42).map(|n| format!("{n} 0 R").into_bytes())); + objects.push(stream("", b"symbols")); + objects.push(stream("/Filter /JBIG2Decode /DecodeParms << /JBIG2Globals 20 0 R >>", b"")); + objects.push(stream("/Filter /JBIG2Decode /DecodeParms << /JBIG2Globals 2 0 R >>", b"")); + let doc = pdf(objects); + let mut run = crate::globals::Run::new(crate::globals::RUN_BUDGET); + let named = |run: &mut crate::globals::Run, n: u32| { + let Object::Stream(s) = &*doc.get(ObjRef::new(n, 0)) else { panic!("object {n} is a stream") }; + run.names_of(&doc, s).unwrap().iter().flat_map(|(list, _)| list.to_vec()).collect::>() + }; + assert_eq!(named(&mut run, 43), (20..=42).collect::>()); + assert_eq!(named(&mut run, 44), (2..=34).collect::>()); +} + +/// An image is charged the decoded size of each dictionary it names, as decoding it may undo that +/// dictionary's filters again. +#[test] +fn an_image_is_charged_the_dictionaries_it_names() { + let image = "/Type /XObject /Subtype /Image /Width 8 /Height 8 /BitsPerComponent 1 /Filter /JBIG2Decode /DecodeParms << /JBIG2Globals 2 0 R >>"; + let doc = pdf(vec![b"<< /Type /Catalog >>".to_vec(), stream("", vec![0u8; 40_000]), stream(image, b"not JBIG2")]); + let mut run = crate::globals::Run::new(crate::globals::RUN_BUDGET); + crate::globals::content(&mut run, &doc, 2, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX)).unwrap(); + assert_eq!(run.spent, 10, "40,000 bytes: a unit and nine whole 4 KiB"); + let Object::Stream(s) = &*doc.get(ObjRef::new(3, 0)) else { panic!("object 3 is the image") }; + assert!(matches!(crate::globals::without_dictionary(&mut run, &doc, s, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX)), Ok(None))); + // The chain to 2 (a fetch and the number held), the dictionary's 10, the image's allowance 2. + assert_eq!(run.spent, 24); +} + +/// A dictionary pays the run's image allowance for what every stage of its filters reads and makes, +/// not for what the last one makes: two Flates inflate a MiB of zero bytes, which hex skips as white +/// space, so it decodes to nothing. Past the allowance the run is refused, through `unshare` too. +#[test] +fn a_dictionary_pays_for_every_stage_of_its_filters() { + let hollow = pdfcraft_filters::encode_flate(&pdfcraft_filters::encode_flate(&vec![0u8; 1 << 20])); + let dict = || stream("/Filter [/FlateDecode /FlateDecode /ASCIIHexDecode]", hollow.clone()); + let doc = pdf(vec![b"<< /Type /Catalog >>".to_vec(), dict()]); + let mut run = crate::globals::Run::new(crate::globals::RUN_BUDGET); + let mut images = pdfcraft_cos::ImageBudget::with_work(u64::MAX); + assert!(crate::globals::content(&mut run, &doc, 2, &mut images).unwrap().is_some()); + assert_eq!(run.spent, 1, "it decodes to nothing: a unit"); + let paid = u64::MAX - images.work_left(); + assert!(paid > 1 << 20, "the MiB the second Flate made is paid for: {paid} steps"); + let mut run = crate::globals::Run::new(crate::globals::RUN_BUDGET); + let refused = crate::globals::content(&mut run, &doc, 2, &mut pdfcraft_cos::ImageBudget::with_work(1 << 20)); + assert_eq!(refused, Err(RedactError::ImagesPastAllowance)); + + // An image naming it leaves its page: finding copies of the dictionary reads it. + let image = "/Type /XObject /Subtype /Image /Width 8 /Height 8 /BitsPerComponent 1 /Filter /JBIG2Decode /DecodeParms << /JBIG2Globals 8 0 R >>"; + let mut doc = one_page(b"q 8 0 0 8 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(image, b""), dict()]); + let before = crate::globals::kept(&doc).unwrap(); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| { + d.remove(b"Resources"); + }) + .unwrap(); + let refused = crate::globals::unshare(&mut doc.clone(), &before, &mut pdfcraft_cos::ImageBudget::with_work(1 << 20)); + assert_eq!(refused, Err(RedactError::ImagesPastAllowance)); + assert_eq!(crate::globals::unshare(&mut doc, &before, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX)), Ok(0)); +} + +/// What can't be decoded is charged as much as it was allowed to decode, so going past a limit +/// costs as much as reaching it: a dictionary `MAX_JBIG2_GLOBALS` (here one behind a filter that +/// isn't decoded here), an image its allowance. +#[test] +fn a_decode_that_fails_is_charged_what_it_was_allowed() { + let image = "/Type /XObject /Subtype /Image /Width 8 /Height 8 /BitsPerComponent 1 /Filter /JBIG2Decode"; + let doc = pdf(vec![b"<< /Type /Catalog >>".to_vec(), stream("/Filter /DCTDecode", b"not a JPEG"), stream(image, b"not JBIG2")]); + let units = pdfcraft_cos::MAX_JBIG2_GLOBALS / 4096 + 1; + let run = |budget| crate::globals::Run::new(budget); + let images = || pdfcraft_cos::ImageBudget::with_work(u64::MAX); + assert!(crate::globals::content(&mut run(units), &doc, 2, &mut images()).is_ok()); + assert_eq!(crate::globals::content(&mut run(units - 1), &doc, 2, &mut images()), Err(RedactError::SharedJbig2Budget)); + // The image's allowance: (one byte a row of 8, and a byte a row) × 3 a byte, and 4096: 4144 + // bytes, two units. + let Object::Stream(s) = &*doc.get(ObjRef::new(3, 0)) else { panic!("object 3 is the image") }; + assert!(matches!(crate::globals::without_dictionary(&mut run(2), &doc, s, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX)), Ok(None))); + assert!(matches!( + crate::globals::without_dictionary(&mut run(1), &doc, s, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX)), + Err(RedactError::SharedJbig2Budget) + )); +} + +/// What streams share by reference is read once a run, however many share it and however they +/// refer to it: a parameters array of 5,000 entries named by 100 streams, directly, each through an +/// object of its own that refers to it, or through a chain of two, costs the array and its one +/// entry, once. +#[test] +fn a_shared_parameters_array_is_read_once_however_many_streams_share_it() { + let entries = " 4 0 R".repeat(5000); + let mut objects = + vec![b"<< /Type /Catalog >>".to_vec(), format!("[{entries}]").into_bytes(), stream("", b"symbols"), b"<< /JBIG2Globals 3 0 R >>".to_vec()]; + // Objects 5 to 54 refer to the array, and 55 to 5. + objects.extend((0..50).map(|_| b"2 0 R".to_vec())); + objects.push(b"5 0 R".to_vec()); + // Streams 56 to 155: fifty name the array directly, forty-nine through objects 5 to 53, one + // through the chain from 55. + objects.extend((0..50).map(|_| stream("/Filter /JBIG2Decode /DecodeParms 2 0 R", b""))); + objects.extend((5..54).map(|n| stream(&format!("/Filter /JBIG2Decode /DecodeParms {n} 0 R"), b""))); + objects.push(stream("/Filter /JBIG2Decode /DecodeParms 55 0 R", b"")); + let doc = pdf(objects); + let mut run = crate::globals::Run::new(crate::globals::RUN_BUDGET); + for n in 56..156 { + let Object::Stream(s) = &*doc.get(ObjRef::new(n, 0)) else { panic!("object {n} is a stream") }; + let lists = run.names_of(&doc, s).unwrap(); + assert_eq!(lists.iter().map(|(list, _)| list.to_vec()).collect::>(), [vec![3]]); + } + assert_eq!(run.read, 2); + // Each object once: the array, its entry, the dictionary it names, each of the 49 objects that + // refer to the array, and 55. + assert_eq!(run.fetched, 53); + // A run whose budget that is past is refused. + let mut run = crate::globals::Run::new(20); + let refused = (56..156).try_for_each(|n| { + let Object::Stream(s) = &*doc.get(ObjRef::new(n, 0)) else { panic!("object {n} is a stream") }; + run.names_of(&doc, s).map(|_| ()) + }); + assert_eq!(refused, Err(RedactError::SharedJbig2Budget)); +} + +/// A run's budget is spent to the unit: a run may spend all of it, and is refused past it. What is +/// decoded spends a unit, and one more for each whole 4 KiB. +#[test] +fn a_runs_budget_is_spent_to_the_unit() { + let mut run = crate::globals::Run::new(10); + assert_eq!(run.decoded(4095), Ok(())); + assert_eq!(run.decoded(8 * 4096), Ok(())); + assert_eq!(run.decoded(0), Err(RedactError::SharedJbig2Budget)); +} + +/// What a stream's size, filters and parameters lead to is fetched once a run, however many streams +/// share them by reference: fifty images naming the same objects fetch each once for each way it is +/// read. +#[test] +fn what_streams_share_is_fetched_once_a_run() { + let mut objects = + vec![b"<< /Type /Catalog >>".to_vec(), b"/FlateDecode".to_vec(), b"<< /Predictor 4 0 R /Columns 4 0 R >>".to_vec(), b"1".to_vec()]; + objects.extend( + (0..50).map(|_| stream("/Width 4 0 R /Height 4 0 R /Filter [2 0 R 2 0 R] /DecodeParms [3 0 R 3 0 R] /ColorSpace 2 0 R /F 4 0 R", b"")), + ); + let doc = pdf(objects); + let mut run = crate::globals::Run::new(crate::globals::RUN_BUDGET); + for n in 5..55 { + let Object::Stream(s) = &*doc.get(ObjRef::new(n, 0)) else { panic!("object {n} is a stream") }; + let made = run.readable(&doc, s).unwrap(); + assert_eq!(made.dict.get(b"Width"), Some(&Object::Int(1))); + assert_eq!(made.dict.get(b"Filter").and_then(Object::as_array).map(Vec::len), Some(2)); + } + // Object 4 as a size and as a parameter, 2 as a filter, 3 as an entry. + assert_eq!(run.fetched, 4); +} + +/// A copy of the dictionary stored with a predictor is found whatever way its parameters are given, +/// through a chain of references among them, as a reader that follows them decodes it. Through a +/// chain the run either stores the image again with the copy's symbols, now that a reference to a +/// reference reads as what it names (#5548), or is refused; it never goes on with the copy left in +/// the file. +#[test] +fn a_copy_is_found_however_its_parameters_are_given() { + let scan = shared_scan(); + let globals = [jbig2_globals(b"LEAK_GLOBALS"), scan.symbols.clone()].concat(); + // PNG rows of one byte, each with no prediction: a byte for the row's tag, then the byte. + let predicted: Vec = globals.iter().flat_map(|b| [0u8, *b]).collect(); + let flate = pdfcraft_filters::encode_flate(&predicted); + let parms = b"<< /Predictor 12 /Columns 1 >>".to_vec(); + for (spelling, extra, stored) in [ + ("/DecodeParms << /Predictor 12 /Columns 1 >>", vec![], true), + ("/DecodeParms 11 0 R", vec![parms.clone()], true), + ("/DecodeParms 11 0 R", vec![b"12 0 R".to_vec(), parms.clone()], false), + ("/DecodeParms [11 0 R]", vec![b"12 0 R".to_vec(), parms.clone()], false), + ("/DecodeParms << /Predictor 11 0 R /Columns 1 >>", vec![b"12 0 R".to_vec(), b"12".to_vec()], false), + // `false` above: stored again or refused, either is safe (see the doc). + ] { + let mut objects = vec![stream(&format!("/Filter /FlateDecode {spelling}"), flate.clone())]; + objects.extend(extra.clone()); + let other = shared_image("/DecodeParms << /JBIG2Globals 10 0 R >>"); + let mut doc = SharedPages { other, extra: objects, ..SharedPages::new(&scan) }.build(&scan); + mark(&mut doc, 0, &[CLEARS], ""); + let at = format!("{spelling} {}", extra.len()); + match apply(&mut doc, None) { + Ok(report) => { + assert_eq!(report.images_restored, 1, "{at}"); + let kept = pdfcraft_cos::reachable_objects(&doc).unwrap(); + assert!(!kept.contains(&10) && !kept.contains(&9), "{at}: the copy or the dictionary stays"); + assert_eq!(drawn_samples(&reopen(&doc), 1), scan.bits, "{at}"); + } + Err(e) => assert!(!stored && matches!(e, RedactError::SharedJbig2Globals(1)), "{at}: {e:?}"), + } + } +} + +/// An image that names the dictionary and another one is stored again without both, but the other +/// was shared with nothing that left: an image that uses only it stays as it was, whichever of the +/// two comes first in the file, and so does that dictionary. +#[test] +fn another_dictionary_named_beside_it_stays_whatever_the_order() { + let scan = shared_scan(); + let other_dictionary = stream("", [jbig2_globals(b"OTHER_GLOBALS"), scan.symbols.clone()].concat()); + let only_other = shared_image("/DecodeParms << /JBIG2Globals 11 0 R >>"); + let chained = "/Type /XObject /Subtype /Image /Width 400 /Height 40 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter [/FlateDecode /JBIG2Decode] /DecodeParms [<< /JBIG2Globals 11 0 R >> << /JBIG2Globals 9 0 R >>]"; + let both_spellings = [ + (chained.to_string(), pdfcraft_filters::encode_flate(&scan.page)), + (format!("{} /DP << /JBIG2Globals 11 0 R >>", shared_image(SHARED)), scan.page.clone()), + ]; + let other_stays = |doc: &Document| { + let saved = reopen(doc); + saved.scan_objects().any(|(_, o)| matches!(&*o, Object::Stream(s) if s.raw.windows(13).any(|w| w == b"OTHER_GLOBALS"))) + }; + for (both, data) in both_spellings { + // Image 8 is drawn on the second page, and image 10 is its alternate. + let only_other_first = SharedPages { + other: format!("{only_other} /Alternates [<< /Image 10 0 R >>]"), + extra: vec![stream(&both, data.clone()), other_dictionary.clone()], + ..SharedPages::new(&scan) + }; + let both_first = SharedPages { + other: format!("{both} /Alternates [<< /Image 10 0 R >>]"), + other_data: data.clone(), + extra: vec![stream(&only_other, scan.page.clone()), other_dictionary.clone()], + ..SharedPages::new(&scan) + }; + for (pages, only_other_at) in [(only_other_first, 8), (both_first, 10)] { + let mut doc = pages.build(&scan); + let untouched = doc.get(pdfcraft_cos::ObjRef::new(only_other_at, 0)); + mark(&mut doc, 0, &[CLEARS], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_restored, 1, "{both}"); + assert_gone(&doc, b"LEAK_GLOBALS"); + assert!(other_stays(&doc), "{both}"); + assert_eq!(doc.get(pdfcraft_cos::ObjRef::new(only_other_at, 0)), untouched, "{both}"); + } + } +} + +/// Remove Hidden Information leaves the same way: comments go, with what their appearances drew. +#[test] +fn removing_hidden_information_takes_a_shared_jbig2_dictionary_too() { + let scan = shared_scan(); + let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /XObject << /Im 7 0 R >> >>", DRAWS); + let annotation = b"<< /Type /Annot /Subtype /Stamp /Rect [0 100 200 120] /AP << /N 11 0 R >> >>".to_vec(); + let mut doc = SharedPages { + content: Vec::new(), + resources: String::new(), + page: "/Annots [10 0 R]".into(), + extra: vec![annotation, form], + ..SharedPages::new(&scan) + } + .build(&scan); + crate::sanitize::remove_hidden(&mut doc, &[crate::sanitize::Hidden::Comments]).unwrap(); + assert_gone(&doc, b"LEAK_GLOBALS"); + assert_eq!(drawn_samples(&reopen(&doc), 1), scan.bits); +} + +/// Remove Hidden Information refuses as a redaction does when the dictionary can't leave, but it has +/// changed the document by then, so the refusal doesn't say nothing was changed. +#[test] +fn a_refused_removal_of_hidden_information_doesnt_say_nothing_changed() { + let scan = shared_scan(); + let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /XObject << /Im 7 0 R >> >>", DRAWS); + let annotation = b"<< /Type /Annot /Subtype /Stamp /Rect [0 100 200 120] /AP << /N 11 0 R >> >>".to_vec(); + let mut doc = SharedPages { + content: Vec::new(), + resources: String::new(), + page: "/Annots [10 0 R]".into(), + other: shared_image(SHARED).replace("/Filter /JBIG2Decode", "/Filter /FlateDecode"), + other_data: pdfcraft_filters::encode_flate(&scan.bits), + extra: vec![annotation, form], + ..SharedPages::new(&scan) + } + .build(&scan); + let annotations = |doc: &Document| crate::annots_of(doc, &pdfcraft_model::pages(doc)[0].dict).len(); + assert_eq!(annotations(&doc), 1); + let refused = crate::sanitize::remove_hidden(&mut doc, &[crate::sanitize::Hidden::Comments]); + assert!(matches!(refused, Err(RedactError::SharedJbig2Globals(1)))); + assert_eq!(annotations(&doc), 0, "the comment went before the refusal"); + let text = refused.unwrap_err().to_string(); + assert!(text.ends_with("this was refused") && !text.contains("nothing was changed"), "{text}"); +} + +/// Remove Hidden Information also unlinks an image its page lists but no longer draws (left there by +/// Delete Image, say): that is a way out too, so the dictionary goes with it. +#[test] +fn an_undrawn_image_unlinked_takes_a_shared_jbig2_dictionary_too() { + let scan = shared_scan(); + let mut doc = SharedPages { content: Vec::new(), ..SharedPages::new(&scan) }.build(&scan); + crate::sanitize::remove_hidden(&mut doc, &[crate::sanitize::Hidden::Comments]).unwrap(); + assert!(!crate::globals::kept(&doc).unwrap().contains(&7), "the image nothing drew is unlinked"); + assert_gone(&doc, b"LEAK_GLOBALS"); + assert_eq!(drawn_samples(&reopen(&doc), 1), scan.bits); +} + +/// A redaction also removes the catalog's XMP when it repeats the removed text (#2448): an image +/// only that packet still refers to leaves the file then, and the dictionary with it. +#[test] +fn an_image_only_the_scrubbed_metadata_refers_to_takes_a_shared_jbig2_dictionary_too() { + let scan = shared_scan(); + let mut doc = SharedPages { + content: [DRAWS, b" BT /F1 10 Tf 10 105 Td (Bluefin) Tj ET".as_slice()].concat(), + resources: "/XObject << /Im 7 0 R >> /Font << /F1 11 0 R >>".into(), + extra: vec![ + stream("/Type /Metadata /Subtype /XML /Thumb 7 0 R", b"Bluefin"), + FONT.replace("95 0 R", "12 0 R").into_bytes(), + widths(), + ], + ..SharedPages::new(&scan) + } + .build(&scan); + let root = doc.root().unwrap(); + doc.update_dict(root, |d| d.set(b"Metadata".to_vec(), Object::Ref(ObjRef::new(10, 0)))).unwrap(); + mark(&mut doc, 0, &[COVERS], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.metadata, report.images_restored), (1, 1), "the packet went, and the image on page 2 was stored again"); + assert_gone(&doc, b"LEAK_GLOBALS"); + assert_eq!(drawn_samples(&reopen(&doc), 1), scan.bits); +} + +/// A redaction that leaves a pattern unpainted unlinks it from a `/Pages` node no page inherits +/// from any more, here one the page's own parent shadowed: an image its cell drew leaves the file +/// then, and the dictionary with it. +#[test] +fn an_image_a_pruned_patterns_cell_drew_takes_a_shared_jbig2_dictionary_too() { + let scan = shared_scan(); + let cell = "/Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 300 300] /XStep 300 /YStep 300 /Resources << /XObject << /Im 7 0 R >> >>"; + let image = shared_image(SHARED); + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [11 0 R 4 0 R] /Count 2 /Resources << /Pattern << /P0 10 0 R >> >> >>".to_vec(), + b"<< /Type /Page /Parent 11 0 R /MediaBox [0 0 300 300] /Contents 5 0 R >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 6 0 R /Resources << /XObject << /Im 8 0 R >> >> >>".to_vec(), + stream("", b"/Pattern cs /P0 scn 0 100 200 20 re f"), + stream("", DRAWS), + stream(&image, &scan.page), + stream(&image, &scan.page), + stream("", [jbig2_globals(b"LEAK_GLOBALS"), scan.symbols.clone()].concat()), + stream(cell, DRAWS), + b"<< /Type /Pages /Parent 2 0 R /Kids [3 0 R] /Count 1 /Resources << /Pattern << /P0 10 0 R >> >> >>".to_vec(), + ]); + mark(&mut doc, 0, &[[-5.0, 95.0, 205.0, 125.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.patterns_removed, report.images_restored), (1, 1)); + assert!(!crate::globals::kept(&doc).unwrap().contains(&10), "the cell is unlinked"); + assert_gone(&doc, b"LEAK_GLOBALS"); + assert_eq!(drawn_samples(&reopen(&doc), 1), scan.bits); +} + +/// [`assert_no_secret`] for a file that may hold JBIG2 streams, which `decoded` doesn't read: a stream +/// stored again can be coded alone. +fn assert_gone(doc: &Document, secret: &[u8]) { + for (r, object) in reopen(doc).scan_objects() { + let mut bytes = Vec::new(); + pdfcraft_cos::serialize(&object, &mut bytes); + assert!(!bytes.windows(secret.len()).any(|w| w == secret), "secret remains in object {r:?}"); + if let Object::Stream(s) = &*object + && let Ok(data) = s.decoded() + { + assert!(!data.windows(secret.len()).any(|w| w == secret), "secret remains in stream {r:?}"); + } + } +} + /// Apply Redactions gives the page its own pruned /Resources, but the dictionary it drew through /// before (inherited from the /Pages node, or shared with another page) still named the removed /// image and form, so a full-rewrite save kept them. @@ -2117,6 +3197,479 @@ fn an_image_a_form_drew_through_the_pages_resources_does_not_survive() { assert!(!bytes.windows(16).any(|w| w == b"REDIMGONECOVERED"), "the image the form drew survived the save"); } +/// How a form can have no resources of its own: no /Resources, a null one, or a reference to an +/// object that isn't there (which is null too). +const NO_RESOURCES: [&str; 3] = ["", "/Resources null", "/Resources 99 0 R"]; + +/// The page draws an image under the mark and, outside it, a form without /Resources of its own +/// that draws the same image through the page's: the page keeps the name, whether the form lies +/// wholly outside the mark or is entered and left as it was. +#[test] +fn an_image_a_form_draws_again_through_the_pages_resources_is_kept() { + for bbox in ["[0 0 100 100]", "[0 0 300 300]"] { + for none in NO_RESOURCES { + let mut doc = one_page( + b"q 50 0 0 50 20 230 cm /Im1 Do Q /FmBare Do", + "/XObject << /FmBare 7 0 R /Im1 8 0 R >>", + vec![ + stream(&format!("/Type /XObject /Subtype /Form /BBox {bbox} {none}"), b"q 50 0 0 50 20 20 cm /Im1 Do Q"), + stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBIMGSHOWNAGAIN"), + ], + ); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.images_removed, r.forms_rewritten), (1, 0), "{bbox} {none}: {r:?}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(content(&saved, 0).contains("/FmBare Do"), "{bbox} {none}"); + assert_eq!(named_image(&saved, 0, b"Im1").as_deref(), Some(&b"PUBIMGSHOWNAGAIN"[..]), "{bbox} {none}: the form still draws it"); + } + } +} + +/// The same one level down: a form with resources of its own draws the image under the mark and, +/// outside it, a form without any that draws it again through the first form's. +#[test] +fn an_image_a_nested_form_draws_again_through_its_callers_resources_is_kept() { + for none in NO_RESOURCES { + let mut doc = one_page( + b"/FmOwn Do", + "/XObject << /FmOwn 7 0 R >>", + vec![ + stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /XObject << /FmBare 8 0 R /Im1 9 0 R >> >>", + b"q 50 0 0 50 20 230 cm /Im1 Do Q /FmBare Do", + ), + stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 100 100] {none}"), b"q 50 0 0 50 20 20 cm /Im1 Do Q"), + stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBIMGSHOWNAGAIN"), + ], + ); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.images_removed, r.forms_rewritten), (1, 1), "{none}: {r:?}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(content(&saved, 0).contains("/PCRedacted1 Do"), "{none}"); + let form = named_stream(&saved, 0, b"PCRedacted1").expect("the rewritten form"); + assert!(form.decoded().unwrap().windows(10).any(|w| w == b"/FmBare Do"), "{none}"); + let res = saved.resolve(form.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = saved.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + let image = xo.get(b"Im1").and_then(Object::as_ref).unwrap_or_else(|| panic!("{none}: the rewritten form still names /Im1")); + let Object::Stream(image) = &*saved.get(image) else { panic!("/Im1 is a stream") }; + assert_eq!(decoded_image(&saved, image).unwrap(), b"PUBIMGSHOWNAGAIN", "{none}"); + } +} + +/// A form without /Resources that won't decode may draw the image under the mark through the +/// page's: the redaction is refused. A page with fonts or properties to clean up refused it +/// already; this one has neither. +#[test] +fn a_form_that_cannot_be_read_refuses_the_redaction_of_an_image_it_may_draw() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /XObject << /FmBroken 5 0 R /Im1 6 0 R >> >> >>" + .to_vec(), + stream("", b"q 50 0 0 50 20 230 cm /Im1 Do Q /FmBroken Do"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Filter /DCTDecode", b"q 50 0 0 50 20 20 cm /Im1 Do Q"), + stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBIMGSHOWNAGAIN"), + ]); + let Object::Stream(broken) = &*doc.get(ObjRef::new(5, 0)) else { panic!("the form is a stream") }; + assert!(broken.decoded().is_err(), "the fixture's form must not decode"); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + assert!(matches!(apply(&mut doc, None), Err(RedactError::Cos(pdfcraft_cos::CosError::Filter(_))))); +} + +/// A page of two content streams, with no fonts or properties: the first draws an image at +/// (20, 230), 50 pt square, named `image` in the page's resources; the second is `tail`, which +/// draws nothing. `catalog` goes in the catalog, and `image_dict` in the image's dictionary. +fn image_and_a_tail(catalog: &str, image: &str, image_dict: &str, tail: &[u8]) -> Document { + pdf(vec![ + format!("<< /Type /Catalog /Pages 2 0 R {catalog} >>").into_bytes(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents [4 0 R 5 0 R] /Resources << /XObject << /{image} 6 0 R >> >> >>") + .into_bytes(), + stream("", format!("q 50 0 0 50 20 230 cm /{image} Do Q").as_bytes()), + stream("", tail), + stream( + &format!("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 {image_dict}"), + b"REDIMGONECOVERED", + ), + b"<< /Type /OCG /Name (Secret layer) >>".to_vec(), + ]) +} + +/// The refusal of the check of what content still draws through its resources (`resource_ops`). +fn unread_content_refusal(result: &Result) -> bool { + matches!(result, Err(RedactError::Cos(pdfcraft_cos::CosError::Syntax { detail, .. })) + if detail == "redaction could not fully read the content that uses these resources") +} + +/// What the page draws can't be told when a content stream the redaction leaves as it was holds +/// bytes the parser can't read: the redaction of an image is refused, as it was already on a page +/// with fonts or properties to clean up. +#[test] +fn unreadable_bytes_in_a_stream_left_as_it_was_refuse_the_redaction_of_an_image() { + for (tail, refused) in [(&b"q Q )"[..], true), (b"q Q", false)] { + let mut doc = image_and_a_tail("", "Im1", "", tail); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + let result = apply(&mut doc, None); + if refused { + assert!(unread_content_refusal(&result), "{result:?}"); + } else { + assert_eq!(result.map(|r| r.images_removed).ok(), Some(1)); + } + } +} + +/// Remove Hidden Information makes the same check: removing an off layer's image is refused on a +/// page whose drawing can't be told. +#[test] +fn unreadable_bytes_in_a_stream_left_as_it_was_refuse_removing_a_hidden_image() { + use crate::sanitize::{Hidden, remove_hidden}; + for (tail, refused) in [(&b"q Q )"[..], true), (b"q Q", false)] { + let mut doc = image_and_a_tail("/OCProperties << /OCGs [7 0 R] /D << /OFF [7 0 R] >> >>", "ImOC", "/OC 7 0 R", tail); + let result = remove_hidden(&mut doc, &[Hidden::HiddenLayers]); + if refused { + assert!(unread_content_refusal(&result), "{result:?}"); + } else { + assert!(result.unwrap().iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0)); + } + } +} + +/// Remove Hidden Information keeps a name that a form without resources still draws, too. Hidden +/// text includes text wholly off the page, so what it removes from a form depends on where it is +/// drawn: the page's /Fm, drawn off the page, is rewritten, while /G draws /Fm again on the page, +/// where its text shows, and stays as it was. The page still names /Fm, with its text. +#[test] +fn removing_hidden_text_keeps_a_form_a_form_without_resources_still_draws() { + use crate::sanitize::{Hidden, remove_hidden}; + for none in NO_RESOURCES { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /XObject << /Fm 5 0 R /G 6 0 R >> >> >>".to_vec(), + stream("", b"q 1 0 0 1 1000 0 cm /Fm Do Q /G Do"), + stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 7 0 R >> >>", + b"BT /F1 10 Tf 20 20 Td (KEEPTEXT) Tj ET", + ), + stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 300 300] {none}"), b"/Fm Do"), + FONT.replace("95 0 R", "8 0 R").into_bytes(), + widths(), + ]); + let removed = remove_hidden(&mut doc, &[Hidden::HiddenText]).unwrap(); + assert!(removed.iter().any(|(h, n)| *h == Hidden::HiddenText && *n > 0), "{none:?}: nothing was removed"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(content(&saved, 0).contains("/G Do"), "{none:?}: /G was rewritten"); + let fm = named_stream(&saved, 0, b"Fm").and_then(|s| s.decoded().ok()); + assert!(fm.is_some_and(|d| d.windows(8).any(|w| w == b"KEEPTEXT")), "{none:?}: the page lost /Fm, which /G still draws"); + } +} + +/// The page's own check follows only forms without resources of their own: a form that names a +/// different image /Im1 in resources of its own doesn't keep the page's redacted /Im1. +#[test] +fn the_pages_check_does_not_follow_a_form_with_resources_of_its_own() { + let image = "/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8"; + let doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [] /Count 0 >>".to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 1 1] /Resources << /XObject << /Im1 4 0 R >> >>", b"/Im1 Do"), + stream(image, b"o"), + stream(image, b"p"), + ]); + let mut xo = Dict::from_iter([(b"FmOwn".to_vec(), Object::Ref(ObjRef::new(3, 0))), (b"Im1".to_vec(), Object::Ref(ObjRef::new(5, 0)))]); + crate::interp::prune(&doc, &mut pdfcraft_cos::DecodeBudget::page(), &mut xo, &[b"Im1".to_vec()], &[b"/FmOwn Do".to_vec()]).unwrap(); + assert!(!xo.contains(b"Im1") && xo.contains(b"FmOwn"), "{xo:?}"); +} + +/// What content draws through its resources is read up to 2,000,000 operators, those of the forms +/// it draws without resources of their own included; past that it is refused. +#[test] +fn what_content_draws_through_its_resources_is_read_up_to_two_million_operators() { + let doc = Document::new_empty(); + for (ops, read) in [(2_000_000, true), (2_000_001, false)] { + let content = b"n ".repeat(ops); + assert_eq!( + crate::interp::resource_ops(&doc, &mut pdfcraft_cos::DecodeBudget::page(), &Dict::new(), &[content]).is_ok(), + read, + "{ops} operators" + ); + } + // The content's own `/Fm Do` is one of them. + for (ops, read) in [(1_999_999, true), (2_000_000, false)] { + let doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [] /Count 0 >>".to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 1 1]", b"n ".repeat(ops)), + ]); + let res = Dict::from_iter([(b"XObject".to_vec(), Object::Dict(Dict::from_iter([(b"Fm".to_vec(), Object::Ref(ObjRef::new(3, 0)))])))]); + assert_eq!( + crate::interp::resource_ops(&doc, &mut pdfcraft_cos::DecodeBudget::page(), &res, &[b"/Fm Do".to_vec()]).is_ok(), + read, + "a form of {ops} operators" + ); + } +} + +/// `content` behind a PNG predictor (Up, one column) whose `/DecodeParms` are given by reference, +/// in a stream with `entries` too: read without the parameters, its bytes are the predictor's tags +/// and differences, not the operators. +fn behind_parms_by_reference(doc: &mut Document, entries: &[(&str, Object)], content: &[u8]) -> Stream { + let mut rows = Vec::new(); + let mut above = 0u8; + for &b in content { + rows.extend([2, b.wrapping_sub(above)]); + above = b; + } + let parms = doc.add(Object::Dict(Dict::from_iter([(b"Predictor".to_vec(), Object::Int(12))]))); + let mut s = Stream::flate(Dict::new(), &rows); + for (key, value) in entries { + s.dict.set(key.as_bytes().to_vec(), value.clone()); + } + s.dict.set(b"DecodeParms".to_vec(), Object::Ref(parms)); + s +} + +/// A form's entries: `/Subtype /Form` and `bbox`. +fn form_entries(bbox: [i64; 4]) -> Vec<(&'static str, Object)> { + vec![("Subtype", Object::name("Form")), ("BBox", Object::Array(bbox.into_iter().map(Object::Int).collect()))] +} + +/// A form outside the mark draws the image again through the page's resources, its content behind +/// a predictor whose `/DecodeParms` are given by reference. Both checks of whether the page still +/// draws it read the form through them, the page's (`resource_ops`) and the unlinking one at the +/// end (`names_content_draws`): the page keeps the image for the form. +#[test] +fn an_image_a_form_with_parameters_by_reference_draws_again_is_kept() { + let mut doc = one_page( + b"q 50 0 0 50 20 230 cm /Im1 Do Q /FmBare Do", + "/XObject << /FmBare 7 0 R /Im1 8 0 R >>", + vec![ + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100]", b""), + stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBIMGSHOWNAGAIN"), + ], + ); + let form = behind_parms_by_reference(&mut doc, &form_entries([0, 0, 100, 100]), b"q 50 0 0 50 20 20 cm /Im1 Do Q"); + doc.set(ObjRef::new(7, 0), form); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.images_removed, 1, "{r:?}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_image(&saved, 0, b"Im1").as_deref(), Some(&b"PUBIMGSHOWNAGAIN"[..]), "the form still draws it"); +} + +/// A surviving form outside the mark shares the page's resources and draws the image itself, its +/// content behind a predictor whose `/DecodeParms` are given by reference: unlinking the redacted +/// image from the forms' resources reads it through them, and keeps the image the form draws. +#[test] +fn an_image_a_surviving_form_with_parameters_by_reference_draws_is_kept() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"q 100 0 0 100 0 0 cm /Im Do Q /Fm Do"), + b"<< /XObject << /Im 6 0 R /Fm 7 0 R >> >>".to_vec(), + stream("/Type /XObject /Subtype /Image /Width 11 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"IMAGESECRET"), + stream("/Type /XObject /Subtype /Form /BBox [150 150 300 300]", b""), + ]); + let mut entries = form_entries([150, 150, 300, 300]); + entries.push(("Resources", Object::Ref(ObjRef::new(5, 0)))); + let form = behind_parms_by_reference(&mut doc, &entries, b"q 50 0 0 50 200 200 cm /Im Do Q"); + doc.set(ObjRef::new(7, 0), form); + mark(&mut doc, 0, &[[0.0, 0.0, 120.0, 120.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_removed, 1); + assert!(surviving_references::full_save_holds(&doc, b"IMAGESECRET"), "the image the form still draws outside the mark"); +} + +/// A form without resources of its own crosses the mark and draws an image, or text, under it, +/// its content behind a predictor whose `/DecodeParms` are given by reference. The walk reads it +/// through them and removes what lies under the mark: the image, whether the page draws it under +/// the mark too or only the form does, and the text's glyphs. Read without them, the form looked +/// empty and was left as it was, still drawing them, and the page's check, which reads it with +/// them, kept the image's name for it. +#[test] +fn what_a_form_with_parameters_by_reference_draws_under_the_mark_is_removed() { + let mut doc = one_page(b"/FmBare Do", "/XObject << /FmBare 7 0 R >>", vec![stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100]", b"")]); + let mut entries = form_entries([0, 0, 100, 100]); + // Its drawing moved up by 210: the text lands at y 250, under the mark. + entries.push(("Matrix", Object::Array([1, 0, 0, 1, 0, 210].into_iter().map(Object::Int).collect()))); + let form = behind_parms_by_reference(&mut doc, &entries, b"BT /F1 10 Tf 20 40 Td (SECRETVALUE) Tj ET"); + doc.set(ObjRef::new(7, 0), form); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.glyphs, r.forms_rewritten), (11, 1), "the text the form draws under the mark: {r:?}"); + for page in [&b"q 50 0 0 50 20 230 cm /Im1 Do Q /FmBare Do"[..], b"/FmBare Do"] { + let mut doc = one_page( + page, + "/XObject << /FmBare 7 0 R /Im1 8 0 R >>", + vec![ + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100]", b""), + stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"REDIMGONECOVERED"), + ], + ); + let mut entries = form_entries([0, 0, 100, 100]); + // Its drawing moved up by 210: the image lands at y 230 to 280, under the mark. + entries.push(("Matrix", Object::Array([1, 0, 0, 1, 0, 210].into_iter().map(Object::Int).collect()))); + let form = behind_parms_by_reference(&mut doc, &entries, b"q 50 0 0 50 20 20 cm /Im1 Do Q"); + doc.set(ObjRef::new(7, 0), form); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.forms_rewritten, 1, "{}: {r:?}", String::from_utf8_lossy(page)); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + assert!(!bytes.windows(16).any(|w| w == b"REDIMGONECOVERED"), "{}: the image the form drew under the mark", String::from_utf8_lossy(page)); + } +} + +/// A form without resources of its own that the redaction rewrites gets a new name. It keeps none +/// of its own either when its copy of the page's loses nothing, which takes a form drawing itself +/// (here off the page, so outside the mark), and then goes on drawing through the page's: itself +/// and, through a form it draws unchanged, the image under the mark again outside it. The page's +/// check of what it still draws follows the rewritten form under its new name, so the page keeps +/// both names for it. +#[test] +fn an_image_a_rewritten_form_draws_again_through_the_pages_resources_is_kept() { + let mut doc = one_page( + b"q 50 0 0 50 20 230 cm /Im1 Do Q /FmBare Do", + "/XObject << /FmBare 7 0 R /Inner 8 0 R /Im1 9 0 R >>", + vec![ + stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300]", + b"BT /F1 10 Tf 20 250 Td (SECRET) Tj ET /Inner Do q 1 0 0 1 0 -1000 cm /FmBare Do Q", + ), + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100]", b"q 50 0 0 50 20 20 cm /Im1 Do Q"), + stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBIMGSHOWNAGAIN"), + ], + ); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.images_removed, r.forms_rewritten), (1, 1), "{r:?}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let page = content(&saved, 0); + let drawn: Vec> = + pdfcraft_content::parse(page.as_bytes()).ops.iter().filter(|op| op.is("Do")).filter_map(|op| op.last_name().map(<[u8]>::to_vec)).collect(); + assert!(drawn.len() == 1 && drawn[0] != b"FmBare", "the form is drawn under its new name: {page}"); + let rewritten = named_stream(&saved, 0, &drawn[0]).unwrap(); + assert!(!rewritten.dict.contains(b"Resources"), "the rewritten form keeps no resources of its own"); + assert!(named_stream(&saved, 0, b"FmBare").is_some(), "the rewritten form still draws the original"); + assert_eq!(named_image(&saved, 0, b"Im1").as_deref(), Some(&b"PUBIMGSHOWNAGAIN"[..]), "the unchanged form still draws it"); +} + +/// The walk of what content draws through its resources charges the forms it reads to the +/// allowance it is given: a form decoding to more than is left is refused, one within it read. +#[test] +fn what_content_draws_through_its_resources_is_read_within_its_allowance() { + let doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [] /Count 0 >>".to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 1 1]", b"n ".repeat(1000)), + ]); + let res = Dict::from_iter([(b"XObject".to_vec(), Object::Dict(Dict::from_iter([(b"Fm".to_vec(), Object::Ref(ObjRef::new(3, 0)))])))]); + for (allowance, read) in [(2000, true), (1999, false)] { + let mut budget = pdfcraft_cos::DecodeBudget::new(allowance, 0); + assert_eq!(crate::interp::resource_ops(&doc, &mut budget, &res, &[b"/Fm Do".to_vec()]).is_ok(), read, "{allowance} bytes"); + } +} + +/// The cleanups after the content walk spend what the walk left of the page's allowance, one +/// allowance between them. A form without resources of its own that crosses the mark is read by +/// the walk, then again by the check of what the page still draws and by the font cleanup: at +/// 100 MiB, three reads are more than the page's 256 MiB, and the redaction is refused; at +/// 50 MiB it goes on. +#[test] +fn the_cleanups_after_the_content_walk_spend_what_it_left_of_the_pages_allowance() { + for (mib, refused) in [(100, true), (50, false)] { + let blank = pdfcraft_filters::encode_flate(&vec![b' '; mib << 20]); + let mut doc = one_page( + b"q 50 0 0 50 20 230 cm /Im1 Do Q /FmBare Do", + "/XObject << /FmBare 7 0 R /Im1 8 0 R >>", + vec![ + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Filter /FlateDecode", &blank), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"o"), + ], + ); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + assert_eq!(apply(&mut doc, None).is_err(), refused, "{mib} MiB"); + } +} + +/// The cleanups of a form the redaction rewrites spend the page's allowance too. The rewritten +/// form has resources of its own, so the page's cleanups don't follow it, and draws through them a +/// form without any that crosses the mark: the walk reads that form, then the check of what the +/// rewritten form still draws and its font cleanup read it again. At 100 MiB, three reads are more +/// than the page's 256 MiB, and the redaction is refused; at 50 MiB it goes on. +#[test] +fn the_cleanups_of_a_rewritten_form_spend_the_pages_allowance() { + for (mib, refused) in [(100, true), (50, false)] { + let blank = pdfcraft_filters::encode_flate(&vec![b' '; mib << 20]); + let mut doc = one_page( + b"/FmText Do", + "/XObject << /FmText 7 0 R >>", + vec![ + stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> /XObject << /FmBare 8 0 R >> >>", + b"BT /F1 10 Tf 20 250 Td (SECRET) Tj ET /FmBare Do", + ), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Filter /FlateDecode", &blank), + ], + ); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + assert_eq!(apply(&mut doc, None).is_err(), refused, "{mib} MiB"); + } +} + +/// Removing hidden text spends what its content walk left of the page's allowance too. The page +/// draws a form holding hidden text, which is rewritten, and a form without resources of its own: +/// the walk reads the second, then the check of what the page still draws and the font cleanup +/// read it again. At 100 MiB, three reads are more than the page's 256 MiB, and the removal is +/// refused; at 50 MiB it goes on. +#[test] +fn removing_hidden_text_spends_what_the_content_walk_left_of_the_pages_allowance() { + use crate::sanitize::{Hidden, remove_hidden}; + for (mib, refused) in [(100, true), (50, false)] { + let blank = pdfcraft_filters::encode_flate(&vec![b' '; mib << 20]); + let mut doc = one_page( + b"/FmHidden Do /FmBare Do", + "/XObject << /FmHidden 7 0 R /FmBare 8 0 R >>", + vec![ + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT /F1 10 Tf 3 Tr 20 250 Td (HIDDEN) Tj ET"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Filter /FlateDecode", &blank), + ], + ); + assert_eq!(remove_hidden(&mut doc, &[Hidden::HiddenText]).is_err(), refused, "{mib} MiB"); + } +} + +/// A form with resources of its own draws through those, not its caller's: one on another page +/// that names a different image /Im1 doesn't keep the redacted /Im1 in the dictionary both pages +/// inherit. +#[test] +fn a_form_with_resources_of_its_own_does_not_keep_its_callers_image() { + let image = "/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8"; + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 /Resources << /XObject << /Im1 7 0 R /FmOwn 8 0 R >> >> >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R >>".to_vec(), + stream("", b"q 50 0 0 50 20 230 cm /Im1 Do Q"), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 6 0 R >>".to_vec(), + stream("", b"/FmOwn Do"), + stream(image, b"REDIMGONECOVERED"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /XObject << /Im1 9 0 R >> >>", b"q 50 0 0 50 20 20 cm /Im1 Do Q"), + stream(image, b"PUBIMGSHOWNAGAIN"), + ]); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.images_removed, 1, "{r:?}"); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + assert!(!bytes.windows(16).any(|w| w == b"REDIMGONECOVERED"), "the redacted image survived the save"); + let saved = Document::open(Arc::new(bytes)).unwrap(); + let form = named_stream(&saved, 1, b"FmOwn").expect("page 2 still draws its form"); + let res = saved.resolve(form.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = saved.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + let Object::Stream(own) = &*saved.get(xo.get(b"Im1").and_then(Object::as_ref).unwrap()) else { panic!("/Im1 is a stream") }; + assert_eq!(decoded_image(&saved, own).unwrap(), b"PUBIMGSHOWNAGAIN", "the form's own image stays"); +} + /// Remove Hidden Information has the same rewrite: an off layer's image named by an inherited /// /Resources must leave the saved file too. #[test] @@ -4369,6 +5922,43 @@ fn untouched_replacement_descriptions_survive_redaction_and_hidden_text_cleanup( } } +/// An image drawn through an ICC profile has as many components as the profile's `/N` says, and +/// is cleared in place like any other, the profile given through a chain of references too. +#[test] +fn an_icc_based_image_is_cleared_in_place_through_any_references() { + for (n, chained) in [(1, false), (3, false), (1, true), (3, true)] { + let img = stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace [/ICCBased 8 0 R] /BitsPerComponent 8", vec![255u8; 2 * n]); + let icc = stream(&format!("/N {n}"), b""); + let objects = if chained { vec![img, b"9 0 R".to_vec(), icc] } else { vec![img, icc] }; + let mut doc = one_page(b"q 20 0 0 10 100 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", objects); + // The left pixel. + mark(&mut doc, 0, &[[95.0, 95.0, 109.0, 115.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.images_removed, r.images_cleared), (0, 1), "/N {n}, chained {chained}: {r:?}"); + let samples = [vec![0u8; n], vec![255u8; n]].concat(); + assert_eq!(named_image(&doc, 0, b"PCRedacted1"), Some(samples), "/N {n}, chained {chained}"); + } +} + +/// A cleared copy keeps the parameters for data in another file only while it has one: `/FFilter` +/// and `/FDecodeParms` mean nothing without a `/F` that isn't null, and may name a JBIG2 +/// dictionary that would then stay. +#[test] +fn a_cleared_copy_keeps_parameters_for_data_in_another_file_only_with_one() { + for (f, kept) in [("/F (scan.jb2)", true), ("/F 9 0 R", true), ("/F null", false), ("/F 8 0 R", false), ("/F 99 0 R", false), ("", false)] { + let dict = format!( + "/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 {f} /FFilter /JBIG2Decode /FDecodeParms << /JBIG2Globals 10 0 R >>" + ); + let objects = vec![stream(&dict, [255u8, 255]), b"null".to_vec(), b"(scan.jb2)".to_vec()]; + let doc = one_page(b"q 20 0 0 10 100 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", objects); + let Object::Stream(s) = &*doc.get(ObjRef::new(7, 0)) else { panic!("object 7 is the image") }; + let left = [[0.0, 0.0, 0.4, 1.0]]; + let copy = crate::image::clear(&doc, s, &pdfcraft_content::Matrix::IDENTITY, &left, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX)); + let Ok(Some(copy)) = copy else { panic!("{f}: cleared in a copy") }; + assert_eq!((copy.dict.contains(b"FFilter"), copy.dict.contains(b"FDecodeParms")), (kept, kept), "{f}"); + } +} + #[test] fn a_hostile_icc_component_count_is_refused_not_multiplied_into_a_crash() { // /N comes from the file. 2^60 × 8 bpc × width 2 overflows the row size; a negative /N wrapped diff --git a/crates/redact/src/tests/surviving_references.rs b/crates/redact/src/tests/surviving_references.rs index d0be2a194a..0d171b4008 100644 --- a/crates/redact/src/tests/surviving_references.rs +++ b/crates/redact/src/tests/surviving_references.rs @@ -185,6 +185,73 @@ fn a_redacted_type3_glyphs_image_does_not_survive_in_a_surviving_glyphs_form() { assert!(full_save_holds(&doc, b"PUBLIC_IMAGE_B"), "the image the surviving glyph's form draws was lost"); } +/// The same Form, its content behind a predictor whose `/DecodeParms` are given by reference: the +/// cleanup of its resources reads it through them, and keeps the image it draws. Read without +/// them, this content's bytes are all operators that use no resource, so nothing refuses and +/// the image would go. +#[test] +fn an_image_a_type3_glyphs_form_with_parameters_by_reference_draws_is_kept() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (AB) Tj ET"), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + b"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 66 /Widths [500 500] /Encoding << /Differences [65 /A /B] >> /CharProcs << /A 7 0 R /B 8 0 R >> /Resources << /XObject << /ImA 9 0 R /Fm 10 0 R >> >> >>".to_vec(), + stream("", b"/ImA Do"), + stream("", b"/Fm Do"), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"CONFIDENTIAL_IMAGE_A"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 1000 1000]", b""), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBLIC_IMAGE_B"), + ]); + let mut entries = super::form_entries([0, 0, 1000, 1000]); + entries.push(( + "Resources", + Object::Dict(Dict::from_iter([( + b"XObject".to_vec(), + Object::Dict(Dict::from_iter([(b"ImA".to_vec(), Object::Ref(ObjRef::new(9, 0))), (b"ImB".to_vec(), Object::Ref(ObjRef::new(11, 0)))])), + )])), + )); + let form = super::behind_parms_by_reference(&mut doc, &entries, b"q 1000 0 0 1000 0 0 cm /ImB Do Q"); + doc.set(ObjRef::new(10, 0), form); + mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 1); + assert!(!full_save_holds(&doc, b"CONFIDENTIAL_IMAGE_A"), "the removed glyph's image survives through the surviving glyph's form"); + assert!(full_save_holds(&doc, b"PUBLIC_IMAGE_B"), "the image the surviving glyph's form draws was lost"); +} + +/// The Form a kept glyph draws is walked, for the names its content uses, within the allowance of +/// a page: two forms without resources of their own that it draws, each past half of it, refuse +/// the redaction rather than being decoded in full. Two small ones are the control. +#[test] +fn the_form_a_kept_type3_glyph_draws_is_walked_within_the_allowance_of_a_page() { + for (size, refused) in [(pdfcraft_cos::MAX_PAGE_DECODED / 2 + 1, true), (1, false)] { + let packed = pdfcraft_filters::encode_flate(&vec![b' '; size]); + let blank = || stream("/Type /XObject /Subtype /Form /BBox [0 0 1000 1000] /Filter /FlateDecode", packed.as_slice()); + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (AB) Tj ET"), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + b"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 66 /Widths [500 500] /Encoding << /Differences [65 /A /B] >> /CharProcs << /A 7 0 R /B 8 0 R >> /Resources << /XObject << /ImA 9 0 R /Fm 10 0 R >> >> >>".to_vec(), + stream("", b"/ImA Do"), + stream("", b"/Fm Do"), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"CONFIDENTIAL_IMAGE_A"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 1000 1000] /Resources << /XObject << /Sub1 11 0 R /Sub2 12 0 R >> >>", b"/Sub1 Do /Sub2 Do"), + blank(), + blank(), + ]); + mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], ""); + let out = apply(&mut doc, None); + if refused { + assert!(matches!(out, Err(RedactError::Cos(pdfcraft_cos::CosError::Filter(_)))), "forms of {size} bytes each: {out:?}"); + } else { + assert_eq!(out.unwrap().glyphs, 1, "forms of {size} bytes each"); + } + } +} + /// When all glyphs of a Type 3 font are redacted, the entire font is removed from Resources (F-011). #[test] fn all_redacted_type3_glyphs_cause_font_removal_from_resources() { @@ -323,6 +390,139 @@ fn type3_font_used_in_a_form_xobject_has_its_redacted_glyph_removed() { assert!(!full_save_holds(&doc, b"SECRET_FORM_GLYPH_PAYLOAD"), "redacted glyph program survived"); } +/// A Type 3 font with glyphs A (its program object 7) and B (object 8), at object 6. +const TYPE3: &str = "<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 66 /Widths [500 500] /Encoding << /Differences [65 /A /B] >> /CharProcs << /A 7 0 R /B 8 0 R >> >>"; + +/// A form outside the mark shows glyph B of a Type 3 font, its content behind a predictor whose +/// `/DecodeParms` are given by reference: the font's cleanup reads it through them, and keeps B. +#[test] +fn a_type3_glyph_a_form_with_parameters_by_reference_shows_is_kept() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (A) Tj ET /Fm Do"), + b"<< /Font << /T3 6 0 R >> /XObject << /Fm 9 0 R >> >>".to_vec(), + TYPE3.as_bytes().to_vec(), + stream("", b"% SECRET_TYPE3_GLYPH_A"), + stream("", b"% PUBLIC_TYPE3_GLYPH_B"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 50]", b""), + ]); + let mut entries = super::form_entries([0, 0, 300, 50]); + entries.push(("Resources", Object::Ref(ObjRef::new(5, 0)))); + let form = super::behind_parms_by_reference(&mut doc, &entries, b"BT /T3 10 Tf 10 20 Td (B) Tj ET"); + doc.set(ObjRef::new(9, 0), form); + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 1); + assert!(!full_save_holds(&doc, b"SECRET_TYPE3_GLYPH_A"), "the redacted glyph program survived"); + assert!(full_save_holds(&doc, b"PUBLIC_TYPE3_GLYPH_B"), "the form still shows B"); +} + +/// A form the page lists but doesn't draw shows glyph B, its content behind a predictor whose +/// `/DecodeParms` are given by reference: the font's cleanup reads every surviving form, this one +/// through them, and keeps B. +#[test] +fn a_type3_glyph_an_undrawn_form_with_parameters_by_reference_shows_is_kept() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (A) Tj ET"), + b"<< /Font << /T3 6 0 R >> /XObject << /Fm 9 0 R >> >>".to_vec(), + TYPE3.as_bytes().to_vec(), + stream("", b"% SECRET_TYPE3_GLYPH_A"), + stream("", b"% PUBLIC_TYPE3_GLYPH_B"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 50]", b""), + ]); + let mut entries = super::form_entries([0, 0, 300, 50]); + entries.push(( + "Resources", + Object::Dict(Dict::from_iter([(b"Font".to_vec(), Object::Dict(Dict::from_iter([(b"T3".to_vec(), Object::Ref(ObjRef::new(6, 0)))])))])), + )); + let form = super::behind_parms_by_reference(&mut doc, &entries, b"BT /T3 10 Tf 10 20 Td (B) Tj ET"); + doc.set(ObjRef::new(9, 0), form); + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 1); + assert!(!full_save_holds(&doc, b"SECRET_TYPE3_GLYPH_A"), "the redacted glyph program survived"); + assert!(full_save_holds(&doc, b"PUBLIC_TYPE3_GLYPH_B"), "the undrawn form still shows B"); +} + +/// Glyph B's program draws an image from the font's resources, behind a predictor whose +/// `/DecodeParms` are given by reference: the font's cleanup reads it through them, and keeps the +/// image B still draws while the one only the redacted A drew goes. +#[test] +fn an_image_a_type3_glyph_with_parameters_by_reference_draws_is_kept() { + let image = "/Type /XObject /Subtype /Image /Width 12 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8"; + let font = TYPE3.replace("/CharProcs", "/Resources << /XObject << /ImA 9 0 R /ImB 10 0 R >> >> /CharProcs"); + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (A) Tj 0 100 Td (B) Tj ET"), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + font.into_bytes(), + stream("", b"/ImA Do"), + stream("", b""), + stream(image, b"SECRET_IMG_A"), + stream(image, b"PUBLIC_IMG_B"), + ]); + let glyph = super::behind_parms_by_reference(&mut doc, &[], b"/ImB Do"); + doc.set(ObjRef::new(8, 0), glyph); + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 1); + assert!(!full_save_holds(&doc, b"SECRET_IMG_A"), "the image only the redacted glyph drew survived"); + assert!(full_save_holds(&doc, b"PUBLIC_IMG_B"), "glyph B still draws its image"); +} + +/// Whether `out` is the redaction refused with a syntax error whose detail holds `detail`. +fn refused_for(out: &Result, detail: &str) -> bool { + matches!(out, Err(RedactError::Cos(pdfcraft_cos::CosError::Syntax { detail: d, .. })) if d.contains(detail)) +} + +/// The font's cleanup scans every page and form for the glyphs they show. A string left open in a +/// form nothing else reads (resources of its own, outside the mark) runs to the end of the +/// stream, hiding the glyph the form shows after it, so the redaction is refused: with the check +/// off, that glyph loses its program. +#[test] +fn unreadable_bytes_in_a_form_the_type3_cleanup_scans_refuse_the_redaction() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (A) Tj ET /Fm Do"), + b"<< /Font << /T3 6 0 R >> /XObject << /Fm 9 0 R >> >>".to_vec(), + TYPE3.as_bytes().to_vec(), + stream("", b"% SECRET_TYPE3_GLYPH_A"), + stream("", b"% PUBLIC_TYPE3_GLYPH_B"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 50] /Resources << /Font << /T3 6 0 R >> >>", b"BT /T3 10 Tf 10 20 Td ( (B) Tj ET"), + ]); + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + let out = apply(&mut doc, None); + assert!(refused_for(&out, "content stream syntax"), "{out:?}"); +} + +/// The font's cleanup reads the program of each glyph it keeps, for the resources it uses. A +/// string left open there runs to the end of the program, hiding the image it draws after it, so +/// the redaction is refused: with the check off, the font's resources lose that image. +#[test] +fn unreadable_bytes_in_a_kept_type3_glyph_program_refuse_the_redaction() { + let font = TYPE3.replace("/CharProcs", "/Resources << /XObject << /ImB 9 0 R >> >> /CharProcs"); + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (A) Tj 0 100 Td (B) Tj ET"), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + font.into_bytes(), + stream("", b"% SECRET_TYPE3_GLYPH_A"), + stream("", b"500 0 d0 ( /ImB Do"), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"o"), + ]); + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + let out = apply(&mut doc, None); + assert!(refused_for(&out, "glyph stream syntax"), "{out:?}"); +} + /// A Form the surviving glyph draws holds `/Resources` as an array that references the removed /// glyph's image: it can't be pruned, so the redaction refuses rather than keep the image. #[test] @@ -430,6 +630,36 @@ fn a_type3_font_only_an_appearance_without_resources_selects_stays_on_the_page() assert!(saved_first_page_lists_font(&doc, b"T4"), "the page no longer lists the font the appearance selects"); } +/// The same appearance, its content behind a predictor whose `/DecodeParms` are given by +/// reference: the page's cleanup reads it through them, and keeps the font it selects. +#[test] +fn a_type3_font_only_an_appearance_with_parameters_by_reference_selects_stays_on_the_page() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R /Annots [8 0 R] >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (AC) Tj ET"), + b"<< /Font << /T3 6 0 R /T4 7 0 R >> >>".to_vec(), + type3_abc(10), + type3_abc(13), + b"<< /Type /Annot /Subtype /FreeText /Rect [150 200 250 220] /P 3 0 R /DA (/T4 10 Tf 0 g) /AP << /N 9 0 R >> >>".to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 20]", b""), + stream("", b"% REDACTED_GLYPH_A"), + stream("", b"% UNUSED_GLYPH_B"), + stream("", b"% PAGE_GLYPH_C"), + stream("", b"% UNUSED_T4_GLYPH_A"), + stream("", b"% APPEARANCE_GLYPH_B"), + stream("", b"% UNUSED_T4_GLYPH_C"), + ]); + let appearance = super::behind_parms_by_reference(&mut doc, &super::form_entries([0, 0, 100, 20]), b"BT /T4 10 Tf 2 5 Td (B) Tj ET"); + doc.set(ObjRef::new(9, 0), appearance); + mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 1); + assert!(!full_save_holds(&doc, b"REDACTED_GLYPH_A"), "the redacted glyph program survived"); + assert!(full_save_holds(&doc, b"APPEARANCE_GLYPH_B"), "the glyph the appearance shows was unlinked"); + assert!(saved_first_page_lists_font(&doc, b"T4"), "the page no longer lists the font the appearance selects"); +} + /// The operators of a page's appearances count toward one total, as the content's do. Two /// appearances under the cap alone pass it together, and the redaction is refused rather than /// judged on part of what draws through the page's fonts. @@ -483,6 +713,35 @@ fn a_page_s_appearances_share_one_decoded_byte_allowance() { assert!(refused.is_err(), "two appearances past the page's decoded-byte allowance together were read in full: {refused:?}"); } +/// The appearances are read within what the page's content left of the page's allowance, not +/// within one of their own: 1 MiB of content and an appearance within half a MiB of the allowance +/// together pass it, though the appearance alone fits. A small appearance is the control. +#[test] +fn a_page_s_appearances_spend_what_its_content_left_of_the_allowance() { + let mut content = b"BT /T3 10 Tf 10 100 Td (AC) Tj ET".to_vec(); + content.resize(1 << 20, b' '); + let content = pdfcraft_filters::encode_flate(&content); + for (size, refused) in [(pdfcraft_cos::MAX_PAGE_DECODED - (1 << 19), true), (1, false)] { + let appearance = pdfcraft_filters::encode_flate(&vec![b' '; size]); + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R /Annots [7 0 R] >>".to_vec(), + stream("/Filter /FlateDecode", content.as_slice()), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + type3_abc(9), + b"<< /Type /Annot /Subtype /FreeText /Rect [150 200 250 220] /P 3 0 R /AP << /N 8 0 R >> >>".to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 20] /Filter /FlateDecode", appearance.as_slice()), + stream("", b"% GLYPH_A"), + stream("", b"% GLYPH_B"), + stream("", b"% GLYPH_C"), + ]); + mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], ""); + let out = apply(&mut doc, None); + assert_eq!(out.is_err(), refused, "an appearance of {size} bytes: {out:?}"); + } +} + /// Whether the first page of a full save of `doc` lists the font `name` in its resources. fn saved_first_page_lists_font(doc: &Document, name: &[u8]) -> bool { let bytes = write_full(doc, &SaveOptions::default()).unwrap(); @@ -524,6 +783,41 @@ fn a_type3_font_only_a_soft_mask_without_resources_selects_stays_on_the_page() { assert!(saved_first_page_lists_font(&doc, b"T4"), "the page no longer lists the font the soft mask selects"); } +/// The same group, its content behind a predictor whose `/DecodeParms` are given by reference: +/// the page's cleanup reads it through them, and keeps the font it selects. +#[test] +fn a_type3_font_only_a_soft_mask_with_parameters_by_reference_selects_stays_on_the_page() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (AC) Tj ET /GS1 gs 150 150 100 100 re f"), + b"<< /Font << /T3 6 0 R /T4 7 0 R >> /ExtGState << /GS1 << /Type /ExtGState /SMask << /Type /Mask /S /Luminosity /G 8 0 R >> >> >> >>" + .to_vec(), + type3_abc(9), + type3_abc(12), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b""), + stream("", b"% REDACTED_GLYPH_A"), + stream("", b"% UNUSED_GLYPH_B"), + stream("", b"% PAGE_GLYPH_C"), + stream("", b"% UNUSED_T4_GLYPH_A"), + stream("", b"% MASK_GLYPH_B"), + stream("", b"% UNUSED_T4_GLYPH_C"), + ]); + let mut entries = super::form_entries([0, 0, 300, 300]); + entries.push(( + "Group", + Object::Dict(Dict::from_iter([(b"S".to_vec(), Object::name("Transparency")), (b"CS".to_vec(), Object::name("DeviceGray"))])), + )); + let group = super::behind_parms_by_reference(&mut doc, &entries, b"1 g BT /T4 40 Tf 160 180 Td (B) Tj ET"); + doc.set(ObjRef::new(8, 0), group); + mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 1); + assert!(!full_save_holds(&doc, b"REDACTED_GLYPH_A"), "the redacted glyph program survived"); + assert!(full_save_holds(&doc, b"MASK_GLYPH_B"), "the glyph the soft mask shows was unlinked"); + assert!(saved_first_page_lists_font(&doc, b"T4"), "the page no longer lists the font the soft mask selects"); +} + /// The cell of a tiling pattern another page fills with shows glyph B: its program is kept. (On /// the redacted page itself, a pattern that shows text refuses the redaction: `pattern_guard`.) #[test] diff --git a/crates/redact/src/type3.rs b/crates/redact/src/type3.rs index 7347bfdb82..4c3f2211e8 100644 --- a/crates/redact/src/type3.rs +++ b/crates/redact/src/type3.rs @@ -131,11 +131,13 @@ struct UsedInGlyphs { } fn collect_used_resources_in_stream(data: &[u8], used: &mut UsedInGlyphs) -> Result<(), CosError> { - let parsed = pdfcraft_content::parse(data); - if parsed.skipped > 0 { + // One operator at a time, so a glyph program of millions of them is never held whole. A byte + // that can't be read anywhere in it still refuses, once the loop has read it all. + let mut read = pdfcraft_content::ops(data); + read.by_ref().for_each(|op| record_used(&op, used)); + if read.skipped() > 0 { return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect glyph stream syntax".into() }); } - parsed.ops.iter().for_each(|op| record_used(op, used)); Ok(()) } @@ -218,9 +220,13 @@ fn prune_font_forms(doc: &mut Document, font_res: &Dict) -> Result<(), CosError> } }, }; - let data = form.decoded()?; + let data = doc.with_direct_parms(form).decoded()?; let mut used = UsedInGlyphs::default(); - crate::interp::resource_ops(doc, &res, std::slice::from_ref(&data))?.iter().for_each(|op| record_used(op, &mut used)); + // Each Form's walk has the allowance of a page, as `prune_retired_properties` gives each + // content it reads. + crate::interp::resource_ops(doc, &mut pdfcraft_cos::DecodeBudget::page(), &res, std::slice::from_ref(&data))? + .iter() + .for_each(|op| record_used(op, &mut used)); // `rg`, `g` and `k` draw through these entries when the Form keeps them as its defaults, // and nothing cites them with `cs`/`CS`: pruning them would change the Form's colours. used.colorspaces.extend([b"DefaultRGB".to_vec(), b"DefaultGray".to_vec(), b"DefaultCMYK".to_vec()]); @@ -287,15 +293,14 @@ fn scan_content_stream( return Err(CosError::Syntax { offset: 0, detail: "redaction exceeded max depth inspecting Type 3 font content".into() }); } let joined = Pieces::join(content); - let parsed = joined.parse(); - if parsed.skipped > 0 { - return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect content stream syntax".into() }); - } + // One operator at a time, so content of millions of them is never held whole. The scan only + // reads, and a byte that can't be read anywhere still refuses, once the loop has read it all. + let mut read = pdfcraft_content::ops(joined.data()); let mut stack: Vec> = Vec::new(); let mut current_font = initial_font; - for op in parsed.ops { + for op in read.by_ref() { match op.op.as_slice() { b"q" => stack.push(current_font), b"Q" => { @@ -322,6 +327,9 @@ fn scan_content_stream( _ => apply_text_op(ctx, res, &op, &mut current_font), } } + if read.skipped() > 0 { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect content stream syntax".into() }); + } Ok(()) } @@ -490,7 +498,7 @@ fn add_soft_mask_ops(doc: &Document, res: &Dict, ops: &mut Vec 0 || ops.len().saturating_add(parsed.ops.len()) > 2_000_000 { return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect soft mask content".into() }); } @@ -517,7 +525,7 @@ fn scan_stream_object( if !ctx.seen_forms.insert((r, current_font, through)) { return Ok(()); } - let data = s.decoded()?; + let data = ctx.doc.with_direct_parms(s).decoded()?; match own_res { Some(own) => scan_content_stream(ctx, std::slice::from_ref(&data), r, &own, current_font, depth + 1), None => scan_content_stream(ctx, std::slice::from_ref(&data), holder_ref, res, current_font, depth + 1), @@ -533,8 +541,15 @@ fn gs_font_object(doc: &Document, state_obj: &Object) -> Option { /// Prune Type 3 fonts from a rewritten page or form's `/Resources` dictionary if the rewritten /// content no longer draws any text using that font name. `page` is the page's dictionary when -/// `res` are a page's resources. -pub(crate) fn prune_type3_from_resources(doc: &Document, res: &mut Dict, content: &[Vec], page: Option<&Dict>) -> Result<(), CosError> { +/// `res` are a page's resources. What it reads is charged to `budget`, what is left of the page's +/// allowance. +pub(crate) fn prune_type3_from_resources( + doc: &Document, + budget: &mut pdfcraft_cos::DecodeBudget, + res: &mut Dict, + content: &[Vec], + page: Option<&Dict>, +) -> Result<(), CosError> { let mut fonts = res_dict(doc, res, b"Font"); if fonts.is_empty() { return Ok(()); @@ -542,20 +557,20 @@ pub(crate) fn prune_type3_from_resources(doc: &Document, res: &mut Dict, content // The operators of forms that draw through these resources count too: dropping a font a child // form still shows would leave its text without one. - let mut ops = crate::interp::resource_ops(doc, res, content)?; + let mut ops = crate::interp::resource_ops(doc, budget, res, content)?; // So do those of a page's annotation appearances, and of soft mask groups, without resources of // their own. They are read only when there is a Type 3 font to keep, so other files are not // refused over them. if fonts.iter().any(|(_, f)| doc.resolve(f).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Type3"))) { if let Some(page) = page { // One total for all of them, as for the content: each appearance under the cap could - // still add up to far more than it. Their decoded bytes share one page's allowance - // too, and each is decoded only in its turn, so they are never all held at once. - let mut budget = pdfcraft_cos::DecodeBudget::page(); + // still add up to far more than it. Their decoded bytes, and their forms', are charged to + // the page's allowance, which the content and its forms spend too, and each is decoded + // only in its turn, so they are never all held at once. for r in resourceless_appearances(doc, page) { let Object::Stream(s) = &*doc.get(r) else { continue }; - let data = budget.decode(s)?; - let more = crate::interp::resource_ops(doc, res, std::slice::from_ref(&data))?; + let data = budget.decode(&doc.with_direct_parms(s))?; + let more = crate::interp::resource_ops(doc, budget, res, std::slice::from_ref(&data))?; if ops.len().saturating_add(more.len()) > 2_000_000 { return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect annotation appearances".into() }); } @@ -696,7 +711,7 @@ pub(crate) fn unlink_type3(doc: &mut Document) -> Result<(), RedactError> { for (form_ref, form_res) in &form_holders { if !ctx.seen_forms.iter().any(|(form, _, _)| form == form_ref) { let Object::Stream(s) = &*ctx.doc.get(*form_ref) else { continue }; - let data = s.decoded()?; + let data = ctx.doc.with_direct_parms(s).decoded()?; scan_content_stream(&mut ctx, std::slice::from_ref(&data), *form_ref, form_res, None, 0)?; } } @@ -830,7 +845,7 @@ fn clean_type3_font( } .into()); }; - let data = s.decoded()?; + let data = doc.with_direct_parms(s).decoded()?; collect_used_resources_in_stream(&data, &mut used_in_glyphs)?; } diff --git a/crates/redact/tests/operator_memory.rs b/crates/redact/tests/operator_memory.rs new file mode 100644 index 0000000000..f360f83cdd --- /dev/null +++ b/crates/redact/tests/operator_memory.rs @@ -0,0 +1,142 @@ +//! Memory regressions for the streams a redaction reads besides its walk: the forms it follows +//! through resources, and what the Type 3 cleanup scans and reads. Their operators are read one +//! at a time, so a stream of millions of them is never held whole. A test binary of its own, so +//! that the one test is all the process runs while its peak is measured. + +use std::sync::Arc; + +use pdfcraft_annot::{Meta, NewAnnotation, Shape, Style, add_annotation, rect_quad}; +use pdfcraft_cos::{Document, ObjRef, Object, Stream}; +use pdfcraft_redact::apply; +use pdfcraft_testkit::{Pdf, stream_obj}; + +const IMAGE: &str = "/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8"; + +/// 32 MiB of `n` operators: 16 million of them, eight times the walk's limit. Held as parsed +/// operators, at about 120 bytes each, nearly 2 GiB. +fn operators() -> Vec { + b"n\n".repeat(16 << 20) +} + +/// `form` with `content`, compressed. +fn compress(doc: &mut Document, form: ObjRef, content: &[u8]) { + let Object::Stream(s) = &*doc.get(form) else { panic!("{form:?} is a stream") }; + let dict = s.dict.clone(); + doc.set(form, Object::Stream(Stream::flate(dict, content))); +} + +fn mark(doc: &mut Document) { + let shape = Shape::Redact { quads: vec![rect_quad([0.0, 200.0, 300.0, 300.0])], overlay: String::new(), look: Default::default() }; + let style = Style::default_for(&shape); + add_annotation(doc, &NewAnnotation { page: 0, shape, style, contents: String::new(), author: "Synthetic test".into() }, &Meta::default()) + .unwrap(); +} + +/// How much the process's peak resident memory grows, in MiB, while `f` runs. `None` where the +/// peak can't be reset (`/proc/self/clear_refs` is Linux's). +fn peak_growth(f: impl FnOnce() -> T) -> Option<(T, u64)> { + fn peak_kib() -> Option { + let status = std::fs::read_to_string("/proc/self/status").ok()?; + status.lines().find_map(|l| l.strip_prefix("VmHWM:"))?.split_whitespace().next()?.parse().ok() + } + std::fs::write("/proc/self/clear_refs", "5").ok()?; + let before = peak_kib()?; + let out = f(); + Some((out, peak_kib()?.saturating_sub(before) >> 10)) +} + +/// A page that draws an image under the mark, a form `form` with resources of its own outside it, +/// and a glyph of a Type 3 font outside it, whose program is `glyph`: the Type 3 cleanup reads +/// every page and form, and the programs of the glyphs it keeps. +fn type3_page(form: &[u8], glyph: &[u8]) -> Document { + let mut doc = Document::open(Arc::new( + Pdf::new() + .objs(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /T3 7 0 R >> /XObject << /Im1 5 0 R /Fm 6 0 R >> >> >>" + .to_vec(), + stream_obj("", b"q 50 0 0 50 20 230 cm /Im1 Do Q /Fm Do BT /T3 10 Tf 10 10 Td (a) Tj ET"), + stream_obj(IMAGE, b"o"), + stream_obj("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Resources << >>", b""), + b"<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1000 1000] /FontMatrix [0.001 0 0 0.001 0 0] /CharProcs << /a 8 0 R >> /Encoding << /Differences [97 /a] >> /FirstChar 97 /LastChar 97 /Widths [1000] /Resources << >> >>" + .to_vec(), + stream_obj("", b""), + ]) + .build(), + )) + .unwrap(); + compress(&mut doc, ObjRef::new(6, 0), form); + compress(&mut doc, ObjRef::new(8, 0), glyph); + mark(&mut doc); + doc +} + +/// Four reads of a stream of 16 million operators: +/// - the page's check of what it still draws walks a form without resources of its own outside +/// the mark, and refuses at 2,000,001 operators; +/// - the unlinking check of a page nobody marked, which shares the marked page's resources, +/// looks through such a form for the image the marked page removed, and finds no `Do`; +/// - the Type 3 cleanup scans a form with resources of its own that nothing else reads, for the +/// glyphs it shows; +/// - the Type 3 cleanup reads the program of a glyph it keeps, for the resources it uses. +/// +/// Each parsed every operator first: the peak grew by 4,415, 2,526, 2,526 and 2,494 MiB. Now +/// the first holds at most one operator past the walk's limit, and the others none. +#[test] +fn the_forms_followed_through_resources_are_read_one_operator_at_a_time() { + // The page's check: an image under the mark, and the form outside it. + let mut doc = Document::open(Arc::new( + Pdf::new() + .objs(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R /Fm 6 0 R >> >> >>" + .to_vec(), + stream_obj("", b"q 50 0 0 50 20 230 cm /Im1 Do Q /Fm Do"), + stream_obj(IMAGE, b"o"), + stream_obj("/Type /XObject /Subtype /Form /BBox [0 0 100 100]", b""), + ]) + .build(), + )) + .unwrap(); + compress(&mut doc, ObjRef::new(6, 0), &operators()); + mark(&mut doc); + let Some((result, grew)) = peak_growth(|| apply(&mut doc, None)) else { return }; + assert!(result.is_err(), "the walk refuses past 2,000,000 operators: {result:?}"); + assert!(grew < 1024, "the page's check grew the peak by {grew} MiB"); + + // The unlinking check: two pages share the /Pages node's resources; the second draws the form. + let mut doc = Document::open(Arc::new( + Pdf::new() + .objs(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [6 0 R 7 0 R] /Count 2 /Resources << /XObject << /Im1 5 0 R /Fm 3 0 R >> >> >>".to_vec(), + stream_obj("/Type /XObject /Subtype /Form /BBox [0 0 100 100]", b""), + stream_obj("", b"q 50 0 0 50 20 230 cm /Im1 Do Q"), + stream_obj(IMAGE, b"o"), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 8 0 R >>".to_vec(), + stream_obj("", b"/Fm Do"), + ]) + .build(), + )) + .unwrap(); + compress(&mut doc, ObjRef::new(3, 0), &operators()); + mark(&mut doc); + let Some((result, grew)) = peak_growth(|| apply(&mut doc, Some(&[0]))) else { return }; + assert_eq!(result.map(|r| r.images_removed).ok(), Some(1), "the marked page's image goes"); + assert!(grew < 1024, "the unlinking check grew the peak by {grew} MiB"); + + let mut doc = type3_page(&operators(), b"1000 0 d0"); + let Some((result, grew)) = peak_growth(|| apply(&mut doc, None)) else { return }; + assert_eq!(result.map(|r| r.images_removed).ok(), Some(1), "the Type 3 scan: the image goes"); + assert!(grew < 1024, "the Type 3 cleanup's scan grew the peak by {grew} MiB"); + + let mut glyph = b"1000 0 d0\n".to_vec(); + glyph.extend(operators()); + let mut doc = type3_page(b"", &glyph); + let Some((result, grew)) = peak_growth(|| apply(&mut doc, None)) else { return }; + assert_eq!(result.map(|r| r.images_removed).ok(), Some(1), "the glyph program: the image goes"); + assert!(grew < 1024, "the Type 3 cleanup's read of a glyph program grew the peak by {grew} MiB"); +} diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 015f32449d..6689831df0 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -40,6 +40,7 @@ pub use model::{ MAX_GROUP_DEPTH, OutlineItem, PageInfo, PageTransition, Popup, Portfolio, PortfolioView, RemoteDestination, ReviewStates, TransitionStyle, Xfa, fill_group_text, group_primaries, link_replies, thread_tops, }; +pub use navigation::named_destination; #[cfg(test)] use navigation::{OUTLINE_DEPTH, OUTLINE_WINDOW}; #[cfg(test)] diff --git a/crates/render/src/inspect/navigation.rs b/crates/render/src/inspect/navigation.rs index 4c6e88400e..c5f2a1be86 100644 --- a/crates/render/src/inspect/navigation.rs +++ b/crates/render/src/inspect/navigation.rs @@ -1,10 +1,10 @@ #[cfg(test)] use super::core::inspect; -use super::core::spend; +use super::core::{load_options, spend}; use super::model::{DestView, LinkTarget, OutlineItem, PageInfo}; use super::{ - BTreeMap, Dictionary, HashMap, HashSet, Inspector, MAX_LABEL_BYTES, MAX_LABEL_TOTAL_BYTES, MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_WORK, - MAX_PREFIX_BYTES, MAX_PREFIX_TOTAL_BYTES, Object, ObjectId, alpha, roman, + Arc, AssertUnwindSafe, BTreeMap, Dictionary, Document, HashMap, HashSet, Inspector, LazyStructure, MAX_LABEL_BYTES, MAX_LABEL_TOTAL_BYTES, + MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_WORK, MAX_PREFIX_BYTES, MAX_PREFIX_TOTAL_BYTES, Object, ObjectId, Structure, alpha, catch_unwind, roman, }; /// `DocInfo::outline` holds the first this many bookmarks in outline order. Opening a document @@ -72,6 +72,17 @@ impl<'a> Inspector<'a> { catalog.get(b"Dests").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(key).ok()) } + /// The destination whose key, read as a text string (UTF-16 or PDFDocEncoding), is `name`: + /// a name tree key, then a key of the PDF 1.1 `/Dests` dictionary, whose names older files + /// write in PDFDocEncoding rather than UTF-8. + fn named_dest_by_text(&self, name: &str) -> Option<&'a Object> { + if let Some((_, dest)) = self.named.iter().find(|(key, _)| text_string(key) == name) { + return Some(dest); + } + let dests = self.doc.catalog().ok()?.get(b"Dests").ok().and_then(|o| self.dict(o))?; + dests.iter().find(|(key, _)| text_string(key) == name).map(|(_, dest)| dest) + } + /// A name tree's leaves keyed by their raw key bytes (first occurrence wins). pub(super) fn name_tree_raw( &self, @@ -460,6 +471,37 @@ fn label_work(left: &mut usize, count: usize) -> Result<(), &'static str> { Ok(()) } +/// The (0-based page, view) of the named destination `name` (ISO 32000-2 §12.3.2.4): a key of +/// the `/Names /Dests` name tree or of the PDF 1.1 catalog `/Dests` dictionary, as a link's or +/// `/OpenAction`'s name resolves. `None` when the document can't be read, has no such name, or +/// the name leads to no page of it. A key matches `name` written in UTF-8 or in PDFDocEncoding, +/// or as a text string (UTF-16 or PDFDocEncoding) that reads as `name`, the way a link's string +/// destination is read. +/// Read like [`inspect`]: the lazy reader first, lopdf's tolerant one when the lazy reader fails. +pub fn named_destination(bytes: Arc>, password: Option<&str>, name: &str) -> Option<(usize, DestView)> { + // PDFDocEncoding bytes that are also UTF-8 read as UTF-8, so only this finds "é" written C3 A9. + let pdfdoc: Option> = name.chars().map(pdfcraft_cos::pdfdoc_byte).collect(); + let find = |doc: &dyn Structure| { + let insp = Inspector::new(doc); + let dest = insp + .named_dest(name.as_bytes()) + .or_else(|| pdfdoc.as_deref().and_then(|key| insp.named_dest(key))) + .or_else(|| insp.named_dest_by_text(name))?; + insp.dest(dest, 1).filter(|(page, _)| *page < insp.page_ids.len()) + }; + let lazy = catch_unwind(AssertUnwindSafe(|| { + let doc = LazyStructure::new(bytes.clone(), password).ok()?; + let found = find(&doc); + (!doc.failed()).then_some(found) + })) + .ok() + .flatten(); + if let Some(found) = lazy { + return found; + } + catch_unwind(AssertUnwindSafe(|| find(&Document::load_mem_with_options(&bytes, load_options(password)).ok()?))).ok().flatten() +} + #[cfg(test)] mod open_dest_tests { use std::sync::Arc; @@ -522,6 +564,39 @@ mod open_dest_tests { ); } + fn named(catalog: &str, extra: &[String], name: &str) -> Option<(usize, DestView)> { + super::named_destination(Arc::new(pdf(catalog, extra)), None, name) + } + + #[test] + fn a_named_destination_is_looked_up_by_its_name() { + // A string key of the /Names /Dests name tree, whose value may be a dictionary with /D. + let tree = "/Names << /Dests 6 0 R >>"; + let nodes = ["<< /Kids [7 0 R] >>".to_string(), "<< /Names [(here) [3 0 R /Fit] (there) << /D [5 0 R /XYZ 100 500 2] >>] >>".to_string()]; + assert_eq!(named(tree, &nodes, "there"), Some((2, XYZ))); + assert_eq!(named(tree, &nodes, "here"), Some((0, DestView::Fit))); + // A name key of the PDF 1.1 catalog /Dests dictionary. + assert_eq!(named("/Dests << /old [4 0 R /FitH 400] >>", &[], "old"), Some((1, DestView::FitH { top: Some(400.0) }))); + // A key written as UTF-16 or in PDFDocEncoding matches the text it reads as. + assert_eq!(named("/Names << /Dests << /Names [ [4 0 R /Fit]] >> >>", &[], "thé"), Some((1, DestView::Fit))); + assert_eq!(named("/Names << /Dests << /Names [(th\\351) [5 0 R /Fit]] >> >>", &[], "thé"), Some((2, DestView::Fit))); + // So does a catalog /Dests name written in PDFDocEncoding, as older files have them, but + // a name written as UTF-8 comes first. + assert_eq!(named("/Dests << /th#E9 [4 0 R /Fit] >>", &[], "thé"), Some((1, DestView::Fit))); + assert_eq!(named("/Dests << /th#E9 [4 0 R /Fit] /th#C3#A9 [5 0 R /Fit] >>", &[], "thé"), Some((2, DestView::Fit))); + // A key whose PDFDocEncoding bytes are also UTF-8 reads as UTF-8 ("é"), yet is found by + // the text it holds in PDFDocEncoding ("é") too. + assert_eq!(named("/Dests << /#C3#A9 [4 0 R /Fit] >>", &[], "é"), Some((1, DestView::Fit))); + assert_eq!(named("/Names << /Dests << /Names [(\\303\\251) [5 0 R /Fit]] >> >>", &[], "é"), Some((2, DestView::Fit))); + // A name that isn't there, one past the last page, one that names itself, no names at + // all, and bytes that aren't a PDF. + assert_eq!(named(tree, &nodes, "missing"), None); + assert_eq!(named("/Dests << /far [7 /Fit] >>", &[], "far"), None); + assert_eq!(named("/Dests << /loop /loop >>", &[], "loop"), None); + assert_eq!(named("", &[], "there"), None); + assert_eq!(super::named_destination(Arc::new(b"not a pdf".to_vec()), None, "there"), None); + } + #[test] fn an_open_action_given_as_a_name_or_string_is_looked_up() { assert_eq!(open_dest("/OpenAction /there /Dests << /there [4 0 R /Fit] >>", &[]), Some((1, DestView::Fit))); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 0edaf558bc..da5071948e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -21,7 +21,7 @@ pub mod transition; pub use inspect::{ Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerItem, LayerOp, LayerRow, Link, LinkTarget, MAX_GROUP_DEPTH, OutlineItem, PageInfo, PageTransition, Popup, Portfolio, PortfolioView, RemoteDestination, ReviewStates, TransitionStyle, Xfa, - attachment_data, check_pages, fill_group_text, group_primaries, inspect, link_replies, pretty_date, thread_tops, + attachment_data, check_pages, fill_group_text, group_primaries, inspect, link_replies, named_destination, pretty_date, thread_tops, }; pub use link_area::{MAX_LINK_QUADS, MAX_URI_BASE_BYTES, link_quads, quad_contains, resolve_uri}; pub use pixels::Pixels; diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 1097da1997..cd88021951 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -269,7 +269,9 @@ pub struct RenderedPage { /// A non-fatal condition reported while interpreting a page. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RenderWarning { - /// Content was skipped after the per-page decoded-content budget was exhausted. + /// Content was left out at one of the renderer's limits: content past the page's + /// decoded-content budget or its JBIG2 allowance, or a picture whose JBIG2 mask is past a + /// decoder limit. ContentTruncated, /// A colour could not be computed (a colour space that cannot be read, or a Separation or /// DeviceN tint transform that cannot be evaluated) and was painted in a fallback colour. diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index 32a428c1e5..86d655677b 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -4234,7 +4234,7 @@ fn inline_images_with_ei_in_their_data_parse_in_linear_time() { /// From the nightly `cargo xtask fuzz`: a JBIG2 image whose stream declares a page and a /// generic region of 65535 × 65535 pixels (each side within JBIG2's limit) behind a small /// /Width and /Height: decoding its 4.3 billion pixels one by one took minutes. Vendored -/// hayro-jbig2 patch: at most 2^28 pixels per bitmap, and 2^26 per symbol dictionary. +/// hayro-jbig2 patch: at most 2^28 pixels per bitmap, and as many per symbol dictionary. #[test] fn jbig2_regions_of_billions_of_pixels_are_refused() { // Embedded JBIG2 segments (ISO 14492 §7.2): page information, then an immediate generic @@ -4302,9 +4302,14 @@ fn jbig2_segment(out: &mut Vec, number: u32, kind: u8, refs: &[u8], data: &[ /// The page information of a 16 × 2 JBIG2 page whose default pixel is black, so that the image, if /// it decodes, draws black whatever its other segments hold. fn black_jbig2_page() -> Vec { + black_jbig2_page_of(16, 2) +} + +/// [`black_jbig2_page`], `width` × `height`. +fn black_jbig2_page_of(width: u32, height: u32) -> Vec { let mut page = Vec::new(); - page.extend_from_slice(&16u32.to_be_bytes()); - page.extend_from_slice(&2u32.to_be_bytes()); + page.extend_from_slice(&width.to_be_bytes()); + page.extend_from_slice(&height.to_be_bytes()); page.extend_from_slice(&[0; 8]); // resolution page.extend_from_slice(&[4, 0, 0]); // flags (default pixel black), striping let mut stream = Vec::new(); @@ -4495,6 +4500,289 @@ fn a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn() { assert_eq!(other, (true, false), "and a page with nothing left says nothing of a mask that isn't JBIG2"); } +/// A symbol dictionary may hold as many pixels as the largest page bitmap. It was held to a +/// quarter of that, and the soft mask of a scanned drawing, one dictionary of 7,559 symbols, passed +/// it: refused, the mask left its picture drawn whole, which covered the page in noise (#6722). +#[test] +fn a_symbol_dictionary_holds_as_many_pixels_as_a_page() { + let stream = |heights: &[u32]| { + let mut stream = black_jbig2_page(); + jbig2_segment(&mut stream, 1, 0, &[], &wide_jbig2_dictionary(65535, heights)); + stream + }; + assert!(jbig2_image_draws(&stream(&[1100])), "72,088,500 pixels, past 2^26"); + assert!(jbig2_image_draws(&stream(&[2047, 2048])), "268,365,825 pixels, within 2^28"); + assert!(!jbig2_image_draws(&stream(&[2048, 2049])), "268,496,895 pixels, past 2^28"); +} + +/// A JBIG2 mask one of the decoder's limits refuses takes its picture with it, as one left out for +/// the page's allowance does: the file isn't to blame, and a scan's colour layer drawn without its +/// mask covers the page in noise (#6722). A mask the file damaged leaves its picture whole, as +/// upstream draws it. A size the file damaged can read as past a limit, and is taken for one. +#[test] +fn a_picture_whose_jbig2_mask_a_decoder_limit_refuses_is_not_drawn() { + let draws = |entry: &str, (width, height): (u32, u32), mask: &[u8]| { + let content = b"q 20 0 0 20 5 5 cm /Im1 Do Q"; + let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj\n".to_vec(); + pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes()); + pdf.extend_from_slice(content); + pdf.extend_from_slice(b"\nendstream endobj\n"); + pdf.extend_from_slice( + format!("5 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceGray {entry} 6 0 R /Length 1 >> stream\n\0\nendstream endobj\n").as_bytes(), + ); + let kind = if entry == "/Mask" { "/ImageMask true" } else { "/ColorSpace /DeviceGray" }; + pdf.extend_from_slice( + format!( + "6 0 obj << /Type /XObject /Subtype /Image /Width {width} /Height {height} /BitsPerComponent 1 {kind} /Filter /JBIG2Decode /Length {} >> stream\n", + mask.len() + ) + .as_bytes(), + ); + pdf.extend_from_slice(mask); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + let page = PageRenderer::new(Arc::new(pdf), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + ..Default::default() + }); + assert!(page.error.is_none(), "{:?}", page.error); + let shown = match &page.rgba[((25 * 40 + 15) * 4)..][..4] { + [0, 0, 0, 255] => true, + [255, 255, 255, 255] => false, + other => panic!("neither the picture nor the page: {other:?}"), + }; + (shown, page.warnings.contains(&RenderWarning::ContentTruncated)) + }; + let out = (false, true); + // A page a column wider than the decoder's bound on a side, as the mask's dictionary says too. + let wide = black_jbig2_page_of(65536, 2); + assert_eq!(draws("/SMask", (65536, 2), &wide), out, "a soft mask past the decoder's bound on a side"); + assert_eq!(draws("/Mask", (65536, 2), &wide), out, "a stencil mask past it"); + // A page of 65,535 rows and columns, past the pixels the renderer decodes, as the dictionary + // says too. + let large = black_jbig2_page_of(65535, 65535); + assert_eq!(draws("/SMask", (65535, 65535), &large), out, "a soft mask past the renderer's bound on pixels"); + assert_eq!(draws("/Mask", (65535, 65535), &large), out, "a stencil mask past it"); + // A dictionary of more symbol pixels than a page holds. + let mut dictionary = black_jbig2_page(); + jbig2_segment(&mut dictionary, 1, 0, &[], &wide_jbig2_dictionary(65535, &[2048, 2049])); + assert_eq!(draws("/SMask", (16, 2), &dictionary), out, "a soft mask whose dictionary is past its bound"); + // Cut short in its dictionary: the file's damage, not a limit. + let mut damaged = black_jbig2_page(); + jbig2_segment(&mut damaged, 1, 0, &[], &wide_jbig2_dictionary(100, &[100])[..12]); + assert_eq!(draws("/SMask", (16, 2), &damaged), (true, false), "a damaged mask is no mask"); + // Control: the stream's page is far larger than the dictionary says. That is damage, which + // reads as past the decoder's bound on pixels, and is taken for it: it can't be told from a + // hostile size, and leaving the picture out is the safe side. + let disagreeing = black_jbig2_page_of(65535, 65535); + assert_eq!(draws("/SMask", (16, 2), &disagreeing), out, "a size damaged past a limit is taken for one"); + + // A stencil mask a limit refused is drawn on its own: it takes nothing else out, and is a + // failed image, not content left out (`a_jbig2_image_drawn_on_its_own_that_a_limit_refuses_is_a_failed_image`). + // Nor does it leave anything behind for the next picture: one whose mask is never decoded (it + // has no /Width) is drawn whole, as upstream draws it. + let content = b"q 4 0 0 4 30 30 cm /St Do Q q 20 0 0 20 5 5 cm /Im1 Do Q"; + let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R /St 7 0 R >> >> >> endobj\n".to_vec(); + pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes()); + pdf.extend_from_slice(content); + pdf.extend_from_slice(b"\nendstream endobj\n5 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"); + let mask = black_jbig2_page(); + pdf.extend_from_slice(format!("6 0 obj << /Type /XObject /Subtype /Image /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /Length {} >> stream\n", mask.len()).as_bytes()); + pdf.extend_from_slice(&mask); + pdf.extend_from_slice(format!("\nendstream endobj\n7 0 obj << /Type /XObject /Subtype /Image /Width 65536 /Height 2 /ImageMask true /Filter /JBIG2Decode /Length {} >> stream\n", wide.len()).as_bytes()); + pdf.extend_from_slice(&wide); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + let page = PageRenderer::new(Arc::new(pdf), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + ..Default::default() + }); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!(&page.rgba[((25 * 40 + 15) * 4)..][..4], [0, 0, 0, 255], "the picture after the stencil is drawn whole"); + assert!(page.warnings.is_empty(), "the stencil left nothing out: {:?}", page.warnings); +} + +/// A JBIG2 image drawn on its own that a limit refuses, a stencil mask included, takes nothing else +/// out, so it is a failed image, as it was before #6722: only a picture's mask refused by a limit +/// is reported as content left out, with the picture it takes along (patch 73). Refused by the +/// decoder's bound on a side or by the renderer's bound on pixels, each image's `/Width` and +/// `/Height` those of its JBIG2 page. +#[test] +fn a_jbig2_image_drawn_on_its_own_that_a_limit_refuses_is_a_failed_image() { + use hayro::hayro_interpret::font::Glyph; + use hayro::hayro_interpret::hayro_syntax::Pdf; + use hayro::hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, + TransformExt, interpret_page, + }; + use kurbo::{Affine, BezPath, Rect}; + + /// Asks each image for its pixels, which decodes it, and draws nothing. + struct Pixels; + impl<'a> Device<'a> for Pixels { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'a>, _: &PathDrawMode) {} + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, _: &Glyph<'a>, _: Affine, _: Affine, _: &Paint<'a>, _: &GlyphDrawMode) {} + fn draw_image(&mut self, image: Image<'a, '_>, _: Affine) { + match image { + Image::Raster(r) => r.with_rgba(|_, _| {}, Some((20, 20))), + Image::Stencil(s) => s.with_stencil(|_, _| {}, Some((20, 20))), + } + } + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + + // The warnings drawing one image reports. + let warnings = |kind: &str, (width, height): (u32, u32)| { + let stream = black_jbig2_page_of(width, height); + let content = "q 20 0 0 20 5 5 cm /Im Do Q"; + let mut pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> >> >> endobj\n4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n5 0 obj << /Type /XObject /Subtype /Image /Width {width} /Height {height} {kind} /Filter /JBIG2Decode /Length {} >> stream\n", + content.len(), + stream.len() + ) + .into_bytes(); + pdf.extend_from_slice(&stream); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + let pdf = Pdf::new(Arc::new(pdf)).expect("parses"); + let page = &pdf.pages()[0]; + let cache = InterpreterCache::new(); + let reported = Arc::new(Mutex::new(Vec::new())); + let settings = InterpreterSettings { + warning_sink: { + let reported = reported.clone(); + Arc::new(move |warning| reported.lock().unwrap().push(format!("{warning:?}"))) + }, + ..InterpreterSettings::default() + }; + let mut ctx = Context::new(page.initial_transform(true).to_kurbo(), Rect::new(0.0, 0.0, 40.0, 40.0), &cache, page.xref(), settings); + interpret_page(page, &mut ctx, &mut Pixels); + reported.lock().unwrap().clone() + }; + let stencil = "/ImageMask true"; + let raster = "/BitsPerComponent 1 /ColorSpace /DeviceGray"; + let failed = ["ImageDecodeFailure"]; + assert_eq!(warnings(stencil, (65536, 2)), failed, "a stencil mask past the decoder's bound on a side"); + assert_eq!(warnings(stencil, (65535, 65535)), failed, "a stencil mask past the renderer's bound on pixels"); + assert_eq!(warnings(raster, (65536, 2)), failed, "an image past the decoder's bound on a side"); + assert_eq!(warnings(raster, (65535, 65535)), failed, "an image past the renderer's bound on pixels"); +} + +/// What refused one image says nothing of the next: after a picture one of the decoder's limits +/// refused, a stencil mask and a soft mask whose filters fail before JBIG2 is reached (LZW data that +/// doesn't decode) are bad, not refused by a limit, on that page and on the next. The decoder's +/// note of the refusal was read before a decode had set it, and outlived its page: the stencil +/// said content was left out, and the picture under the soft mask wasn't drawn. +#[test] +fn a_limit_refusal_says_nothing_of_the_next_image() { + let refused = black_jbig2_page_of(65536, 2); + let bad = [0xff; 64]; + let page = |number: u8, content: u8| { + format!("{number} 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents {content} 0 R /Resources << /XObject << /Big 7 0 R /St 8 0 R /Im 9 0 R >> >> >> endobj\n").into_bytes() + }; + let content = |number: u8, text: &str| format!("{number} 0 obj << /Length {} >> stream\n{text}\nendstream endobj\n", text.len()).into_bytes(); + let object = |number: u8, dict: &str, data: &[u8]| { + let mut object = format!("{number} 0 obj << {dict} /Length {} >> stream\n", data.len()).into_bytes(); + object.extend_from_slice(data); + object.extend_from_slice(b"\nendstream endobj\n"); + object + }; + let mut pdf = + b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n".to_vec(); + pdf.extend(page(3, 5)); + pdf.extend(page(4, 6)); + pdf.extend(content(5, "q 10 0 0 10 30 30 cm /Big Do Q q 10 0 0 10 30 0 cm 0 g /St Do Q q 20 0 0 20 5 5 cm /Im Do Q")); + pdf.extend(content(6, "q 10 0 0 10 30 0 cm 0 g /St Do Q q 20 0 0 20 5 5 cm /Im Do Q")); + pdf.extend(object( + 7, + "/Type /XObject /Subtype /Image /Width 65536 /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode", + &refused, + )); + pdf.extend(object(8, "/Type /XObject /Subtype /Image /Width 16 /Height 2 /ImageMask true /Filter [/LZWDecode /JBIG2Decode]", &bad)); + pdf.extend(object(9, "/Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceGray /SMask 10 0 R", &[0])); + pdf.extend(object( + 10, + "/Type /XObject /Subtype /Image /Width 16 /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter [/LZWDecode /JBIG2Decode]", + &bad, + )); + pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF\n"); + // Both pages from one renderer, on one thread. + let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let [first, second] = + [0, 1].map(|page| renderer.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() })); + let drawn = |page: &RenderedPage| { + assert!(page.error.is_none(), "{:?}", page.error); + (page.rgba[((25 * 40 + 15) * 4)..][..4] == [0, 0, 0, 255], page.warnings.contains(&RenderWarning::ContentTruncated)) + }; + // The next page first: on the page of the refusal, the stale note shows whether or not it + // outlives the page. + assert_eq!(drawn(&second), (true, false), "on the next page, the picture is drawn and nothing was left out"); + assert_eq!(drawn(&first), (true, false), "on its page, the picture is drawn and nothing was left out"); +} + +/// A JPX picture is decoded again for each size a page draws it at, while a soft mask far larger +/// than where it is drawn is averaged down once and kept for the sizes it serves (patch 82). A +/// JBIG2 mask a decoder limit refused is kept refused, so the picture drawn at another size is left +/// out again, not drawn whole because the mask wasn't decoded that time. The scans of #6722 are +/// JPX under such a mask. +#[test] +fn a_jpx_picture_whose_jbig2_mask_a_limit_refuses_stays_out_at_every_size() { + // A page a column wider than the decoder's bound on a side, as the mask's dictionary says too. + let wide = black_jbig2_page_of(65536, 2); + // A 64 × 64 grey JPX codestream, every sample 128 (as `jpx_midpoint_pdf`'s, larger): larger + // than where it is drawn, it is decoded for each size it is drawn at. + let mut codestream = vec![0xff, 0x4f, 0xff, 0x51, 0, 41, 0, 0]; // SOC, SIZ, Rsiz. + for value in [64_u32, 64, 0, 0, 64, 64, 0, 0] { + codestream.extend_from_slice(&value.to_be_bytes()); + } + codestream.extend_from_slice(&[0, 1, 7, 1, 1]); // One component, eight bits. + // COD: one decomposition level, so it decodes at half its size for a small target; QCD: no + // quantization, the four subbands' exponents. + codestream.extend_from_slice(&[0xff, 0x52, 0, 12, 0, 0, 0, 1, 0, 1, 4, 4, 0, 1]); + codestream.extend_from_slice(&[0xff, 0x5c, 0, 7, 0x40, 0x40, 0x48, 0x48, 0x50]); + codestream.extend_from_slice(&[0xff, 0x90, 0, 10, 0, 0, 0, 0, 0, 16, 0, 1, 0xff, 0x93, 0, 0]); // SOT, SOD, two empty packets. + codestream.extend_from_slice(&[0xff, 0xd9]); // EOC. + // At 10, 20 and 10 units square: the second draw is a new size for the picture. + let content = b"q 10 0 0 10 2 2 cm /Im1 Do Q q 20 0 0 20 16 16 cm /Im1 Do Q q 10 0 0 10 2 28 cm /Im1 Do Q"; + let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj\n".to_vec(); + pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes()); + pdf.extend_from_slice(content); + pdf.extend_from_slice( + format!("\nendstream endobj\n5 0 obj << /Type /XObject /Subtype /Image /Width 64 /Height 64 /BitsPerComponent 8 /ColorSpace /DeviceGray /SMask 6 0 R /Filter /JPXDecode /Length {} >> stream\n", codestream.len()) + .as_bytes(), + ); + pdf.extend_from_slice(&codestream); + pdf.extend_from_slice( + format!("\nendstream endobj\n6 0 obj << /Type /XObject /Subtype /Image /Width 65536 /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /Length {} >> stream\n", wide.len()) + .as_bytes(), + ); + pdf.extend_from_slice(&wide); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + let page = PageRenderer::new(Arc::new(pdf), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + ..Default::default() + }); + assert!(page.error.is_none(), "{:?}", page.error); + // The middle of each draw, in rows from the top. + let shown = [(33, 7), (14, 26), (7, 7)].map(|(row, column)| page.rgba[(row * 40 + column) * 4..][..4] != [255, 255, 255, 255]); + assert_eq!(shown, [false; 3], "drawn at 10, 20 and 10"); + assert!(page.warnings.contains(&RenderWarning::ContentTruncated)); +} + /// A page's allowance ends with it, so what decodes on its thread afterwards has no page to run out /// on, and a `Context` used again starts afresh. #[test] @@ -4747,6 +5035,40 @@ fn narrow_jbig2_dictionary(symbols: u32, height: u32) -> Vec { dictionary } +/// A Huffman-coded symbol dictionary of one white symbol of `width` columns in each height class +/// of `heights` (rising), none exported: wide symbols hold many pixels in few retained words. +fn wide_jbig2_dictionary(width: u32, heights: &[u32]) -> Vec { + let mut bits = Bits::default(); + let mut previous = 0; + for &height in heights { + match height - previous { + // Height class delta (table B.4): 1, or the upper line. + 1 => bits.put(0, 1), + delta => { + bits.put(0b11111, 5); + bits.put(delta - 76, 32); + } + } + previous = height; + bits.put(0b111110, 6); // width delta, the upper line of table B.2 + bits.put(width - 75, 32); + bits.put(0b111111, 6); // end of the class (B.2) + let rows = height.div_ceil(8) + 1; // an MMR-coded white bitmap: one bit a row + bits.table_b1(rows); // BMSIZE (B.1) + bits.align(); + for _ in 0..rows { + bits.put(0xFF, 8); + } + } + let symbols = u32::try_from(heights.len()).unwrap(); + bits.table_b1(symbols); // the first export run, not exported, is all of them + let mut dictionary = vec![0x00, 0x01]; // Huffman, tables B.4, B.2, B.1, B.1 + dictionary.extend_from_slice(&0u32.to_be_bytes()); // exported + dictionary.extend_from_slice(&symbols.to_be_bytes()); // new + dictionary.extend_from_slice(&bits.bytes); + dictionary +} + /// An intermediate generic region, white, every row typical: it decodes in a few bytes however /// large it is. fn jbig2_white_region(width: u32, height: u32) -> Vec { diff --git a/crates/render/src/raster/tests/failed_image_decodes.rs b/crates/render/src/raster/tests/failed_image_decodes.rs index bd0329ed31..5e8995f23a 100644 --- a/crates/render/src/raster/tests/failed_image_decodes.rs +++ b/crates/render/src/raster/tests/failed_image_decodes.rs @@ -237,3 +237,114 @@ fn a_picture_left_out_for_the_pages_allowance_draws_on_the_next_page() { assert!(black(&second, 10) && black(&second, 30), "A, and B left out of the first page, draw on the second"); assert!(second.1.is_empty(), "{:?}", second.1); } + +/// A JBIG2 mask its decoding work refuses: four typical-row regions of 16,384 × 16,384 take more +/// than a stream may. +fn jbig2_mask_past_its_work() -> Vec { + use super::{black_jbig2_page, jbig2_segment, jbig2_white_region}; + let mut mask = black_jbig2_page(); + for number in 1..=4 { + jbig2_segment(&mut mask, number, 38, &[], &jbig2_white_region(16384, 16384)); // immediate generic region + } + mask +} + +/// What drawing 64 × 64 pictures under JBIG2 soft masks did, with the page's JBIG2 allowance +/// `work`: `/P` under `mask` (`/Width` × `/Height` `size`) and `/W` under +/// [`jbig2_mask_past_its_work`], each of `draws` a name and a size (units square). The decodes the +/// cache ran, how many times content was said to be left out, and what the device was handed. +fn draw_under_jbig2_masks(mask: &[u8], size: (u32, u32), draws: &[(&str, u32)], work: u64) -> (usize, usize, Draws) { + let pixels = vec![0; 64 * 64 * 3]; + let picture = |mask: u8| { + format!( + "<< /Type /XObject /Subtype /Image /Width 64 /Height 64 /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask {mask} 0 R /Length {} >>", + pixels.len() + ) + }; + let soft_mask = |(width, height): (u32, u32), data: &[u8]| { + format!( + "<< /Type /XObject /Subtype /Image /Width {width} /Height {height} /ColorSpace /DeviceGray /BitsPerComponent 1 /Filter /JBIG2Decode /Length {} >>", + data.len() + ) + }; + let past_its_work = jbig2_mask_past_its_work(); + let content = draws.iter().map(|(name, s)| format!("q {s} 0 0 {s} 10 10 cm /{name} Do Q")).collect::>().join(" "); + let bytes = image_cache_pdf( + &content, + "<< /XObject << /P 5 0 R /W 7 0 R >> >>", + &[(&picture(6), &pixels), (&soft_mask(size, mask), mask), (&picture(8), &pixels), (&soft_mask((16, 2), &past_its_work), &past_its_work)], + ); + let parsed = Pdf::new(Arc::new(bytes)).expect("parses"); + let page = &parsed.pages()[0]; + let cache = InterpreterCache::new(); + let truncated = Arc::new(AtomicUsize::new(0)); + let others = Arc::new(Mutex::new(Vec::new())); + let settings = InterpreterSettings { + warning_sink: { + let (truncated, others) = (truncated.clone(), others.clone()); + Arc::new(move |warning| match warning { + InterpreterWarning::ContentTruncated => { + truncated.fetch_add(1, Ordering::Relaxed); + } + other => others.lock().unwrap().push(format!("{other:?}")), + }) + }, + jbig2_work: work, + ..InterpreterSettings::default() + }; + let initial = page.initial_transform(true).to_kurbo(); + let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, 130.0, 70.0), &cache, page.xref(), settings); + let mut device = Draws::default(); + interpret_page(page, &mut ctx, &mut device); + assert!(others.lock().unwrap().is_empty(), "reported {:?}", others.lock().unwrap()); + (cache.decoded_image_decodes(), truncated.load(Ordering::Relaxed), device) +} + +/// A picture whose JBIG2 soft mask a decoder limit refuses isn't drawn (patch 73), and the limit +/// is the stream's own: decoding it again refuses it again. So the refusal is kept with the picture +/// that went with it (patch 91), and a page drawing it a hundred times decodes them once, as for +/// one draw. Kept as content left out, every draw still says so. The refusal wasn't kept, and the +/// picture, of any size, was decoded again at every draw: 2.45 s for a hundred draws of a 4,096 × +/// 4,096 JPX picture, against 0.23 s under a mask the file damaged. A mask larger than its picture +/// is averaged down for each size it is drawn at, from one kept at an intermediate size (patch 82), +/// so a second size finds the refusal kept there and keeps it for that size too, also after +/// another picture's mask was refused for its work, which isn't kept. +#[test] +fn a_picture_whose_jbig2_mask_a_limit_refuses_is_decoded_once_however_often_it_is_drawn() { + use super::{black_jbig2_page, black_jbig2_page_of, jbig2_segment, wide_jbig2_dictionary}; + let mut dictionary = black_jbig2_page(); + jbig2_segment(&mut dictionary, 1, 0, &[], &wide_jbig2_dictionary(65535, &[2048, 2049])); + let masks = [ + ("past the decoder's bound on a side", black_jbig2_page_of(65536, 2), (65536, 2)), + ("past the renderer's bound on pixels", black_jbig2_page_of(65535, 65535), (65535, 65535)), + ("whose dictionary is past its bound", dictionary, (16, 2)), + ]; + let work = InterpreterSettings::default().jbig2_work; + for (what, mask, size) in masks { + let (once, _, _) = draw_under_jbig2_masks(&mask, size, &[("P", 20)], work); + let (decodes, truncated, draws) = draw_under_jbig2_masks(&mask, size, &[("P", 20); 100], work); + assert_eq!(draws.rasters, 0, "a mask {what} leaves the picture out"); + assert_eq!(decodes, once, "a mask {what}: a hundred draws decode as one does"); + assert_eq!(truncated, 100, "a mask {what}: every draw says content was left out"); + // With no page allowance to run out of, so that the mask past its work is refused by its + // own limit and the page's allowance doesn't stop what is kept. + let each = [("P", 20), ("W", 20), ("P", 40)]; + let (once_each, _, _) = draw_under_jbig2_masks(&mask, size, &each, u64::MAX); + let (decodes, _, draws) = draw_under_jbig2_masks(&mask, size, &[&each[..], &[("P", 40); 2]].concat(), u64::MAX); + assert_eq!(draws.rasters, 0, "a mask {what} leaves the picture out at either size"); + assert_eq!(decodes, once_each, "a mask {what}: drawn again at its second size, decoded as once at each"); + } +} + +/// A JBIG2 mask refused for the work its decoding takes isn't kept: that work may be what its page +/// had left, which another page has afresh. So the picture under it is decoded at every draw, here +/// with no page allowance to run out of, where the stream's own limit is what refuses it. +#[test] +fn a_picture_whose_jbig2_mask_is_refused_for_its_work_is_decoded_at_every_draw() { + let mask = super::black_jbig2_page(); + let (once, _, _) = draw_under_jbig2_masks(&mask, (16, 2), &[("W", 20)], u64::MAX); + let (decodes, truncated, draws) = draw_under_jbig2_masks(&mask, (16, 2), &[("W", 20); 3], u64::MAX); + assert_eq!(draws.rasters, 0, "the picture is left out"); + assert_eq!(decodes, 3 * once, "decoded at every draw"); + assert_eq!(truncated, 3, "every draw says content was left out"); +} diff --git a/crates/render/src/raster/tests/isolated_cmyk_groups.rs b/crates/render/src/raster/tests/isolated_cmyk_groups.rs index c9cc9aa492..aa15019277 100644 --- a/crates/render/src/raster/tests/isolated_cmyk_groups.rs +++ b/crates/render/src/raster/tests/isolated_cmyk_groups.rs @@ -280,3 +280,48 @@ fn the_color_blend_mode_over_an_empty_cmyk_group_keeps_every_k_level() { } assert!(wrong.is_empty(), "K levels drawn wrong (level, plain, blended): {wrong:?}"); } + +/// A group larger than the renderer blends in CMYK at once (hayro patch (27): 2^24 device +/// pixels) used to be blended in RGB instead: a page enlarged to 11 x 17 in for Print as image +/// at 600 dpi drew the X's of a print test file's CMYK blend patches. It is now blended in bands +/// of whole rows. Here the band is lowered to a few rows, so the group is blended in many +/// bands, across the anti-aliased edge of a triangle as well: the result is the same as in one +/// piece, and still CMYK's (magenta under black with Difference is green). +#[test] +fn a_cmyk_group_blended_in_bands_is_the_group_blended_whole() { + let group = "0 1 0 0 k 0 0 m 200 0 l 0 200 l f /Diff gs 0 0 0 1 k 0 0 200 200 re f"; + let pdf = format!( + "%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 220 220] /Contents 4 0 R /Resources << /XObject << /X0 5 0 R >> >> >> endobj +4 0 obj << /Length 28 >> stream +q 1 0 0 1 10 10 cm /X0 Do Q +endstream endobj +5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /CS /DeviceCMYK /I true >> /Resources << /ExtGState << /Diff << /BM /Difference >> >> >> /Length {} >> stream +{group} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + group.len() + ); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let request = RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0, ..Default::default() }; + let whole = r.render(request); + // 400 device pixels a row: bands of 7 rows, the last one shorter. + hayro::set_cmyk_band_pixels(Some(2_800)); + let banded = r.render(request); + hayro::set_cmyk_band_pixels(None); + assert!(whole.error.is_none() && banded.error.is_none(), "{:?} {:?}", whole.error, banded.error); + assert_eq!((whole.width, whole.height), (banded.width, banded.height)); + let worst = whole.rgba.iter().zip(banded.rgba.iter()).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0); + assert!(worst <= 1, "bands differ from the whole group by up to {worst}"); + // Inside the triangle (device (100, 340), the group's point (40, 40)): magenta under black. + let at = |p: &RenderedPage, x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..3].to_vec(); + for page in [&whole, &banded] { + let rgb = at(page, 100, 340); + // CMYK's 1 0 1 0 through the CMYK profile is (0, 166, 81); blended in RGB it would be + // magenta, red and blue high and green low. + assert!(rgb[1] > 120 && rgb[0] < 60 && rgb[2] < 120, "blended in CMYK, green: {rgb:?}"); + } +} diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 9f3439e158..e717e5ac61 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -93,6 +93,15 @@ pub fn fold_case(s: &str) -> String { out } +/// Byte ranges in `text` where `needle` occurs, with the find bar's rules ([`PageText::find_opts`]): +/// case folded unless `case_sensitive`, any run of whitespace matching one space, and with +/// `whole_words` only where no letter or digit adjoins the match. At most `limit` ranges, in order +/// and not overlapping. Find and replace matches a paragraph's text with it, so what the find bar +/// highlights is what gets replaced. +pub fn find_in_text(text: &str, needle: &str, case_sensitive: bool, whole_words: bool, limit: usize) -> Vec> { + search::find_in_text(text, needle, case_sensitive, whole_words, limit) +} + /// The text of one page, in content order, with line structure. #[derive(Clone, Debug, Default, PartialEq)] pub struct PageText { diff --git a/crates/render/src/text/search.rs b/crates/render/src/text/search.rs index 42df5c0a83..0d0b95d5e5 100644 --- a/crates/render/src/text/search.rs +++ b/crates/render/src/text/search.rs @@ -50,16 +50,7 @@ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: b let texts: Vec = page.glyphs.iter().map(|g| fold(&g.text)).collect(); let divided: Vec = (0..texts.len()).map(|i| divides(page, &texts, i)).collect(); let readings: &[Hyphen] = if divided.contains(&true) { &[Hyphen::Written, Hyphen::Joined, Hyphen::Kept] } else { &[Hyphen::Written] }; - // KMP reuses a matched prefix instead of comparing a long query again at every position. - // The table is bounded by the page's already materialized character count. - let mut prefix = vec![0; needle.len()]; - let mut matched = 0; - for (i, &c) in needle.iter().enumerate().skip(1) { - matched = advance(&needle, &prefix, matched, c); - if let Some(slot) = prefix.get_mut(i) { - *slot = matched; - } - } + let prefix = prefix_table(&needle); let mut hits: Vec<(Range, usize)> = Vec::new(); for (r, &reading) in readings.iter().enumerate() { let (chars, zero_width_space) = characters(page, &texts, ÷d, reading); @@ -92,6 +83,58 @@ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: b kept } +/// [`super::find_in_text`]: the same search over a string (find and replace matches a paragraph's +/// text with it), as the byte ranges of the characters each hit covers. Hyphenated line ends do +/// not arise in a string, so there is the one reading. +pub(super) fn find_in_text(text: &str, query: &str, sensitive: bool, whole_words: bool, limit: usize) -> Vec> { + if limit == 0 { + return Vec::new(); + } + let fold = |s: &str| if sensitive { ligature_letters(s.to_owned()) } else { fold_case(&ligature_letters(s.to_owned())) }; + let query: String = fold(query).chars().filter(|&c| !is_invisible(c)).collect(); + let needle: Vec = query.split_whitespace().collect::>().join(" ").chars().collect(); + if needle.is_empty() { + return Vec::new(); + } + // Each character is owned by its index in the string, turned back into bytes at the end. + let mut chars: Vec<(char, usize)> = Vec::new(); + let mut zero_width_space: Vec = Vec::new(); + let mut pending_zero_width_space = false; + for (index, (_, c)) in text.char_indices().enumerate() { + for c in fold(c.encode_utf8(&mut [0; 4])).chars() { + if is_invisible(c) { + pending_zero_width_space |= c == '\u{200B}'; + continue; + } + let c = if c.is_whitespace() { ' ' } else { c }; + if c != ' ' || chars.last().is_none_or(|last| last.0 != ' ') { + chars.push((c, index)); + zero_width_space.push(pending_zero_width_space && c != ' '); + pending_zero_width_space = false; + } + } + } + let starts: Vec = text.char_indices().map(|(at, _)| at).chain(std::iter::once(text.len())).collect(); + search(&chars, &zero_width_space, &needle, &prefix_table(&needle), whole_words, limit) + .into_iter() + .filter_map(|hit| Some(*starts.get(hit.start)?..*starts.get(hit.end)?)) + .collect() +} + +/// KMP reuses a matched prefix instead of comparing a long query again at every position. The +/// table is bounded by the page's already materialized character count. +fn prefix_table(needle: &[char]) -> Vec { + let mut prefix = vec![0; needle.len()]; + let mut matched = 0; + for (i, &c) in needle.iter().enumerate().skip(1) { + matched = advance(needle, &prefix, matched, c); + if let Some(slot) = prefix.get_mut(i) { + *slot = matched; + } + } + prefix +} + /// Whether glyph `i` ends its line with a hyphen that follows a letter of its word, and the next /// line starts with a letter. `texts` are the glyphs' folded texts; the invisible characters find /// skips are passed over on both sides. diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 15e091a5b9..77349bf1a1 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -1123,6 +1123,33 @@ fn turkish_dotted_and_dotless_i_match_in_any_case() { assert_eq!(lower.find("KIŞ"), vec![13..16]); } +/// Find and replace matches a paragraph's text with the find bar's own rules: the same +/// matches as the page search, as byte ranges of the text (multi-byte letters included). +#[test] +fn text_search_agrees_with_the_page_search() { + let mut v = Vec::new(); + word(&mut v, "İSTANBUL", 10.0, 10.0, 7.0); + word(&mut v, "cat", 80.0, 10.0, 7.0); + word(&mut v, "Cats", 110.0, 10.0, 7.0); + word(&mut v, "cat.", 150.0, 10.0, 7.0); + let page = layout(v); + let text = page.plain_text(); + assert_eq!(text, "İSTANBUL cat Cats cat."); + for (needle, case, whole) in + [("cat", false, false), ("cat", true, false), ("cat", false, true), ("istanbul cat", false, false), ("CATS", true, true)] + { + let glyphs: Vec = page.find_opts(needle, case, whole).into_iter().map(|r| page.text_of(r)).collect(); + let bytes: Vec<&str> = find_in_text(&text, needle, case, whole, usize::MAX).into_iter().filter_map(|r| text.get(r)).collect(); + assert_eq!(glyphs, bytes, "{needle:?} case {case} whole {whole}"); + } + assert_eq!(find_in_text(&text, "cat", false, true, usize::MAX), vec![10..13, 19..22]); + assert_eq!(find_in_text(&text, "istanbul", false, false, usize::MAX), vec![0..9], "İ is two bytes"); + assert_eq!(find_in_text("a\n b", "a b", false, false, usize::MAX), vec![0..5], "any whitespace run is one space"); + assert_eq!(find_in_text("cat cat", "cat", false, false, 1), vec![0..3]); + assert!(find_in_text("cat", " ", false, false, usize::MAX).is_empty()); + assert!(find_in_text("cat", "cat", false, false, 0).is_empty()); +} + #[test] fn greek_sigma_forms_match_without_changing_exact_search_or_glyph_ranges() { let mut v = Vec::new(); diff --git a/crates/sign/Cargo.toml b/crates/sign/Cargo.toml index bfb04a26cb..a72d91ad2a 100644 --- a/crates/sign/Cargo.toml +++ b/crates/sign/Cargo.toml @@ -17,6 +17,7 @@ pdfcraft-content = { workspace = true } pdfcraft-fonts = { workspace = true } pdfcraft-model = { workspace = true } thiserror.workspace = true +zeroize.workspace = true # ASN.1 UTCTime/GeneralizedTime conversion goes through chrono (no hand-rolled calendar code). chrono = { workspace = true } base64 = "0.23" diff --git a/crates/sign/src/keys.rs b/crates/sign/src/keys.rs index 0521c5d914..b5d466ae81 100644 --- a/crates/sign/src/keys.rs +++ b/crates/sign/src/keys.rs @@ -4,6 +4,8 @@ //! (constant-time; ADR-0009: the `rsa` crate is never used for private keys there). ECDSA //! signing uses RustCrypto with deterministic nonces (RFC 6979). +use zeroize::Zeroizing; + use crate::SignError; use crate::der::{self, Tlv, tag}; @@ -588,7 +590,7 @@ pub struct PrivateKey { inner: Inner, public: PublicKey, /// The PKCS#8 `PrivateKeyInfo` it came from (to write it back into a PKCS#12 file). - pkcs8: Vec, + pub(crate) pkcs8: Zeroizing>, } impl std::fmt::Debug for PrivateKey { @@ -601,7 +603,7 @@ impl PrivateKey { /// A key held outside PdfCraft's memory (its public half is `public`). It can't be /// exported to a PKCS #12 file. pub fn external(public: PublicKey, key: std::sync::Arc) -> PrivateKey { - PrivateKey { inner: Inner::External(key), public, pkcs8: Vec::new() } + PrivateKey { inner: Inner::External(key), public, pkcs8: Zeroizing::new(Vec::new()) } } /// Whether the key lives outside the app (and so can't be exported). @@ -631,7 +633,7 @@ impl PrivateKey { ); #[cfg(target_arch = "wasm32")] let inner = Inner::Rsa; - Ok(PrivateKey { inner, public: PublicKey::Rsa { n, e }, pkcs8: info.to_vec() }) + Ok(PrivateKey { inner, public: PublicKey::Rsa { n, e }, pkcs8: Zeroizing::new(info.to_vec()) }) } oid::EC => { // ECPrivateKey: version, privateKey OCTET STRING, [0] parameters, [1] publicKey. @@ -650,12 +652,12 @@ impl PrivateKey { oid::P256 => { let k = p256::ecdsa::SigningKey::from_slice(d).map_err(|_| SignError::Malformed("P-256 key".into()))?; let public = PublicKey::P256(k.verifying_key().to_sec1_point(false).as_bytes().to_vec()); - Ok(PrivateKey { inner: Inner::P256(k), public, pkcs8: info.to_vec() }) + Ok(PrivateKey { inner: Inner::P256(k), public, pkcs8: Zeroizing::new(info.to_vec()) }) } oid::P384 => { let k = p384::ecdsa::SigningKey::from_slice(d).map_err(|_| SignError::Malformed("P-384 key".into()))?; let public = PublicKey::P384(k.verifying_key().to_sec1_point(false).as_bytes().to_vec()); - Ok(PrivateKey { inner: Inner::P384(k), public, pkcs8: info.to_vec() }) + Ok(PrivateKey { inner: Inner::P384(k), public, pkcs8: Zeroizing::new(info.to_vec()) }) } other => Err(SignError::Unsupported(format!("elliptic curve {other}"))), } @@ -689,13 +691,15 @@ impl PrivateKey { /// A new P-256 key. pub fn generate_p256() -> Result { loop { - let mut d = [0u8; 32]; - getrandom::fill(&mut d).map_err(|e| SignError::Crypto(format!("no randomness: {e}")))?; - if let Ok(k) = p256::ecdsa::SigningKey::from_slice(&d) { + let mut d = Zeroizing::new([0u8; 32]); + getrandom::fill(&mut *d).map_err(|e| SignError::Crypto(format!("no randomness: {e}")))?; + if let Ok(k) = p256::ecdsa::SigningKey::from_slice(&*d) { let point = k.verifying_key().to_sec1_point(false).as_bytes().to_vec(); // PKCS#8 with an ECPrivateKey (RFC 5915) carrying the public key. - let ec = der::seq(&[&der::int(1), &der::octets(&d), &der::explicit(1, &der::bit_string(&point))]); - let info = der::seq(&[&der::int(0), &der::seq(&[&der::oid(oid::EC), &der::oid(oid::P256)]), &der::octets(&ec)]); + let d_octets = Zeroizing::new(der::octets(&*d)); + let ec = Zeroizing::new(der::seq(&[&der::int(1), &d_octets, &der::explicit(1, &der::bit_string(&point))])); + let ec_octets = Zeroizing::new(der::octets(&ec)); + let info = Zeroizing::new(der::seq(&[&der::int(0), &der::seq(&[&der::oid(oid::EC), &der::oid(oid::P256)]), &ec_octets])); return PrivateKey::from_pkcs8(&info); } } diff --git a/crates/sign/src/pkcs12.rs b/crates/sign/src/pkcs12.rs index a71d5c8584..77a1019ff6 100644 --- a/crates/sign/src/pkcs12.rs +++ b/crates/sign/src/pkcs12.rs @@ -20,6 +20,7 @@ use base64::Engine as _; use cbc::cipher::block_padding::Pkcs7; use cbc::cipher::{BlockModeDecrypt, BlockModeEncrypt, InnerIvInit, KeyIvInit}; use hmac::Mac; +use zeroize::Zeroizing; use crate::SignError; use crate::der::{self, Tlv, tag}; @@ -100,14 +101,14 @@ fn bad(what: &str) -> SignError { } /// The password as a BMPString with its terminating NUL (RFC 7292 Appendix B.1). -fn bmp(password: &str) -> Vec { - let mut v: Vec = password.encode_utf16().flat_map(u16::to_be_bytes).collect(); +fn bmp(password: &str) -> Zeroizing> { + let mut v = Zeroizing::new(password.encode_utf16().flat_map(u16::to_be_bytes).collect::>()); v.extend([0, 0]); v } /// The PKCS #12 key derivation function (RFC 7292 Appendix B.2). -fn pkcs12_kdf(alg: DigestAlg, password: &[u8], salt: &[u8], id: u8, iterations: u32, n: usize) -> Vec { +fn pkcs12_kdf(alg: DigestAlg, password: &[u8], salt: &[u8], id: u8, iterations: u32, n: usize) -> Zeroizing> { // Callers only pass the SHA-1/SHA-2 digests PKCS #12 files use (`open` restricts the MAC digest). let (u, v) = match alg { DigestAlg::Sha1 => (20, 64), @@ -115,25 +116,25 @@ fn pkcs12_kdf(alg: DigestAlg, password: &[u8], salt: &[u8], id: u8, iterations: DigestAlg::Sha256 => (32, 64), DigestAlg::Sha384 => (48, 128), DigestAlg::Sha512 => (64, 128), - _ => return Vec::new(), + _ => return Zeroizing::new(Vec::new()), }; let _ = u; - let fill = |s: &[u8]| -> Vec { + let fill = |s: &[u8]| -> Zeroizing> { if s.is_empty() { - return Vec::new(); + return Zeroizing::new(Vec::new()); } let len = v * s.len().div_ceil(v); - s.iter().cycle().take(len).copied().collect() + Zeroizing::new(s.iter().cycle().take(len).copied().collect()) }; let d = vec![id; v]; - let mut i: Vec = [fill(salt), fill(password)].concat(); - let mut out = Vec::with_capacity(n); + let mut i = Zeroizing::new([fill(salt).as_slice(), fill(password).as_slice()].concat()); + let mut out = Zeroizing::new(Vec::with_capacity(n)); while out.len() < n { - let mut a = alg.digest(&[&d, &i]); + let mut a = Zeroizing::new(alg.digest(&[&d, &i])); for _ in 1..iterations.max(1) { - a = alg.digest(&[&a]); + a = Zeroizing::new(alg.digest(&[&a])); } - let b: Vec = a.iter().cycle().take(v).copied().collect(); + let b = Zeroizing::new(a.iter().cycle().take(v).copied().collect::>()); for chunk in i.chunks_mut(v) { // chunk = (chunk + b + 1) mod 2^(8v) let mut carry = 1u16; @@ -169,7 +170,7 @@ impl Cipher { } } - fn decrypt(self, key: &[u8], iv: &[u8], ct: &[u8]) -> Result, SignError> { + fn decrypt(self, key: &[u8], iv: &[u8], ct: &[u8]) -> Result>, SignError> { let wrong = || SignError::WrongPassword; let r = match self { Cipher::Aes128 => cbc::Decryptor::::new_from_slices(key, iv).map_err(|_| wrong())?.decrypt_padded_vec::(ct), @@ -181,7 +182,7 @@ impl Cipher { cbc::Decryptor::::inner_iv_slice_init(c, iv).map_err(|_| wrong())?.decrypt_padded_vec::(ct) } }; - r.map_err(|_| wrong()) + r.map(Zeroizing::new).map_err(|_| wrong()) } } @@ -205,8 +206,8 @@ fn hmac(alg: DigestAlg, key: &[u8], data: &[u8]) -> Result, SignError> { } } -fn pbkdf2(prf: DigestAlg, password: &[u8], salt: &[u8], rounds: u32, len: usize) -> Result, SignError> { - let mut out = vec![0u8; len]; +fn pbkdf2(prf: DigestAlg, password: &[u8], salt: &[u8], rounds: u32, len: usize) -> Result>, SignError> { + let mut out = Zeroizing::new(vec![0u8; len]); match prf { DigestAlg::Sha1 => pbkdf2::pbkdf2_hmac::(password, salt, rounds, &mut out), DigestAlg::Sha224 => pbkdf2::pbkdf2_hmac::(password, salt, rounds, &mut out), @@ -219,7 +220,7 @@ fn pbkdf2(prf: DigestAlg, password: &[u8], salt: &[u8], rounds: u32, len: usize) } /// Decrypt `ct` with the password-based scheme `alg` (an AlgorithmIdentifier). -fn pbe_decrypt(alg: &Tlv<'_>, password: &str, ct: &[u8], budget: &mut Budget) -> Result, SignError> { +fn pbe_decrypt(alg: &Tlv<'_>, password: &str, ct: &[u8], budget: &mut Budget) -> Result>, SignError> { let parts = alg.children()?; let o = parts.first().ok_or_else(|| bad("algorithm"))?.oid()?; let params = parts.get(1).ok_or_else(|| bad("algorithm parameters"))?; @@ -280,8 +281,7 @@ fn pbe_decrypt(alg: &Tlv<'_>, password: &str, ct: &[u8], budget: &mut Budget) -> let mut key = pkcs12_kdf(DigestAlg::Sha1, &pw, &salt, 1, rounds, key_len); if key.len() == 16 && matches!(cipher, Cipher::TripleDes) { // Two-key 3DES: K1 K2 K1. - let k1 = key[..8].to_vec(); - key.extend(k1); + key.extend_from_within(..8); } let iv = pkcs12_kdf(DigestAlg::Sha1, &pw, &salt, 2, rounds, iv_len); cipher.decrypt(&key, &iv, ct) @@ -297,7 +297,7 @@ struct Bag { } enum BagKind { - Key(Vec), + Key(Zeroizing>), Cert(Vec), } @@ -319,7 +319,7 @@ fn bags(safe_contents: &[u8], password: &str, out: &mut Vec, budget: &mut B } } let kind = match id.as_str() { - KEY_BAG => BagKind::Key(value.raw.to_vec()), + KEY_BAG => BagKind::Key(Zeroizing::new(value.raw.to_vec())), SHROUDED_KEY_BAG => { let p = value.children()?; let [alg, data] = p.as_slice() else { return Err(bad("EncryptedPrivateKeyInfo")) }; @@ -549,4 +549,16 @@ mod tests { assert_eq!(budget.spend(DigestAlg::Sha1, 0, 20).ok(), Some(0)); assert_eq!(budget.0, OPEN_BUDGET - 1); } + + #[test] + fn key_and_kdf_buffers_are_zeroizing() { + let key = PrivateKey::generate_p256().unwrap(); + let _: &Zeroizing> = &key.pkcs8; + let pw: Zeroizing> = bmp("secret12"); + assert_eq!(&*pw, &[0, b's', 0, b'e', 0, b'c', 0, b'r', 0, b'e', 0, b't', 0, b'1', 0, b'2', 0, 0]); + let kdf: Zeroizing> = pkcs12_kdf(DigestAlg::Sha256, &pw, &[1, 2, 3, 4], 1, 1, 32); + assert_eq!(kdf.len(), 32); + let derived: Zeroizing> = pbkdf2(DigestAlg::Sha256, b"secret12", &[1, 2, 3, 4], 1, 32).unwrap(); + assert_eq!(derived.len(), 32); + } } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 7f9f39894e..77132de42f 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -10,6 +10,7 @@ rust-version = "1.95" workspace = true [dependencies] +edit-session = { workspace = true } craft-ui = { workspace = true } # The menus: native on macOS, an egui bar elsewhere, from one definition (menus.rs). craft-menu = { workspace = true } @@ -34,6 +35,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "acces serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +zeroize = { workspace = true } # UTC/calendar display (combine modified-time stamp, the form date picker) goes through chrono # (no hand-rolled calendar code); std-only, so it stays wasm-safe. chrono = { workspace = true } diff --git a/crates/ui-egui/examples/properties_scenarios.rs b/crates/ui-egui/examples/properties_scenarios.rs new file mode 100644 index 0000000000..41fd3dfa74 --- /dev/null +++ b/crates/ui-egui/examples/properties_scenarios.rs @@ -0,0 +1,312 @@ +//! JSONL adapter for interaction-check, driving the real Document Properties dialog. +//! +//! Each process owns two synthetic PDFs. Widget edits/clicks use egui_kittest; external +//! document edits, reloads and save/reopen use the real engine. No network or shipped API. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::io::{BufRead, Read, Write}; +use std::path::PathBuf; +use std::sync::Arc; + +use egui::accesskit::Role; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use pdfcraft_engine::{DocId, Edit, Session}; +use pdfcraft_ui_egui::{Dialog, PdfCraftApp}; +use serde::Deserialize; +use serde_json::{Value, json}; + +const LIMIT: u64 = 1024 * 1024; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Request { + protocol: u64, + id: u64, + event: Event, +} + +#[derive(Deserialize)] +#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] +enum Event { + Open, + Type { field: String, value: String }, + Click { label: String }, + Escape, + ExternalEdit { key: String, value: String }, + ExternalEditUndo { key: String, value: String }, + Switch { document: usize }, + Reload { title: String }, + CloseDocument, + Undo, + Redo, + SaveReopen, + Inspect, +} + +/// Build and save a synthetic two-page PDF with known standard and custom metadata. +fn fixture(title: &str) -> Result>, String> { + let mut session = Session::new(); + let bytes = session.create_blank(200.0, 300.0, 2).map_err(|e| e.to_string())?; + let id = session.open("fixture.pdf", None, bytes, None).map_err(|e| e.to_string())?; + session + .apply( + id, + Edit::Batch { + label: "Fixture metadata".into(), + edits: vec![ + Edit::SetInfo { key: "Title".into(), value: title.into() }, + Edit::SetInfo { key: "Author".into(), value: "Original author".into() }, + Edit::SetInfo { key: "Untouched".into(), value: "Keep me".into() }, + ], + }, + ) + .map_err(|e| e.to_string())?; + session.save_full_bytes(id).map_err(|e| e.to_string()) +} + +/// Expose actual engine metadata, edit generation and history labels for subset assertions. +fn document_state(doc: &pdfcraft_engine::Document) -> Value { + let custom: serde_json::Map = doc.custom_info().into_iter().map(|(k, v)| (k, json!(v))).collect(); + let custom_count = custom.len(); + json!({ + "title": doc.info_value("Title"), "author": doc.info_value("Author"), + "subject": doc.info_value("Subject"), "keywords": doc.info_value("Keywords"), + "custom": custom, "custom_count": custom_count, "display_title": doc.initial_view().display_title, + "dirty": doc.dirty, "undo": doc.can_undo(), "redo": doc.can_redo(), + "generation": doc.edit_generation(), "pages": doc.info.pages.len(), + }) +} + +struct Adapter { + h: Harness<'static, PdfCraftApp>, + originals: Vec<(DocId, Arc>)>, + reopened: Option, + artifacts: Option, +} + +impl Adapter { + /// Open two independent fixtures and create a deterministic 60 Hz headless UI harness. + fn new(artifacts: Option) -> Result { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en")?; + let mut originals = Vec::new(); + for (name, title) in [("A.pdf", "Original"), ("B.pdf", "Second document")] { + let bytes = fixture(title)?; + app.open_bytes(name, None, bytes.as_ref().clone()).map_err(|e| e.to_string())?; + let id = app.active_ids().map(|(_, id)| id).ok_or("fixture did not open")?; + originals.push((id, bytes)); + } + app.active = Some(0); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| app); + h.run_steps(4); + if let Some(path) = &artifacts { + std::fs::create_dir_all(path).map_err(|e| e.to_string())?; + } + Ok(Self { h, originals, reopened: None, artifacts }) + } + + /// Resolve the current document, returning a protocol error when no tab is active. + fn active(&self) -> Result { + self.h.state().active_ids().map(|(_, id)| id).ok_or_else(|| "no active document".into()) + } + + /// Report whether the active modal is a Document Properties tab. + fn properties_open(&self) -> bool { + matches!(self.h.state().dialog, Some(Dialog::Properties(_))) + } + + /// Replace an enabled property field through focus and keyboard events; never set its buffer directly. + fn type_text(&mut self, field: &str, value: &str) -> Result<(), String> { + if !self.properties_open() || !matches!(field, "Title" | "Author" | "Subject" | "Keywords" | "Name:" | "Value:") { + return Err("no editable property field".into()); + } + if value.len() > 65536 { + return Err("property input exceeds 64 KiB".into()); + } + { + let mut nodes = self.h.query_all_by_role_and_label(Role::TextInput, field); + let node = nodes.next().ok_or_else(|| format!("missing field: {field}"))?; + if nodes.next().is_some() || node.accesskit_node().is_disabled() { + return Err(format!("ambiguous or disabled field: {field}")); + } + node.focus(); + } + self.h.run_steps(1); + self.h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + self.h.run_steps(1); + self.h.key_press(egui::Key::Backspace); + self.h.event(egui::Event::Text(value.to_owned())); + Ok(()) + } + + /// Click one enabled button or radio control; reject unsupported or ambiguous targets. + fn click(&mut self, label: &str) -> Result<(), String> { + if !self.properties_open() { + return Err("properties dialog is closed".into()); + } + if !matches!( + label, + "OK" | "Cancel" + | "Close" + | "Description" + | "Initial View" + | "Custom" + | "Advanced" + | "Space Audit" + | "Add" + | "Update" + | "Delete" + | "Document Title" + | "File Name" + ) { + return Err("unsupported property action".into()); + } + let role = if matches!(label, "Document Title" | "File Name") { Role::RadioButton } else { Role::Button }; + let mut nodes = self.h.query_all_by_role_and_label(role, label); + let node = nodes.next().ok_or_else(|| format!("missing action: {label}"))?; + if nodes.next().is_some() || node.accesskit_node().is_disabled() { + return Err(format!("ambiguous or disabled action: {label}")); + } + node.click(); + Ok(()) + } + + /// Dispatch a semantic event through UI controls or the documented external-change engine path. + fn event(&mut self, event: Event) -> Result<(), String> { + match event { + Event::Open => { + self.active()?; + if self.properties_open() { + return Err("properties dialog is already open".into()); + } + self.h.state_mut().run_command("file.properties"); + } + Event::Type { field, value } => self.type_text(&field, &value)?, + Event::Click { label } => self.click(&label)?, + Event::Escape => self.h.key_press(egui::Key::Escape), + Event::ExternalEdit { key, value } => { + let id = self.active()?; + self.h.state_mut().session.apply(id, Edit::SetInfo { key, value }).map_err(|e| e.to_string())?; + } + Event::ExternalEditUndo { key, value } => { + let id = self.active()?; + self.h.state_mut().session.apply(id, Edit::SetInfo { key, value }).map_err(|e| e.to_string())?; + self.h.state_mut().session.undo(id).map_err(|e| e.to_string())?; + } + Event::Switch { document } => { + // Scenario document numbers are 1-based, like PdfCraft's automation pages. + let index = document.checked_sub(1).ok_or("document numbers start at 1")?; + if self.h.state().views.get(index).is_none() { + return Err("document is not open".into()); + } + self.h.state_mut().active = Some(index); + } + Event::Reload { title } => { + let id = self.active()?; + self.h.state_mut().session.reload(id, fixture(&title)?).map_err(|e| e.to_string())?; + } + Event::CloseDocument => { + let id = self.active()?; + let app = self.h.state_mut(); + app.session.close(id); + app.views.retain(|view| view.id != id); + app.active = (!app.views.is_empty()).then_some(0); + } + Event::Undo | Event::Redo => { + let id = self.active()?; + let doc = self.h.state().session.get(id).ok_or("document is not open")?; + let undo = matches!(event, Event::Undo); + if (if undo { doc.can_undo() } else { doc.can_redo() }).is_none() { + return Err("history action is unavailable".into()); + } + self.h.state_mut().run_command(if undo { "edit.undo" } else { "edit.redo" }); + } + Event::SaveReopen => { + let id = self.active()?; + let bytes = self.h.state().session.save_bytes(id).map_err(|e| e.to_string())?; + if let Some(path) = &self.artifacts { + std::fs::write(path.join("saved.pdf"), bytes.as_slice()).map_err(|e| e.to_string())?; + } + let mut fresh = Session::new(); + let reopened = fresh.open("reopened.pdf", None, bytes, None).map_err(|e| e.to_string())?; + self.reopened = fresh.get(reopened).map(document_state); + } + Event::Inspect => {} + } + Ok(()) + } + + /// Report engine and draft state after an event, retaining any action error for the runner to assert. + fn state(&self, error: Option) -> Value { + let app = self.h.state(); + let doc = app.active_ids().and_then(|(_, id)| app.session.get(id)); + let original_bytes = + doc.is_some_and(|doc| self.originals.iter().any(|(id, bytes)| *id == doc.id && bytes.as_slice() == doc.bytes.as_slice())); + let draft = app.props_draft.as_ref().map(|(_, fields)| { + let custom: serde_json::Map = + app.custom_draft.as_ref().into_iter().flat_map(|(_, d)| d.props.iter().map(|(k, v)| (k.clone(), json!(v)))).collect(); + json!({ + "title": fields.first(), "author": fields.get(1), "subject": fields.get(2), "keywords": fields.get(3), + "custom": custom, "display_title": app.view_draft.as_ref().map(|(_, v)| v.display_title), + }) + }); + json!({ + "error": error, "properties_open": self.properties_open(), "active": doc.map(|d| &d.name), + "notice": app.toast.as_ref().map(|(message, _)| message), + "document": doc.map(document_state), "draft": draft, "original_bytes": original_bytes, + "reopened": self.reopened, + }) + } + + /// Optionally render a local PNG, reporting renderer setup or file errors as failures. + fn snapshot(&mut self, step: u64) -> Result<(), String> { + let Some(path) = &self.artifacts else { return Ok(()) }; + // kittest's optional renderer panics when no GPU/software adapter is available. + // Report that setup failure without turning it into a successful scenario. + let image = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.h.render())) + .map_err(|_| "headless renderer failed; check GPU/software-renderer availability".to_owned())??; + image.save(path.join(format!("step-{step:03}.png"))).map_err(|e| e.to_string()) + } +} + +/// Serve bounded protocol-1 JSONL requests in order, returning one state per request until EOF. +fn main() -> Result<(), String> { + let mut args = std::env::args().skip(1); + let artifacts = match args.next().as_deref() { + None => None, + Some("--artifacts") => Some(PathBuf::from(args.next().ok_or("--artifacts needs a directory")?)), + _ => return Err("usage: properties_scenarios [--artifacts DIR]".into()), + }; + if args.next().is_some() { + return Err("unexpected command-line argument".into()); + } + let mut adapter = Adapter::new(artifacts)?; + let mut input = std::io::stdin().lock(); + let mut output = std::io::stdout().lock(); + let mut expected_id = 1_u64; + loop { + let mut line = String::new(); + let read = input.by_ref().take(LIMIT + 1).read_line(&mut line).map_err(|e| e.to_string())?; + if read == 0 { + break; + } + if read as u64 > LIMIT { + return Err("request exceeds 1 MiB".into()); + } + let request: Request = serde_json::from_str(&line).map_err(|e| e.to_string())?; + if request.protocol != 1 || request.id != expected_id { + return Err("expected protocol 1 and consecutive request IDs starting at 1".into()); + } + let error = adapter.event(request.event).err(); + // Tabs animate the modal's height and position. Advance a bounded 16 frames + // (about 267 ms of simulated time) before inspecting or targeting the next control. + adapter.h.run_steps(16); + adapter.snapshot(request.id)?; + serde_json::to_writer(&mut output, &json!({"protocol": 1, "id": request.id, "state": adapter.state(error)})).map_err(|e| e.to_string())?; + writeln!(output).map_err(|e| e.to_string())?; + output.flush().map_err(|e| e.to_string())?; + expected_id = expected_id.checked_add(1).ok_or("too many requests")?; + } + Ok(()) +} diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index bf1d166461..7276b6aa10 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -64,7 +64,7 @@ impl PdfCraftApp { } } - /// Construct an application with the default session, panels, theme and control state. + /// Construct default session, panels, theme and control state with no active property-edit session. pub fn new() -> Self { Self { panes: Default::default(), @@ -132,7 +132,10 @@ impl PdfCraftApp { updates: updates::Updates::default(), printer_source: None, options_source: None, + default_paper_source: None, + printer_papers_source: None, spooler: None, + two_sided_source: None, print_tasks: print_ui::PrintTasks::default(), palette_open: false, palette_query: String::new(), @@ -163,6 +166,8 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] password_retry: None, #[cfg(not(target_arch = "wasm32"))] + pending_destinations: Vec::new(), + #[cfg(not(target_arch = "wasm32"))] next_open_token: 0, #[cfg(not(target_arch = "wasm32"))] launch_layout: None, @@ -208,6 +213,7 @@ impl PdfCraftApp { attachment_description_draft: None, view_draft: None, custom_draft: None, + props_session: None, requests: Default::default(), #[cfg(not(target_arch = "wasm32"))] pickers: Default::default(), @@ -277,6 +283,7 @@ impl PdfCraftApp { object_clip_mark: Default::default(), grid_drop: None, last_snapshot: None, + last_snapshot_of: None, last_copied_image: None, last_copied_file: None, system_clipboard: true, @@ -300,6 +307,8 @@ impl PdfCraftApp { deferred_commands: Vec::new(), native_menu: None, menu_input: Vec::new(), + menu_pastes: 0, + page_keys: Default::default(), dialog_seen: None, dialog_epoch: 0, ctx: None, @@ -378,6 +387,61 @@ impl PdfCraftApp { self.active.and_then(|i| self.views.get(i).map(|v| (i, v.id))) } + /// Whether something over the pages has the keyboard: what covers them + /// ([`Self::covers_pages`]), the hotbar's tools window, a popup, a menu of the menu bar, or the + /// bar holding the keyboard (Alt, F10). + pub(crate) fn over_pages(&self, ctx: &egui::Context) -> bool { + self.covers_pages() + || self.hotbar.picker_over_pages(ctx) + || egui::Popup::is_any_open(ctx) + || craft_menu::bar::is_open(ctx) + || craft_menu::bar::is_focused(ctx) + } + + /// Edit ▸ Copy, Cut, Select all and Delete stand for keys, and Edit ▸ Paste for the paste the + /// platform answers with. They go where the user's would: to a text field that has the + /// keyboard, else to the pages. They join the input once the menu bar has let go of the keys, + /// which an in-window menu's click does a pass later (craft-menu stores the menu as open on + /// the pass its click closes it). A native menu's click leaves egui's popups and the Hotbar + /// tools window open, which hold the pages' keys; a click anywhere else closes them, and so + /// does this. Edit ▸ Paste asks the platform for the clipboard only then, so its answer, on a + /// later pass, is an ordinary paste. With a text field focused, a native click on Copy, Cut, + /// Paste or Select all doesn't come here: craft-menu taps the shortcut for the field instead. + fn release_menu_input(&mut self, ctx: &egui::Context) { + if self.menu_input.is_empty() && self.menu_pastes == 0 { + return; + } + if craft_menu::bar::is_open(ctx) || craft_menu::bar::is_focused(ctx) { + ctx.request_repaint(); + return; + } + if !ctx.text_edit_focused() { + egui::Popup::close_all(ctx); + self.hotbar.close_picker(); + } + let events = std::mem::take(&mut self.menu_input); + ctx.input_mut(|i| i.events.extend(events)); + for _ in 0..std::mem::take(&mut self.menu_pastes) { + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste); + } + } + + /// Read mode and full screen hide the hotbar (the quick tools), and its tools window with it. + pub(crate) fn hotbar_hidden(&self) -> bool { + self.mode == crate::Mode::Read || self.full_screen + } + + /// The pages are covered, and no page key reaches them, a menu click's included: by a dialog or + /// prompt that turns the menus off (`modal_open`), by the full-screen and update prompts, which + /// leave the menus on, by the palette, which turns them off, or by the OCR Verify screen, which + /// takes the pages' place. + pub(crate) fn covers_pages(&self) -> bool { + // Full screen draws the page alone: not the palette, nor the OCR Verify screen it was + // entered over. Dialogs and prompts show there too. + let shown = !(self.full_screen && self.active.is_some()); + self.modal_open() || self.pending_full_screen.is_some() || self.updates.prompt_open() || (self.palette_open || self.ocr_verify.open) && shown + } + /// A dialog or prompt is open over the window: the keyboard is its own, and nothing may act /// on the document underneath it. pub(crate) fn modal_open(&self) -> bool { @@ -539,6 +603,7 @@ impl PdfCraftApp { } self.poll_pending_opens(); self.show_reveal_errors(); + self.poll_pending_destinations(); } self.check_restored_recent(); // Chinese wants its own faces before the Japanese ones (one baseline per line). @@ -676,11 +741,13 @@ impl PdfCraftApp { // Shortcuts deferred last frame: the text field has taken that frame's typing since. let deferred = std::mem::take(&mut self.deferred_commands); self.native_menu_clicks(ctx); - // The keys a menu click stands for (⌘C, Delete…), before anything reads this frame's keys. - if !self.menu_input.is_empty() { - let events = std::mem::take(&mut self.menu_input); - ctx.input_mut(|i| i.events.extend(events)); - } + // The input a menu click stands for (⌘C, Delete…), before anything reads this frame's keys. + self.release_menu_input(ctx); + // A command's chord pressed with the keyboard on the menu bar (Alt, F10) takes it off the + // bar, so the chord runs in the same press, the pages' as well as the registry's. + craft_menu::bar::leave_for_shortcut(ctx); + self.hotbar.close_hidden_picker(ctx, self.hotbar_hidden()); + self.page_keys = page_keys::PageKeys::new(self.over_pages(ctx)); self.shortcuts(ctx); // Scrolling and group-drag previews are transient: never resume after changing tabs, // opening a modal/palette, or returning to a window that lost focus. diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs index 3de7164f49..ce4b60bcd3 100644 --- a/crates/ui-egui/src/app/clipboard.rs +++ b/crates/ui-egui/src/app/clipboard.rs @@ -46,7 +46,8 @@ impl PdfCraftApp { } /// ⌘C / ⌘X / ⌘V copy, cut and paste text, images or comments on canvas (unless a text field - /// has focus). With no text selected, ⌘C copies the selected comments, each with its replies. + /// has focus, or something is over the pages). With no text selected, ⌘C copies the selected + /// comments, each with its replies. pub(super) fn clipboard_keys(&mut self, i: usize, ctx: &egui::Context) { use egui::{Key, KeyboardShortcut, Modifiers}; let pastes: Vec<_> = ctx.input(|input| { @@ -60,7 +61,7 @@ impl PdfCraftApp { }) .collect() }); - let free = self.dialog.is_none() && !self.palette_open && !ctx.egui_wants_keyboard_input() && !self.is_preparing(); + let free = self.dialog.is_none() && !self.palette_open && !self.page_keys.over() && !ctx.egui_wants_keyboard_input() && !self.is_preparing(); if !free || self.views.get(i).is_some_and(|v| v.forms.focus.is_some() || v.organize) { self.receive_pastes(i, pastes, false); return; diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 986ec745d4..058280e32b 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -61,6 +61,21 @@ impl PdfCraftApp { pub(crate) fn select_tool(&mut self, id: &'static str) { // The tool's panel shows, not the expanded All tools view over it. self.all_tools_full = false; + // Edit a PDF turns on Edit text & images at once, as the Edit tab does (`enter_mode`), so + // the page is editable without a second click. Leaving it for another tool from All tools + // puts editing down again; under the Edit tab the workspace keeps it, as before. Read mode + // hides the quick toolbar and keeps every tool down (`enter_mode`), so the control + // channel's `tool edit` there leaves editing off. + if id == "edit" { + if self.mode != Mode::Read { + self.quick_tool = QuickTool::EditText; + } + } else if self.mode != Mode::Edit + && self.left == LeftPanel::Tool("edit") + && matches!(self.quick_tool, QuickTool::EditText | QuickTool::AddText) + { + self.put_down_edit_text(); + } self.left = LeftPanel::Tool(id); self.reveal_tools(); if let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) { diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 9381664527..a545b4165a 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -161,6 +161,7 @@ impl PdfCraftApp { if let Some(Dialog::Marks(kind)) = self.dialog { self.marks_draft.opened(kind); } + dialogs::discard_inactive_password_drafts(self); } ("tools", _) if value == "full" => self.expand_all_tools(), ("tools", _) => { diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 1a873a7c11..8c96de4529 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -107,6 +107,16 @@ impl PdfCraftApp { /// later as a pending open, under the returned token. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn schedule_open(&mut self, request: OpenRequest) -> Result { + self.schedule_open_with_priority(request, false) + } + + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn schedule_open_priority(&mut self, request: OpenRequest) -> Result { + self.schedule_open_with_priority(request, true) + } + + #[cfg(not(target_arch = "wasm32"))] + fn schedule_open_with_priority(&mut self, request: OpenRequest, priority: bool) -> Result { if self.open_worker.is_none() { self.open_worker = Some( open_worker::OpenWorker::new( @@ -124,7 +134,11 @@ impl PdfCraftApp { // A new document opens with the remembered Highlight fields, which its scripts read; the // session keeps the document in front's. let context = self.session.open_context().with_highlight(self.view_defaults.highlight_fields); - let receiver = worker.submit(token, context, request.name.clone(), request.path.clone(), request.password, request.input)?; + let receiver = if priority { + worker.submit_priority(token, context, request.name.clone(), request.path.clone(), request.password, request.input)? + } else { + worker.submit(token, context, request.name.clone(), request.path.clone(), request.password, request.input)? + }; self.next_open_token = token; self.pending_opens.push(PendingOpen { token, @@ -156,7 +170,7 @@ impl PdfCraftApp { let mut queue = std::mem::take(&mut self.deferred_opens); while self.deferred_opens.is_empty() && self.pending_opens.len() < self.open_slots() { let Some(next) = queue.pop_front() else { break }; - self.open_path_request(&next.path, next.then, next.display_override); + self.open_file_request(&next.path, next.after_open, next.then, next.display_override); } // A file put back (the queue filled again) stays ahead of the ones behind it. self.deferred_opens.append(&mut queue); @@ -484,7 +498,9 @@ impl PdfCraftApp { } } - /// Save an attachment to disk, or open a PDF attachment in a new tab. + /// Save an attachment to disk, or open a PDF attachment in a new tab (or go to the tab it is + /// already open in). Only bytes that are a PDF open, whatever the file's name says; anything + /// else asked to open is offered to save. pub fn attachment_action(&mut self, doc: DocId, index: usize, open: bool) { let Some(d) = self.session.get(doc) else { return }; let Some(att) = d.info.attachments.get(index).cloned() else { return }; @@ -493,13 +509,30 @@ impl PdfCraftApp { let source = d.path.clone(); match (data, open) { (Err(e), _) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]), - (Ok(bytes), true) => { + (Ok(bytes), true) if looks_like_pdf(&bytes) => { // The tab name later names files written from the tab (Split, Extract pages). - if let Err(e) = self.open_bytes(&crate::files::attachment_file_name(&att.name), None, bytes) { - self.notify_fmt("Couldn't open {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]); + let name = crate::files::attachment_file_name(&att.name); + // Already open: the tab opened from this attachment of this document (its bytes + // may have grown since, as a form laid out on open appends its layout). + let origin = (doc, att.name.clone()); + let open_in = self.views.iter().position(|v| v.from_attachment.as_ref() == Some(&origin) && self.session.get(v.id).is_some()); + if let Some(i) = open_in { + self.active = Some(i); + return; + } + let before = self.views.len(); + match self.open_bytes(&name, None, bytes) { + // Opening bytes adds their tab at once (unless a password is asked for first). + Ok(()) if self.views.len() > before => { + if let Some(v) = self.views.last_mut() { + v.from_attachment = Some(origin); + } + } + Ok(()) => {} + Err(e) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]), } } - (Ok(bytes), false) => { + (Ok(bytes), _) => { #[cfg(not(target_arch = "wasm32"))] self.ask_one( pickers::Ask::Save(rfd::AsyncFileDialog::new().set_file_name(crate::files::attachment_file_name(&att.name))), @@ -627,11 +660,12 @@ impl PdfCraftApp { /// Answer the password prompt (`None` cancels). pub fn submit_password(&mut self, password: Option) { - let Some(p) = self.password_prompt.take() else { return }; - let Some(pw) = password else { + let Some(mut p) = self.password_prompt.take() else { return }; + zeroize::Zeroize::zeroize(&mut p.input); + let Some(pw) = password.map(zeroize::Zeroizing::new) else { // A cancelled recovery goes back on offer; its snapshot stays in the store and must // not attach itself to whatever document is unlocked next (#813). - if let Some(meta) = p.recovered { + if let Some(meta) = p.recovered.take() { self.recoverable.push(meta); } #[cfg(not(target_arch = "wasm32"))] @@ -640,7 +674,7 @@ impl PdfCraftApp { }; #[cfg(not(target_arch = "wasm32"))] if self.ctx.is_some() && !self.run_inline { - let result = self.schedule_open(OpenRequest { + let result = self.schedule_open_priority(OpenRequest { name: p.name.clone(), path: p.path.clone(), stamp: p.stamp, @@ -669,7 +703,9 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] let (path_for_watch, stamp_for_watch) = (p.path.clone(), p.stamp); let after_open = p.after_open.clone(); - match self.try_open(&p.name, p.path, p.bytes, Some(&pw), p.activate) { + let opened = self.try_open(&p.name, p.path.take(), p.bytes.clone(), Some(&pw), p.activate); + drop(pw); + match opened { Err(e) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &p.name), ("e", &e.to_string())]), // A recovered encrypted document is open once its prompt is gone. Ok(()) if self.password_prompt.is_none() => { @@ -706,7 +742,7 @@ impl PdfCraftApp { // and the tool waiting for it. Ok(()) => { if let Some(prompt) = self.password_prompt.as_mut() { - prompt.recovered = p.recovered; + prompt.recovered = p.recovered.take(); prompt.then = p.then; prompt.after_open = after_open; prompt.display_override = p.display_override; @@ -726,8 +762,11 @@ impl PdfCraftApp { /// Answer the permissions password prompt (`None` cancels). pub fn submit_unlock(&mut self, password: Option) { let Some(mut p) = self.unlock_prompt.take() else { return }; - let Some(pw) = password else { return }; - match self.session.unlock(p.doc, &pw) { + zeroize::Zeroize::zeroize(&mut p.input); + let Some(mut pw) = password else { return }; + let unlocked = self.session.unlock(p.doc, &pw); + zeroize::Zeroize::zeroize(&mut pw); + match unlocked { // Carry on with what asked for the password, if its document is still the one shown. Ok(()) => match p.then { Some(command) if self.active_ids().is_some_and(|(_, id)| id == p.doc) => { @@ -736,7 +775,6 @@ impl PdfCraftApp { _ => self.notify_tr("Restrictions lifted"), }, Err(pdfcraft_engine::EditError::WrongPassword) => { - p.input.clear(); p.error = Some("Incorrect password. Try again.".to_string()); self.unlock_prompt = Some(p); } @@ -778,6 +816,24 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] fn open_path_request(&mut self, path: &str, then: Option<&'static str>, display_override: Option<(canvas::PageLayout, bool)>) -> OpenOutcome { + // `manual.pdf#nameddest=Intro`, as a help system starts a PDF reader (#3425): the file, + // shown at that destination once open. A path that names a file is never split. + match pdfcraft_engine::open_parameters::split(path, |p| std::path::Path::new(p).is_file()) { + Some((file, destination)) => self.open_file_request(&file, Some(destination), then, display_override), + None => self.open_file_request(path, None, then, display_override), + } + } + + /// Open the file `path`, shown at `after_open` once open. A file that waits for room in the + /// worker's queue keeps its destination with it, as its path is the file alone (#3425). + #[cfg(not(target_arch = "wasm32"))] + fn open_file_request( + &mut self, + path: &str, + after_open: Option, + then: Option<&'static str>, + display_override: Option<(canvas::PageLayout, bool)>, + ) -> OpenOutcome { // One file, one tab (#2166): a file that is open already comes to the front instead, // and the tool it was opened for runs on it. One still being opened is left to that. if let Some(i) = self.tab_showing(path) { @@ -789,6 +845,9 @@ impl PdfCraftApp { view.set_display(layout, continuous); self.launch_display_target = Some(view.id); } + if let Some(destination) = after_open { + self.apply_destination_at(i, destination); + } if let Some(command) = then { self.execute(command); } @@ -798,14 +857,22 @@ impl PdfCraftApp { if display_override.is_some() { pending.display_override = display_override; } + // Asked for another place before it finished opening: it opens at the latest one. + if after_open.is_some() { + pending.after_open = after_open; + } return OpenOutcome::Pending; } // The same file named again while its password is being asked for joins that prompt - // instead of queuing a second one; a display override or a tool it brings is kept. + // instead of queuing a second one; a display override, a place to open at or a tool it + // brings is kept. if let Some(prompt) = self.password_prompt.as_mut().filter(|p| p.path.as_deref().is_some_and(|p| same_file(p, path))) { if display_override.is_some() { prompt.display_override = display_override; } + if after_open.is_some() { + prompt.after_open = after_open; + } if then.is_some() { prompt.then = then; } @@ -815,6 +882,9 @@ impl PdfCraftApp { if display_override.is_some() { deferred.display_override = display_override; } + if after_open.is_some() { + deferred.after_open = after_open; + } if then.is_some() { deferred.then = then; } @@ -841,9 +911,10 @@ impl PdfCraftApp { if let Some(prompt) = self.password_prompt.as_mut().filter(|p| { p.path.as_deref() == Some(path) && previous_prompt.as_ref().is_none_or(|bytes| !std::sync::Arc::ptr_eq(bytes, &p.bytes)) }) { - // Encrypted: the tool runs once the password is in. + // Encrypted: the destination, the layout and the tool wait for the password. prompt.then = then; prompt.display_override = display_override; + prompt.after_open = after_open; } else if self.views.len() > before { if let Some((layout, continuous)) = display_override && let Some(view) = self.views.last_mut() @@ -854,6 +925,9 @@ impl PdfCraftApp { if let Some(doc) = self.views.last().map(|view| view.id) { self.watch.known_as(doc, stamp); } + if let Some(destination) = after_open { + self.apply_remote_destination(destination); + } if let Some(command) = then { self.execute(command); } @@ -874,7 +948,7 @@ impl PdfCraftApp { // The worker queue is bounded. A long file list (a launch behind a password prompt, // or many files at once) waits here, in order, and is submitted as opens finish. if self.pending_opens.len() >= self.open_slots() || !self.deferred_opens.is_empty() { - self.deferred_opens.push_back(DeferredOpen { path: path.to_string(), then, display_override }); + self.deferred_opens.push_back(DeferredOpen { path: path.to_string(), after_open, then, display_override }); return OpenOutcome::Pending; } match self.schedule_open(OpenRequest { @@ -884,7 +958,7 @@ impl PdfCraftApp { input: open_worker::Input::Path(std::path::PathBuf::from(path)), password: None, activate: true, - after_open: None, + after_open, recovered: None, then, }) { @@ -1023,3 +1097,8 @@ impl PdfCraftApp { } } } + +/// Whether `bytes` are a PDF: a `%PDF-` header within the first 1024 bytes, as readers accept. +fn looks_like_pdf(bytes: &[u8]) -> bool { + bytes.get(..1024).unwrap_or(bytes).windows(5).any(|w| w == b"%PDF-") +} diff --git a/crates/ui-egui/src/app/keys.rs b/crates/ui-egui/src/app/keys.rs index 419f7a9ed5..7894643a4b 100644 --- a/crates/ui-egui/src/app/keys.rs +++ b/crates/ui-egui/src/app/keys.rs @@ -1,4 +1,5 @@ use super::*; +use crate::page_keys::PageKeys; impl PdfCraftApp { pub(super) fn shortcuts(&mut self, ctx: &egui::Context) { @@ -31,57 +32,42 @@ impl PdfCraftApp { return; } // The in-window menu bar holds the keys while one of its menus is open or it has the - // keyboard (Alt, F10); a command shortcut leaves it and runs. - craft_menu::bar::leave_for_shortcut(ctx); - if !craft_menu::bar::is_open(ctx) && !craft_menu::bar::is_focused(ctx) { + // keyboard (Alt, F10); a command shortcut has left it already (`logic`) and runs. The + // chords wait meanwhile: the registered commands', Select all and the view's. + let chords = !craft_menu::bar::is_open(ctx) && !craft_menu::bar::is_focused(ctx); + // The pages' own chords (Select all, the view's) don't reach pages something covers: the + // full-screen and update prompts, or the OCR Verify screen in their place (a dialog has + // returned above, and the palette holds the keyboard). + let page_chords = !self.covers_pages(); + if chords { self.registry_shortcuts(ctx); } // (Esc closes a Combine file shown large first.) - if self.full_screen && self.combine_tab.preview.is_none() && ctx.input(|i| i.key_pressed(Key::Escape)) { + if self.full_screen && self.combine_tab.preview.is_none() && self.page_keys.pressed(ctx, Key::Escape) { self.set_full_screen(ctx, false); } if let Some(i) = self.active { - // Select all belongs to the document or page grid, unless a text field or - // overlay owns the keyboard. Other canvas shortcuts keep their own handling. - let pages_owns_keyboard = - self.right == Some(RightPanel::Pages) && self.views[i].pages_focus_id.is_some_and(|id| ctx.memory(|m| m.has_focus(id))); - if self.dialog.is_none() - && !self.palette_open - && (pages_owns_keyboard || !ctx.egui_wants_keyboard_input()) - && ctx.input_mut(|input| input.consume_shortcut(&egui::KeyboardShortcut::new(egui::Modifiers::COMMAND, Key::A))) - { - let view = &self.views[i]; - // A paragraph or text box open for typing keeps ⌘A for its own text. - let edit_objects = self.quick_tool == QuickTool::EditText && view.line_editor.is_none() && view.content.draft.is_none(); - if pages_owns_keyboard { - self.views[i].select_all_pages(); - } else if let Some(doc) = self.session.get(view.id).filter(|d| edit_objects && d.allows_modification()) { - object_ui::select_all(&mut self.views[i], doc); - } else if self.is_preparing() { - if let Some(doc) = self.session.get(self.views[i].id) { - prepare::select_all(&mut self.views[i], &doc.form); - } - } else { - self.views[i].select_all(); - } + if page_chords && chords { + self.select_all_keys(i, ctx); } self.tool_keys(i, ctx); + let keys = self.page_keys; if self.quick_tool == QuickTool::EditText && let Some(doc) = self.session.get(self.views[i].id).filter(|d| d.allows_modification()) { - object_ui::nudge_keys(ctx, &mut self.views[i], doc); - object_ui::delete_keys(ctx, &mut self.views[i]); + object_ui::nudge_keys(ctx, keys, &mut self.views[i], doc); + object_ui::delete_keys(ctx, keys, &mut self.views[i]); } else if let Some(doc) = self.session.get(self.views[i].id).filter(|d| d.allows_annotation()) { - comments::nudge_keys(ctx, &mut self.views[i], &doc.info); + comments::nudge_keys(ctx, keys, &mut self.views[i], &doc.info); } // A selected field moves with the arrows too (Prepare a form, #2608), before the view // turns the page with them. if self.is_preparing() && let Some(doc) = self.session.get(self.views[i].id).filter(|d| d.allows_modification()) { - prepare::nudge_keys(ctx, &mut self.views[i], doc); + prepare::nudge_keys(ctx, keys, &mut self.views[i], doc); } - canvas::shortcuts(&mut self.views[i], ctx, self.scroll_speed); + canvas::shortcuts(&mut self.views[i], ctx, keys, chords && page_chords, self.scroll_speed); self.clipboard_keys(i, ctx); self.image_pick_keys(i, ctx); } @@ -89,13 +75,43 @@ impl PdfCraftApp { } impl PdfCraftApp { + /// ⌘A selects all of the document or the page grid, unless a text field or overlay owns the + /// keyboard. + fn select_all_keys(&mut self, i: usize, ctx: &egui::Context) { + use egui::Key; + let pages_owns_keyboard = + self.right == Some(RightPanel::Pages) && self.views[i].pages_focus_id.is_some_and(|id| ctx.memory(|m| m.has_focus(id))); + if self.dialog.is_none() + && !self.palette_open + && (pages_owns_keyboard || !ctx.egui_wants_keyboard_input()) + && ctx.input_mut(|input| input.consume_shortcut(&egui::KeyboardShortcut::new(egui::Modifiers::COMMAND, Key::A))) + { + let view = &self.views[i]; + // A paragraph or text box open for typing keeps ⌘A for its own text. + let edit_objects = self.quick_tool == QuickTool::EditText && view.line_editor.is_none() && view.content.draft.is_none(); + if pages_owns_keyboard { + self.views[i].select_all_pages(); + } else if let Some(doc) = self.session.get(view.id).filter(|d| edit_objects && d.allows_modification()) { + object_ui::select_all(&mut self.views[i], doc); + } else if self.is_preparing() { + if let Some(doc) = self.session.get(self.views[i].id) { + prepare::select_all(&mut self.views[i], &doc.form); + } + } else { + self.views[i].select_all(); + } + } + } + /// The quick tools' keys, as in Acrobat: V selects, H pans, and holding Space pans until it - /// is released. Plain letters, so not while a text field, a form field, a dialog or the - /// palette has the keyboard. + /// is released. Plain letters, so not while a text field, a form field, a dialog, the palette + /// or a menu has the keyboard. fn tool_keys(&mut self, i: usize, ctx: &egui::Context) { use egui::Key; + let keys = self.page_keys; let free = self.dialog.is_none() && !self.palette_open && !ctx.egui_wants_keyboard_input() && self.views[i].forms.focus.is_none(); - let (plain, space) = ctx.input(|input| (input.modifiers.is_none(), input.key_down(Key::Space))); + let free = free && !keys.over(); + let (plain, space) = (ctx.input(|input| input.modifiers.is_none()), PageKeys::held(ctx, Key::Space)); if let Some(previous) = self.space_hand && !space { @@ -107,11 +123,11 @@ impl PdfCraftApp { if !free || !plain { return; } - if ctx.input(|input| input.key_pressed(Key::V)) { + if keys.pressed(ctx, Key::V) { self.quick_tool = QuickTool::Select; self.space_hand = None; } - if ctx.input(|input| input.key_pressed(Key::H)) { + if keys.pressed(ctx, Key::H) { self.quick_tool = QuickTool::Hand; self.space_hand = None; } diff --git a/crates/ui-egui/src/app/links.rs b/crates/ui-egui/src/app/links.rs index 77f9519972..028560b17b 100644 --- a/crates/ui-egui/src/app/links.rs +++ b/crates/ui-egui/src/app/links.rs @@ -66,13 +66,69 @@ impl PdfCraftApp { } pub(super) fn apply_remote_destination(&mut self, destination: pdfcraft_render::RemoteDestination) { - let Some(index) = self.views.len().checked_sub(1) else { return }; - let Some(doc) = self.session.get(self.views[index].id) else { return }; + if let Some(index) = self.views.len().checked_sub(1) { + self.apply_destination_at(index, destination); + } + } + + /// Show `destination` in the tab at `index`: a document just opened from a link or with open + /// parameters (#3425), or one already open that such a link or launch names. The latest request + /// for a document wins over a named destination still being looked up. + pub(super) fn apply_destination_at(&mut self, index: usize, destination: pdfcraft_render::RemoteDestination) { + let Some(id) = self.views.get(index).map(|view| view.id) else { return }; + #[cfg(not(target_arch = "wasm32"))] + self.pending_destinations.retain(|pending| pending.doc != id); + let destination = match destination { + pdfcraft_render::RemoteDestination::Named(name) => { + let Some(doc) = self.session.get(id) else { return }; + let (bytes, password) = (doc.bytes.clone(), doc.password.clone()); + // Live, the name tree is read off the UI thread; startup and tests stay inline. + #[cfg(not(target_arch = "wasm32"))] + if let Some(ctx) = self.ctx.clone().filter(|_| !self.run_inline) { + let (sender, receiver) = std::sync::mpsc::channel(); + let (work_bytes, work_password, work_name) = (bytes.clone(), password.clone(), name.clone()); + let work = move || { + let _ = sender.send(pdfcraft_render::named_destination(work_bytes, work_password.as_deref(), &work_name)); + ctx.request_repaint(); + }; + if std::thread::Builder::new().name("pdfcraft-named-destination".into()).spawn(work).is_ok() { + self.pending_destinations.push(PendingDestination { doc: id, name, receiver }); + return; + } + } + found_destination(pdfcraft_render::named_destination(bytes, password.as_deref(), &name), name) + } + other => other, + }; + self.show_destination_at(index, destination); + } + + /// Show each named destination whose lookup has finished, in its document's tab. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn poll_pending_destinations(&mut self) { + for pending in std::mem::take(&mut self.pending_destinations) { + let found = match pending.receiver.try_recv() { + Ok(found) => found, + Err(std::sync::mpsc::TryRecvError::Empty) => { + self.pending_destinations.push(pending); + continue; + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => None, + }; + if let Some(index) = self.views.iter().position(|view| view.id == pending.doc) { + self.show_destination_at(index, found_destination(found, pending.name)); + } + } + } + + fn show_destination_at(&mut self, index: usize, destination: pdfcraft_render::RemoteDestination) { + let Some(view) = self.views.get_mut(index) else { return }; + let Some(doc) = self.session.get(view.id) else { return }; let warning = match destination { pdfcraft_render::RemoteDestination::Default => None, pdfcraft_render::RemoteDestination::Named(_) => Some("The linked PDF opened, but its named destination was not applied."), - pdfcraft_render::RemoteDestination::Page { page, view } if page < doc.info.pages.len() => { - self.views[index].go_to_dest(page, view, &doc.info); + pdfcraft_render::RemoteDestination::Page { page, view: dest } if page < doc.info.pages.len() => { + view.go_to_dest(page, dest, &doc.info); None } pdfcraft_render::RemoteDestination::Page { .. } => Some("The linked PDF opened, but its page destination was out of range."), @@ -123,3 +179,12 @@ impl PdfCraftApp { } } } + +/// A named destination's lookup result as a destination to show: its page, or the name again when +/// the document doesn't define it, which shows a warning. +fn found_destination(found: Option<(usize, pdfcraft_render::DestView)>, name: String) -> pdfcraft_render::RemoteDestination { + match found { + Some((page, view)) => pdfcraft_render::RemoteDestination::Page { page, view }, + None => pdfcraft_render::RemoteDestination::Named(name), + } +} diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 67604292bb..9546857d41 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -37,6 +37,7 @@ fn rgb_triple(v: &serde_json::Value) -> Option<[f64; 3]> { } impl PdfCraftApp { + /// Sets the user's interface theme preference (Light, Dark, or System). pub fn set_theme_preference(&mut self, preference: ThemePreference) { self.theme_preference = preference; if let Some(ctx) = self.ctx.clone() { @@ -176,6 +177,8 @@ impl PdfCraftApp { v["pinned_tools"] = serde_json::json!(self.pinned_tools); // Set outside json! above, which is at serde_json's macro recursion limit. v["hide_empty_highlight_hover"] = serde_json::Value::Bool(self.comment_prefs.hide_empty_highlight_hover); + // The Print dialog's own paper sizes (print.page-setup). + v["print_custom_papers"] = serde_json::json!(self.print_draft.custom_papers); v["show_page_edge_shadows"] = self.show_page_edge_shadows.into(); // Not `check_updates_at_start`: that key was an opt-in of its own, off unless chosen, and // settings written then would turn this off unasked. @@ -185,10 +188,17 @@ impl PdfCraftApp { v["show_secured_notice"] = serde_json::Value::Bool(self.show_secured_notice); v["text_links"] = serde_json::Value::Bool(self.text_links); v["organize_zoom"] = serde_json::json!(self.organize_zoom); - // Canonical names survive changes to the order of PAPERS and do not depend on UI language. - v["print_paper"] = serde_json::json!(pdfcraft_engine::print::PAPERS.get(self.print_draft.paper).map(|(name, _)| *name)); + // Canonical names survive changes to the order of PAPERS and do not depend on UI language; + // a custom paper is stored by the name it was given. + let d = &self.print_draft; + v["print_paper"] = serde_json::json!((d.paper < d.paper_count()).then(|| d.paper_name())); + let size = self.print_draft.paper_size(); + v["print_paper_size"] = serde_json::json!([size.0, size.1]); + v["print_paper_overridden"] = serde_json::json!(self.print_draft.paper_overridden); // Also set outside json! above, which is at serde_json's macro recursion limit. v["print"] = print; + // Each printer's paper, sizing and two-sided choice, the one printed to last first (#2001). + v["print_printers"] = crate::print_ui::setups_to_json(&self.print_draft.setups); v.to_string() } @@ -298,32 +308,42 @@ impl PdfCraftApp { } } - /// Switches for reopening, window chrome and file watching. - fn restore_behavior_prefs(&mut self, v: &serde_json::Value) { - if let Some(show) = v["default_page_gaps"].as_bool() { - self.view_defaults.show_page_gaps = show; + /// The Print dialog's papers, each printer's setup and the choices it keeps (#2001). + fn restore_print_prefs(&mut self, v: &serde_json::Value) { + // The Print dialog's own paper sizes; settings are untrusted, so each custom paper is + // checked as if it were typed. + if v.get("print_custom_papers").is_some() { + self.print_draft.custom_papers = print_paper::read_custom(&v["print_custom_papers"]); } - if let Ok(units) = serde_json::from_value::(v["units"].clone()) { - self.units = units; + // After the custom papers, so a custom paper chosen last time is found by its name. + if let Some(name) = v.get("print_paper").and_then(serde_json::Value::as_str) { + let saved_size = v.get("print_paper_size").and_then(|s| { + let w = s.get(0)?.as_f64()?; + let h = s.get(1)?.as_f64()?; + if w >= 72.0 && h >= 72.0 && w.is_finite() && h.is_finite() { Some((w, h)) } else { None } + }); + let matched_pos = self + .print_draft + .papers + .iter() + .position(|(n, s)| n == name && saved_size.is_none_or(|sz| (s.0 - sz.0).abs() <= 1.0 && (s.1 - sz.1).abs() <= 1.0)) + .or_else(|| { + pdfcraft_engine::print::PAPERS.iter().position(|(canonical, s)| { + *canonical == name && saved_size.is_none_or(|sz| (s.0 - sz.0).abs() <= 1.0 && (s.1 - sz.1).abs() <= 1.0) + }) + }) + .or_else(|| self.print_draft.paper_index(name)); + if let Some(paper) = matched_pos { + self.print_draft.paper = paper; + self.print_draft.paper_overridden = v.get("print_paper_overridden").and_then(serde_json::Value::as_bool).unwrap_or(false); + } else if let Some(size) = saved_size { + self.print_draft.papers.push((name.to_string(), size)); + self.print_draft.paper = self.print_draft.papers.len() - 1; + self.print_draft.paper_overridden = v.get("print_paper_overridden").and_then(serde_json::Value::as_bool).unwrap_or(false); + } } - if let Some(on) = v["always_default_view"].as_bool() { - self.always_default_view = on; - } - if let Some(paper) = v - .get("print_paper") - .and_then(serde_json::Value::as_str) - .and_then(|name| pdfcraft_engine::print::PAPERS.iter().position(|(canonical, _)| *canonical == name)) - { - self.print_draft.paper = paper; - } - if let Some(on) = v["highlight_fields"].as_bool() { - self.view_defaults.highlight_fields = on; - } - if let Some(closed) = v["comments_panel_closed"].as_bool() { - self.comments_panel_closed = closed; - } - if let Some(closed) = v["right_panel_closed"].as_bool() { - self.right_panel_closed = closed; + if let Some(setups) = crate::print_ui::setups_from_json(&v["print_printers"]) { + self.print_draft.setups = setups; } // The Print dialog's remembered choices (#2001). Settings are untrusted: an unknown name // or a scale outside the dialog's range keeps the default. The scale is read before the @@ -360,6 +380,29 @@ impl PdfCraftApp { if let Some(dpi) = print["dpi"].as_f64().filter(|d| print_ui::IMAGE_DPI.contains(d)) { draft.dpi = dpi; } + } + + /// Switches for reopening, window chrome and file watching. + fn restore_behavior_prefs(&mut self, v: &serde_json::Value) { + if let Some(show) = v["default_page_gaps"].as_bool() { + self.view_defaults.show_page_gaps = show; + } + if let Ok(units) = serde_json::from_value::(v["units"].clone()) { + self.units = units; + } + if let Some(on) = v["always_default_view"].as_bool() { + self.always_default_view = on; + } + self.restore_print_prefs(v); + if let Some(on) = v["highlight_fields"].as_bool() { + self.view_defaults.highlight_fields = on; + } + if let Some(closed) = v["comments_panel_closed"].as_bool() { + self.comments_panel_closed = closed; + } + if let Some(closed) = v["right_panel_closed"].as_bool() { + self.right_panel_closed = closed; + } // Scan & OCR choices (language, engine, strategy, output mode, preprocessing and skip // flags); unknown or malformed values keep the defaults. self.ocr_draft.apply_pref(&v["ocr"]); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index eebcece9b2..f6afc0327c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -30,6 +30,7 @@ mod navigation; mod organize; pub(super) mod page_cursor; mod page_input; +pub(super) mod page_keys; pub(super) mod page_pointer; pub(super) mod page_router; mod presenter; @@ -59,7 +60,7 @@ use cache::{ use cache::{OLD_TILES, PageTex, STALE_TAG, TEXT_TAG, TILE_BYTES, scale_tag, tag_scale, texture_bytes, tile_side}; pub use find::Find; use find::find_bar; -pub use navigation::shortcuts; +pub(crate) use navigation::shortcuts; pub(crate) use organize::drop_gap; #[cfg(test)] pub(crate) use organize::grid_page_rect; @@ -102,39 +103,15 @@ impl PainterExt for egui::Painter { } } -/// User-space selectable bounding box for an image object. -/// If the image object has an active clipping path applied to it (the crop box), -/// this is the mathematical intersection of the full CTM-derived rect and the clipping path's rect. +/// User-space editing frame: explicit PDF/editor crops apply, but the page edge does not +/// truncate a selected image's handles. Body hit testing still respects page visibility. pub fn image_selectable_bounds(im: &pdfcraft_engine::PageImage) -> [f64; 4] { - if let Some(clip) = im.clip_rect { - let r = [im.rect[0].max(clip[0]), im.rect[1].max(clip[1]), im.rect[2].min(clip[2]), im.rect[3].min(clip[3])]; - if r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1] { - return r; - } - } - im.visible_rect.unwrap_or(im.rect) + im.frame_rect().unwrap_or(im.rect) } -/// Screen-space selectable bounding box for an image object. -/// If the image object has an active clipping path applied to it (the crop box), -/// the final selectable `egui::Rect` is the mathematical intersection of the image's -/// full CTM-derived rect and the clipping path's rect. +/// Screen-space editing frame, including selected handles outside the page. pub fn image_screen_rect(im: &pdfcraft_engine::PageImage, xf: &PageXform, info: &DocInfo, page: usize) -> Rect { - let full = xf.user_rect_f64(info, page, im.rect); - if let Some(clip) = im.clip_rect { - let clip_r = xf.user_rect_f64(info, page, clip); - let inter = full.intersect(clip_r); - if inter.is_positive() { - return inter; - } - } - if let Some(vr) = im.visible_rect { - let r = xf.user_rect_f64(info, page, vr); - if r.is_positive() { - return r; - } - } - full + xf.user_rect_f64(info, page, image_selectable_bounds(im)) } /// An Oriented Bounding Box (OBB) representing a rotated rectangle in screen space, @@ -507,6 +484,9 @@ pub struct DocView { /// views of one document apart (`crate::panes`). pub(crate) pane: crate::panes::ViewPane, pub id: DocId, + /// The document and attached file this tab was opened from, when a form or the Attachments + /// panel opened it: asking for that attachment again comes back to this tab. + pub from_attachment: Option<(DocId, String)>, pub zoom: f32, pub fit: Fit, pub layout: PageLayout, @@ -762,6 +742,10 @@ pub struct DocView { /// Marquee Zoom / Snapshot: the rectangle being dragged (page, start), and a finished one. pub marquee: Option<(usize, Pos2)>, pub marquee_done: Option, + /// Snapshot: the area just copied (page, view-space rect), kept with a small menu under it + /// (Print area…, Save as image…), and what was chosen there. + pub area_pick: Option<(usize, [f32; 4])>, + pub area_choice: Option, /// Fill & Sign text being typed. pub fill_text: Option, /// A queued Fill & Sign signature: select it after its edit succeeds, then leave placement. @@ -875,6 +859,10 @@ pub enum ViewAction { CameraLevel, /// After Enhance page: leave the tool for this command (Crop pages, Organize pages). CameraNext(&'static str), + /// The find bar's Replace (the highlighted match) or Replace all. + Replace { + all: bool, + }, /// Lock now, on the notice shown after a signature is placed (#2149). LockSignatures, } @@ -1036,6 +1024,7 @@ impl DocView { Self { pane: Default::default(), id, + from_attachment: None, zoom: defaults.zoom, fit: defaults.fit, layout: defaults.layout, @@ -1158,7 +1147,7 @@ impl DocView { page_widget: None, pan_hold_until: 0.0, slow_pages: HashSet::new(), - comments: Default::default(), + comments: crate::comments::CommentView::from_info(info), measure: Default::default(), forms: Default::default(), prepare: Default::default(), @@ -1180,6 +1169,8 @@ impl DocView { pages_focus_id: None, marquee: None, marquee_done: None, + area_pick: None, + area_choice: None, fill_text: None, fill_signature_page: None, lock_offer_dismissed: false, @@ -1208,6 +1199,10 @@ impl DocView { if self.select_anchor.is_some_and(|a| a > last) { self.select_anchor = None; } + // A Snapshot area names a page by its index and a rectangle on it: after pages were + // deleted or moved it would outline, print and save a different page. + self.area_pick = None; + self.area_choice = None; self.goto = None; self.goto_point = None; self.zoom_anchor = None; @@ -1216,6 +1211,7 @@ impl DocView { // Thumbnail drags hold page indexes from before the change. self.panel_drag = None; self.org_drag = None; + self.comments.document_changed(info); self.reset_coarse_state(); } @@ -1364,6 +1360,8 @@ struct PageEnv<'a> { initials: &'a Option, key_scroll: f32, key_side: f32, + /// This frame's keys as the pages see them. + keys: crate::page_keys::PageKeys, middle_gesture: bool, modal: bool, navigation_enabled: bool, @@ -1716,7 +1714,7 @@ fn cached_edit_images<'a>(view: &'a mut DocView, doc: &pdfcraft_engine::Document } fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i: usize) { - let PageEnv { allowed, comments_hidden, doc, editing_content, info, ppp, prefs, rects, redact_mark, scale, t, tool, .. } = frame.env; + let PageEnv { allowed, comments_hidden, doc, editing_content, info, keys, ppp, prefs, rects, redact_mark, scale, t, tool, .. } = frame.env; let PageFrame { ptr, origin, visible, pressed, pointer, .. } = *frame; let r = snap_to_pixels(rects[i].translate(origin.to_vec2()), ppp); if !r.intersects(visible.expand(400.0)) { @@ -1757,7 +1755,7 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i if frame.env.text_links { view.refresh_text_links(i); } - let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; + let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark, keys }; // A cancelled press takes nothing until the button is up, though the page's menu still opens // where a right-click is. A page full screen doesn't show yet takes nothing at all. (A press // there gives nothing on the page an owner, so its release, once the page is in, takes nothing @@ -1860,7 +1858,12 @@ fn draw_pages( if delta != Vec2::ZERO && delta.is_finite() { ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); } - let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag()); + // A resized handle can sit in the window beside a page that does not fill it. The + // response has to cover that visible area or the next press never starts: the pointer + // is reported only inside this rectangle once the previous drag has been released. + // Page positions stay on `content_w`/`content_h`; the extra area is not a new page. + let response_size = vec2(content_w.max(viewport.width()), content_h.max(viewport.height())); + let (resp_rect, resp) = ui.allocate_exact_size(response_size, Sense::click_and_drag()); view.page_widget = Some(resp.id); // A drag lasts only while egui reports it, so the mark can't outlive one. if !resp.dragged_by(egui::PointerButton::Primary) { @@ -2807,8 +2810,9 @@ fn scroll_direction(dir: i8, dy: f32, resized: bool) -> i8 { /// The hover tip, find bar, size hint and the in-place editors drawn over the pages. Whether the /// text editor was finished. fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, primary_down: bool, hover_text: Option<(Pos2, String)>) -> bool { - let PageEnv { added, allowed, author, avail, can_modify, comments_hidden, doc, form, info, prefs, t, today, tool, units, unobstructed, .. } = - *env; + let PageEnv { + added, allowed, author, avail, can_modify, comments_hidden, doc, form, info, keys, prefs, t, today, tool, units, unobstructed, .. + } = *env; if let Some((pos, text)) = hover_text { // Click-through, as a tooltip is: the pages lose the pointer under anything that isn't. let tip = egui::Area::new(egui::Id::unique("canvas-hover")).order(egui::Order::Tooltip).interactable(false); @@ -2819,12 +2823,12 @@ fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, prim }); }); } - find_bar(view, info.pages.len(), avail, ui, t); + find_bar(view, info.pages.len(), avail, tool == QuickTool::EditText && can_modify, ui, t); page_size_hint(view, info, units, avail, unobstructed, ui, t); if tool == QuickTool::EnhanceCamera { crate::camera_ui::bar(ui.ctx(), view, avail, t, can_modify); } - if let Some(e) = comments::composer(ui.ctx(), view, info, prefs) { + if let Some(e) = comments::composer(ui.ctx(), keys, view, info, prefs) { view.pending_edit = Some(e); } if let Some(e) = comments::popups(ui.ctx(), view, info, prefs, comments_hidden, allowed) { @@ -2835,17 +2839,17 @@ fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, prim None => e, }); } - if let Some(e) = crate::edit_text_ui::overlay(ui.ctx(), view, info) { + if let Some(e) = crate::edit_text_ui::overlay(ui.ctx(), keys, view, info) { view.pending_edit = Some(e); } let text_box = crate::forms_ui::text_box(doc, view, form); - if let Some(e) = crate::forms_ui::overlay(ui.ctx(), view, info, form, primary_down, text_box.as_ref(), today) { + if let Some(e) = crate::forms_ui::overlay(ui.ctx(), keys, view, info, form, primary_down, text_box.as_ref(), today) { view.pending_edit = Some(e); } - if let Some(e) = crate::fill_sign::type_box(ui.ctx(), view, info, author) { + if let Some(e) = crate::fill_sign::type_box(ui.ctx(), keys, view, info, author) { view.pending_edit = Some(e); } - let (typed, text_done) = crate::content_ui::editor(ui.ctx(), view, info, added); + let (typed, text_done) = crate::content_ui::editor(ui.ctx(), keys, view, info, added); if typed.is_some() { view.pending_edit = typed; } @@ -2910,6 +2914,21 @@ fn finish_sign_requests(app: &mut PdfCraftApp, index: usize, mut tool: QuickTool if let Some(done) = app.views[index].marquee_done.take() { app.finish_marquee(index, done); } + // The copied area and its menu belong to Snapshot: another tool puts them away. The tool is + // the app's, not the tab's, so every tab's area goes, not only the one on screen. + if tool != QuickTool::Snapshot { + for v in &mut app.views { + v.area_pick = None; + } + } + if let Some(choice) = app.views[index].area_choice.take() + && let Some((page, area)) = app.views[index].area_pick + { + match choice { + crate::zoom_snap::AreaChoice::Print => app.open_print_area(page, area), + crate::zoom_snap::AreaChoice::Save => app.save_snapshot_image(index, page, area), + } + } // A signature rectangle was drawn, or an empty signature field clicked. let drawn = app.views[index].sign.drawn.take(); if let (Some((page, rect)), QuickTool::SignArea { certify }) = (drawn, tool) { @@ -3219,13 +3238,16 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // No dialog, close prompt or palette over the page: only then does page input count. let modal = app.modal_open(); let unobstructed = !modal && !app.palette_open; + // A menu click can open a dialog after the frame's first look. + app.page_keys = app.page_keys.also(app.covers_pages()); + let keys = app.page_keys; let organize_zoom = app.organize_zoom; let invert_middle = app.invert_middle_drag; let view = &mut app.views[index]; if view.organize { // One remembered grid size for every document, kept in the settings. view.set_grid_zoom(organize_zoom); - organize_grid(view, doc, unobstructed, app.show_page_edge_shadows, invert_middle, ui, &t); + organize_grid(view, doc, unobstructed, keys, app.show_page_edge_shadows, invert_middle, ui, &t); app.organize_zoom = view.grid_zoom(); return; } @@ -3330,7 +3352,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } else { view.prepare.selected = None; } - view.objects.prepare(doc, tool == QuickTool::EditText && can_modify, ui.ctx()); + view.objects.prepare(doc, tool == QuickTool::EditText && can_modify, ui.ctx(), keys); view.text_background.prepare(ui.ctx(), doc, view.line_editor.as_ref(), view.content.draft.as_ref(), scale); let added = if editing_content { doc.added().to_vec() } else { Vec::new() }; let doc_links = if tool == QuickTool::Link { doc.links().to_vec() } else { Vec::new() }; @@ -3378,6 +3400,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { initials: &initials, key_scroll, key_side, + keys, middle_gesture, modal, navigation_enabled, @@ -3484,19 +3507,19 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // One crop, then back to selecting (as Acrobat does). let cropped = view.pending_edit.as_ref().is_some_and(|e| matches!(e, pdfcraft_engine::Edit::SetPageBox { .. })); let mut tool = app.quick_tool; - comments::keys(ui.ctx(), view, &mut tool, allowed); + comments::keys(ui.ctx(), keys, view, info, &mut tool, allowed); if preparing { - crate::prepare::keys(ui.ctx(), view); + crate::prepare::keys(ui.ctx(), keys, view); } if tool != QuickTool::EditText { view.image_selection = None; crate::content_ui::release_image_selection(view, &added); } if editing_content { - crate::content_ui::keys(ui.ctx(), view, tool == QuickTool::EditText); + crate::content_ui::keys(ui.ctx(), keys, view, tool == QuickTool::EditText); } if tool == QuickTool::Link { - crate::link_ui::keys(ui.ctx(), view); + crate::link_ui::keys(ui.ctx(), keys, view); } else { view.links.selected = None; } @@ -3819,7 +3842,7 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, // Read mode hides the tools (#2646), and full screen is the page and nothing else (#1459); // the quick tools come back with the window. // A document that hides the toolbars (`HideToolbar`) hides this one too. - if app.mode != crate::Mode::Read && !app.full_screen && !app.views.get(index).is_some_and(|v| v.interface.hidden.toolbar) { + if !app.hotbar_hidden() && !app.views.get(index).is_some_and(|v| v.interface.hidden.toolbar) { // The page nearest the left edge, whose margin the bar sits in. let page_left = app.views[index].screen_rects.iter().map(|(_, r)| r.left()).reduce(f32::min); quick_bar(app, avail, page_left, ui); diff --git a/crates/ui-egui/src/canvas/area.rs b/crates/ui-egui/src/canvas/area.rs index 53b1edd328..2e3b63b5f8 100644 --- a/crates/ui-egui/src/canvas/area.rs +++ b/crates/ui-egui/src/canvas/area.rs @@ -201,6 +201,7 @@ pub(super) fn run_button(app: &mut PdfCraftApp, index: usize, name: &str, action .map(|name| pdfcraft_engine::FieldChange { name, value: None, + formatted: false, read_only: None, required: None, display: Some(if hide { 1 } else { 0 }), diff --git a/crates/ui-egui/src/canvas/find.rs b/crates/ui-egui/src/canvas/find.rs index 73b76eb3a6..94e5bf61cf 100644 --- a/crates/ui-egui/src/canvas/find.rs +++ b/crates/ui-egui/src/canvas/find.rs @@ -1,4 +1,4 @@ -use super::{Align2, Color32, CornerRadius, DocView, Range, Rect, Stroke, Tokens, icons, theme, vec2, widgets}; +use super::{Align2, Color32, CornerRadius, DocView, Range, Rect, Stroke, Tokens, ViewAction, icons, theme, vec2, widgets}; /// The find bar (⌘F): query, matches across the document, current match. #[derive(Default)] @@ -26,6 +26,11 @@ pub struct Find { pub whole_words: bool, /// Shown in the Search panel (Advanced Search) instead of the find bar. pub in_panel: bool, + /// Find and replace (in Edit text mode): the Replace row shows, with what replaces a match. + pub replacing: bool, + pub replacement: String, + /// Focus the Replace field on the next frame. + pub focus_replace: bool, } /// What a text layer arriving after a search began may do with the automatic choice. @@ -80,6 +85,19 @@ impl DocView { f.focus = true; } + /// Open the find bar with its Replace row (Edit text mode shows it): the Find field takes the + /// keyboard while it is empty, else the Replace field. + pub fn open_replace(&mut self) { + let f = self.find.get_or_insert_with(Find::default); + f.in_panel = false; + f.replacing = true; + if f.query.trim().is_empty() { + f.focus = true; + } else { + f.focus_replace = true; + } + } + /// Re-run the search on all pages whose text is known (after the query changed). pub fn rerun_find(&mut self) { let Some(f) = self.find.as_mut() else { return }; @@ -249,8 +267,10 @@ impl DocView { } } -/// Acrobat-style floating find bar at the top-right of the document area. -pub(super) fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut egui::Ui, t: &Tokens) { +/// Acrobat-style floating find bar at the top-right of the document area. In Edit text mode +/// (`editing`) it offers a Replace row: Replace changes the highlighted match, Replace all every +/// match, each inside its own paragraph. +pub(super) fn find_bar(view: &mut DocView, pages: usize, area: Rect, editing: bool, ui: &mut egui::Ui, t: &Tokens) { let Some(find) = view.find.as_mut() else { return }; // The Search panel shows this search. if find.in_panel { @@ -258,6 +278,7 @@ pub(super) fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut eg } let mut close = false; let mut step: Option = None; + let mut replace: Option = None; let searched = view.texts.len() + view.text_failed.len(); egui::Area::new(egui::Id::unique(("find-bar", view.pane.key))) .order(egui::Order::Middle) @@ -318,6 +339,13 @@ pub(super) fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut eg if icons::button(ui, "chevron-down", 26.0, false, &tip).clicked() { step = Some(true); } + if editing { + let tip = crate::commands::command_tip(ui.ctx(), tl!("Replace ({key})"), "edit.replace"); + if icons::button(ui, "replace", 26.0, find.replacing, &tip).clicked() { + find.replacing = !find.replacing; + find.focus_replace = find.replacing; + } + } let opts = icons::button(ui, "settings-2", 26.0, find.case_sensitive || find.whole_words, tl!("Find options")); egui::Popup::menu(&opts).show(|ui| { let a = ui.add(widgets::checkbox(&mut find.whole_words, tl!("Whole words only"))).changed(); @@ -331,12 +359,49 @@ pub(super) fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut eg close = true; } }); + if editing && find.replacing { + ui.horizontal(|ui| { + ui.add(icons::image("replace", 16.0, t.text_muted)); + let edit = egui::TextEdit::singleline(&mut find.replacement) + .id(egui::Id::unique("replace-input")) + .hint_text(tl!("Replace with")) + .desired_width(220.0) + .frame(egui::Frame::NONE); + let r = ui.add(edit).accessible_name(tl!("Replace with")); + if find.focus_replace { + r.request_focus(); + find.focus_replace = false; + } + let searched = !find.case_query.trim().is_empty(); + // Enter in the field replaces the highlighted match, as the button does. + if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && find.current.is_some() { + replace = Some(false); + r.request_focus(); + } + if r.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + close = true; + } + ui.add_enabled_ui(find.current.is_some(), |ui| { + if crate::widgets::pill_button(ui, tl!("Replace"), false).clicked() { + replace = Some(false); + } + }); + ui.add_enabled_ui(searched, |ui| { + if crate::widgets::pill_button(ui, tl!("Replace all"), true).clicked() { + replace = Some(true); + } + }); + }); + } }); }); if close { view.find = None; return; } + if let Some(all) = replace { + view.pending_action = Some(ViewAction::Replace { all }); + } if find.query != find.case_query { view.rerun_find(); } diff --git a/crates/ui-egui/src/canvas/image_pick.rs b/crates/ui-egui/src/canvas/image_pick.rs index 81ae3ba757..d36bfdd88e 100644 --- a/crates/ui-egui/src/canvas/image_pick.rs +++ b/crates/ui-egui/src/canvas/image_pick.rs @@ -422,7 +422,7 @@ impl PdfCraftApp { if ctx.egui_wants_keyboard_input() { return; } - if ctx.input(|inp| inp.key_pressed(egui::Key::Escape)) { + if self.page_keys.pressed(ctx, egui::Key::Escape) { if let Some(view) = self.views.get_mut(i) { view.picked_image = None; view.pending_pick = None; @@ -430,7 +430,7 @@ impl PdfCraftApp { return; } let Some(picked) = self.views.get(i).and_then(|v| v.picked_image) else { return }; - let copy = ctx.input(|inp| inp.events.iter().any(|e| matches!(e, egui::Event::Copy))); + let copy = self.page_keys.clipboard(ctx).copy; if !copy || self.views[i].selected_text().is_some() { return; } diff --git a/crates/ui-egui/src/canvas/navigation.rs b/crates/ui-egui/src/canvas/navigation.rs index 47196ca140..0fdb2d3bfd 100644 --- a/crates/ui-egui/src/canvas/navigation.rs +++ b/crates/ui-egui/src/canvas/navigation.rs @@ -379,16 +379,33 @@ fn zoom_shortcuts(view: &mut DocView, pressed: &impl Fn(egui::KeyboardShortcut) } } -/// The document view's keyboard shortcuts: find, rotation, zoom (or the page grid's size), -/// history, Escape, and the paging and scrolling keys. An open form field keeps the paging -/// and scrolling keys for itself. -pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_speed::ScrollSpeed) { +/// The view's keys: its chords, then its plain keys. `chords`: no menu of the menu bar has the +/// keyboard and nothing covers the pages, so chords may run (they wait for a menu, as the +/// registered commands' do). An open form field keeps the paging and scrolling keys for itself. +pub(crate) fn shortcuts( + view: &mut DocView, + ctx: &egui::Context, + keys: crate::page_keys::PageKeys, + chords: bool, + speed: crate::scroll_speed::ScrollSpeed, +) { + if let Some((arrows, field_open)) = view_chords(view, ctx, chords) { + view_keys(view, ctx, keys, speed, arrows, field_open); + } +} + +/// The view's chords: Find and its steps, rotation, zoom and the view history, unless a menu of the +/// menu bar has the keyboard or something covers the pages (`chords`). `None` while a text field +/// has the keyboard, and then it has the plain keys too; else whether the arrows are the view's (a +/// focused radio button keeps them), and whether an open form field (a drop-down's or a list box's +/// items) keeps the plain keys. +fn view_chords(view: &mut DocView, ctx: &egui::Context, chords: bool) -> Option<(bool, bool)> { use egui::{Key, KeyboardShortcut, Modifiers}; - if ctx.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::F))) { + let pressed = |s: KeyboardShortcut| chords && ctx.input_mut(|i| i.consume_shortcut(&s)); + if pressed(KeyboardShortcut::new(Modifiers::COMMAND, Key::F)) { view.open_find(); } let cmd = |k| KeyboardShortcut::new(Modifiers::COMMAND, k); - let pressed = |s: KeyboardShortcut| ctx.input_mut(|i| i.consume_shortcut(&s)); // ⌘G / ⇧⌘G step through the matches from the find field too, as in Acrobat and Preview // (#2669): ahead of the text-input check, but only while there is a search to step. if view.find.is_some() { @@ -402,7 +419,7 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s // radio button for the arrows that move within its group: the other page keys still work. let form_box = view.forms.keys.filter(|(id, _)| ctx.memory(|m| m.focused()) == Some(*id)); if ctx.egui_wants_keyboard_input() && form_box.is_none() { - return; + return None; } let arrows = !form_box.is_some_and(|(_, radio)| radio); // Rotation first: egui matches ⌘+ loosely with respect to Shift. @@ -418,11 +435,26 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s if pressed(cmd(Key::CloseBracket)) || pressed(alt(Key::ArrowRight)) { view.view_history(true); } - let key = |k| (arrows || !matches!(k, Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown)) && ctx.input(|i| i.key_pressed(k)); // An open form field (a drop-down's or a list box's items) takes the arrows, Page Up / Page // Down, Home, End and Escape. A focused check box or radio button uses none of them, so the // page keys still work there. let field_open = view.forms.focus.is_some() && form_box.is_none(); + Some((arrows, field_open)) +} + +/// The view's plain keys: Escape, Home and End, full screen's keys, the arrows and Page Up / Page +/// Down. Not while something is over the pages or a form field is open (`field_open`), which keeps +/// them. +fn view_keys( + view: &mut DocView, + ctx: &egui::Context, + keys: crate::page_keys::PageKeys, + speed: crate::scroll_speed::ScrollSpeed, + arrows: bool, + field_open: bool, +) { + use egui::Key; + let key = |k| (arrows || !matches!(k, Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown)) && keys.pressed(ctx, k); // An open menu takes Escape first and closes; the selection stays for the next one (#3535). // So does an open field, whose list closes. if key(Key::Escape) && !egui::Popup::is_any_open(ctx) && !field_open { @@ -443,9 +475,16 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s // (Shift+⌘ on macOS) with ↑ / ↓ or Page Up / Page Down (#6434; *Adobe Acrobat Help*, 2019, // "Keys for navigating a PDF", p. 83). These take the key from the page turning and // scrolling below. - let shift_cmd = |k| KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, k); - let to_start = pressed(shift_cmd(Key::ArrowUp)) || pressed(shift_cmd(Key::PageUp)); - let to_end = pressed(shift_cmd(Key::ArrowDown)) || pressed(shift_cmd(Key::PageDown)); + let (mut to_start, mut to_end) = (false, false); + keys.take_presses(ctx, |k, m| { + let shift_cmd = m.matches_logically(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT); + match k { + Key::ArrowUp | Key::PageUp if shift_cmd => to_start = true, + Key::ArrowDown | Key::PageDown if shift_cmd => to_end = true, + _ => return false, + } + true + }); if key(Key::Home) || to_start { view.go_to_page(0); } diff --git a/crates/ui-egui/src/canvas/organize.rs b/crates/ui-egui/src/canvas/organize.rs index 25c99543e4..2917ffdcae 100644 --- a/crates/ui-egui/src/canvas/organize.rs +++ b/crates/ui-egui/src/canvas/organize.rs @@ -4,6 +4,7 @@ use super::{ Align2, Color32, CornerRadius, DocInfo, DocView, Edit, Pos2, Rect, RenderRequest, RequestKind, Sense, Stroke, Tokens, Vec2, ViewAction, device_pixels, icons, pos2, theme, vec2, widgets, }; +use crate::page_keys::PageKeys; /// How far a sharp grid render may be from the size drawn before it is redone: a pinch changes /// the size on every frame, and a slightly soft or slightly large image is fine meanwhile. @@ -92,7 +93,7 @@ pub(crate) fn grid_page_rect(cell: Rect, page: Vec2) -> Rect { } /// The organize toolbar: page operations on the selection (Acrobat's Organize Pages bar). -fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool, unobstructed: bool, ui: &mut egui::Ui, t: &Tokens) { +fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool, keys: PageKeys, ui: &mut egui::Ui, t: &Tokens) { let targets = view.target_pages(); let n = info.pages.len(); let (first, last) = (targets.first().copied().unwrap_or(0), targets.last().copied().unwrap_or(0)); @@ -153,19 +154,12 @@ fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, dirty: b }); // Keys act on the selection unless a text field or a dialog has them (Delete under // Preferences deleted the selected pages). - if editable && unobstructed && !ui.ctx().egui_wants_keyboard_input() { - use egui::{Key, Modifiers}; - let (del, esc) = ui.input_mut(|i| { - ( - i.consume_key(Modifiers::NONE, Key::Delete) || i.consume_key(Modifiers::NONE, Key::Backspace), - i.consume_key(Modifiers::NONE, Key::Escape), - ) - }); + if editable && !ui.ctx().egui_wants_keyboard_input() { + use egui::Key; + let ctx = ui.ctx(); + let (del, esc) = (keys.take(ctx, Key::Delete) || keys.take(ctx, Key::Backspace), keys.take(ctx, Key::Escape)); // ⌘C / ⌘X / ⌘V copy, cut and paste pages (egui delivers them as clipboard events). - let (copy, cut, paste) = ui.input(|i| { - let any = |f: &dyn Fn(&egui::Event) -> bool| i.events.iter().any(f); - (any(&|e| matches!(e, egui::Event::Copy)), any(&|e| matches!(e, egui::Event::Cut)), any(&|e| matches!(e, egui::Event::Paste(_)))) - }); + let crate::page_keys::Clipboard { copy, cut, paste } = keys.clipboard(ctx); if copy || cut { view.pending_action = Some(ViewAction::CopyPages { cut }); } @@ -267,10 +261,12 @@ fn request_sharper( } } +#[allow(clippy::too_many_arguments)] pub(super) fn organize_grid( view: &mut DocView, doc: &pdfcraft_engine::Document, unobstructed: bool, + keys: PageKeys, show_page_edge_shadows: bool, invert_middle: bool, ui: &mut egui::Ui, @@ -285,7 +281,7 @@ pub(super) fn organize_grid( let anchor = view.grid_anchor.take(); let reveal = view.grid_reveal.take(); let mut open_page = None; - organize_toolbar(view, info, editable, dirty, unobstructed, ui, t); + organize_toolbar(view, info, editable, dirty, keys, ui, t); let viewport = ui.available_rect_before_wrap(); view.viewport_screen = viewport; let auto_delta = if unobstructed { diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index fa0a15562b..b39c16fde0 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -10,6 +10,7 @@ use super::{ signature_badges, verdict_mark_rect, }; use crate::page_cursor::{Layer, Subject}; +use crate::page_keys::PageKeys; use crate::page_pointer::PagePointer; use crate::page_router::{Owner, PagePress, top}; use crate::{QuickTool, comments, icons}; @@ -45,6 +46,8 @@ pub(crate) struct PageToolContext<'a> { pub page: usize, pub doc: &'a pdfcraft_engine::Document, pub info: &'a DocInfo, + /// This frame's keys as the pages see them. + pub keys: PageKeys, } /// The tools' input on one page. The hits at the press point decide who holds the press, and the @@ -64,6 +67,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut editing_content, form, info, + keys, placing, prefs, preparing, @@ -76,8 +80,8 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut } = frame.env; let PageSlot { i, xf, .. } = *slot; let PageFrame { resp, ptr, pointer, .. } = *frame; - let cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info }; - let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; + let cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info, keys }; + let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark, keys }; let (lines, images) = if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { edit_inventory(view, doc, i) } else { Default::default() }; let field_tool = match tool { @@ -210,7 +214,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut // the palette, another window or another tab has it (`gate`), as before. let gate = !(tool == QuickTool::EditText && view.objects.input_blocked()); if tool == QuickTool::EditText && can_modify && gate { - crate::edit_text_ui::picture_keys(ui.ctx(), view, i, &images, ptr.any_down); + crate::edit_text_ui::picture_keys(ui.ctx(), keys, view, i, &images, ptr.any_down); } let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, press, view); let picks_up = matches!(tool, QuickTool::Select | QuickTool::AddText | QuickTool::EditText); @@ -346,6 +350,7 @@ fn tool_input( doc, info, initials, + keys, signature, tool, units, @@ -356,7 +361,7 @@ fn tool_input( } = frame.env; let PageSlot { i, xf, .. } = *slot; let PageFrame { resp, ptr, .. } = *frame; - let cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info }; + let cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info, keys }; let free = press == PagePress::Free; if let QuickTool::Measure(measure_tool) = tool { crate::measure_ui::page_input(ui, crate::measure_ui::Clicks::of(resp, free), doc, view, pcx, measure_tool, units); @@ -892,6 +897,7 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P form, generation, info, + keys, prepare_tab_order, prefs, preparing, @@ -905,8 +911,8 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P let PageSlot { i, r, xf } = *slot; let PageFrame { resp, ptr, pointer, .. } = *frame; let painter = ui.painter(); - let tool_cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info }; - let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; + let tool_cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info, keys }; + let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark, keys }; // A press nobody holds picks the picture under it: a link's click follows the link only (ruling // 5), though its drag or double-click still lets the picked picture go, as does a drag the page // it is over first sees, whichever page it began on. diff --git a/crates/ui-egui/src/canvas/page_keys.rs b/crates/ui-egui/src/canvas/page_keys.rs new file mode 100644 index 0000000000..c70188e6cc --- /dev/null +++ b/crates/ui-egui/src/canvas/page_keys.rs @@ -0,0 +1,173 @@ +//! The keyboard as the pages see it. +//! +//! A dialog, the palette or a menu over the pages has the keys, but the pages are drawn before the +//! dialogs and read egui's keys first. So page modules read keys through [`PageKeys`], which has +//! none while something is over the pages: Delete under the Keyboard shortcuts window deleted the +//! comment selected behind it, and the Escape that closed it deselected the comment too. +//! +//! Each module still asks for itself whether a text field has the keyboard. + +use egui::{Context, Event, Key, Modifiers}; + +/// A frame's Copy, Cut and Paste, as egui delivers them (⌘C, ⌘X, ⌘V, or a menu's). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct Clipboard { + pub copy: bool, + pub cut: bool, + pub paste: bool, +} + +/// One frame's keyboard, as the pages see it: their keys, or none while something is over them. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct PageKeys { + /// A dialog, the palette or a menu is over the pages. + over: bool, +} + +impl PageKeys { + pub(crate) fn new(over: bool) -> Self { + Self { over } + } + + /// The keys once something more is found over the pages this frame. Only ever more covered: a + /// menu that this frame's Escape closes still had that Escape. + #[must_use] + pub(crate) fn also(self, over: bool) -> Self { + Self { over: self.over || over } + } + + /// `key` was pressed for the pages this frame. It stays for whatever reads it next. + pub(crate) fn pressed(self, ctx: &Context, key: Key) -> bool { + !self.over && ctx.input(|i| i.key_pressed(key)) + } + + /// Take `key`, pressed without ⌘ or Ctrl (egui lets an extra Shift or Alt through), for the + /// pages: nothing after reads it. + pub(crate) fn take(self, ctx: &Context, key: Key) -> bool { + !self.over && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, key)) + } + + /// Take the frame's key presses that `take` wants for the pages, each judged by its own + /// modifiers (the frame's can differ when a key is let go in the same frame). `take` says + /// whether it took the press; a press taken is gone for whatever reads keys next. + pub(crate) fn take_presses(self, ctx: &Context, mut take: impl FnMut(Key, Modifiers) -> bool) { + if self.over { + return; + } + ctx.input_mut(|i| { + i.events.retain(|e| match e { + Event::Key { key, pressed: true, modifiers, .. } => !take(*key, *modifiers), + _ => true, + }); + }); + } + + /// The frame's Copy, Cut and Paste for the pages: none while something is over them. They stay + /// for whatever reads them next. + pub(crate) fn clipboard(self, ctx: &Context) -> Clipboard { + if self.over { + return Clipboard::default(); + } + ctx.input(|i| { + let any = |f: fn(&Event) -> bool| i.events.iter().any(f); + Clipboard { copy: any(|e| matches!(e, Event::Copy)), cut: any(|e| matches!(e, Event::Cut)), paste: any(|e| matches!(e, Event::Paste(_))) } + }) + } + + /// Something is over the pages: their keys wait. + pub(crate) fn over(self) -> bool { + self.over + } + + /// `key` is held down. Not the pages' alone: a key let go while something is over them is let + /// go all the same. + pub(crate) fn held(ctx: &Context, key: Key) -> bool { + ctx.input(|i| i.key_down(key)) + } +} + +#[cfg(test)] +mod tests { + use egui::RawInput; + + use super::*; + + fn press(key: Key) -> Event { + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE } + } + + /// Run one frame with `events`, calling `f` with its context. + fn frame(events: Vec, f: impl FnOnce(&Context)) { + let ctx = Context::default(); + let mut f = Some(f); + let mut output = ctx.run_ui(RawInput { events, ..Default::default() }, |ui| { + if let Some(f) = f.take() { + f(ui.ctx()); + } + }); + // An input-only test has no renderer to take the font texture. + output.textures_delta.clear(); + } + + #[test] + fn something_found_over_the_pages_stays_over_them_for_the_frame() { + assert_eq!(PageKeys::new(false).also(true), PageKeys::new(true)); + assert_eq!(PageKeys::new(true).also(false), PageKeys::new(true)); + assert_eq!(PageKeys::new(false).also(false), PageKeys::new(false)); + } + + #[test] + fn with_something_over_the_pages_they_have_no_keys_and_leave_them_there() { + frame(vec![press(Key::Delete)], |ctx| { + let keys = PageKeys::new(true); + assert!(!keys.pressed(ctx, Key::Delete)); + assert!(!keys.take(ctx, Key::Delete)); + assert!(ctx.input(|i| i.key_pressed(Key::Delete)), "the key stays for the dialog"); + }); + } + + #[test] + fn the_pages_take_the_presses_they_want_with_their_own_modifiers() { + let shifted = Event::Key { key: Key::ArrowLeft, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT }; + frame(vec![press(Key::ArrowRight), shifted, Event::Copy, press(Key::Escape)], |ctx| { + let mut seen = Vec::new(); + PageKeys::new(true).take_presses(ctx, |key, _| { + seen.push(key); + true + }); + assert!(seen.is_empty(), "nothing while something is over the pages"); + PageKeys::new(false).take_presses(ctx, |key, modifiers| { + seen.push(key); + key == Key::ArrowLeft && modifiers.shift + }); + assert_eq!(seen, [Key::ArrowRight, Key::ArrowLeft, Key::Escape]); + assert!(!ctx.input(|i| i.key_pressed(Key::ArrowLeft)), "the press taken is gone"); + assert!(ctx.input(|i| i.key_pressed(Key::ArrowRight) && i.key_pressed(Key::Escape)), "the others stay"); + assert!(ctx.input(|i| i.events.contains(&Event::Copy)), "and so does what isn't a key"); + }); + } + + #[test] + fn the_clipboard_is_the_pages_only_with_nothing_over_them() { + frame(vec![Event::Copy, Event::Paste("text".into())], |ctx| { + assert_eq!(PageKeys::new(true).clipboard(ctx), Clipboard::default()); + assert_eq!(PageKeys::new(false).clipboard(ctx), Clipboard { copy: true, cut: false, paste: true }); + }); + frame(vec![Event::Cut], |ctx| { + assert_eq!(PageKeys::new(true).clipboard(ctx), Clipboard::default()); + assert_eq!(PageKeys::new(false).clipboard(ctx), Clipboard { copy: false, cut: true, paste: false }); + }); + } + + #[test] + fn the_pages_peek_at_a_key_or_take_it() { + frame(vec![press(Key::Delete)], |ctx| { + let keys = PageKeys::new(false); + assert!(keys.pressed(ctx, Key::Delete)); + assert!(keys.pressed(ctx, Key::Delete), "a peek leaves it"); + assert!(keys.take(ctx, Key::Delete)); + assert!(!keys.pressed(ctx, Key::Delete), "a take doesn't"); + assert!(!keys.take(ctx, Key::Escape)); + }); + } +} diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 4868b22e4a..b0bf54e3b8 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -797,3 +797,120 @@ fn the_page_guards_read_hit_tests_split_calls_and_aliases() { let aliases: Vec<(String, String)> = [("hit", "corner_under"), ("picture_hit", "under")].map(|(a, b)| (a.to_owned(), b.to_owned())).to_vec(); assert_eq!(aliased_hit_tests(source), aliases); } + +/// egui's raw key state: a key pressed, held, let go or taken, and the key, text and clipboard +/// events themselves, however they are imported. +const RAW_KEY_READS: [&str; 17] = [ + "Event::Copy", + "Event::Cut", + "Event::Paste", + "Event::Text", + "Event::{", + "Event::*", + "Event as ", + "InputState::", + "key_pressed(", + "key_released(", + "key_down(", + "keys_down", + "num_presses(", + "consume_key(", + "consume_shortcut(", + "count_and_consume_key(", + "Event::Key", +]; + +/// Functions in the files the key guard scans ([`key_files`]) that read egui's keys themselves: +/// a window's or a field's own keys, which `PageKeys` would hide, and the pages' reads that +/// `PageKeys` 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): (file, function, why). +const NOT_PAGE_KEYS: [(&str, &str, &str); 17] = [ + ( + "app/clipboard.rs", + "clipboard_keys", + "the pages' clipboard: Copy, Cut and Paste (events and chords), which act only when `page_keys.over()` is false, \ + and the pastes it hands on to text fields while something is over the pages", + ), + ( + "app/keys.rs", + "select_all_keys", + "Select all, a chord that waits for a menu of the menu bar as the registered commands' do, and isn't called while something covers the pages", + ), + ("canvas/area.rs", "glide_interrupted", "any key stops the touchpad's glide"), + ("canvas/autoscroll.rs", "update_latched", "Escape ends the middle-button scroll, which holds the whole view"), + ("canvas/autoscroll.rs", "escape", "Escape ends the middle-button scroll, which holds the whole view"), + ("canvas/find.rs", "find_bar", "the Find field's own keys, while it has the focus"), + ( + "canvas/navigation.rs", + "view_chords", + "the view's chords, which wait for a menu of the menu bar as the registered commands' do, and for whatever covers the pages", + ), + ("canvas/page_input.rs", "cancel_on_escape", "Escape cancels a press the pages hold, which goes on under a dialog opened during it"), + ("tools/calc_order_ui.rs", "panel", "the Calculation order panel's own Alt+arrows"), + ("tools/comments/composer.rs", "submit_key", "the comment field's own Enter, while it has the focus (the Comments panel's too)"), + ("tools/field_table_ui.rs", "show", "the Text field table window's own Escape"), + ("tools/field_table_ui.rs", "column_dividers", "the Text field table window's own arrows"), + ("tools/fill_sign.rs", "signature_pad", "the signature dialog's own ⌘Z"), + ("tools/forms_ui.rs", "combo_editor", "the combo box's own Enter, while its field has the focus"), + ("tools/prepare/properties.rs", "body", "the field properties' own Enter, while one of their fields has the focus"), + ("tools/redact_ui.rs", "search_body", "the redaction search field's own Enter, while it has the focus"), + ("tools/sign_ui/dialog.rs", "sign_as", "the signing dialog's own Enter"), +]; + +/// The files whose key reads the key guard checks, relative to `src`: every file under `canvas/` +/// and `tools/` and the canvas's root, whether or not it is a page module yet, the app's own keys +/// and clipboard, and the Pages panel's thumbnails; not their tests, and not `PageKeys` itself. A +/// helper in another file that page code hands egui's input to isn't seen. +fn key_files() -> Vec { + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files = ["canvas.rs", "app/keys.rs", "app/clipboard.rs", "panels/pages.rs"].map(str::to_owned).to_vec(); + let mut dirs = vec![src.join("canvas"), src.join("tools")]; + while let Some(dir) = dirs.pop() { + for entry in std::fs::read_dir(dir).expect("source directory") { + let path = entry.expect("source entry").path(); + let stem = path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + if stem == "tests" || stem.ends_with("_tests") { + continue; + } + if path.is_dir() { + dirs.push(path); + } else if path.extension().is_some_and(|ext| ext == "rs") { + let file = + path.strip_prefix(&src).expect("under src").components().map(|c| c.as_os_str().to_string_lossy()).collect::>().join("/"); + if file != "canvas/page_keys.rs" { + files.push(file); + } + } + } + } + files.sort(); + files +} + +/// Page code reads keys only through `PageKeys`. The pages are drawn before the dialogs and the +/// palette and read egui's keys first, so a raw read acts on what is selected or typed behind +/// whatever is over them: Delete under a dialog deleted the comment selected behind it, and the +/// Escape that closed one threw away the paragraph being typed. +#[test] +fn page_modules_read_keys_only_through_page_keys() { + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let files = key_files(); + let mut found = Vec::new(); + for file in &files { + let source = std::fs::read_to_string(src.join(file)).expect("source file").replace("\r\n", "\n"); + for (n, function, text) in code_lines(&source) { + for name in RAW_KEY_READS.iter().filter(|name| uses(&text, name)) { + found.push((file.as_str(), function.map(str::to_owned), n, (*name).to_owned())); + } + } + } + let problems = misuses_in( + found, + &NOT_PAGE_KEYS, + "reads egui's keys in page code. Read them through `PageKeys` (src/canvas/page_keys.rs) instead: it has none \ + while a dialog, the palette or a menu is over the pages, which read keys before those are drawn. Not the \ + pages' keys? Add the function to NOT_PAGE_KEYS in src/canvas/page_pointer/tests.rs, with why.", + "reads no keys of its own any more: remove it from NOT_PAGE_KEYS.", + ); + assert!(problems.is_empty(), "{}", problems.join("\n")); +} diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index dab411bdf0..87bda69e86 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -763,7 +763,7 @@ fn keyboard_find_step_is_explicit_and_a_late_text_layer_cannot_undo_it() { ctx.input_mut(|i| { i.events.push(egui::Event::Key { key: egui::Key::G, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); }); - shortcuts(&mut v, &ctx, Default::default()); + shortcuts(&mut v, &ctx, Default::default(), true, Default::default()); assert_eq!(v.current, 2, "the keyboard step chose the next match"); assert_eq!(v.find.as_ref().unwrap().auto_from, None, "the keyboard step is the user's choice"); diff --git a/crates/ui-egui/src/canvas/zoom.rs b/crates/ui-egui/src/canvas/zoom.rs index 1e0e456073..e9e2eae82b 100644 --- a/crates/ui-egui/src/canvas/zoom.rs +++ b/crates/ui-egui/src/canvas/zoom.rs @@ -62,6 +62,11 @@ impl DocView { }) } + /// The display saved before entering full screen, while presenting. + pub fn saved_display_mut(&mut self) -> Option<&mut SavedDisplay> { + self.presentation.as_mut() + } + /// Whether the view is in full screen's one-page-at-a-time display. pub fn presenting(&self) -> bool { self.presentation.is_some() diff --git a/crates/ui-egui/src/canvas/zoom_snap.rs b/crates/ui-egui/src/canvas/zoom_snap.rs index 93e0b13210..6412241699 100644 --- a/crates/ui-egui/src/canvas/zoom_snap.rs +++ b/crates/ui-egui/src/canvas/zoom_snap.rs @@ -13,6 +13,15 @@ use crate::{PdfCraftApp, QuickTool}; /// [x0, y0, x1, y1]); a plain click has an empty rectangle. pub type Marquee = (usize, Rect, [f32; 4]); +/// What was chosen in the menu under a copied area. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AreaChoice { + /// Print only this area (Acrobat's "print selected area"). + Print, + /// Save the copied picture as a PNG file. + Save, +} + /// Drag a rectangle on page `page` (Marquee Zoom, Snapshot), with a press nobody holds (`free`). pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, free: bool, view: &mut DocView) { let (pointer, origin) = (ptr.pos, ptr.origin); @@ -25,6 +34,30 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, p && let Some(o) = origin.filter(|o| xf.rect.contains(*o)) { view.marquee = Some((page, o)); + view.area_pick = None; + } + // The area just copied stays outlined, with what can be done with it underneath. + if let Some((p, area)) = view.area_pick + && p == page + && view.marquee.is_none() + { + let r = xf.view_rect(area); + ui.painter().rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.5, Color32::from_rgb(0x14, 0x73, 0xE6)), egui::StrokeKind::Middle); + egui::Area::new(egui::Id::unique("snapshot-area-menu")) + .order(egui::Order::Foreground) + .fixed_pos(r.left_bottom() + egui::vec2(0.0, 6.0)) + .show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.horizontal(|ui| { + if ui.button(tl!("Print area…")).clicked() { + view.area_choice = Some(AreaChoice::Print); + } + if ui.button(tl!("Save as image…")).clicked() { + view.area_choice = Some(AreaChoice::Save); + } + }); + }); + }); } if let Some((p, start)) = view.marquee && p == page @@ -74,6 +107,7 @@ impl PdfCraftApp { } match self.snapshot(index, page, view_rect) { Ok((w, h)) => { + self.views[index].area_pick = Some((page, view_rect)); self.notify_fmt("The selected area has been copied ({w} × {h} pixels)", &[("w", &w.to_string()), ("h", &h.to_string())]) } Err(e) => self.notify_fmt("Couldn't take the snapshot: {e}", &[("e", &e.to_string())]), @@ -120,10 +154,83 @@ impl PdfCraftApp { Ok(()) } + /// Print ▸ Selected area: the Print dialog for only `view_rect` (page view points, y down) of + /// `page`, sized like a page of its own (Fit to paper or Actual size). + pub(crate) fn open_print_area(&mut self, page: usize, view_rect: [f32; 4]) { + let Some((_, id)) = self.active_ids() else { return }; + let Some(height) = self.session.get(id).and_then(|d| d.info.pages.get(page)).map(|p| f64::from(p.height)) else { return }; + self.open_print(); + if self.dialog == Some(crate::Dialog::Print) { + let [x0, y0, x1, y1] = view_rect.map(f64::from); + let d = &mut self.print_draft; + // The print crate takes the area in display space, y up. + d.area = Some((page, [x0.min(x1), height - y0.max(y1), x0.max(x1), height - y0.min(y1)])); + d.which = crate::print_ui::Which::Area; + d.handling = crate::print_ui::Handling::Size; + d.sheet = 0; + } + } + + /// Save the area `view_rect` of `page` that view `index` copied as a PNG file (asked where; + /// `save_override` in tests). The last snapshot is used when it was taken of that area; + /// otherwise (another tab snapshotted since) the area is rendered again. + pub(crate) fn save_snapshot_image(&mut self, index: usize, page: usize, view_rect: [f32; 4]) { + let this_area = self.views.get(index).is_some_and(|v| self.last_snapshot_of == Some((v.id, page, view_rect))); + let png = match self.last_snapshot.as_ref() { + Some((w, h, pixels)) if this_area => pdfcraft_engine::export::encode_png(*w, *h, pixels), + _ => self.render_area(index, page, view_rect).and_then(|(w, h, pixels)| pdfcraft_engine::export::encode_png(w, h, &pixels)), + }; + let png = match png { + Ok(png) => png, + Err(e) => { + self.notify_fmt("Couldn't save the image: {e}", &[("e", &e)]); + return; + } + }; + let stem = self + .active_ids() + .and_then(|(_, id)| self.session.get(id)) + .map_or_else(String::new, |d| d.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_string()); + let name = format!("{stem} area.png"); + #[cfg(not(target_arch = "wasm32"))] + { + let write = move |app: &mut Self, path: std::path::PathBuf| match crate::editing::write_atomically(&path.to_string_lossy(), &png) { + Ok(()) => app.notify_fmt("Exported to {path}", &[("path", &path.display().to_string())]), + Err(e) => app.notify_fmt("Couldn't write {name}: {e}", &[("name", &path.display().to_string()), ("e", &e.to_string())]), + }; + match self.save_override.clone() { + Some(p) => write(self, p.into()), + None => { + let dialog = + rfd::AsyncFileDialog::new().set_title(tl!("Save as image").to_string()).add_filter("PNG", &["png"]).set_file_name(name); + self.ask_one(crate::pickers::Ask::Save(dialog), None, write); + } + } + } + #[cfg(target_arch = "wasm32")] + if let Err(e) = crate::editing::download(&name, &png) { + self.notify_error(e); + } + } + /// Render `view_rect` (page view points) of `page` and copy it to the clipboard (and keep it /// in `last_snapshot`), at the current zoom or [`SNAPSHOT_MIN_SCALE`] if that is finer. pub fn snapshot(&mut self, index: usize, page: usize, view_rect: [f32; 4]) -> Result<(u32, u32), String> { - let view = &self.views[index]; + let (w, h, rgba) = self.render_area(index, page, view_rect)?; + #[cfg(not(target_arch = "wasm32"))] + if self.system_clipboard { + let img = arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(&rgba) }; + crate::os_clipboard::with(|c| c.set_image(img)).map_err(|e| e.to_string())?; + } + self.last_snapshot = Some((w, h, rgba)); + self.last_snapshot_of = Some((self.views[index].id, page, view_rect)); + Ok((w, h)) + } + + /// Render `view_rect` (page view points) of `page` in view `index` at its current zoom: + /// (width, height, premultiplied RGBA). + fn render_area(&self, index: usize, page: usize, view_rect: [f32; 4]) -> Result<(u32, u32, pdfcraft_render::Pixels), String> { + let view = self.views.get(index).ok_or("no document")?; let doc = self.session.get(view.id).ok_or("no document")?; let ppp = self.ctx.as_ref().map_or(2.0, |c| c.pixels_per_point()); let scale = snapshot_scale(view.render_scale(ppp), view_rect[2] - view_rect[0], view_rect[3] - view_rect[1]); @@ -142,14 +249,7 @@ impl PdfCraftApp { if let Some(e) = out.error { return Err(e); } - let (w, h) = (out.width, out.height); - #[cfg(not(target_arch = "wasm32"))] - if self.system_clipboard { - let img = arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(&out.rgba) }; - crate::os_clipboard::with(|c| c.set_image(img)).map_err(|e| e.to_string())?; - } - self.last_snapshot = Some((w, h, out.rgba)); - Ok((w, h)) + Ok((out.width, out.height, out.rgba)) } } diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index ac52586062..549a877172 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -113,7 +113,7 @@ impl PdfCraftApp { /// Last-resort guard (AGENTS.md §4): a command that panics is reported and the app, with its /// open documents, keeps running. Edits are applied to a copy, so the document is unchanged. pub fn execute(&mut self, id: &str) -> bool { - match pdfcraft_engine::guard(|| self.execute_unguarded(id)) { + let done = match pdfcraft_engine::guard(|| self.execute_unguarded(id)) { Ok(done) => done, Err(m) => { // A command stopped part way through closing or replacing a tab must not leave @@ -122,7 +122,9 @@ impl PdfCraftApp { self.notify_fmt("That didn't work: an internal error stopped it ({m}). Your documents are unchanged.", &[("m", m.as_str())]); true } - } + }; + crate::dialogs::discard_inactive_password_drafts(self); + done } /// Why the command can't run right now, for the user. @@ -273,6 +275,16 @@ impl PdfCraftApp { } "view.select_tool" => self.quick_tool = crate::QuickTool::Select, "view.hand_tool" => self.quick_tool = crate::QuickTool::Hand, + // Find and replace changes page text, so it runs in Edit text mode (as Acrobat's + // does): the command turns the mode on, and the find bar shows its Replace row there. + "edit.replace" => { + if let Some(i) = active { + self.quick_tool = crate::QuickTool::EditText; + self.left = crate::LeftPanel::Tool("edit"); + self.reveal_tools(); + self.views[i].open_replace(); + } + } "view.side_panel" => self.toggle_side_panel(), "view.tools_pane" => self.left_open = !self.left_open, "view.focus_page_input" => { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0f93ae8e2d..1d768e72a2 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -26,10 +26,21 @@ pub(super) mod word_count_ui; #[cfg(test)] mod tests; -pub(crate) use prompts::{PromptButton, save_prompt_key}; +pub(crate) use prompts::{PromptButton, save_prompt_key, shorten_middle}; use prompts::{full_screen_prompt, link_prompt, password, remote_link_prompt, save_prompt, unlock}; -const INFO_KEYS: [&str; 4] = ["Title", "Author", "Subject", "Keywords"]; +use crate::properties_session::INFO_KEYS; + +/// Drop (and zeroize) password drafts as soon as their owning dialog is inactive. +/// Commands and control requests also call this: close/reopen can happen between frames. +pub(crate) fn discard_inactive_password_drafts(app: &mut PdfCraftApp) { + if app.dialog != Some(Dialog::Protect) { + app.protect_draft = Default::default(); + } + if app.dialog != Some(Dialog::Sign) { + app.sign_draft = None; + } +} /// What the dialog's widgets asked for this frame; acted on after the modal is drawn. #[derive(Default)] @@ -106,17 +117,19 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { if app.dialog != Some(Dialog::FieldTable) { app.field_table_draft = None; } + if !matches!(app.dialog, Some(Dialog::Properties(_))) { + app.discard_properties(); + } + discard_inactive_password_drafts(app); // A movable window, not this modal: the page under it shows the table as it changes. if app.dialog == Some(Dialog::FieldTable) { crate::field_table_ui::show(app, ctx); return; } - let Some(dialog) = app.dialog else { - app.props_draft = None; - app.view_draft = None; - app.custom_draft = None; + let Some(dialog) = app.dialog else { return }; + if matches!(dialog, Dialog::Properties(_)) && !app.prepare_properties() { return; - }; + } // Properties ▸ Fonts shows the fonts as they are now, after any content edits. if let (Dialog::Properties(PropsTab::Fonts), Some((_, id))) = (dialog, app.active_ids()) { app.session.refresh_fonts(id); @@ -125,15 +138,6 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { if dialog == Dialog::Properties(PropsTab::SpaceAudit) { app.refresh_space_audit(); } - // Seed the editable Description fields from the document when the dialog opens. - if let (Dialog::Properties(_), Some((_, id))) = (dialog, app.active_ids()) - && app.props_draft.as_ref().is_none_or(|(d, _)| *d != id) - && let Some(doc) = app.session.get(id) - { - app.props_draft = Some((id, INFO_KEYS.map(|k| doc.info_value(k).unwrap_or_default()))); - app.view_draft = Some((id, doc.initial_view())); - app.custom_draft = Some((id, crate::CustomDraft { props: doc.custom_info(), ..Default::default() })); - } let mut req = Pending::default(); // Every dialog shares this modal. A different dialog than the one shown before starts from // its own size, not from the last one's: rows that fill the height they are offered would @@ -1781,7 +1785,8 @@ fn dialog_pin_tool(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) /// Draw the active dialog's footer and record any requested action in `req`. fn dialog_footer(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) { ui.add_space(16.0); - let changed = draft_changes(app).is_some_and(|c| !c.is_empty()); + app.stage_properties(); + let changed = app.properties_changed(); // One row high: on its own, a right-to-left layout centres the buttons in all the height // left, which after a taller Properties tab is the room that tab left behind. ui.horizontal(|ui| { @@ -2184,10 +2189,8 @@ fn finish_edits(app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) { if let Some(by) = req.split_now.take() { app.split_active(&by); } - if req.apply - && let Some(edits) = draft_changes(app) - { - app.apply_edit(Edit::Batch { label: "Change document properties".into(), edits }); + if req.apply && matches!(dialog, Dialog::Properties(_)) { + req.close = app.finish_properties(); } } @@ -2206,14 +2209,13 @@ fn finish_close(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req: if dialog == Dialog::AttachmentDescription { app.attachment_description_draft = None; } - app.props_draft = None; - app.view_draft = None; - app.custom_draft = None; + app.discard_properties(); app.bulk_field_props = None; app.unmarked_page = Default::default(); } else { app.dialog = Some(req.next.unwrap_or(dialog)); } + discard_inactive_password_drafts(app); if let Some(cmd) = req.link_command { app.execute(cmd); } @@ -2248,37 +2250,137 @@ fn finish_close(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req: } } -/// Info edits needed to make the document match the Description draft. -fn draft_changes(app: &PdfCraftApp) -> Option> { - let (id, draft) = app.props_draft.as_ref()?; - let doc = app.session.get(*id)?; - let mut edits: Vec = INFO_KEYS - .iter() - .zip(draft.iter()) - .filter(|(k, v)| doc.info_value(k).unwrap_or_default().trim() != v.trim()) - .map(|(k, v)| Edit::SetInfo { key: (*k).to_string(), value: v.clone() }) - .collect(); - if let Some((vid, v)) = &app.view_draft - && vid == id - && *v != doc.initial_view() - { - edits.push(Edit::SetInitialView(Box::new(v.clone()))); - } - // Custom: deleted properties are cleared, then added and changed ones are set. - if let Some((cid, d)) = &app.custom_draft - && cid == id - { - let before = doc.custom_info(); - for (k, _) in before.iter().filter(|(k, _)| !d.props.iter().any(|(n, _)| n == k)) { - edits.push(Edit::SetInfo { key: k.clone(), value: String::new() }); - } - for (k, v) in d.props.iter().filter(|(k, v)| !before.iter().any(|(n, w)| n == k && w == v)) { - edits.push(Edit::SetInfo { key: k.clone(), value: v.clone() }); - } - } - Some(edits) -} - fn yes(b: bool) -> String { tl!(if b { "Yes" } else { "No" }).to_string() } + +#[cfg(test)] +mod password_draft_tests { + use super::*; + use egui_kittest::Harness; + + fn seed_password_drafts(app: &mut PdfCraftApp) { + app.start_signing(0, None, None, None); + let sign = app.sign_draft.as_mut().unwrap(); + sign.step = crate::sign_ui::SignStep::Choose; + sign.password = "sign password".into(); + sign.new_id.password = "new ID password".into(); + sign.new_id.confirm = "new ID password".into(); + sign.new_id.file_password = "import password".into(); + app.protect_draft.password = "view password".into(); + app.protect_draft.confirm = "view password".into(); + app.protect_draft.protection.open_password = Some("open password".into()); + app.protect_draft.protection.permissions_password = Some("owner password".into()); + } + + fn assert_protect_cleared(app: &PdfCraftApp) { + assert_eq!(app.protect_draft, crate::protect::ProtectDraft::default()); + } + + #[test] + fn inactive_password_drafts_are_cleared_before_early_returns() { + // None and FieldTable return before the ordinary modal-close path; About replaces it. + for dialog in [None, Some(Dialog::FieldTable), Some(Dialog::About)] { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + seed_password_drafts(&mut app); + app.dialog = dialog; + app + }); + h.run_steps(2); + assert_protect_cleared(h.state()); + assert!(h.state().sign_draft.is_none()); + } + } + + #[test] + fn rendering_preserves_only_the_active_password_draft() { + for dialog in [Dialog::Protect, Dialog::Sign] { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + seed_password_drafts(&mut app); + app.dialog = Some(dialog); + app + }); + h.run_steps(2); + if dialog == Dialog::Protect { + assert_eq!(h.state().protect_draft.password, "view password"); + assert_eq!(h.state().protect_draft.confirm, "view password"); + assert!(h.state().sign_draft.is_none()); + } else { + assert_protect_cleared(h.state()); + let sign = h.state().sign_draft.as_ref().unwrap(); + assert_eq!(sign.password, "sign password"); + assert_eq!(sign.new_id.password, "new ID password"); + assert_eq!(sign.new_id.confirm, "new ID password"); + assert_eq!(sign.new_id.file_password, "import password"); + } + } + } + + #[test] + fn control_close_and_reopen_clears_passwords_without_a_render_between_requests() { + let mut app = PdfCraftApp::new(); + // The control queue can handle several ui.set requests before the next rendered frame. + // Repeat both close and replacement flows so stale drafts cannot return on later opens. + for _ in 0..2 { + for replacement in ["none", "about", "sign", "certify"] { + app.set_option("dialog", "protect").unwrap(); + app.protect_draft.password = "old password".into(); + app.protect_draft.confirm = "old password".into(); + app.set_option("dialog", replacement).unwrap(); + assert_protect_cleared(&app); + app.set_option("dialog", "protect").unwrap(); + assert_protect_cleared(&app); + assert!(app.sign_draft.is_none()); + } + for replacement in ["none", "about", "protect"] { + seed_password_drafts(&mut app); + app.set_option("dialog", replacement).unwrap(); + assert!(app.sign_draft.is_none(), "inactive Sign draft retained after {replacement}"); + app.set_option("dialog", "sign").unwrap(); + let sign = app.sign_draft.as_ref().unwrap(); + assert!(sign.password.is_empty()); + assert_eq!(sign.new_id, crate::sign_ui::NewIdDraft::default()); + } + } + } + + #[test] + fn selecting_the_active_protect_dialog_keeps_its_password() { + let mut app = PdfCraftApp::new(); + app.set_option("dialog", "protect").unwrap(); + app.protect_draft.password = "current password".into(); + app.protect_draft.confirm = "current password".into(); + app.set_option("dialog", "protect").unwrap(); + assert_eq!(app.protect_draft.password, "current password"); + assert_eq!(app.protect_draft.confirm, "current password"); + } + + #[test] + fn commands_discard_replaced_password_drafts_before_the_next_control_request() { + let mut app = PdfCraftApp::new(); + for _ in 0..2 { + seed_password_drafts(&mut app); + app.dialog = Some(Dialog::Protect); + assert!(app.execute("help.about")); + assert_eq!(app.dialog, Some(Dialog::About)); + assert_protect_cleared(&app); + assert!(app.sign_draft.is_none()); + app.set_option("dialog", "protect").unwrap(); + assert_protect_cleared(&app); + + seed_password_drafts(&mut app); + assert!(app.execute("help.shortcuts")); + assert_eq!(app.dialog, Some(Dialog::Shortcuts)); + assert_protect_cleared(&app); + assert!(app.sign_draft.is_none()); + app.set_option("dialog", "sign").unwrap(); + let sign = app.sign_draft.as_ref().unwrap(); + assert!(sign.password.is_empty()); + assert_eq!(sign.new_id, crate::sign_ui::NewIdDraft::default()); + } + } +} diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs index 883d0b68a6..02384f6b58 100644 --- a/crates/ui-egui/src/dialogs/preferences.rs +++ b/crates/ui-egui/src/dialogs/preferences.rs @@ -4,7 +4,7 @@ use egui::{Color32, CornerRadius, Margin, Rect, RichText, Sense, Stroke, pos2, vec2}; use crate::PdfCraftApp; -use crate::canvas::{Fit, PageLayout}; +use crate::canvas::{Fit, PageLayout, ViewDefaults}; use crate::motion::AnimationEffects; use crate::scroll_speed::ScrollSpeed; use crate::side_panels::ToolsSide; @@ -690,6 +690,61 @@ fn card_appearance(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &m reading_colours_section(ui, app, t, ctx); } +#[derive(Clone, Copy, Default)] +struct ViewPrefChanges { + always_reset: bool, + layout: bool, + continuous: bool, + gaps: bool, + fit: bool, + zoom: bool, +} + +fn apply_view_pref_changes(view: &mut crate::DocView, defaults: &ViewDefaults, changes: ViewPrefChanges) { + if changes.always_reset || changes.gaps { + view.set_page_gaps(defaults.show_page_gaps); + } + if let Some(saved) = view.saved_display_mut() { + if changes.always_reset || changes.layout { + saved.layout = defaults.layout; + } + if changes.always_reset || changes.continuous { + saved.continuous = defaults.continuous; + } + if changes.always_reset || changes.fit { + saved.fit = defaults.fit; + if defaults.fit == Fit::None { + saved.zoom = defaults.zoom; + } + } + if changes.zoom { + saved.fit = Fit::None; + saved.zoom = defaults.zoom; + } + } else { + if changes.always_reset || (changes.layout && changes.continuous) { + view.set_display(defaults.layout, defaults.continuous); + } else { + if changes.layout { + view.set_layout(defaults.layout); + } + if changes.continuous { + view.set_continuous(defaults.continuous); + } + } + if changes.always_reset || changes.fit { + match defaults.fit { + Fit::None => view.set_zoom(defaults.zoom), + fit if view.fit != fit => view.set_fit(fit), + _ => {} + } + } + if changes.zoom { + view.set_zoom(defaults.zoom); + } + } +} + /// Content for Card 3: Documents and View fn card_documents_and_view(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut PrefSearchContext) { ui.horizontal(|ui| { @@ -710,37 +765,65 @@ fn card_documents_and_view(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx.label_secondary(ui, tl!("Used when opening PDFs. An explicit launch or control mode takes precedence for the session."), t); ui.add_space(6.0); - let defaults = &mut app.view_defaults; + let mut layout_choice = app.view_defaults.layout; + let mut continuous = app.view_defaults.continuous; + let mut layout_changed = false; + let mut continuous_changed = false; ui.horizontal(|ui| { ctx.label_primary(ui, tl!("Default page display"), t); - let mut layout_choice = defaults.layout; if ctx.pill_choice(ui, t, &mut layout_choice, &[(PageLayout::Single, tl!("Single page")), (PageLayout::TwoUp, tl!("Two-page view"))]) { - defaults.layout = layout_choice; + app.view_defaults.layout = layout_choice; + layout_changed = true; } - let mut continuous = defaults.continuous; if ctx.toggle_pill(ui, t, &mut continuous, tl!("Continuous scrolling")) { - defaults.continuous = continuous; + app.view_defaults.continuous = continuous; + continuous_changed = true; } }); - ctx.checkbox(ui, &mut defaults.show_page_gaps, tl!("Show gaps between pages"), t); + let mut show_page_gaps = app.view_defaults.show_page_gaps; + let gaps_changed = ctx.checkbox(ui, &mut show_page_gaps, tl!("Show gaps between pages"), t).changed(); + if gaps_changed { + app.view_defaults.show_page_gaps = show_page_gaps; + } ctx.label_secondary(ui, tl!("Hide gaps to join page edges. Margins inside the PDF remain unchanged."), t); ui.add_space(6.0); + let mut fit_changed = false; + let mut zoom_changed = false; ui.horizontal(|ui| { ctx.label_primary(ui, tl!("Default zoom"), t); - ctx.pill_choice( - ui, - t, - &mut defaults.fit, - &[(Fit::Width, tl!("Fit to width")), (Fit::Page, tl!("Zoom to page level")), (Fit::None, tl!("Custom"))], - ); - let mut percent = defaults.zoom * 100.0; + let mut fit = app.view_defaults.fit; + if ctx.pill_choice(ui, t, &mut fit, &[(Fit::Width, tl!("Fit to width")), (Fit::Page, tl!("Zoom to page level")), (Fit::None, tl!("Custom"))]) + { + app.view_defaults.fit = fit; + fit_changed = true; + } + let mut percent = app.view_defaults.zoom * 100.0; if ui.add(egui::DragValue::new(&mut percent).range(8.0..=6400.0).max_decimals(0).suffix("%")).changed() { - (defaults.fit, defaults.zoom) = (Fit::None, percent / 100.0); + (app.view_defaults.fit, app.view_defaults.zoom) = (Fit::None, percent / 100.0); + zoom_changed = true; } }); ctx.label_secondary(ui, tl!("Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages."), t); - ctx.checkbox(ui, &mut app.always_default_view, tl!("Always use my default zoom and page layout"), t); + let always_changed = ctx.checkbox(ui, &mut app.always_default_view, tl!("Always use my default zoom and page layout"), t).changed(); + + let changes = ViewPrefChanges { + always_reset: always_changed, + layout: layout_changed, + continuous: continuous_changed, + gaps: gaps_changed, + fit: fit_changed, + zoom: zoom_changed, + }; + if app.always_default_view && (always_changed || layout_changed || continuous_changed || gaps_changed || fit_changed || zoom_changed) { + let defaults = app.view_defaults; + for view in &mut app.views { + apply_view_pref_changes(view, &defaults, changes); + } + } else if let Some(i) = app.active { + let defaults = app.view_defaults; + apply_view_pref_changes(&mut app.views[i], &defaults, ViewPrefChanges { always_reset: false, ..changes }); + } ctx.checkbox(ui, &mut app.text_links, tl!("Open web and email addresses written in the page text"), t); ctx.label_secondary(ui, tl!("Only web (http, https) and email addresses open, and PdfCraft asks first, as for other links."), t); ui.add_space(6.0); diff --git a/crates/ui-egui/src/dialogs/prompts.rs b/crates/ui-egui/src/dialogs/prompts.rs index 91e069bca3..b1aa162790 100644 --- a/crates/ui-egui/src/dialogs/prompts.rs +++ b/crates/ui-egui/src/dialogs/prompts.rs @@ -130,7 +130,7 @@ pub(super) fn save_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { /// `name` cut to at most `max` characters by replacing its middle with "…", keeping the start and /// the end, where the extension and version suffixes sit. Counts `char`s, so it never splits one. -fn shorten_middle(name: &str, max: usize) -> String { +pub(crate) fn shorten_middle(name: &str, max: usize) -> String { let count = name.chars().count(); if count <= max { return name.to_string(); @@ -351,7 +351,7 @@ pub(super) fn password(app: &mut PdfCraftApp, ctx: &egui::Context) { let message = crate::i18n::fmt(tl!("“{name}” is protected. Enter a password to open it."), &[("name", &prompt.name)]); match password_modal(ctx, "password", tl!("Password required"), message, &mut prompt.input, prompt.error.as_deref(), tl!("Open")) { Some(PasswordAnswer::Submit) => { - let pw = prompt.input.clone(); + let pw = std::mem::take(&mut prompt.input); app.submit_password(Some(pw)); } Some(PasswordAnswer::Cancel) => app.submit_password(None), @@ -366,7 +366,7 @@ pub(super) fn unlock(app: &mut PdfCraftApp, ctx: &egui::Context) { crate::i18n::fmt(tl!("“{name}” has security restrictions. Enter its permissions password to lift them."), &[("name", &prompt.name)]); match password_modal(ctx, "unlock", tl!("Permissions password"), message, &mut prompt.input, prompt.error.as_deref(), tl!("Unlock")) { Some(PasswordAnswer::Submit) => { - let pw = prompt.input.clone(); + let pw = std::mem::take(&mut prompt.input); app.submit_unlock(Some(pw)); } Some(PasswordAnswer::Cancel) => app.submit_unlock(None), diff --git a/crates/ui-egui/src/dialogs/protect.rs b/crates/ui-egui/src/dialogs/protect.rs index ffc8b53f8e..cc79f3c82c 100644 --- a/crates/ui-egui/src/dialogs/protect.rs +++ b/crates/ui-egui/src/dialogs/protect.rs @@ -25,6 +25,20 @@ impl Default for ProtectDraft { } } +impl Drop for ProtectDraft { + fn drop(&mut self) { + use zeroize::Zeroize as _; + self.password.zeroize(); + self.confirm.zeroize(); + if let Some(pw) = &mut self.protection.open_password { + pw.zeroize(); + } + if let Some(pw) = &mut self.protection.permissions_password { + pw.zeroize(); + } + } +} + impl ProtectDraft { /// Why Apply is disabled, if it is. pub fn problem(&self) -> Option<&'static str> { @@ -251,4 +265,40 @@ mod tests { assert_eq!(strength("Abcdefg1").0, "Medium"); assert_eq!(strength("Correct-Horse-9").0, "Strong"); } + + #[test] + fn closing_the_protect_dialog_clears_the_password_draft() { + use egui_kittest::{Harness, kittest::Queryable}; + + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.dialog = Some(crate::Dialog::Protect); + app.protect_draft.password = "TopSecret123!".into(); + app.protect_draft.confirm = "TopSecret123!".into(); + app + }); + h.run_steps(2); + + // Clicking Cancel closes Dialog::Protect and clears the draft passwords. + h.get_by_label("Cancel").click(); + h.run_steps(2); + assert_eq!(h.state().dialog, None); + assert!(h.state().protect_draft.password.is_empty(), "password must not linger after cancelling Dialog::Protect"); + assert!(h.state().protect_draft.confirm.is_empty(), "confirm must not linger after cancelling Dialog::Protect"); + + // Pressing Escape on Dialog::Protect also clears the draft passwords. + { + let app = h.state_mut(); + app.dialog = Some(crate::Dialog::Protect); + app.protect_draft.password = "AnotherSecret!".into(); + app.protect_draft.confirm = "AnotherSecret!".into(); + } + h.run_steps(2); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert_eq!(h.state().dialog, None); + assert!(h.state().protect_draft.password.is_empty(), "password must not linger after closing Dialog::Protect"); + assert!(h.state().protect_draft.confirm.is_empty(), "confirm must not linger after closing Dialog::Protect"); + } } diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 089b58ddeb..29180aeab7 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -15,6 +15,8 @@ pub use save::PendingLock; pub use save::PendingOverwrite; #[cfg(target_arch = "wasm32")] pub(crate) use save::download; +#[doc(hidden)] +pub use save::download_mime_type; #[cfg(not(target_arch = "wasm32"))] pub(crate) use save::in_document_folder; pub use save::{SaveTarget, write_atomically}; @@ -28,6 +30,16 @@ fn new_text_typed(v: &crate::DocView) -> bool { v.content.draft.as_ref().is_some_and(|d| d.index.is_none() && !d.text.trim().is_empty()) } +/// Clear an unfinished page-anchored drawing or composer when a page edit shifts or removes `page`. +fn clear_shifted_page_comment_state(view: &mut crate::DocView, shifted_from: usize) { + if matches!(view.comments.gesture, Some(crate::comments::Gesture::Draw { page, .. }) if page >= shifted_from) { + view.comments.gesture = None; + } + if view.comments.composer.as_ref().is_some_and(|c| !matches!(c.kind, crate::comments::ComposerKind::Edit(_)) && c.page >= shifted_from) { + view.comments.composer = None; + } +} + /// Keeps the pages (or comment) the user acted on selected, where they now are. `annots_before` /// is the page's annotations before a delete or a paste of comments (empty for other edits). fn select_after_edit( @@ -39,17 +51,27 @@ fn select_after_edit( ) { match edit { Edit::MovePages { pages, to } => { + clear_shifted_page_comment_state(view, 0); let n = pages.iter().collect::>().len(); let to = (*to).min(info.pages.len() - n); view.select_pages(&(to..to + n).collect::>()); } Edit::DuplicatePages { pages, at: Some(at) } => { + clear_shifted_page_comment_state(view, *at); let n = pages.iter().collect::>().len(); let at = (*at).min(info.pages.len().saturating_sub(n)); view.select_pages(&(at..at + n).collect::>()); } - Edit::InsertBlankPage { at, .. } => view.select_pages(&[(*at).min(info.pages.len() - 1)]), - Edit::DeletePages { .. } => view.select_pages(&[]), + Edit::InsertBlankPage { at, .. } => { + clear_shifted_page_comment_state(view, *at); + view.select_pages(&[(*at).min(info.pages.len() - 1)]); + } + Edit::DeletePages { pages } => { + if let Some(&first) = pages.iter().min() { + clear_shifted_page_comment_state(view, first); + } + view.select_pages(&[]); + } // A stroke drawn with the pen stays unselected, so the selection box and // author popup don't sit over the next stroke (#429). Edit::AddAnnotation(a) @@ -76,7 +98,6 @@ fn select_after_edit( view.comments.open_editor(a.page, n, String::new()); } } - Edit::DeleteAnnotation { page, index } => view.comments.annotation_deleted(annots_before, &info.annotations, (*page, *index)), // A group moved together stays selected (its positions don't change), so it // can be moved or copied again; anything else in a batch clears the selection. Edit::Batch { edits, .. } if edits.iter().all(|e| matches!(e, Edit::MoveAnnotation { .. })) => {} @@ -207,25 +228,15 @@ impl PdfCraftApp { .map(|before| form_edit_pages(&before, &doc.form)); let one_page = comment_page(&edit).or_else(|| content_page(&edit)); if signatures_before.is_some_and(|before| crate::canvas::signature_states_changed(&before, &doc.signatures)) { - // A signature shows its state on its own page, which an edit elsewhere can - // change (a certification forbidding it, say): every page redraws. An edit that - // moves no page (a form edit, or one to a page's comments or content) keeps the - // values typed, in it and before it, showing until their pages redraw. - let waiting = (form_edit.is_some() || one_page.is_some()).then(|| std::mem::take(&mut view.forms.unbaked)); - view.document_changed(info); - if let Some(waiting) = waiting { - view.forms.unbaked = waiting; - } - if let Some((_, unbaked)) = form_edit { - view.forms.unbaked.extend(unbaked); - } + signatures_changed_after_edit(view, &edit, info, &annots_before, form_edit.map(|(_, u)| u), one_page); } else if let Some(page) = one_page { - one_page_changed(view, &edit, page); + one_page_changed(view, &edit, page, &annots_before, info); } else if let Some((changed_pages, unbaked)) = form_edit { view.forms.unbaked.extend(unbaked); for page in changed_pages { view.page_changed(page); } + view.comments.document_changed(info); } else { view.document_changed(info); } @@ -559,6 +570,7 @@ impl PdfCraftApp { self.leave_camera(i); self.execute(command); } + Some(crate::canvas::ViewAction::Replace { all }) => self.replace_found(i, all), None => {} } // That tab comes forward when its value was refused (its editor is open again with the @@ -1109,13 +1121,51 @@ fn content_page(edit: &Edit) -> Option { } } +/// A signature shows its state on its own page, which an edit elsewhere can change (a +/// certification forbidding it, say): every page redraws. An edit that moves no page (a form edit, +/// or one to a page's comments or content) keeps the values typed, in it and before it, showing +/// until their pages redraw. +fn signatures_changed_after_edit( + view: &mut crate::DocView, + edit: &Edit, + info: &pdfcraft_render::DocInfo, + annots_before: &[pdfcraft_render::Annotation], + form_unbaked: Option>, + one_page: Option, +) { + let waiting = (form_unbaked.is_some() || one_page.is_some()).then(|| std::mem::take(&mut view.forms.unbaked)); + if let Edit::DeleteAnnotation { page, index } = edit { + // Remap surviving comments before generic slot pruning loses their state. + view.comments.annotation_deleted(annots_before, info, (*page, *index)); + } + view.document_changed(info); + if let Some(waiting) = waiting { + view.forms.unbaked = waiting; + } + if let Some(unbaked) = form_unbaked { + view.forms.unbaked.extend(unbaked); + } +} + /// A comment or content edit changes one page, `page`: keep every other raster. Opening, closing /// or moving a pop-up changes none: the window is drawn over the page, and a /Popup annotation /// paints nothing in the raster (ISO 32000-2 §12.5.6.14). -fn one_page_changed(view: &mut crate::DocView, edit: &Edit, page: usize) { +fn one_page_changed( + view: &mut crate::DocView, + edit: &Edit, + page: usize, + annots_before: &[pdfcraft_render::Annotation], + info: &pdfcraft_render::DocInfo, +) { if !matches!(edit, Edit::SetAnnotationPopup { .. }) { view.page_changed(page); } + if let Edit::DeleteAnnotation { page, index } = edit { + // Remap surviving comments before generic slot pruning loses their state. + view.comments.annotation_deleted(annots_before, info, (*page, *index)); + } else { + view.comments.document_changed(info); + } } fn comment_page(edit: &Edit) -> Option { diff --git a/crates/ui-egui/src/editing/save.rs b/crates/ui-egui/src/editing/save.rs index b27b953cfa..ea29f7fe46 100644 --- a/crates/ui-egui/src/editing/save.rs +++ b/crates/ui-egui/src/editing/save.rs @@ -429,6 +429,31 @@ fn short_name(path: &str) -> String { std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string()) } +/// The MIME type a download of `name` is offered as, from its extension. +#[doc(hidden)] +pub fn download_mime_type(name: &str) -> &'static str { + let ext = std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); + match ext.as_str() { + "pdf" => "application/pdf", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "tif" | "tiff" => "image/tiff", + "svg" => "image/svg+xml", + "csv" => "text/csv", + "txt" => "text/plain", + "html" | "htm" => "text/html", + "xml" => "application/xml", + "json" => "application/json", + "xfdf" => "application/vnd.adobe.xfdf", + "fdf" => "application/vnd.fdf", + "rtf" => "application/rtf", + "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + _ => "application/octet-stream", + } +} + /// Offer bytes as a browser download. #[cfg(target_arch = "wasm32")] pub(crate) fn download(name: &str, bytes: &[u8]) -> Result<(), String> { @@ -437,7 +462,7 @@ pub(crate) fn download(name: &str, bytes: &[u8]) -> Result<(), String> { let array = js_sys::Uint8Array::from(bytes); let parts = js_sys::Array::of1(&array); let opts = web_sys::BlobPropertyBag::new(); - opts.set_type("application/pdf"); + opts.set_type(download_mime_type(name)); let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(err)?; let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(err)?; let document = web_sys::window().and_then(|w| w.document()).ok_or("no document")?; diff --git a/crates/ui-egui/src/editing/tests.rs b/crates/ui-egui/src/editing/tests.rs index b53ac49305..1f205ae759 100644 --- a/crates/ui-egui/src/editing/tests.rs +++ b/crates/ui-egui/src/editing/tests.rs @@ -375,6 +375,40 @@ fn save_commits_the_open_text_box() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn moving_pages_cancels_an_in_progress_drawing() { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] >>", + ]; + 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()); + + let mut app = crate::PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("a.pdf", None, pdf).unwrap(); + let drawing = + || Some(crate::comments::Gesture::Draw { page: 0, tool: crate::comments::CommentTool::Ink, points: vec![[10.0, 10.0], [30.0, 20.0]] }); + app.views[0].comments.gesture = drawing(); + assert!(app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 })); + assert!(app.views[0].comments.gesture.is_some(), "rotating the page keeps the drawing on it"); + app.views[0].comments.gesture = drawing(); + assert!(app.apply_edit(pdfcraft_engine::Edit::MovePages { pages: vec![1], to: 0 })); + assert!(app.views[0].comments.gesture.is_none(), "the drawing's page index would now be a different page"); +} + #[test] fn save_asks_where_to_keep_an_email_attachment_and_leaves_the_temporary_copy_alone() { let dir = staging_dir("mail-temp"); diff --git a/crates/ui-egui/src/hotbar.rs b/crates/ui-egui/src/hotbar.rs index 34b3480b40..e0730de201 100644 --- a/crates/ui-egui/src/hotbar.rs +++ b/crates/ui-egui/src/hotbar.rs @@ -23,6 +23,45 @@ pub(crate) struct Hotbar { search: String, #[serde(skip)] picker_open: bool, + /// Where the Hotbar tools window stays while the Toolbar size slider is being adjusted. + #[serde(skip)] + picker_drag_pos: Option, + /// The Toolbar size slider is being adjusted with the keyboard (until it loses focus). + #[serde(skip)] + keyboard_sizing: bool, + /// The pass the Hotbar tools window was last drawn on. + #[serde(skip)] + picker_shown: Option, +} + +impl Hotbar { + /// The Hotbar tools window is over the pages: open, and drawn on the last pass. Read mode, full + /// screen, Organize and a document that hides the toolbars don't draw the hotbar, and a window + /// they hide holds no keys. + pub(crate) fn picker_over_pages(&self, ctx: &egui::Context) -> bool { + self.picker_open && self.picker_shown.is_some_and(|pass| pass.saturating_add(1) >= ctx.cumulative_pass_nr()) + } + + /// Closes the Hotbar tools window when the hotbar is `hidden` on this pass (Read mode or full + /// screen, which a native menu's click may have turned on just now), or the last pass didn't + /// draw it (Organize, or a document that hides the toolbars). Left open, it would hold the keys of the pass that hides it, and come + /// back over the pages on the pass that shows the hotbar again, after that pass's page keys + /// had been read without it. + pub(crate) fn close_hidden_picker(&mut self, ctx: &egui::Context, hidden: bool) { + if self.picker_open && (hidden || !self.picker_over_pages(ctx)) { + self.close_picker(); + } + } + + /// Closes the Hotbar tools window. + pub(crate) fn close_picker(&mut self) { + self.picker_open = false; + self.picker_shown = None; + // Closed on a pass that doesn't draw the hotbar, `show` can't end a size adjustment: the + // next opening would start at the held position. + self.picker_drag_pos = None; + self.keyboard_sizing = false; + } } impl Default for Hotbar { @@ -38,6 +77,9 @@ impl Default for Hotbar { placed: false, search: String::new(), picker_open: false, + picker_drag_pos: None, + keyboard_sizing: false, + picker_shown: None, } } } @@ -543,6 +585,31 @@ fn segmented_dock_picker(ui: &mut egui::Ui, bar: &mut Hotbar, t: &Tokens) { } } +/// The arrow keys the Toolbar size slider answers to while it has focus. +const SIZE_SLIDER_KEYS: [egui::Key; 4] = [egui::Key::ArrowLeft, egui::Key::ArrowRight, egui::Key::ArrowUp, egui::Key::ArrowDown]; + +/// Whether an arrow key was pressed this frame. Read it before the slider is added, because the +/// slider consumes the keys it acts on. +fn size_slider_key_pressed(ui: &egui::Ui) -> bool { + ui.input(|input| SIZE_SLIDER_KEYS.iter().any(|key| input.key_pressed(*key))) +} + +/// Whether the Toolbar size slider is being adjusted, which keeps its picker from moving. +/// +/// A pointer adjustment lasts while the button is held. A keyboard one starts with the first arrow +/// key press that changes the value and lasts until the slider loses focus, so the picker does not +/// jump between presses. `keyboard` carries that state from frame to frame. Focusing the slider, or +/// releasing the pointer on it, does not count as a keyboard adjustment. +fn size_slider_adjusting(slider: &egui::Response, key_pressed: bool, keyboard: &mut bool) -> bool { + let pointer = slider.is_pointer_button_down_on(); + if pointer || !slider.has_focus() { + *keyboard = false; + } else if key_pressed && slider.changed() { + *keyboard = true; + } + pointer || *keyboard +} + /// Draw the unified floating or side-docked toolbar, combining quick actions, core tools, and custom shortcuts. pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, page_left: Option, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); @@ -731,7 +798,11 @@ pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, page_left: Option, ui } else { bar_rect.top().clamp(screen.top(), (screen.bottom() - 480.0).max(screen.top())) }; - let picker_pos = egui::pos2(picker_x.clamp(screen.left(), (screen.right() - outer_width).max(screen.left())), picker_y); + // Resizing the bar can move or flip its picker; hold the picker in place until the pointer is + // released, or, for the keyboard, until the slider loses focus. + let picker_pos = + bar.picker_drag_pos.unwrap_or_else(|| egui::pos2(picker_x.clamp(screen.left(), (screen.right() - outer_width).max(screen.left())), picker_y)); + let mut sizing = false; let mut close_dialog = false; let picker_resp = egui::Window::new(tl!("Hotbar tools")) .id(picker_id) @@ -773,7 +844,9 @@ pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, page_left: Option, ui ui.label(egui::RichText::new(tl!("Toolbar size")).size(12.5).color(t.text)); let slider_width = (ui.available_width() - 55.0).clamp(50.0, 140.0); ui.spacing_mut().slider_width = slider_width; - ui.add(egui::Slider::new(&mut bar.button_size, 24.0..=64.0).show_value(false)); + let key_pressed = size_slider_key_pressed(ui); + let slider = ui.add(egui::Slider::new(&mut bar.button_size, 24.0..=64.0).show_value(false)); + sizing = size_slider_adjusting(&slider, key_pressed, &mut bar.keyboard_sizing); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.label(egui::RichText::new(format!("{:.0} px", bar.button_size)).size(12.0).color(t.text_muted)); }); @@ -866,11 +939,16 @@ pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, page_left: Option, ui if close_dialog { picker_open = false; } + if !picker_open { + bar.keyboard_sizing = false; + } + bar.picker_drag_pos = (picker_open && sizing).then_some(picker_pos); ctx.data_mut(|data| { data.insert_temp(id, shown.response.rect.size()); data.insert_temp(egui::Id::unique("custom-hotbar"), shown.response.rect.size()); }); bar.picker_open = picker_open; + bar.picker_shown = picker_open.then(|| ctx.cumulative_pass_nr()); app.hotbar = bar; if let Some(id) = run { app.execute(id); @@ -880,6 +958,140 @@ pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, page_left: Option, ui #[cfg(test)] mod tests { use super::*; + use egui_kittest::{Harness, kittest::Queryable}; + + // Build a harness that draws the hotbar docked to `$side`, with its tools picker open. + macro_rules! hotbar_harness { + ($side:expr, $vertical:expr, $full_side:expr) => {{ + let mut app = PdfCraftApp::new(); + app.hotbar.place($side).unwrap(); + app.hotbar.vertical = $vertical; + app.hotbar.full_side = $full_side; + app.hotbar.position = [0.5, 0.5]; + app.hotbar.picker_open = true; + let mut fonts = false; + let mut harness = Harness::builder().with_size(vec2(1000.0, 900.0)).with_accessibility_check(false).build_ui_state( + move |ui, app: &mut PdfCraftApp| { + if !fonts { + ui.ctx().set_fonts(theme::font_definitions()); + fonts = true; + } + show(app, ui.max_rect(), None, ui); + }, + app, + ); + harness.run_steps(5); + harness + }}; + } + + // Drag the size slider from `$from` to `$to` and release, checking on every step that the picker + // (found through its slider) has not left `$pinned`. + macro_rules! drag_slider_pinned { + ($h:expr, $pinned:expr, $from:expr, $to:expr, $what:expr) => {{ + let (from, to): (egui::Pos2, egui::Pos2) = ($from, $to); + $h.hover_at(from); + $h.run_steps(1); + $h.drag_at(from); + $h.run_steps(1); + for step in 1..=8 { + $h.hover_at(from + (to - from) * (step as f32 / 8.0)); + $h.run_steps(2); + assert_eq!($h.get_by_role(egui::accesskit::Role::Slider).rect(), $pinned, "{}: picker moved during drag", $what); + } + $h.drop_at(to); + $h.run_steps(3); + }}; + } + + /// Dragging the Toolbar size slider with the pointer keeps the picker where it is, in both + /// directions and in every layout, and the picker takes its recalculated place after release. + #[test] + fn picker_stays_put_while_resizing_hotbar() { + for (side, vertical, full_side) in [ + ("top", false, false), + ("bottom", false, false), + ("float", false, false), + ("top", false, true), + ("bottom", false, true), + ("left", true, false), + ("right", true, false), + ("float", true, false), + ("left", true, true), + ("right", true, true), + ] { + let what = format!("{side}, vertical={vertical}, strip={full_side}"); + let mut h = hotbar_harness!(side, vertical, full_side); + + // Drag the slider up to the largest size. + let slider = h.get_by_role(egui::accesskit::Role::Slider).rect(); + drag_slider_pinned!(h, slider, slider.left_center() + vec2(slider.width() * 0.2, 0.0), slider.right_center() - vec2(2.0, 0.0), what); + assert!(h.state().hotbar.button_size > 60.0, "{what}: slider did not reach the upper end"); + assert!(h.state().hotbar.picker_drag_pos.is_none(), "{what}: release must unpin the picker"); + let resized = h.get_by_role(egui::accesskit::Role::Slider).rect(); + assert_ne!(resized, slider, "{what}: picker should take its new place after release"); + if !vertical && !full_side { + assert!(resized.left() < slider.left(), "{what}: picker should move to the left after release"); + } + + // And back down to the smallest. + drag_slider_pinned!(h, resized, resized.right_center() - vec2(2.0, 0.0), resized.left_center() + vec2(2.0, 0.0), what); + assert!(h.state().hotbar.button_size < 28.0, "{what}: slider did not reach the lower end"); + assert!(h.state().hotbar.picker_drag_pos.is_none(), "{what}: release must unpin the picker"); + let restored = h.get_by_role(egui::accesskit::Role::Slider).rect(); + assert_ne!(restored, resized, "{what}: picker should take its new place after the reverse drag"); + if !vertical && !full_side { + assert!(restored.left() > resized.left(), "{what}: picker should return to the right"); + } + } + } + + /// Adjusting the Toolbar size slider with the arrow keys also keeps the picker where it is, until + /// the slider loses focus. + #[test] + fn picker_stays_put_while_resizing_hotbar_with_keyboard() { + for (side, vertical, full_side) in [("top", false, false), ("float", false, false), ("bottom", false, true), ("left", true, false)] { + let what = format!("{side}, vertical={vertical}, strip={full_side}"); + let mut h = hotbar_harness!(side, vertical, full_side); + let slider = h.get_by_role(egui::accesskit::Role::Slider).rect(); + h.get_by_role(egui::accesskit::Role::Slider).focus(); + h.run_steps(2); + assert!(h.state().hotbar.picker_drag_pos.is_none(), "{what}: focusing the slider alone must not pin the picker"); + + // The slider moves about a pixel per key press, so this crosses its whole travel. + for _ in 0..150 { + h.key_press(egui::Key::ArrowRight); + h.run_steps(1); + assert_eq!(h.get_by_role(egui::accesskit::Role::Slider).rect(), slider, "{what}: picker moved during keyboard resize"); + } + assert!(h.state().hotbar.button_size > 60.0, "{what}: keys did not reach the upper end"); + assert!(h.state().hotbar.picker_drag_pos.is_some(), "{what}: picker must stay pinned while the slider has focus"); + + // Moving focus away ends the adjustment. + h.key_press(egui::Key::Tab); + h.run_steps(3); + assert!(h.state().hotbar.picker_drag_pos.is_none(), "{what}: losing focus must unpin the picker"); + assert_ne!(h.get_by_role(egui::accesskit::Role::Slider).rect(), slider, "{what}: picker should take its new place after focus leaves"); + } + } + + /// A picker closed while the hotbar isn't drawn (Read mode, full screen, Organize) ends a size + /// adjustment too, so it doesn't open again at the position it was held at. + #[test] + fn closing_the_picker_ends_a_size_adjustment() { + let mut h = hotbar_harness!("top", false, false); + h.get_by_role(egui::accesskit::Role::Slider).focus(); + h.run_steps(2); + for _ in 0..20 { + h.key_press(egui::Key::ArrowRight); + h.run_steps(1); + } + assert!(h.state().hotbar.picker_drag_pos.is_some() && h.state().hotbar.keyboard_sizing, "pinned while the keys resize the bar"); + // What `close_hidden_picker` does on a pass that hides the hotbar: `show` doesn't run. + h.state_mut().hotbar.close_picker(); + assert!(h.state().hotbar.picker_drag_pos.is_none(), "closing ends the held position"); + assert!(!h.state().hotbar.keyboard_sizing, "closing ends the keyboard adjustment"); + } #[test] fn hotbar_validation_and_placement() { diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index f30edd8c86..a0e6a4938b 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1557,6 +1557,12 @@ fn date_languages_are_the_interface_languages() { assert_eq!(dates, interface); } +/// The stale-draft notice remains discoverable after the UI modules are reorganized. +#[test] +fn properties_stale_notice_is_in_translation_scan() { + assert!(ui_literals().contains("The document changed. Reopen Document Properties to edit the current version.")); +} + /// A report, not a check: the notifications pasting an image and pinning a tool show, in each /// language that has no translation for them yet (they show in English). #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 98d6518507..0c64ab3a11 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -42,6 +42,7 @@ pub use combine_ui::{Columns as CombineColumns, CombineView, Lock as CombineLock mod commands; pub mod control; mod menus; +mod properties_session; /// Header & footer / watermark / background dialog types (tests and automation). pub mod marks { pub use crate::marks_ui::{MarksDraft, PageRange, Subset}; @@ -59,7 +60,10 @@ mod home; mod hotbar; mod panels; pub mod panes; -pub use print_ui::{Handling as PrintHandling, OptionsSource, PrintDraft, PrinterSource, Spooler, Which as PrintWhich, system_printers}; +pub use print_ui::{ + DefaultPaperSource, Handling as PrintHandling, OptionsSource, PrintDraft, PrinterPapersSource, PrinterSetup, PrinterSource, Spooler, + TwoSidedSource, Which as PrintWhich, system_printers, system_two_sided, +}; pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft, SearchMode as RedactSearchMode}; pub mod i18n; pub mod theme; @@ -69,6 +73,8 @@ mod widgets; use pdfcraft_engine::{DocId, Session}; pub use canvas::{DocView, RasterMemory}; +#[doc(hidden)] +pub use editing::download_mime_type; /// Smallest window the shell is laid out for, in egui points (logical pixels, so the OS scale /// factor is already applied: 480 points are 600 px at 125 % and 720 px at 150 %). 480 is half of a @@ -101,6 +107,7 @@ use canvas::crop; use canvas::fling; use canvas::image_pick; use canvas::page_cursor; +use canvas::page_keys; use canvas::page_pointer; use canvas::page_router; pub use canvas::scroll_speed; @@ -200,6 +207,7 @@ use workflows::ocr_verify; #[cfg(target_arch = "wasm32")] use workflows::ocr_web; use workflows::optimize_ui; +use workflows::print_paper; use workflows::print_ui; use workflows::scan_clean_ui; use workflows::web_print; @@ -277,6 +285,7 @@ struct PendingOpen { #[cfg(not(target_arch = "wasm32"))] struct DeferredOpen { path: String, + after_open: Option, then: Option<&'static str>, display_override: Option<(canvas::PageLayout, bool)>, } @@ -285,6 +294,15 @@ struct DeferredOpen { /// used instead. pub type HtmlClipboard = Box bool>; +/// A named destination being looked up off the UI thread: reading a large document's name tree +/// takes a noticeable moment. +#[cfg(not(target_arch = "wasm32"))] +struct PendingDestination { + doc: DocId, + name: String, + receiver: std::sync::mpsc::Receiver>, +} + pub struct PdfCraftApp { /// Split view (`panes`); which pane shows each tab is its view's. pub panes: panes::Panes, @@ -418,8 +436,15 @@ pub struct PdfCraftApp { pub printer_source: Option, /// Where Properties… reads a printer's driver options: the spooler's unless a test stands in. pub options_source: Option, + /// Where a printer's configured default paper comes from: DEVMODE / driver settings unless a test stands in. + pub default_paper_source: Option, + /// Where a printer's supported paper sizes come from: driver capabilities unless a test stands in. + pub printer_papers_source: Option, /// What print jobs go to: the system spooler unless a test stands in ([`Spooler`]). pub spooler: Option, + /// Reads the picked printer's two-sided support in the background (the desktop app sets it; + /// see [`TwoSidedSource`]). Without one the dialog goes by the printer list alone. + pub two_sided_source: Option, // A browser has no background print work: the type is empty there. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub(crate) print_tasks: print_ui::PrintTasks, @@ -477,6 +502,8 @@ pub struct PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] password_retry: Option, #[cfg(not(target_arch = "wasm32"))] + pending_destinations: Vec, + #[cfg(not(target_arch = "wasm32"))] next_open_token: u64, /// Explicit CLI layout waits for flagged launch opens, never targeting restored tabs. #[cfg(not(target_arch = "wasm32"))] @@ -562,6 +589,8 @@ pub struct PdfCraftApp { pub view_draft: Option<(DocId, pdfcraft_engine::InitialView)>, /// Document Properties ▸ Custom being edited. pub custom_draft: Option<(DocId, CustomDraft)>, + /// Original values and document revision for the editable Properties tabs. + pub(crate) props_session: Option, /// Files picked asynchronously for combine / insert (web). pub requests: files::Requests, /// Native file pickers in flight (they never block the frame; see `pickers`). @@ -687,6 +716,8 @@ pub struct PdfCraftApp { /// The last snapshot (width, height, RGBA); `system_clipboard` also puts it on the /// system clipboard (tests turn that off). pub last_snapshot: Option<(u32, u32, pdfcraft_render::Pixels)>, + /// What `last_snapshot` was taken of: (document, page, rectangle in page view points). + last_snapshot_of: Option<(DocId, usize, [f32; 4])>, /// The last image copied with Copy Image (width, height, straight RGBA), also put on the /// system clipboard when `system_clipboard` is on. pub last_copied_image: Option<(u32, u32, Vec)>, @@ -728,8 +759,14 @@ pub struct PdfCraftApp { deferred_commands: Vec<(&'static str, Option)>, /// The native menu bar (macOS), while it is installed; the in-window bar shows otherwise. pub(crate) native_menu: Option, - /// Input a menu click stands for (⌘C, Delete…), added to the next frame's input in `logic`. + /// Input a menu click stands for (⌘C, Delete…), waiting to join a pass's input (see + /// `release_menu_input`). pub(crate) menu_input: Vec, + /// Edit ▸ Paste clicks waiting to ask the platform for the clipboard (see + /// `release_menu_input`). + pub(crate) menu_pastes: usize, + /// This frame's keys as the pages see them: none while something is over them. + pub(crate) page_keys: page_keys::PageKeys, /// The dialog seen at the last check, and a counter bumped whenever it changes (see /// [`Self::dialog_epoch`]). dialog_seen: Option, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 066a914e0d..94418de4c8 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -147,6 +147,7 @@ const EDIT: &[&str] = &[ SELECT_ALL, SEP, "edit.find", + "edit.replace", "edit.advanced_search", SEP, "edit.column_select", @@ -552,7 +553,7 @@ impl PdfCraftApp { } "edit.copy" => self.menu_input.push(egui::Event::Copy), CUT => self.menu_input.push(egui::Event::Cut), - PASTE => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste), + PASTE => self.menu_pastes = self.menu_pastes.saturating_add(1), SELECT_ALL => self.menu_input.extend(key(egui::Key::A, egui::Modifiers::COMMAND)), DELETE => self.menu_input.extend(key(egui::Key::Delete, egui::Modifiers::NONE)), PANEL => self.choose_right_panel(panel(command)), diff --git a/crates/ui-egui/src/panels/comments_panel.rs b/crates/ui-egui/src/panels/comments_panel.rs index 102da43db8..62614b3173 100644 --- a/crates/ui-egui/src/panels/comments_panel.rs +++ b/crates/ui-egui/src/panels/comments_panel.rs @@ -611,6 +611,11 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti let menu_id = ui.scope_id().with(("card-menu", key)); if open_menu { egui::Popup::open_id(ui.ctx(), menu_id); + } else if more_rect.is_none() { + // The button is drawn on the selected card alone. Without it (Undo deselected the card + // with its menu open), egui would keep the menu open with nowhere to show it, and an + // open popup holds every page key (`PageKeys`). + egui::Popup::close_id(ui.ctx(), menu_id); } // Hang the menu from the "…" button (a pointer anchor would follow the mouse to the items). let anchor = more_rect.map_or(egui::PopupAnchor::PointerFixed, egui::PopupAnchor::ParentRect); diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 1d16331eb9..b53fb12575 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -87,7 +87,17 @@ pub(super) fn thumbnail_size(ui: &mut egui::Ui, t: &Tokens, view: &mut crate::Do /// (the selection page commands and Print act on) without moving the document. Right-click /// offers Extract, Cut, Copy and Paste on the selection, or on the page clicked; dragging /// moves the selection, or the page grabbed, to the gap it is dropped on. -pub(super) fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocView, modal: bool, editable: bool, nav: &mut Option