diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8249dcf0c7..388b44f032 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,13 +48,21 @@ jobs: echo "CRAFT_FONTS_DIR=$GITHUB_WORKSPACE/craft-fonts" >> "$GITHUB_ENV" echo "CRAFT_FONTS_REQUIRED=1" >> "$GITHUB_ENV" - - name: Install Linux system dependencies (egui/winit/rfd) + - name: Install Linux system dependencies (egui/winit/rfd) and the oracles if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \ - libgl1-mesa-dev libgtk-3-dev + libgl1-mesa-dev libgtk-3-dev \ + qpdf poppler-utils + + # The independent oracles (crates/oracle) are only worth having when they run. With this + # set, a missing one fails the job instead of printing a skip notice that reads as a pass. + # Only the tools the job actually uses are installed above. + - name: Require the oracles (Linux) + if: runner.os == 'Linux' + run: echo "PDFCRAFT_ORACLES_REQUIRED=1" >> "$GITHUB_ENV" - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 3b451f246d..c9acb0512a 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,7 +4,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (270) +## In this repository (272) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| @@ -28,6 +28,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; reviewed by a native Lithuanian-speaking contributor | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; reviewed by a native Vietnamese-speaking contributor | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | @@ -278,6 +279,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/pdf.rs): the legacy adbe.x509.rsa_sha1 format | | `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file | ## Compiled in through dependencies (6) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 07433fc453..3f3791f719 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "389982dcbb7f1ac29fb150c9af43f9529db6e59a091b9971f0401684847d8b53" +sha256 = "cefa955aa92790d6773cb1d1e15e19019b3165a00528d57d6617362e1d1988ab" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "d8ea23644d81476e3fd35c09780ab9b3ac8c9e28bed4e14e1cbc6493bb896d91" +sha256 = "1de29a6cc089be31cb6206bf41e0715f8ddcf74ef887b45ccee235d743bed223" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "26dd0b0d697fd40db996fc102f3c7f2320bdeb6d6af38dbdde353bd5d940baad" +sha256 = "8c6d5b1505d76bc7ba606c98660afa60b1152c5b4c2e7d345104a6ef3d280ed2" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "322b8cda21a2b8d157716182846619bd92bfb31263d1f8d13da7eaad365fbfd7" +sha256 = "8a8aed18bd8fceabd1389580361ba75b0dccf141d845650a353d8dfe19c95536" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "21bf9c73b482eb987ddaf839b4a4aa8f9938d5aa10428e6d39beea254a95a0c0" +sha256 = "1c197ac045810950aceb733152a296f2224f38857b1169032fdc7092ab4aa3f7" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "fef35ae1771edf2e044e72aebb958bb4827ded57234207f96b6cb7a250fedb22" +sha256 = "a9cf0e062b006b40f549ce2316b88d4b0196d70bd9cecd66c12d980d699bf679" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "d723622298b10400e9a1ba697cbebd831f5e0f7e5bbe4545b97f61e64db7535f" +sha256 = "ef5c84bd13ef8318f7deab7f42136cfe3c14cbef6fd5231014b94167baaecd2a" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "686ab9658b7ef9dd7f3af05ce73f99f1ab2a75f8357bad6f7e987d40a8730df9" +sha256 = "be689af8bb22537879269ad9ab9bdcc4b730665cf0b27c6bf4bad560cacf3b69" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "90b94038699aa74aa48ffbda3e90a2255b65d0dbf081e8ecf20c5804940fc129" +sha256 = "989d2e5f57cf22c21a85951835ca9fbd05e314da67eb4c17332df1f03679fa00" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "5aa6475df91a3e82010aee198c2e29bb80328505bbd0240f3828e36a5d6dfdf3" +sha256 = "425c42acb8d98ac31b216b13c05a285e0676318dd379dcfb9d57cc7329e9d7a5" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e6d306d9bf289fac2fe8fcfa6f9d0a51b650728e45669289d391be2031076692" +sha256 = "988bcf2e0322a46c0464ff7b91ab5d90193b0ea7f454812d12595eb843de6fc7" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "277f4995a87297a2fcc473df1feebdbdf3378fa460a7f85eb1a0a151cee40a2b" +sha256 = "ebe7aa9152baff38ae7e36aeb4f6a7ed2198166ab93425a612a66d53e43cb363" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "01b32370cedce935b74925858a79ee691dc2a5560ba5eb758de2de1c243c528e" +sha256 = "69d2da21949f02c3c40f71159e377eef2fdd1a87f749d6c504e1d151ef33f643" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "f8ae97901c61dfdc0a844a23356173211bb052b3ff54128d7edc8097c535eee9" +sha256 = "98359685aa555f391846f2259ac302ce1234c7a180fb4abe07c36e0c1c62211e" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7107ba77577794395451dd85355a86ef3e033a7f240b72414b877b9b43adb19e" +sha256 = "9f6296d9c79c848167fc80bb4cfaacc9e60f72edb70cc1d70f7b6088ada83578" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "114b3d50531eb1682535c7a646e62914b0c9afaa5d5d1b9f66b94fd472e4e7b3" +sha256 = "3528e85afd4117de2c22b9f37d32a380a073a49834fa5a3063d21b803606ffbf" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "f56ec5f4cb7d1992041df0783f1cfd76c27d5d180c3f73c60b104e3fd02f600d" +sha256 = "4db7e23074d44305f4584e2cd8b9a0fc35b433cb187dad493c8f7d78a8cd4ad2" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "113fdf57e28fcff90d212fb468011bedebfb98ac100218d29b584f4de4c87912" +sha256 = "2af01de9509db4b4ae61193f41ff0f7f3b70ca5f91cd2afda667e90d34980b25" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2b5edfe026578ed475f07f94f1d1ed4a3dac93a745c19209589936e2d67f6ec5" +sha256 = "de988b6959572931c4878a0588109c236bf8c5d46ef57c7a406b6ab42de51339" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" @@ -228,7 +228,19 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "fe58835b0538f470b463f078e1b4cdf78a8e2632056bc61c6946071478f39a64" +sha256 = "63955758227bd57329b22dc7c16ed9cee8b0a15df9453477755849c886424dd5" + + +[[asset]] +path = "crates/ui-egui/src/i18n/vi.tsv" +title = "Vietnamese interface translation catalog" +author = "PdfCraft contributors" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; reviewed by a native Vietnamese-speaking contributor" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "translation" +usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +sha256 = "b54adaa6515dbfb0de3859b30989054e5fcc4f56e66e9cc713c7bf509bdbbac3" [[asset]] path = "assets/icons/accessibility.svg" @@ -3351,4 +3363,15 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9bbbd0d516f284b571ad6e2ed811f6bbdee283cea8483686f4aa939db38ea288" +sha256 = "5881cc7fbfe9da5f5f611309dacf267f95db3a143b916a113b649839019b292c" + +[[asset]] +path = "vendor/vello_common/assets/probe.png" +title = "vello_common probe reference image (rendered from probe.rgba)" +author = "Linebender vello contributors" +source = "vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary" +licence = "MIT OR Apache-2.0" +licence_file = "vendor/vello_common/LICENSE-MIT" +kind = "image" +usage = "Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file" +sha256 = "78c59a0cfd2c829909d77006f94e34dac1ae5d1f90e7f10f72e84de541fef697" diff --git a/Cargo.lock b/Cargo.lock index 52485e1e32..97135ccc3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1145,7 +1145,7 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=f03ec27ab2c405969428512178f746183cd55ca0#f03ec27ab2c405969428512178f746183cd55ca0" +source = "git+https://github.com/storytold/craft-libs?rev=666c358065261a2816b376fa3bb10538dbf53040#666c358065261a2816b376fa3bb10538dbf53040" dependencies = [ "miniz_oxide 0.8.9", "roxmltree", @@ -1154,6 +1154,18 @@ dependencies = [ "winsafe", ] +[[package]] +name = "craft-quit" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=f58bc1a3df490992d633393d0898af0588ce882c#f58bc1a3df490992d633393d0898af0588ce882c" +dependencies = [ + "dispatch2", + "egui", + "muda", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", +] + [[package]] name = "craft-ui" version = "0.1.0" @@ -1184,6 +1196,15 @@ dependencies = [ "x509-cert", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.8" @@ -2067,6 +2088,15 @@ dependencies = [ "serde", ] +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -3005,6 +3035,16 @@ dependencies = [ "cpufeatures 0.3.1", ] +[[package]] +name = "keyboard-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" +dependencies = [ + "bitflags 2.13.2", + "serde", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -3248,6 +3288,24 @@ dependencies = [ "pxfm", ] +[[package]] +name = "muda" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03" +dependencies = [ + "crossbeam-channel", + "dpi", + "keyboard-types", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "png 0.18.1", + "thiserror 2.0.21", + "windows-sys 0.60.2", +] + [[package]] name = "multiversion_no_op" version = "1.0.0" @@ -3875,8 +3933,9 @@ dependencies = [ [[package]] name = "pdfcraft" -version = "0.7.1" +version = "0.8.0" dependencies = [ + "craft-quit", "eframe", "egui", "fmv-macos-events", @@ -3884,6 +3943,7 @@ dependencies = [ "pdfcraft-engine", "pdfcraft-ui-egui", "pollster 1.0.1", + "raw-window-handle", "rustls-native-certs", "serde_json", "uds_windows", @@ -3891,13 +3951,14 @@ dependencies = [ "wayland-client", "wayland-protocols", "windows", + "winit", "winresource", "winsafe", ] [[package]] name = "pdfcraft-a11y" -version = "0.7.1" +version = "0.8.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3909,7 +3970,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.7.1" +version = "0.8.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -3920,7 +3981,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.7.1" +version = "0.8.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3931,7 +3992,7 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.7.1" +version = "0.8.0" dependencies = [ "base64 0.22.1", "flate2", @@ -3952,13 +4013,14 @@ dependencies = [ [[package]] name = "pdfcraft-cli" -version = "0.7.1" +version = "0.8.0" dependencies = [ "base64 0.22.1", "pdfcraft-audit", "pdfcraft-automation", "pdfcraft-cos", "pdfcraft-engine", + "pdfcraft-platform", "pdfcraft-render", "rustix 1.1.5", "serde_json", @@ -3967,22 +4029,24 @@ dependencies = [ [[package]] name = "pdfcraft-compare" -version = "0.7.1" +version = "0.8.0" [[package]] name = "pdfcraft-content" -version = "0.7.1" +version = "0.8.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-cos" -version = "0.7.1" +version = "0.8.0" dependencies = [ "hayro-syntax", "pdfcraft-crypt", "pdfcraft-filters", + "pdfcraft-oracle", + "pdfcraft-testkit", "proptest", "serde_json", "thiserror 2.0.21", @@ -3990,7 +4054,7 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.7.1" +version = "0.8.0" dependencies = [ "encoding_rs", "hayro-syntax", @@ -4006,7 +4070,7 @@ dependencies = [ [[package]] name = "pdfcraft-crypt" -version = "0.7.1" +version = "0.8.0" dependencies = [ "aes", "md-5", @@ -4018,7 +4082,7 @@ dependencies = [ [[package]] name = "pdfcraft-edit" -version = "0.7.1" +version = "0.8.0" dependencies = [ "hayro-syntax", "kurbo 0.13.1", @@ -4034,7 +4098,7 @@ dependencies = [ [[package]] name = "pdfcraft-engine" -version = "0.7.1" +version = "0.8.0" dependencies = [ "chrono", "icu_properties", @@ -4066,19 +4130,20 @@ dependencies = [ "pdfcraft-xfdf", "png 0.18.1", "thiserror 2.0.21", + "unicode-segmentation", "url", ] [[package]] name = "pdfcraft-export" -version = "0.7.1" +version = "0.8.0" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-filters" -version = "0.7.1" +version = "0.8.0" dependencies = [ "flate2", "proptest", @@ -4088,7 +4153,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.7.1" +version = "0.8.0" dependencies = [ "harfrust", "pdfcraft-cos", @@ -4098,7 +4163,7 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.7.1" +version = "0.8.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4110,37 +4175,38 @@ dependencies = [ [[package]] name = "pdfcraft-geom" -version = "0.7.1" +version = "0.8.0" [[package]] name = "pdfcraft-js" -version = "0.7.1" +version = "0.8.0" dependencies = [ "boa_engine", ] [[package]] name = "pdfcraft-measure" -version = "0.7.1" +version = "0.8.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", + "pdfcraft-testkit", "serde", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-model" -version = "0.7.1" +version = "0.8.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.7.1" +version = "0.8.0" dependencies = [ "ocrs", "pdfcraft-fonts", @@ -4150,7 +4216,7 @@ dependencies = [ [[package]] name = "pdfcraft-optimize" -version = "0.7.1" +version = "0.8.0" dependencies = [ "image", "pdfcraft-annot", @@ -4161,36 +4227,43 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "pdfcraft-oracle" +version = "0.8.0" + [[package]] name = "pdfcraft-organize" -version = "0.7.1" +version = "0.8.0" dependencies = [ "hayro-syntax", "pdfcraft-content", "pdfcraft-cos", + "pdfcraft-oracle", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-platform" -version = "0.7.1" +version = "0.8.0" dependencies = [ "async-channel", "futures-lite", + "rustix 1.1.5", "zbus", ] [[package]] name = "pdfcraft-preflight" -version = "0.7.1" +version = "0.8.0" dependencies = [ + "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", ] [[package]] name = "pdfcraft-print" -version = "0.7.1" +version = "0.8.0" dependencies = [ "craft-print", "pdfcraft-content", @@ -4201,7 +4274,7 @@ dependencies = [ [[package]] name = "pdfcraft-redact" -version = "0.7.1" +version = "0.8.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4210,12 +4283,14 @@ dependencies = [ "pdfcraft-fonts", "pdfcraft-forms", "pdfcraft-model", + "pdfcraft-testkit", + "pdfcraft-xfa", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-render" -version = "0.7.1" +version = "0.8.0" dependencies = [ "bytemuck", "flate2", @@ -4234,7 +4309,7 @@ dependencies = [ [[package]] name = "pdfcraft-sign" -version = "0.7.1" +version = "0.8.0" dependencies = [ "aes", "aws-lc-rs", @@ -4266,18 +4341,24 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "pdfcraft-testkit" +version = "0.8.0" + [[package]] name = "pdfcraft-ui-egui" -version = "0.7.1" +version = "0.8.0" dependencies = [ "arboard", "base64 0.22.1", "bytemuck", + "craft-quit", "craft-ui", "eframe", "egui", "egui_extras", "egui_kittest", + "fontconfig-parser", "getrandom 0.4.3", "image", "js-sys", @@ -4310,7 +4391,7 @@ dependencies = [ [[package]] name = "pdfcraft-web" -version = "0.7.1" +version = "0.8.0" dependencies = [ "eframe", "egui", @@ -4322,7 +4403,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.7.1" +version = "0.8.0" dependencies = [ "base64 0.22.1", "image", @@ -4336,7 +4417,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfdf" -version = "0.7.1" +version = "0.8.0" dependencies = [ "pdfcraft-annot", "pdfcraft-cos", @@ -5399,9 +5480,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", "log", @@ -6869,8 +6950,6 @@ dependencies = [ [[package]] name = "vello_common" version = "0.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3361bff7f7d82c0c496b92048db83846691f0e844cc28dee92b1c824291b55ee" dependencies = [ "bytemuck", "fearless_simd", @@ -7947,6 +8026,8 @@ version = "0.1.0" dependencies = [ "anyhow", "lopdf", + "pdfcraft-cos", + "pdfcraft-oracle", "png 0.18.1", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index e32b544441..597dd316fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ exclude = ["vendor"] default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"] [workspace.package] -version = "0.7.1" +version = "0.8.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -14,40 +14,44 @@ repository = "https://github.com/storytold/pdfcraft" [workspace.dependencies] # Shared frontend components; engines remain independent of egui. craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } +# Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`. +craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f58bc1a3df490992d633393d0898af0588ce882c" } # Shared system spooler; document composition remains in pdfcraft-print. -craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f03ec27ab2c405969428512178f746183cd55ca0" } +craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "666c358065261a2816b376fa3bb10538dbf53040" } # internal -pdfcraft-geom = { path = "crates/geom", version = "0.7.0" } -pdfcraft-filters = { path = "crates/filters", version = "0.7.0" } -pdfcraft-cos = { path = "crates/cos", version = "0.7.0" } -pdfcraft-crypt = { path = "crates/crypt", version = "0.7.0" } -pdfcraft-organize = { path = "crates/organize", version = "0.7.0" } -pdfcraft-measure = { path = "crates/measure", version = "0.7.0" } -pdfcraft-annot = { path = "crates/annot", version = "0.7.0" } -pdfcraft-audit = { path = "crates/audit", version = "0.7.0" } -pdfcraft-fonts = { path = "crates/fonts", version = "0.7.0" } -pdfcraft-model = { path = "crates/model", version = "0.7.0" } -pdfcraft-content = { path = "crates/content", version = "0.7.0" } -pdfcraft-redact = { path = "crates/redact", version = "0.7.0" } -pdfcraft-print = { path = "crates/print", version = "0.7.0" } -pdfcraft-xfdf = { path = "crates/xfdf", version = "0.7.0" } -pdfcraft-sign = { path = "crates/sign", version = "0.7.0" } -pdfcraft-optimize = { path = "crates/optimize", version = "0.7.0" } -pdfcraft-a11y = { path = "crates/a11y", version = "0.7.0" } -pdfcraft-ocr = { path = "crates/ocr", version = "0.7.0" } -pdfcraft-js = { path = "crates/js", version = "0.7.0" } -pdfcraft-compare = { path = "crates/compare", version = "0.7.0" } -pdfcraft-preflight = { path = "crates/preflight", version = "0.7.0" } -pdfcraft-export = { path = "crates/export", version = "0.7.0" } -pdfcraft-edit = { path = "crates/edit", version = "0.7.0" } -pdfcraft-create = { path = "crates/create", version = "0.7.0" } -pdfcraft-forms = { path = "crates/forms", version = "0.7.0" } -pdfcraft-xfa = { path = "crates/xfa", version = "0.7.0" } -pdfcraft-render = { path = "crates/render", version = "0.7.0" } -pdfcraft-engine = { path = "crates/engine", version = "0.7.0" } -pdfcraft-platform = { path = "crates/platform", version = "0.7.0" } -pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.7.0" } -pdfcraft-automation = { path = "crates/automation", version = "0.7.0", default-features = false } +pdfcraft-geom = { path = "crates/geom", version = "0.8.0" } +pdfcraft-filters = { path = "crates/filters", version = "0.8.0" } +pdfcraft-cos = { path = "crates/cos", version = "0.8.0" } +pdfcraft-crypt = { path = "crates/crypt", version = "0.8.0" } +pdfcraft-organize = { path = "crates/organize", version = "0.8.0" } +pdfcraft-measure = { path = "crates/measure", version = "0.8.0" } +pdfcraft-annot = { path = "crates/annot", version = "0.8.0" } +pdfcraft-audit = { path = "crates/audit", version = "0.8.0" } +pdfcraft-fonts = { path = "crates/fonts", version = "0.8.0" } +pdfcraft-model = { path = "crates/model", version = "0.8.0" } +pdfcraft-content = { path = "crates/content", version = "0.8.0" } +pdfcraft-redact = { path = "crates/redact", version = "0.8.0" } +pdfcraft-print = { path = "crates/print", version = "0.8.0" } +pdfcraft-xfdf = { path = "crates/xfdf", version = "0.8.0" } +pdfcraft-sign = { path = "crates/sign", version = "0.8.0" } +pdfcraft-optimize = { path = "crates/optimize", version = "0.8.0" } +pdfcraft-a11y = { path = "crates/a11y", version = "0.8.0" } +pdfcraft-oracle = { path = "crates/oracle", version = "0.8.0" } +pdfcraft-testkit = { path = "crates/testkit", version = "0.8.0" } +pdfcraft-ocr = { path = "crates/ocr", version = "0.8.0" } +pdfcraft-js = { path = "crates/js", version = "0.8.0" } +pdfcraft-compare = { path = "crates/compare", version = "0.8.0" } +pdfcraft-preflight = { path = "crates/preflight", version = "0.8.0" } +pdfcraft-export = { path = "crates/export", version = "0.8.0" } +pdfcraft-edit = { path = "crates/edit", version = "0.8.0" } +pdfcraft-create = { path = "crates/create", version = "0.8.0" } +pdfcraft-forms = { path = "crates/forms", version = "0.8.0" } +pdfcraft-xfa = { path = "crates/xfa", version = "0.8.0" } +pdfcraft-render = { path = "crates/render", version = "0.8.0" } +pdfcraft-engine = { path = "crates/engine", version = "0.8.0" } +pdfcraft-platform = { path = "crates/platform", version = "0.8.0" } +pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.8.0" } +pdfcraft-automation = { path = "crates/automation", version = "0.8.0", default-features = false } # pdf hayro = "0.7.1" @@ -110,3 +114,4 @@ hayro-jbig2 = { path = "vendor/hayro-jbig2" } lopdf = { path = "vendor/lopdf" } winit = { path = "vendor/winit" } egui-wgpu = { path = "vendor/egui-wgpu" } +vello_common = { path = "vendor/vello_common" } diff --git a/README.md b/README.md index b20179fff4..bd33ad23be 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ Open **Organize pages** to see every page at once: **Saves you can trust:** - *Incremental:* the original bytes stay byte-for-byte intact. - *Atomic:* the file is written to a temporary copy, then swapped in. -- *Verified:* independently checked with qpdf. +- *Verified:* independently checked with qpdf, on every CI run. When open documents exceed the window width, scroll over the tab strip with the mouse wheel or trackpad, or use its horizontal scrollbar. Opening or switching to a document brings its tab into view. @@ -199,7 +199,7 @@ Every page of a combined document renders pixel-identical to its source. ```sh pdfcraft-cli combine report.pdf appendix.pdf --out combined.pdf -pdfcraft-cli extract report.pdf --pages 1,3,5 --out highlights.pdf +pdfcraft-cli extract report.pdf --pages 1,3,5-8 --out highlights.pdf pdfcraft-cli split report.pdf --every 10 --out-dir parts/ ``` @@ -285,6 +285,7 @@ Every engine feature is reachable without the GUI, through one table of JSON-Sch pdfcraft-cli run text_find doc=1 query=invoice # key=value; values parse as JSON pdfcraft-cli run --script review.json # e.g. comment_add {"type": "highlight", "find": "total due"} pdfcraft-cli run --script steps.json --root ./work # several steps in one session + pdfcraft-cli run --script steps.json --report # one JSON document: per step its number, tool, status, result or error ``` With `--root`, every file the script's steps read or write stays in that directory, including the PNG a step saves with `"out"` (the script itself is read from wherever you name it). @@ -365,7 +366,8 @@ On top of those, two corpus sweeps run over real-world files: - **Opening and rendering:** of the 983 pdf.js test files, 963 open and render cleanly, with 0 crashes. - **Open, edit and save round trips:** 958 succeed. -The output is verified with independent tools: hayro, qpdf and poppler. +The output is verified against independent implementations: hayro and qpdf on every CI run, and +poppler over the corpus on demand (`cargo xtask text-oracle`). PdfCraft is a clean-room implementation. Its behaviour comes from the ISO 32000 specification and black-box observation, never from anyone else's code. Every icon, font and image is openly licensed and listed in [ATTRIBUTION.md](ATTRIBUTION.md). @@ -451,17 +453,40 @@ logs and crash recovery in a `PdfCraftData` folder next to `pdfcraft.exe`, so no `%APPDATA%`; delete that file to use the normal per-user folders. The MSI installs for all users and requires administrator privileges. It adds "Edit with PdfCraft" -to the right-click menu of PDF files in File Explorer (on Windows 11, under "Show more options"), -without changing which app opens PDFs by default. For unattended deployment without a desktop -shortcut, run from an elevated terminal: +to the right-click menu of PDF files and "Create PDF with PdfCraft…" to the right-click menu of +images (.png .jpg .jpeg .tif .tiff .gif .bmp .jp2 .j2k .jpx) in File Explorer (on Windows 11, under +"Show more options"), without changing which app opens them by default. For unattended deployment +without a desktop shortcut, run from an elevated terminal: ```powershell msiexec /i "pdfcraft--windows-x64.msi" /qn /norestart INSTALLDESKTOPSHORTCUT=0 ``` -Add `INSTALLPDFCONTEXTMENU=0` to leave out the right-click menu entry. Pass both on upgrades too, -since each install applies its own choices. Use the MSI for your architecture. Per-user -installation overrides are not supported. +| Property | `=0` leaves out | +|---|---| +| `INSTALLDESKTOPSHORTCUT` | the all-users desktop shortcut | +| `INSTALLPDFCONTEXTMENU` | "Edit with PdfCraft" on PDF files | +| `INSTALLIMAGECONTEXTMENU` | "Create PDF with PdfCraft…" on images | + +All three are on for a first install. An upgrade keeps what the installed version has: a shortcut +or menu entry that is there stays, and one that is missing (left out at install, or a desktop +shortcut someone deleted) stays missing, so you don't need to pass them again. A value on the +command line always wins: `=1` puts one back and `=0` removes it. Upgrading from a release that +didn't have a menu yet (0.6.0 and earlier for the PDF menu, 0.2.1 and earlier for the image menu) +adds it, as a first install would. Use the MSI for your architecture. Per-user installation +overrides are not supported. + +**Pin the checksum, not just the version.** Release candidates of a version share its MSI version +(MSI versions are numbers only, so 1.2.3-rc.1 and 1.2.3 are both 1.2.3), and the installer lets an +MSI of the same version replace the installed one without asking. Two different `0.7.1` MSIs can't +be told apart by version, so if your deployment tool checks the file, pin its SHA-256 from +`SHA256SUMS.txt` (`Get-FileHash -Algorithm SHA256`). + +**Signing identities.** The Windows MSIs and both executables are signed through Azure Trusted +Signing with a certificate issued to `Learning Machines Inc` (Atlanta, Georgia, US). The macOS app +and command-line tool are signed with the Developer ID `Learning Machines LLC (DJ6XS33FX8)` and +notarized. The MSI's manufacturer, shown as the publisher in Installed apps, is +`Learning Machines LLC`. ### macOS diff --git a/ROADMAP.md b/ROADMAP.md index b4ab4ea8a6..fe9723fdf6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,8 +1,14 @@ # PdfCraft roadmap -**Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away +**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M4 #1910: Organize Pages opens on the current page; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks keep press/release together on slow frames and preserve text multi-click selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4 #1310 #1311 #1312: undo, revert and the next save keep the last saved revision; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: packed TIFF-predictor RGB rows retain their byte boundaries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -10,6 +16,7 @@ > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M5 #1398: stamps read upright on rotated pages; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (gap 6: cloudy comments stay when flatten cannot draw them, a refused text-box edit keeps its properties, arrow defaults stay off lines, a custom match stops at a separator, and the vertical-text redaction error no longer claims an overlap; #1317, #1327, #1302, #1315, #1340; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M7 #1316: replacing a line keeps a neighbouring line whose glyph box overlaps; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M5: comment submission shortcuts; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M1 #1339: an unchanged incremental save of a classic cross-reference table writes a zero-length subsection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M14 Windows low-DPI font-hinting candidate measured on macOS; no Windows runtime claim; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M3: Windows' Animation effects setting is followed, and Preferences has Animation effects: system, on or off); earlier: exclusive image-crop resize follow-up; M2 short same-line redraws split before placement; estimates unchanged · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -47,7 +54,7 @@ and add a line to the [progress log](#progress-log); when a gap closes, remove i |---|---|---| | **Feature breadth** | **≈ 67%** tier-weighted (56.9% unweighted: 423 shipped + 74 partial of 808; P0 94.2%) | measured, `parity/acrobat-features.toml` | | Feature breadth, mainstream practitioner | ≈ 70% tier-weighted (63.2% unweighted; P0 95.3%) | measured ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) | -| **Ready for real work**, full Acrobat Pro | **≈ 49%** (45–53%) | estimated, weighted sum over [dimensions](#by-dimension) | +| **Ready for real work**, full Acrobat Pro | **≈ 50%** (45–53%) | estimated, weighted sum over [dimensions](#by-dimension) | | **Ready for real work**, mainstream practitioner | **≈ 55%** (50–60%) | estimated ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) | | **Ready for real work**, essentials user | **≈ 66%** (60–70%) | estimated ([method](docs/target-app-parity.md#essentials-user)) | | Remaining to **beta** | ≈ 400–700 Opus 5.5 agent-hours | estimated | @@ -64,7 +71,7 @@ and 245 open issues keep it far from beta. | Audience | Ready | Opus 5.5 agent-hours to ≈ 95% | Work that dominates | |---|---|---|---| -| Full Acrobat Pro (ready for real work) | ≈ 49% | ≈ 780–1,400 h (≈ 300–550 h wall clock with 3–5 agents; 60–70% parallelizes) | Own renderer and fidelity harness, editing existing content, OCR and Office conversion, Preflight and PDF/A/X/UA, accessibility tagging, signatures for institutions | +| Full Acrobat Pro (ready for real work) | ≈ 50% | ≈ 780–1,400 h (≈ 300–550 h wall clock with 3–5 agents; 60–70% parallelizes) | Own renderer and fidelity harness, editing existing content, OCR and Office conversion, Preflight and PDF/A/X/UA, accessibility tagging, signatures for institutions | | Mainstream practitioner | ≈ 55% | ≈ 500–900 h (≈ 200–400 h wall clock; ≈ 60% parallelizes) | Rendering fidelity, editing existing text (CJK, RTL, subsets), conversion (Word layout, Excel, PowerPoint, Office→PDF), OCR languages, the bug backlog | | Essentials user | ≈ 66% | ≈ 180–330 h (≈ 90–180 h wall clock; ≈ 50% parallelizes) | Rendering fidelity on everyday files, launch and GPU stability, simple text fixes, Word export layout, Chinese UI glyphs | @@ -79,13 +86,13 @@ Hours are calibrated as in [Effort and calibration](docs/target-app-parity.md#ef | File formats and conversion | ≈ 40% | 12% | 120–220 (within features) | [file-format-parity.md](docs/file-format-parity.md) | | PDF specification and standards | ≈ 60% | 8% | 150–260 (within features) | [pdf-spec-parity.md](docs/pdf-spec-parity.md) | | Hardware | ≈ 35% | 4% | 25–50 | [hardware-parity.md](docs/hardware-parity.md) | -| Localization | ≈ 59% | 5% | 35–60 | [localization-parity.md](docs/localization-parity.md) | +| Localization | ≈ 68% | 5% | 35–60 | [localization-parity.md](docs/localization-parity.md) | | Performance | ≈ 40% | 6% | 25–45 | [gaps.md](docs/gaps.md) gap 13 | | Stability | ≈ 50% | 8% | 45–80 | [gaps.md](docs/gaps.md) gap 6 | | Platforms | ≈ 70% | 5% | 15–30 | [hardware-parity.md](docs/hardware-parity.md#platforms) | | Ecosystem and plug-ins | ≈ 30% | 1% | 10–20 (within features) | [gaps.md](docs/gaps.md) | | AI features | ≈ 0% (in scope: local providers) | 1% | 20–40 (within features) | [gaps.md](docs/gaps.md) gap 23 | -| **Total (weighted sum)** | **≈ 49%** | 100% | **≈ 780–1,400** | | +| **Total (weighted sum)** | **≈ 50%** | 100% | **≈ 780–1,400** | | ## Features @@ -110,8 +117,8 @@ What's strong and missing in each: [target-app-parity.md](docs/target-app-parity ## Languages -Measured from `crates/ui-egui/src/i18n/*.tsv` (2,143 keys). Detail and script notes: -[localization-parity.md](docs/localization-parity.md). +Twelve key languages (historical % snapshot vs 2,143 keys, except Vietnamese vs the current +union). Detail and script notes: [localization-parity.md](docs/localization-parity.md). | Language | Code | Strings | Status | |---|---|---|---| @@ -126,10 +133,11 @@ Measured from `crates/ui-egui/src/i18n/*.tsv` (2,143 keys). Detail and script no | Japanese | `ja` | 99.1% | partial | | German | `de` | 99.3% | partial | | Korean | `ko` | 0% | none | -| Vietnamese | `vi` | 0% | none | +| Vietnamese | `vi` | 100% of current union | partial (dialogs / tooltips; no help) | -Also shipped: Traditional Chinese, Russian, Bulgarian, Telugu, Hungarian, Ukrainian, Italian -(94–100%) and Czech (menus only): **8 more languages**. +Also shipped: Traditional Chinese, Russian, Bulgarian, Telugu, Hungarian, Ukrainian, Italian, +Kazakh, Polish, Slovenian, Greek, Turkish, Lithuanian and Czech (menus only): **14 more catalogs** +(23 interface languages including English). ## Upcoming @@ -148,9 +156,59 @@ 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-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. +- **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged. +- **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor). +- **2026-10-12 (M2, inline-image boundaries):** Unfiltered inline images with known sample layouts keep pixel bytes that look like the `EI` terminator when content is rewritten. Regression tests cover row padding, colour-space and parameter aliases, masks and malformed sizes; classic and packed saved PDFs retain the pixels in independent readers and renderers. Filtered and resource-defined layouts retain the existing tolerant heuristic. Estimates unchanged. +- **2026-10-12 (M3, drawing markups):** While a comment is being drawn, the pointer in a band along any edge of the view (or just past it) scrolls the view up, down, left or right, so points beyond what is shown can be reached. A polygon, cloud or run of connected lines takes back its last point with Backspace, Delete or Undo (the first point's removal cancels it). Edge scrolling has unit tests of its maths only; it has not been exercised end to end in the app by an automated test. +- **2026-10-12 (gap 6, #1319, #1305, #1324):** Editing an XFA form keeps a radio item's stored text when it contains a space, so a later layout still selects it. Replace Pages on a split selection replaces those pages and leaves the gap; editing the dialog range still replaces the span. Marking a figure decorative rewrites only that figure's content stream, so a header, added text and unknown stream entries stay. Estimates unchanged. +- **2026-10-15 (M14, gap 24):** `misc.localization` and `misc.installers` move from planned to partial. Localization: 22 catalogs ship, but Chinese shows tofu in releases, Telugu has no release font and Arabic is not mirrored. Installers: macOS and Windows releases are signed, Linux packages are not. The Acrobat menu diff itself remains open. Estimates unchanged. +- **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design. +- **2026-10-12 (M6, XML field names):** AcroForm XML data export constrains generated element names to XML 1.0 NCNames, so Latin-1 names such as `Area²` and `Aµ` produce readable data files. Original PDF field names travel through the existing `xfdf:original` attribute; previously valid tags, including Greek and CJK, keep their spelling. Saved-PDF regressions cover invalid prefixes, combining letters and normalized-name collisions. Estimates unchanged. +- **2026-10-12 (M6, CSV carriage returns):** Form-data CSV export quotes carriage returns in field names and values, so a trailing carriage return cannot turn into a record separator and disappear on reimport. Saved-PDF round-trip regressions cover CR, LF, CRLF, commas and quotes; estimates unchanged. +- **2026-10-13 (community, M14, #1553 review):** Relative Fontconfig includes resolve beside each including config, including nested configuration selected through `FONTCONFIG_PATH`. Directory includes accept only digit-prefixed `*.conf` in lexical order. Isolated regressions reproduce both review findings; configuration size/count/depth limits, cycles, symlinked config and valid reset ordering are covered. Estimates unchanged. +- **2026-10-12 (community, M14, NixOS UI fonts):** Linux/Unix interface fallback fonts are also discovered in Fontconfig's configured directories, including Nix store and user font paths. Existing candidate names, glyph checks, size limits and the screenshot opt-out are retained. Synthetic regressions cover included configuration, symlinks, cycles, depth limits and candidate ordering. Estimates unchanged. +- **2026-10-12 (M7, local text-box candidate):** Add Text opens source text for editing and both text tools retype added items on one click. Draft boxes grow toward the page edge and downwards, with the grown width saved. Single visual rows grow across style boundaries, while multi-row paragraph widths stay fixed. Added-text previews remove only the selected item on a clone, with UI and read-only tool regressions. Arabic shaping, ordering and alignment are outside this candidate. Estimates unchanged. +- **2026-10-12 (M14, #1395):** XDG desktops use the configuration base for app.ron, safely copying existing preferences without overwriting a destination. Original preferences, logs, recovery and digital IDs are retained; failed migration falls back to the original location. Portable and non-XDG locations remain unchanged; estimates unchanged. + +- **2026-10-12 (M10, Windows print progress, follow-up to #944):** While a job spools, the progress card counts its sheets ("Sending sheet 2 of 3 to Office…") as craft-print reports them (`submit_with_progress`, storytold/craft-libs#27), and steps aside for another long job's card. Pins craft-libs 666c358. Estimates unchanged. +- 2026-10-12 · M3: selected page text can be copied from Edit and the canvas context menu (#1225). Five focused regressions, full CI and native Unicode clipboard checks pass; floating Copy shares the document permission gate. Estimates unchanged. + +- **2026-10-13 (M2, page-label prefixes):** Preserve literal leading and trailing whitespace in page-label prefixes without changing generic metadata normalization. Nine original public-inspection cases cover prefix spaces, encodings and empty-prefix controls. Existing empty-prefix fallback remains a compatibility policy, not a conformance claim. Estimates unchanged. +- **2026-10-14 (M14, Italian catalog):** Italian gains the 201 entries the other catalogs had and the code looks up: the sentences the accessibility checker, preflight, signature validation and page-range parser word, certificate and calendar labels, digital-ID dialog messages and five plurals. It now has 2,528 entries. 64 keys other catalogs carry but no code looks up (tooltip texts with a fixed shortcut, older wordings, generic labels only Lithuanian has) were left out. AI-assisted, written from the meaning of the English; native review welcome. Estimates unchanged. +- **2026-10-15 (M3, #2166 part 1):** Save asks before writing over a version of the file the document did not come from (something else wrote it since the open or the last save), with Save as… to keep it, and the changed-on-disk notice says what Reload and Save each do; a file that is open already comes to the front instead of opening twice; a running instance's autosaves are locked, so a second instance neither offers nor discards them. Part 2 (every writer checked and atomic) is #2182. +- **2026-10-14 (M1, #2166):** No PDF leaves the engine unchecked and no writer truncates: new files from Combine, Extract, Split and Create, signed, timestamped and LTV-embedded files and the OCR batch output reopen with their pages before anyone writes them, as saves already did; the desktop app, the automation tools (with or without `--root`) and the CLI's edit, combine, extract and split all write through the one staging-file writer in `pdfcraft-platform`, so a failed write or `--out` naming the input never leaves a half-written file. Special files such as `/dev/stdout` are still written as given. +- **2026-10-14 (M3 #2080 candidate):** Existing per-tab page history gains explicit search jumps, rail buttons and control-channel commands. Continuous scrolling and automatic search refresh stay outside history; page-level restoration and estimates unchanged. + +- **2026-10-13 (M2, page-unit transform precision):** Preserve small transform coefficients in page marks, added content and print output. Regression cases cover accepted page units of 4096 and 75000; existing page-unit interpretation and estimates are unchanged. +- **2026-10-14 (M5, PR #1457 branch refresh):** Merged upstream main and reconciled roadmap entries while preserving comment submission shortcuts. Estimates unchanged. + +- **2026-10-12 (M3, page-label navigation):** The page box displays logical labels and keeps the current page when its unchanged value is submitted, including duplicate labels. Editable-control regressions also cover numeric fallback, invalid input, active typing and focused same-document replacement with changed labels or fewer pages. Clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Estimates unchanged. +- **2026-10-13 (community, M2/M6, form-heavy documents):** An image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN spot-colour image, a 1-4-bit gray or indexed one) is converted once per possible pixel value instead of once per pixel (vendored hayro-interpret patch 28). A fillable character sheet whose 3304 × 4105 DeviceN background ran a PostScript tint transform per pixel drew each page in 3.8 s and 430 MB, on opening and again after every field was filled; it now draws in 0.11 s and 100 MB, pixel for pixel the same. The inspector places widgets without `/P` in one pass over the pages (a form of 8,000 such fields opened in 1.17 s), and the Fields panel pairs its rows with the form through one map a frame and draws only the rows in view (8,000 fields: about 15× less CPU a frame with the panel open). Regressions fail before each fix. +- **2026-10-14 (M9, signature appearance labels):** A visible signature's labels ("Digitally signed by", "Reason: ", …) follow the interface language when Helvetica can draw all of them, and fall back to English as a set otherwise, with a note under the preview; the preview shows the same labels and the signing time. A drawn label the font can't draw is refused naming the label and character, never written as `?`. `sign_document` takes `labels` and `appearance`. Sign, automation and kittest regressions cover German, Chinese and the refusals. Estimates unchanged. +- **2026-10-13 (M4, #1910 Organize on the current page):** Opening Organize Pages from the toolbar, tool panel, command or `--organize` selects the page being read and scrolls its cell into view, replacing a stale grid selection that did not include it. One UI regression covers a middle page, the last page and reopening after an earlier selection. Estimates unchanged. +- **2026-10-13 (M5, signature pad undo):** The Draw tab of Create signature/initials has an Undo button and Ctrl/Cmd+Z that take back the last stroke (#1963). Pad is UI-only, so no automation tool. Estimates unchanged. +- **2026-10-13 (M2, raw crop and page-unit floor):** The renderer applies its existing one-point minimum in physical points for non-nearly-zero raw crop dimensions on scaled pages. Five synthetic regressions cover inspection, crop origins and rotations, reciprocal rounding and bounded raster controls. Existing UserUnit policy, nearly-zero fallback, feature statuses and estimates are unchanged. +- **2026-10-13 (M5, text box font size, #1908):** Comment Properties has a Font size control for text boxes, typewriter text and callouts (1-400 pt); it writes `/DA` and the rich-text strings, redraws the appearance and grows the box to fit. `comment_edit` takes `font_size`. Annot, tool and UI regressions. Estimates unchanged. +- **2026-10-13 (M3, Single Page edge paging, #1924):** In Single Page view a page taller than the window pans with the wheel, and one more notch (or a fresh trackpad swipe) at the bottom/top edge shows the next page at its top / the previous page at its bottom. A swipe that panned to the edge, momentum included, does not turn. Estimates unchanged. +- **2026-10-13 (M3, #1980):** Ctrl+Alt+Q in the digital ID email field no longer requests application exit. Quit requires exact Ctrl+Q/Cmd+Q modifiers and uses the existing unsaved-document confirmation. Synthetic keyboard regressions cover email input and clean/dirty quit requests; estimates unchanged. + +- **2026-10-14 (M4, page transitions read, §12.4.4):** `/Trans` and `/Dur` are read into `DocInfo::transitions` and listed by `doc_info`, the data a full-screen presentation needs to play transitions and auto-advance; playing and editing them stay planned. +- **2026-10-14 (M4, page transitions set, §12.4.4):** `page_transition_set` (one undoable edit) writes or removes `/Trans` and `/Dur` on chosen pages with the values checked first, the counterpart of reading them into `DocInfo::transitions`; playing them and the dialog stay planned. +- **2026-10-14 (M12, transition geometry):** `pdfcraft_render::transition` gives the picture of any of the twelve page transitions at a moment of its run (which parts of the old and new page to paint, where, how opaque), in page fractions, tested for every style and direction; the full-screen presentation will paint from it once it plays transitions. +- **2026-10-14 (M3, Open in Full Screen mode honoured):** a document with `/PageMode /FullScreen` now asks (Full Screen / Not now) before the app enters full screen, as Acrobat does; before, the setting was written and read but never acted on when opening. +- **2026-10-14 (M3, control-click candidate):** Coordinate clicks retain hover warmup and pair press/release in one input pass so slow frames do not turn a click into a hold. Text multi-click selection accepts the actual press event when egui has already cleared its origin. Regression cases cover slow-frame button activation, disabled controls and coalesced text selection; estimates unchanged. +- **2026-10-14 (community, M7, #128 Hebrew in Add text):** Add text and `page_add_text` draw Hebrew with the craft-fonts Hebr face (Noto Sans Hebrew), through the same shaped path as Arabic and Thai: right-to-left display order with numbers and Latin in place, points and cantillation kept on their letter as one Type 3 glyph, ToUnicode and per-line ActualText so extraction, search and the editor get typing order. Builds without the face refuse Hebrew rather than write `?`. Regular style only. Estimates unchanged. +- **2026-10-14 (M9, #1787):** Changes after signing are judged by the role an object held when signed for a page's resources too: a rewritten font, XObject, graphics state or resources dictionary is page content however its new version is labelled, so a `/FT` or `/Subtype /Widget` added by the attacker no longer reads as a permitted form fill. A category dictionary that only gained a name is judged as before. - **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged. +- **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged. +- **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged. +- **2026-10-13 (M6, choice indices):** Multi-select choice fields write ascending `/I` option indices while preserving the caller's `/V` order. Synthetic forms and headless engine regressions cover reverse selection and save/reopen. Estimates unchanged. + - **2026-10-12 (M10, Windows printing without PowerShell, #1107):** craft-print now prints on Windows in-process: printers from EnumPrinters/GetDefaultPrinter, jobs drawn as vectors by Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling), so printing works where App Control for Business or AppLocker run PowerShell in Constrained Language Mode. Portrait and landscape sheets still print as one job (turned with /Rotate); a sheet size the driver lacks is refused rather than shrunk. Pins craft-libs f03ec27 (craft-libs#28); deny.toml allows the storytold/winprint.rs fork and adds a webpki-root-certs licence exception and a RUSTSEC-2024-0370 ignore for winprint's build-dependencies. An earlier craft-print revision was checked in Windows Sandbox under enforced WDAC: printers listed, CLI and desktop jobs spooled, and no powershell.exe in the 4688 audit. The final pinned revision passed craft-libs Windows CI, including a real paused-queue job; that Sandbox run has not been repeated. Estimates unchanged. +- **2026-10-12 (M1, #1338):** A full rewrite used to build a new trailer from `/Root`, `/Info`, `/Encrypt` and `/ID`, so accepted unknown entries such as a custom string or `/Version` disappeared, including when an incremental save was forced to rewrite. Those entries are copied, and objects reachable only from them are written and renumbered with the rest of the file. Ordinary incremental saves are unchanged. Estimates unchanged. +- **2026-10-12 (M4, #1310, #1311, #1312):** Undo back to the opened file left it marked unsaved, so recovery and close treated unchanged work as dirty. After a save, Revert followed the restored snapshot's older base and the next save dropped the latest saved revision. The last saved file is remembered on its own: undo and redo are clean only when they land on it, Revert opens it, and a later save appends to it. Estimates unchanged. - **2026-10-12 (M13, CLI robustness scans):** `pdfcraft-cli check` visits a directory once even when input roots overlap or directory symlinks lead back into the tree. PDFs reached through file symlinks remain accepted. Three real CLI regressions cover overlapping roots, a directory cycle and directory/file aliases. Estimates unchanged. - **2026-10-12 (M4, Type 3 extraction fidelity):** Extract and Split preserve shared image/resource dictionaries reached through Type 3 glyphs, including nested forms, patterns and soft masks. A bounded object-graph walk protects unknown glyph content; unreadable pages/forms retain their glyph resources, and shared explicit fonts are visited once. Separate page dictionaries still prune unused images. Synthetic save/reopen regressions and an opt-in Poppler pixel oracle cover resource ownership, nested forms, cycles, limits and repeated fonts. Estimates unchanged. - **2026-10-12 (M2, TIFF predictor rows):** Packed RGB images using TIFF horizontal differencing keep every row when samples do not fill the last byte. The renderer aligns each decoded row before moving to the next. Synthetic decoding tests cover 1/2/4-bit samples, horizontal differencing and an 8-bit control; a red/green page-render regression verifies visible output. Estimates unchanged. @@ -162,6 +220,9 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-12 (gap 6, #1317, #1327, #1302, #1315, #1340):** Flattening keeps a visible comment it cannot draw, such as a cloudy square with no stored appearance, and still draws a solid square, a hidden comment and a cloudy square that already has an appearance. A text box another program drew is left unchanged when a restyle or resize is refused. Make Current Properties Default on an arrow updates the Arrow tool and leaves plain lines alone. A custom text match that ends on a reconstructed word or line separator no longer includes the next glyph. The vertical-writing redaction refusal still fails closed, and its message no longer says the text was under the mark. Estimates unchanged. - **2026-10-12 (M12, text-only auto-tag):** Automatically tag PDF now writes marked text objects, paragraph tags, a Document element and a parent tree for untagged text-only PDFs. The result is undoable and opens the Tags panel for review. Mixed paint, forms, annotations, existing tags and malformed streams are refused before mutation. Synthetic two-page engine and tool tests check the tree, checker, unchanged rendering and save/reopen. Richer content and role classification remain open. Estimates unchanged. - **2026-10-12 (M7, #1316):** Replacing a line or paragraph no longer removes a distinct neighbour whose glyph box overlaps the edited line. A second drawing is removed only when it also sits on the same baseline, so fake-bold and invisible copies still go. Synthetic regressions cover 6 pt leading, the same word on the next line, ordinary spacing, a separate paragraph, same-position copies, and incremental and full save/reopen. Estimates unchanged. +- **2026-10-12 (M5, comment shortcuts):** Ctrl+Enter posts highlight comment text and replies; Shift+Enter adds a newline. Replies keep Enter-to-post, and floating comment composers only submit from their focused editor. Estimates unchanged. + +- **2026-10-12 (M7, #1443):** Adding a word to a multi-line paragraph no longer reflows or merges its lines. Two causes: in Chrome- and Skia-made subset fonts, `/W` leaves out glyphs of the `/DW` width, which were treated as missing, so unchanged rows measured too wide and rewrapped (and new letters fell back to Helvetica); and lines drawn turned on the page (a landscape page drawn a quarter turn round) were compared along the page's x axis, so all of them became one line. Lines and rows are now found along each text's own baseline, a turned paragraph is rewritten in its own text space, and that rewrite keeps explicit line breaks. Formatting part of turned text is refused rather than misplaced. Synthetic regressions cover three turns and a default-width subset. Estimates unchanged. - **2026-10-12 (M1, #1339):** An incremental save of an unchanged classic-table file used to append `xref` and then `trailer` with no subsection, which qpdf reports as a damaged file. The section now has a zero-length subsection, so no object entry changes and a trailer-only edit is still written. Estimates unchanged. - **2026-10-12 (M12, auto-tag preview):** The engine and `accessibility_autotag_preview` propose paragraph and large-heading roles for bounded text blocks on one page, with locations and reasons. Every proposal needs review; images remain unclassified. No PDF tags are written yet. Synthetic tool tests cover normal text, a larger heading, bounds and unchanged source bytes. Estimates unchanged. - **2026-10-12 (M10, editing OCR text on scans):** Editing a recognised word on a scanned page now changes what the page shows. Before, the rewrite stayed invisible OCR text, so the scan still showed the old word; on a form, the whole OCR layer also grouped into one paragraph that rewrapped when one word changed. OCR words now group into phrases (a table cell or a tab stop splits them, and lines stay apart); an edit draws the phrase visibly in the scan's sampled ink colour over a cover in the sampled paper colour, keeping each word's width and growing rightwards. The editor covers the scanned word while typing. OCR words are now sized from their letters (a capitals-only word's box spans the cap height, not the full ascender-descender span), so recognised and rewritten text is as tall as the scanned word and sits on its baseline. Five engine tests on synthetic scans and a kittest UI test; `ocr.editable-text-output` is partial. @@ -175,6 +236,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-12 (M12, XFA scripts turned off):** When a runaway script turns an XFA form's scripts off, the app now says so: a toast as it happens (at open or after a click), and the notice bar from then on, ahead of its other notices, with a JavaScript Console button. Before, the error was only in the console, so calculations and buttons just stopped. A notice bar the user had closed comes back for it; closed again, it stays closed. The engine reports the state (`Document::xfa_scripts_off`, and `xfa_scripts_off` in `doc_info`) instead of the UI reading error text. Two kittest UI tests (a runaway at open; a runaway click after the bar was closed), engine assertions and a `doc_info` tool test. Overall unchanged. - **2026-10-12 (M14, Windows low-DPI font-hinting candidate):** The Windows policy disables symmetric rendering for embedded JetBrains Mono only. On the locked-font macOS probe, horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150% scaling, and is down 0.79% at 200%; Inter is pixel-identical. Logical layout hashes and 8 MiB atlas size match, repeated fractional offsets add no atlas dimensions, and baseline/candidate time and RSS ranges overlap. Windows runtime and hardware remain unverified; estimates unchanged. - **2026-10-12 (M7, #1200 candidate):** Bounded local GoToR file specifications open in desktop tabs; explicit numeric page views apply, while named destinations open at the default view with a warning. The automation tool resolves relative to the source and uses existing `--root` confinement. Appearance-only Link Properties edits preserve the existing action. URL/network paths remain refused; `/NewWindow` is a tab hint. Estimates unchanged. +- **2026-10-12 (M1, #1337):** COS opening repairs exhausted cross-reference entries and records the skipped row; allocation safely falls back to unused numbers for exhausted /Size hints and high edits. Classic-table and stream regressions reproduce the former panic; ordinary, zero-offset and repaired-range controls preserve opening and allocation. Estimates unchanged. - **2026-10-12 (M7, private image crops):** Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. - **2026-10-12 (M12, accessibility finding locations):** Checker findings for figures, annotations and form widgets carry verified page bounds. Selecting one flashes its location; the headless check returns the same PDF-space rectangle. Malformed annotation rectangles remain page-only findings. Guided review and tagging estimates unchanged. @@ -200,6 +262,7 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-12 (M3, GNOME title bar, #553):** On GNOME's Wayland session, whose compositor draws no window frames (it offers no `zxdg_decoration_manager_v1`), PdfCraft no longer shows winit's fallback title bar: it turns the frame off and the tab strip becomes the header bar, as in GNOME's own apps. Minimize, maximize/restore and close are drawn in Adwaita's style, in the order and on the side GNOME's `button-layout` names (Fedora's default shows only close); the window's edges and corners resize it, and a hairline outlines it while it isn't maximized. Preferences ▸ Use system title bar (shown only there) gives the window back winit's frame at once, without a restart (part of #1001), and `PDFCRAFT_TITLEBAR=app|system` overrides the choice. KDE, Sway, Hyprland, COSMIC and X11 keep the frame their desktop draws. Not done: right-clicking the header bar doesn't open GNOME's window menu (egui can't ask for it), the window has no shadow or rounded corners, the resize edges lie just inside the window rather than outside it, and Windows still uses its system frame (the rest of #1001). Estimates unchanged. - **2026-10-12 (M0, build label):** A local or patched build can carry a label, set at compile time with `PDFCRAFT_BUILD_LABEL`: `pdfcraft --version`, `pdfcraft-cli --version`, About and the log show `0.7.1 (titlebar)`, and `packaging/linux/package.sh` names the files `pdfcraft-0.7.1-titlebar-…` and makes the .rpm/.deb release `1.titlebar`, which upgrades over the release of the same version (and the next release over it). The version itself is unchanged, so the update check still compares it with published releases. Unlabelled builds are as before. Estimates unchanged. +- **2026-10-11 (M6, #1208):** Dropdown and list-box Field Properties expose the existing Left/Center/Right alignment picker. Existing alignment stays intact until changed; cancel, undo/redo and save/reopen are covered through the real UI without changing options, values or flags. Estimates unchanged. - 2026-10-11: Organize Pages shows an editable, synchronized physical page-range selection (#1092), with bounded validation and UI/operation regression coverage; overall estimate unchanged. - **2026-10-11 (M5.7, scanned signatures, #1110):** Fill & Sign ▸ Add signature has a Scan tab: a phone photo of a signature on paper is framed automatically (all words; table, sheet edge and dust ignored; a sideways signature turned upright), the frame can be moved, resized or redrawn, the photo turned, and an ink-sensitivity slider picks up faint ink. A morphological paper estimate removes shadows and uneven light, and the trimmed ink is saved and reused like an image signature. Photos and images can also be dropped on the Image and Scan tabs. Photos keep the image-signature limits (#1124: a 4 MiB file, up to 32 megapixels decoded, scaled down to 4 megapixels), so a scan never processes more than 4 megapixels. `fill_sign_add` takes `scan` and `sensitivity`. Synthetic engine, UI and automation tests; checked by hand on real phone photos. Milestone and effort estimates unchanged. @@ -427,6 +490,10 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. | +| 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. | +| 2026-10-13 | minor | M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. Estimates unchanged. | +| 2026-10-14 | minor | M7/#128: Hebrew in Add text with the craft-fonts Hebr face (display order, points, ToUnicode, ActualText); estimates unchanged. | | 2026-10-12 | minor | M3/#1364 candidate: absolute local GoToR links require desktop consent or headless allow_absolute; existing relative navigation and validation retained. Estimates unchanged. | | 2026-10-12 | minor | M8: redaction retires inherited `/Resources` so a parent `/Pages` can't keep a removed XObject reachable; only redacted pages that inherit are pinned (one shared reference), unredacted pages are untouched; estimates unchanged | | 2026-10-12 | minor | M7/#1200 candidate: bounded local GoToR opening, root-confined automation, and action-preserving appearance edits; named destinations and window behavior remain partial; estimates unchanged. | @@ -443,5 +510,7 @@ Newest first: the date, what moved, and the new overall estimate. | 2026-10-10 | major | Restructured to the progress-docs standard (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; assessment, gaps and milestones moved to `docs/`; log sorted newest first and a stray merged entry dated | | 2026-10-07 | minor | 806 tracked features, 51.0% shipped, effort-weighted ≈ 30–35%, 600–1,100 h | | 2026-10-05 | major | Honest assessment and "Where we're lacking" added | +| 2026-10-12 | minor | M8: image redaction removes alternate images and unsupported masks only on affected images. Estimates unchanged. | - **2026-10-12 (M8):** Redaction cleans indirect and form structure references, preserving surviving form tags. +- **2026-10-13 (M7):** Edit a PDF ▸ Add content: Header and footer, Watermark and Background open an Add / Update / Remove menu (Update and Remove enabled only when the document has that mark), as Acrobat's Watermark control does. The catalogue gains `ToolItem::actions`, so any tool can list more commands (#1192). diff --git a/apps/pdfcraft-cli/Cargo.toml b/apps/pdfcraft-cli/Cargo.toml index abe2657fbc..d7b52fbd66 100644 --- a/apps/pdfcraft-cli/Cargo.toml +++ b/apps/pdfcraft-cli/Cargo.toml @@ -19,6 +19,7 @@ pdfcraft-engine = { workspace = true } pdfcraft-audit = { workspace = true } pdfcraft-cos = { workspace = true } pdfcraft-automation = { workspace = true } +pdfcraft-platform = { workspace = true } base64 = "0.22" [target.'cfg(unix)'.dependencies] diff --git a/apps/pdfcraft-cli/src/automation_args.rs b/apps/pdfcraft-cli/src/automation_args.rs new file mode 100644 index 0000000000..868cdf66a0 --- /dev/null +++ b/apps/pdfcraft-cli/src/automation_args.rs @@ -0,0 +1,90 @@ +//! Parse `--automation-read-root` / `--automation-write-root` before the server starts. A malformed +//! flag must not silently remove a root and leave the server unconfined. + +use std::path::PathBuf; + +pub struct AutomationArgs { + pub read_root: Option, + pub write_root: Option, + read_given: bool, + write_given: bool, +} + +impl AutomationArgs { + pub fn new(read_root: Option, write_root: Option) -> Self { + Self { read_root, write_root, read_given: false, write_given: false } + } + + /// Returns whether this argument is a root flag. A second occurrence of a flag is an error, + /// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed. + pub fn consume(&mut self, arg: &str, args: &mut impl Iterator) -> Result { + let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value))); + let (root, given) = match flag { + "--automation-read-root" => (&mut self.read_root, &mut self.read_given), + "--automation-write-root" => (&mut self.write_root, &mut self.write_given), + _ => return Ok(false), + }; + if *given { + return Err(format!("{flag} given twice")); + } + let value = match inline { + Some(value) => value.to_owned(), + None => { + let value = args.next().ok_or_else(|| format!("{flag} needs a folder"))?; + if value.starts_with('-') { + return Err(format!("{flag} needs a folder; use {flag}= for a folder starting with '-'")); + } + value + } + }; + if value.is_empty() { + return Err(format!("{flag} needs a non-empty folder")); + } + *root = Some(value.into()); + *given = true; + Ok(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roots_are_taken_once_and_other_arguments_are_left() { + let mut roots = AutomationArgs::new(None, None); + let mut args = ["a folder".to_owned(), "report.pdf".to_owned()].into_iter(); + assert!(roots.consume("--automation-read-root", &mut args).unwrap()); + assert!(roots.consume("--automation-write-root=out", &mut args).unwrap()); + assert!(!roots.consume("--compact", &mut args).unwrap()); + assert_eq!(roots.read_root, Some("a folder".into())); + assert_eq!(roots.write_root, Some("out".into())); + assert_eq!(args.next().as_deref(), Some("report.pdf")); + assert!(roots.consume("--automation-read-root=other", &mut args).is_err()); + assert_eq!(roots.read_root, Some("a folder".into())); + } + + #[test] + fn invalid_operands_leave_roots_unchanged() { + for flag in ["--automation-read-root", "--automation-write-root"] { + for operand in [None, Some(""), Some("--version"), Some("--root"), Some("-folder")] { + let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into())); + let mut args = operand.map(str::to_owned).into_iter(); + assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}"); + assert_eq!(roots.read_root, Some("read".into())); + assert_eq!(roots.write_root, Some("write".into())); + } + let mut roots = AutomationArgs::new(None, None); + assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err()); + } + } + + #[test] + fn explicit_dash_folder_and_relative_dash_folder_are_valid() { + let mut roots = AutomationArgs::new(None, None); + assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap()); + assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap()); + assert_eq!(roots.read_root, Some("-folder".into())); + assert_eq!(roots.write_root, Some("./-folder".into())); + } +} diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index 190cb3da9f..0e24800d39 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -17,6 +17,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(feature = "mcp")] +mod automation_args; mod batch; use std::io::Write; @@ -33,8 +35,14 @@ use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind, in macro_rules! mcp_usage { () => { "\ -pdfcraft-cli mcp [--root DIR] [--compact] MCP server on stdin/stdout (opt-in) +pdfcraft-cli mcp [--root DIR] [--compact] [--automation-read-root DIR] [--automation-write-root DIR] + MCP server on stdin/stdout (opt-in) --compact lists a core set of tools plus tool_search and tool_call + --automation-read-root / --automation-write-root confine the files the + agent's tools read (open, combine, import, fonts, certificates…) and + write (save, export, extract, split, reports…) to DIR, links followed; + a root left out grants none of its access. --root, when also given, + applies as well " }; } @@ -55,7 +63,7 @@ pdfcraft-cli edit --out out.pdf [--rotate 1,3:90] [--delete 2,4] [--m [--insert-blank 1] [--title T] [--author A] [--full] (options run in order; page numbers refer to the document after the earlier options) pdfcraft-cli combine … --out combined.pdf -pdfcraft-cli extract --pages 1,3,5 --out out.pdf +pdfcraft-cli extract --pages 1,3,5-8 --out out.pdf (pages and ranges, in the order given) pdfcraft-cli split (--every N | --before 3,7) [--out-dir DIR] pdfcraft-cli check [--timeout 20] [--dpi 36] [--json out.json] pdfcraft-cli audit [--password PW] find faux redactions (fails when found) @@ -64,7 +72,8 @@ pdfcraft-cli batch --steps convert,ocr,optimize --out-dir DIR [--ppi 150] [--quality 60] [--dpi 300] [--language en] [--ocr-all-pages] pdfcraft-cli tools automation tools and their JSON Schemas pdfcraft-cli run [key=value …] [--root DIR] [--out image.png] -pdfcraft-cli run --script steps.json [--root DIR] [{\"tool\": \"doc_open\", \"args\": {…}}, …] +pdfcraft-cli run --script steps.json [--root DIR] [--report] + [{\"tool\": \"doc_open\", \"args\": {…}}, …]; --report prints one JSON document for the run ", mcp_usage!(), "\ @@ -161,15 +170,20 @@ fn audit(args: &[String]) -> Result<(), CliError> { validate_options("audit", args, AUDIT_OPTIONS)?; let path = *positional(args).first().ok_or("audit: missing file")?; let bytes = read(path)?; - let doc = pdfcraft_cos::Document::open_with_password(bytes, flag(args, "--password")).map_err(|e| format!("{path}: {e}"))?; + let password = flag(args, "--password"); + let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map_err(|e| format!("{path}: {e}"))?; let findings = pdfcraft_audit::audit_redactions(&doc).map_err(|e| format!("{path}: {e}"))?; + // Rectangles in points from the top-left of the displayed page, as `doc_audit_redactions` + // reports them; the page geometry is read only when there is something to place. + let info = if findings.is_empty() { None } else { Some(inspect(bytes, password).map_err(|e| format!("{path}: {e}"))?) }; let items: Vec = findings .into_iter() .map(|f| { + let rect = f.rect.map(|v| v as f32); serde_json::json!({ "page": f.page + 1, "kind": f.kind.id(), - "rect": f.rect, + "rect": info.as_ref().map_or(rect, |i| pdfcraft_automation::view_rect(i, f.page, rect)), "covered_text": f.covered_text, }) }) @@ -251,6 +265,7 @@ const RUN_OPTIONS: &[OptionSpec] = &[ OptionSpec { name: "--root", takes_value: true }, OptionSpec { name: "--script", takes_value: true }, OptionSpec { name: "--out", takes_value: true }, + OptionSpec { name: "--report", takes_value: false }, ]; const UI_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--control", takes_value: true }, OptionSpec { name: "--out", takes_value: true }]; @@ -305,6 +320,7 @@ fn info(args: &[String]) -> Result<(), CliError> { "xfa_layout": xfa_layout, "bookmarks": info.outline.len(), "annotations": info.annotations.len(), "fields": info.fields.len(), "links": info.links.len(), "layers": info.layers.len(), "attachments": info.attachments.len(), + "transitions": info.transitions.len(), "page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::>(), "warnings": info.warnings, }); @@ -376,9 +392,30 @@ fn text(args: &[String]) -> Result<(), CliError> { } } -/// 1-based page list ("1,3,5") → 0-based indices. +/// The most pages one `A-B` range may name: a bound on what a typed range can allocate. +const MAX_RANGE: usize = 1_000_000; + +/// 1-based pages ("1,3,5-8": single pages and inclusive ranges, in the order given) → 0-based +/// indices (#1923). fn page_list(s: &str) -> Result, String> { - s.split(',').map(|p| p.trim().parse::().ok().filter(|n| *n > 0).map(|n| n - 1).ok_or(format!("bad page number {p:?}"))).collect() + let number = |t: &str, item: &str| t.trim().parse::().ok().filter(|n| *n > 0).ok_or_else(|| format!("bad page number {item:?}")); + let mut out = Vec::new(); + for item in s.split(',').map(str::trim) { + match item.split_once('-') { + Some((from, to)) => { + let (from, to) = (number(from, item)?, number(to, item)?); + if to < from { + return Err(format!("bad page range {item:?}: it ends before it starts")); + } + if to - from >= MAX_RANGE { + return Err(format!("bad page range {item:?}: more than {MAX_RANGE} pages")); + } + out.extend(from - 1..to); + } + None => out.push(number(item, item)? - 1), + } + } + Ok(out) } /// The options `edit` understands, for its "unknown option" message. @@ -469,7 +506,16 @@ fn edit(args: &[String]) -> Result<(), CliError> { session.apply(id, e).map_err(|e| e.to_string())?; } let bytes = if full { session.save_full_bytes(id) } else { session.save_bytes(id) }.map_err(|e| e.to_string())?; - std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}").into()) + // As the app's Save: the file must reopen with its pages before it replaces anything (#1698). + session.check_save(id, &bytes).map_err(|e| e.to_string())?; + write_pdf(Path::new(out), &bytes) +} + +/// Write a PDF a command produced, through a staging file beside the target (#2166): a write +/// that fails, or `--out` naming the input, never leaves a truncated file where a good one was. +fn write_pdf(out: &Path, bytes: &[u8]) -> Result<(), CliError> { + use pdfcraft_platform::staging::{StagingName, write_atomically}; + write_atomically(out, bytes, StagingName::SuffixThenTag).map_err(|e| format!("{}: {e}", out.display()).into()) } fn file_stem(path: &str) -> String { @@ -485,7 +531,7 @@ fn combine(args: &[String]) -> Result<(), CliError> { } let sources = inputs.iter().map(|p| Ok((file_stem(p), read(p)?))).collect::, String>>()?; let bytes = pdfcraft_engine::Session::new().combine(&sources).map_err(|e| e.to_string())?; - std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}").into()) + write_pdf(Path::new(out), &bytes) } fn extract(args: &[String]) -> Result<(), CliError> { @@ -496,7 +542,7 @@ fn extract(args: &[String]) -> Result<(), CliError> { let mut session = pdfcraft_engine::Session::new(); let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?; let bytes = session.extract(id, &pages).map_err(|e| e.to_string())?; - std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}").into()) + write_pdf(Path::new(out), &bytes) } fn split(args: &[String]) -> Result<(), CliError> { @@ -518,7 +564,7 @@ fn split(args: &[String]) -> Result<(), CliError> { std::fs::create_dir_all(&dir).map_err(|e| format!("split: --out-dir {}: {e}", dir.display()))?; for (a, b, bytes) in parts { let name = dir.join(if a == b { format!("{stem}-p{a}.pdf") } else { format!("{stem}-p{a}-{b}.pdf") }); - std::fs::write(&name, bytes.as_slice()).map_err(|e| format!("{}: {e}", name.display()))?; + write_pdf(&name, &bytes)?; stdout_line(format_args!("{}", name.display()))?; } Ok(()) @@ -539,8 +585,11 @@ fn render(args: &[String]) -> Result<(), CliError> { Some("pam") => None, _ => return Err(format!("render: --out {out}: use a .png, .jpg, .tif or .pam name").into()), }; + // The app renders a display copy that draws comments missing their /AP streams; the CLI + // must agree with it (and with independent viewers), while keeping the file it reads intact. + let bytes = read(path)?; let mut r = PageRenderer::new( - read(path)?, + pdfcraft_engine::display_bytes_for(bytes.clone(), flag(args, "--password")), RenderConfig { password: flag(args, "--password").map(Arc::from), reject_oversize: true, ..Default::default() }, ); let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false }); @@ -757,22 +806,88 @@ fn print_output(auto: &pdfcraft_automation::Automation, content: Vec stdout_line(format_args!( - "{}", - serde_json::json!({ "image": "png", "width": width, "height": height, "bytes": data.len(), "hint": "pass --out file.png to save it" }) - ))?, + None => stdout_line(format_args!("{}", png_summary(&data, width, height)))?, }, } } Ok(()) } +/// What a PNG result is reported as when it isn't written to a file. +fn png_summary(data: &[u8], width: u32, height: u32) -> serde_json::Value { + serde_json::json!({ "image": "png", "width": width, "height": height, "bytes": data.len(), "hint": "pass --out file.png to save it" }) +} + +/// A tool's result as JSON values, for the `--report` of a script. Same content as +/// [`print_output`], collected instead of printed. +fn output_values( + auto: &pdfcraft_automation::Automation, + content: Vec, + out: Option<&str>, +) -> Result, String> { + let mut values = Vec::new(); + for c in content { + values.push(match c { + pdfcraft_automation::Content::Json(v) => v, + pdfcraft_automation::Content::Png { data, width, height } => match out { + Some(path) => { + let written = auto.write_output(path, &data).map_err(|e| e.to_string())?; + serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height }) + } + None => png_summary(&data, width, height), + }, + }); + } + Ok(values) +} + +/// Run a script and print one JSON document: per step its number, tool and `ok`/`error`/`skipped` +/// with the result or the message. The first failing step ends the run, as without `--report`; +/// the steps after it are listed as skipped and the exit status is the same. +fn run_script_report(auto: &mut pdfcraft_automation::Automation, steps: &[serde_json::Value]) -> Result<(), CliError> { + let mut report = Vec::with_capacity(steps.len()); + let mut failure: Option = None; + for (i, step) in steps.iter().enumerate() { + let n = i + 1; + let tool = step["tool"].as_str(); + if failure.is_some() { + report.push(serde_json::json!({ "step": n, "tool": tool, "status": "skipped" })); + continue; + } + let outcome = match tool { + None => Err(format!("step {n}: missing \"tool\"")), + Some(t) => auto + .call(t, &step["args"]) + .map_err(|e| e.to_string()) + .and_then(|content| output_values(auto, content, step["out"].as_str())) + .map_err(|e| format!("step {n} ({t}): {e}")), + }; + match outcome { + Ok(result) => report.push(serde_json::json!({ "step": n, "tool": tool, "status": "ok", "result": result })), + Err(message) => { + report.push(serde_json::json!({ "step": n, "tool": tool, "status": "error", "error": message })); + failure = Some(message); + } + } + } + let document = serde_json::json!({ "ok": failure.is_none(), "steps": report }); + stdout_line(format_args!("{}", serde_json::to_string_pretty(&document).unwrap_or_default()))?; + failure.map_or(Ok(()), |message| Err(CliError::Message(message))) +} + fn run(args: &[String]) -> Result<(), CliError> { validate_options("run", args, RUN_OPTIONS)?; + let report = args.iter().any(|a| a == "--report"); + if report && flag(args, "--script").is_none() { + return Err("run: --report needs --script".to_string().into()); + } let mut auto = automation(args)?; if let Some(script) = flag(args, "--script") { let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?; let steps: Vec = serde_json::from_str(&text).map_err(|e| format!("{script}: {e}"))?; + if report { + return run_script_report(&mut auto, &steps); + } for (i, step) in steps.iter().enumerate() { let tool = step["tool"].as_str().ok_or(format!("step {}: missing \"tool\"", i + 1))?; let content = auto.call(tool, &step["args"]).map_err(|e| format!("step {} ({tool}): {e}", i + 1))?; @@ -794,11 +909,42 @@ fn run(args: &[String]) -> Result<(), CliError> { print_output(&auto, content, flag(args, "--out")) } +/// Take `--automation-read-root` / `--automation-write-root` (each `--flag DIR` or `--flag=DIR`, +/// once) out of `args`, and open the roots, which must be existing folders. Returns the arguments +/// that are left. A malformed root flag is an error, never a root silently left out. +#[cfg(feature = "mcp")] +fn automation_roots(command: &str, args: &[String]) -> Result<(Option>, Vec), String> { + let mut found = automation_args::AutomationArgs::new(None, None); + let mut left = Vec::new(); + let mut it = args.iter().cloned(); + while let Some(arg) = it.next() { + if !found.consume(&arg, &mut it).map_err(|e| format!("{command}: {e}"))? { + left.push(arg); + } + } + let roots = pdfcraft_automation::file_access::AutomationRoots::new(found.read_root.as_deref(), found.write_root.as_deref()) + .map_err(|e| format!("{command}: {e}"))?; + Ok((roots, left)) +} + #[cfg(feature = "mcp")] fn mcp(args: &[String]) -> Result<(), CliError> { + let (roots, args) = automation_roots("mcp", args)?; + let args = args.as_slice(); validate_options("mcp", args, MCP_OPTIONS)?; let compact = args.iter().any(|a| a == "--compact"); - let mut server = pdfcraft_automation::mcp::McpServer::new(automation(args)?).with_compact(compact); + let mut auto = automation(args)?; + if let Some(roots) = roots { + let show = |root: Option<&Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string()); + let _ = writeln!( + std::io::stderr().lock(), + "pdfcraft-cli: files confined: read root {}, write root {}", + show(roots.read_root()), + show(roots.write_root()) + ); + auto = auto.with_automation_roots(Some(roots)); + } + let mut server = pdfcraft_automation::mcp::McpServer::new(auto).with_compact(compact); let _ = writeln!( std::io::stderr().lock(), "pdfcraft-cli: MCP server on stdio (protocol {}{}); close stdin to stop", diff --git a/apps/pdfcraft-cli/tests/audit.rs b/apps/pdfcraft-cli/tests/audit.rs new file mode 100644 index 0000000000..eb99fa11d5 --- /dev/null +++ b/apps/pdfcraft-cli/tests/audit.rs @@ -0,0 +1,66 @@ +//! `audit` reports the rectangles of faux redactions where `doc_audit_redactions` does: in +//! points from the top-left of the page as displayed, after `/Rotate`. + +use std::process::Command; + +const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); + +/// Two US Letter pages with "TOP SECRET" under an opaque `90 688 220 32 re`; the second page is +/// turned a quarter (`/Rotate 90`). +fn fixture() -> Vec { + let body = "BT /F1 24 Tf 100 700 Td (TOP SECRET) Tj ET\n0 g\n90 688 220 32 re f"; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [4 0 R 5 0 R] /Count 2 /MediaBox [0 0 612 792] >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 3 0 R >> >> >>".into(), + "<< /Type /Page /Parent 2 0 R /Rotate 90 /Contents 6 0 R /Resources << /Font << /F1 3 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// The `rect` of each finding, in page order. +fn rects(report: &serde_json::Value) -> Vec { + report["findings"].as_array().expect("findings").iter().map(|f| f["rect"].clone()).collect() +} + +#[test] +fn audit_rectangles_match_the_tool_in_top_left_points() { + let dir = std::env::temp_dir().join(format!("pdfcraft-cli-audit-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("faux.pdf"), fixture()).unwrap(); + + let out = Command::new(BIN).arg("audit").arg(dir.join("faux.pdf")).output().unwrap(); + assert!(!out.status.success(), "findings must fail the audit"); + let cli: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(cli["count"], 2, "{cli}"); + // Page 1 upright: y measured down from the top of the 792 pt page. Page 2 turned a quarter + // clockwise: 792 pt wide, and the cover's user-space y becomes its x. + let expected = [serde_json::json!([90.0, 72.0, 310.0, 104.0]), serde_json::json!([688.0, 90.0, 720.0, 310.0])]; + assert_eq!(rects(&cli), expected, "{cli}"); + + // The tool reports the same rectangles. + let steps = serde_json::json!([ + { "tool": "doc_open", "args": { "path": "faux.pdf" } }, + { "tool": "doc_audit_redactions", "args": { "doc": 1 } }, + ]); + std::fs::write(dir.join("steps.json"), steps.to_string()).unwrap(); + let out = Command::new(BIN).arg("run").arg("--script").arg(dir.join("steps.json")).arg("--root").arg(&dir).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let stdout = String::from_utf8_lossy(&out.stdout); + let tool = serde_json::Deserializer::from_str(&stdout).into_iter::().last().unwrap().unwrap(); + assert_eq!(rects(&tool), rects(&cli), "{tool}"); +} diff --git a/apps/pdfcraft-cli/tests/automation_roots.rs b/apps/pdfcraft-cli/tests/automation_roots.rs new file mode 100644 index 0000000000..162d79bafd --- /dev/null +++ b/apps/pdfcraft-cli/tests/automation_roots.rs @@ -0,0 +1,123 @@ +//! `pdfcraft-cli mcp --automation-read-root DIR --automation-write-root DIR`, on the real binary: +//! both spellings of the flags confine the server, and a malformed flag stops it instead of +//! leaving it unconfined. +#![cfg(feature = "mcp")] + +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +use serde_json::{Value, json}; + +const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); + +/// `in/a.pdf` and `outside/secret.pdf`, blank one-page PDFs made by the binary itself. +fn folders(tag: &str) -> PathBuf { + let base = std::env::temp_dir().join(format!("pdfcraft-cli-roots-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for d in ["in", "out", "outside"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + for f in ["in/a.pdf", "outside/secret.pdf"] { + let script = json!([ + {"tool": "doc_create", "args": {"from": "blank", "pages": 1, "width": 100, "height": 100}}, + {"tool": "doc_save", "args": {"doc": 1, "path": f}}, + ]); + std::fs::write(base.join("steps.json"), script.to_string()).unwrap(); + let out = Command::new(BIN).args(["run", "--script"]).arg(base.join("steps.json")).arg("--root").arg(&base).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + } + base +} + +/// Run `mcp `, send `calls` as `tools/call` requests and return the replies. +fn session(flags: &[String], calls: &[(&str, Value)]) -> (Vec, String) { + let mut child = Command::new(BIN).arg("mcp").args(flags).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + let mut input = String::from("{\"jsonrpc\":\"2.0\",\"id\":0,\"method\":\"initialize\",\"params\":{}}\n"); + for (i, (name, args)) in calls.iter().enumerate() { + let r = json!({"jsonrpc": "2.0", "id": i + 1, "method": "tools/call", "params": {"name": name, "arguments": args}}); + input.push_str(&format!("{r}\n")); + } + child.stdin.take().unwrap().write_all(input.as_bytes()).unwrap(); + let out = child.wait_with_output().unwrap(); + assert!(out.status.success()); + let replies = String::from_utf8(out.stdout).unwrap().lines().skip(1).map(|l| serde_json::from_str(l).unwrap()).collect(); + (replies, String::from_utf8(out.stderr).unwrap()) +} + +fn s(p: &Path) -> String { + p.to_string_lossy().into_owned() +} + +fn flags(spelling: &str, base: &Path) -> Vec { + let (r, w) = (s(&base.join("in")), s(&base.join("out"))); + if spelling == "equals" { + vec![format!("--automation-read-root={r}"), format!("--automation-write-root={w}")] + } else { + vec!["--automation-read-root".into(), r, "--automation-write-root".into(), w] + } +} + +fn text(reply: &Value) -> String { + reply["result"]["content"][0]["text"].as_str().unwrap_or_default().to_owned() +} + +#[test] +fn both_spellings_confine_the_server() { + for spelling in ["equals", "separate"] { + let base = folders(spelling); + let calls = [ + ("doc_open", json!({"path": s(&base.join("in/a.pdf"))})), + ("doc_open", json!({"path": s(&base.join("outside/secret.pdf"))})), + ("doc_save", json!({"doc": 1, "path": s(&base.join("out/copy.pdf"))})), + ("doc_save", json!({"doc": 1, "path": s(&base.join("outside/copy.pdf"))})), + ]; + let (replies, stderr) = session(&flags(spelling, &base), &calls); + assert_eq!(replies[0]["result"]["isError"], false, "{spelling}: {replies:?}"); + assert!(text(&replies[1]).contains("automation path rejected: outside the read root"), "{spelling}: {replies:?}"); + assert_eq!(replies[2]["result"]["isError"], false, "{spelling}: {replies:?}"); + assert!(text(&replies[3]).contains("automation path rejected: outside the write root"), "{spelling}: {replies:?}"); + assert!(base.join("out/copy.pdf").is_file() && !base.join("outside/copy.pdf").exists()); + assert!(stderr.contains("files confined: read root") && stderr.contains("write root"), "{stderr}"); + } +} + +#[test] +fn a_root_left_out_grants_none_of_its_access() { + let base = folders("one"); + let read_only = vec!["--automation-read-root".to_string(), s(&base.join("in"))]; + let calls = [("doc_open", json!({"path": s(&base.join("in/a.pdf"))})), ("doc_save", json!({"doc": 1, "path": s(&base.join("out/x.pdf"))}))]; + let (replies, _) = session(&read_only, &calls); + assert_eq!(replies[0]["result"]["isError"], false); + assert!(text(&replies[1]).contains("write authority is absent"), "{replies:?}"); + assert!(!base.join("out/x.pdf").exists()); +} + +#[test] +fn no_flags_means_no_confinement() { + let base = folders("none"); + let (replies, stderr) = session(&[], &[("doc_open", json!({"path": s(&base.join("outside/secret.pdf"))}))]); + assert_eq!(replies[0]["result"]["isError"], false); + assert!(!stderr.contains("confined"), "{stderr}"); +} + +fn fails(args: &[&str]) -> String { + let out: Output = Command::new(BIN).arg("mcp").args(args).stdin(Stdio::null()).output().unwrap(); + assert_eq!(out.status.code(), Some(1), "{args:?} should fail"); + assert!(out.stdout.is_empty(), "{args:?} must not start a server"); + String::from_utf8(out.stderr).unwrap() +} + +#[test] +fn malformed_flags_are_errors_not_a_missing_root() { + let dir = s(&std::env::temp_dir()); + assert!(fails(&["--automation-read-root"]).contains("--automation-read-root needs a folder")); + assert!(fails(&["--automation-write-root", "--compact"]).contains("--automation-write-root needs a folder; use --automation-write-root=")); + assert!(fails(&["--automation-read-root="]).contains("--automation-read-root needs a non-empty folder")); + assert!(fails(&["--automation-read-root", &dir, "--automation-read-root", &dir]).contains("--automation-read-root given twice")); + assert!(fails(&["--automation-write-root", &dir, &format!("--automation-write-root={dir}")]).contains("--automation-write-root given twice")); + let missing = fails(&["--automation-read-root", "/definitely/not/a/folder"]); + assert!(missing.contains("mcp: cannot open the automation read root"), "{missing}"); + let file = std::env::current_exe().unwrap(); + assert!(fails(&["--automation-write-root", &s(&file)]).contains("cannot open the automation write root")); +} diff --git a/apps/pdfcraft-cli/tests/edit.rs b/apps/pdfcraft-cli/tests/edit.rs index c9a5e7e2b3..b35b71dddc 100644 --- a/apps/pdfcraft-cli/tests/edit.rs +++ b/apps/pdfcraft-cli/tests/edit.rs @@ -92,6 +92,20 @@ fn the_documented_options_still_edit_and_save() { } } +/// `--out` naming the input replaces it through a staging file (#2166): the result is the +/// edited, reopenable file, and nothing is left beside it. +#[test] +fn editing_in_place_leaves_a_valid_file_and_no_staging_file() { + let input = tmp("in-place", "doc.pdf"); + std::fs::write(&input, fixture()).unwrap(); + let out = Command::new(BIN).args(["edit", input.to_str().unwrap(), "--out", input.to_str().unwrap(), "--rotate", "1:90"]).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert_eq!(first_page_rotation(&input), 90); + let names: Vec = + std::fs::read_dir(input.parent().unwrap()).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + assert_eq!(names, ["doc.pdf"], "no staging file beside the result"); +} + mod one_based_arguments { use super::*; use std::sync::atomic::{AtomicU64, Ordering}; @@ -225,6 +239,102 @@ fn the_append_destination_and_counts_grown_by_earlier_edits_are_accepted() { } } +/// Two pages whose catalog points at a page tree object the file does not have (#1656). The +/// reader finds the pages by scanning, in file order, so `edit` must work on the same pages. +/// Object 3 is written before object 2, so file order and object numbers disagree. +fn fixture_without_page_tree() -> Vec { + let stream = |content: &str| format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes(); + let objs: Vec<(usize, Vec)> = vec![ + (1, b"<< /Type /Catalog /Pages 6 0 R >>".to_vec()), + (3, b"<< /Type /Page /Parent 6 0 R /MediaBox [0 0 200 300] /Contents 4 0 R >>".to_vec()), + (2, b"<< /Type /Page /Parent 6 0 R /MediaBox [0 0 300 200] /Contents 5 0 R >>".to_vec()), + (4, stream("0 0 m 10 10 l S")), + (5, stream("0 0 m 20 20 l S")), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = vec![0; objs.len()]; + for (num, o) in &objs { + offsets[num - 1] = out.len(); + out.extend_from_slice(format!("{num} 0 obj\n").as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + 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 +} + +#[test] +fn a_document_whose_page_tree_is_missing_edits_the_pages_the_reader_shows() { + let input = tmp("no-page-tree", "in.pdf"); + std::fs::write(&input, fixture_without_page_tree()).unwrap(); + let before = pdfcraft_render::inspect(std::sync::Arc::new(fixture_without_page_tree()), None).unwrap(); + assert_eq!(before.pages.iter().map(|p| (p.width, p.height)).collect::>(), [(200.0, 300.0), (300.0, 200.0)]); + for (name, args, expected) in [ + // Width and height are as displayed: the turned page reads 200 x 300. + ("rotate.pdf", ["--rotate", "2:90"], &[(200.0, 300.0, 0), (200.0, 300.0, 90)][..]), + ("delete.pdf", ["--delete", "1"], &[(300.0, 200.0, 0)][..]), + ] { + let output = tmp("no-page-tree", name); + let mut all = vec!["edit", input.to_str().unwrap(), "--out", output.to_str().unwrap()]; + all.extend(args); + let out = Command::new(BIN).args(&all).output().unwrap(); + assert!(out.status.success(), "{name}: {}", String::from_utf8_lossy(&out.stderr)); + let info = pdfcraft_render::inspect(std::sync::Arc::new(std::fs::read(&output).unwrap()), None).unwrap(); + let pages: Vec<_> = info.pages.iter().map(|p| (p.width, p.height, p.rotation)).collect(); + assert_eq!(pages, expected, "{name}"); + } +} + +/// Like [`fixture_without_page_tree`], but the scanning reader shows different pages from the +/// page objects: it skips the blank 200 x 200 page (no `/Contents`) and shows the Text +/// annotation (which has `/Contents`) as a page. No page tree may be rebuilt from such a file. +fn fixture_whose_scan_disagrees() -> Vec { + let stream = |content: &str| format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes(); + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 9 0 R >>".to_vec(), + b"<< /Type /Page /Parent 9 0 R /MediaBox [0 0 100 100] /Contents 5 0 R >>".to_vec(), + b"<< /Type /Page /Parent 9 0 R /MediaBox [0 0 200 200] >>".to_vec(), + b"<< /Type /Page /Parent 9 0 R /MediaBox [0 0 300 300] /Contents 6 0 R /Annots [7 0 R] >>".to_vec(), + stream("0 0 m 10 10 l S"), + stream("0 0 m 20 20 l S"), + b"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (hello) >>".to_vec(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + 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 +} + +#[test] +fn a_page_tree_is_not_rebuilt_when_the_reader_shows_other_pages() { + let input = tmp("scan-disagrees", "in.pdf"); + std::fs::write(&input, fixture_whose_scan_disagrees()).unwrap(); + let shown = pdfcraft_render::inspect(std::sync::Arc::new(fixture_whose_scan_disagrees()), None).unwrap(); + assert_eq!(shown.pages.len(), 3); + assert!(shown.pages.iter().all(|p| p.width != 200.0), "the reader must not show the blank page"); + let output = tmp("scan-disagrees", "out.pdf"); + let out = Command::new(BIN).args(["edit", input.to_str().unwrap(), "--out", output.to_str().unwrap(), "--delete", "2"]).output().unwrap(); + assert_eq!(out.status.code(), Some(1)); + assert_eq!(String::from_utf8_lossy(&out.stderr), "pdfcraft-cli: the document's page tree lists no pages: the file is damaged\n"); + assert!(!output.exists(), "no page may be deleted"); +} + /// A page past the end names its own option and the page count left by the earlier options (#1550). #[test] fn a_page_past_the_end_after_earlier_edits_names_that_option() { diff --git a/apps/pdfcraft-cli/tests/extract.rs b/apps/pdfcraft-cli/tests/extract.rs new file mode 100644 index 0000000000..25f1aabc29 --- /dev/null +++ b/apps/pdfcraft-cli/tests/extract.rs @@ -0,0 +1,129 @@ +//! `extract --pages`: single pages and ranges (#1923), and an out-of-range page named as the +//! user typed it (1-based; it was off by one in 0.7.1). + +use std::path::PathBuf; +use std::process::{Command, Output}; + +const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); + +/// A PDF with `n` empty 200×300 pt pages. +fn fixture(n: usize) -> Vec { + let mut objs: Vec> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()]; + let kids: Vec = (0..n).map(|i| format!("{} 0 R", 3 + i)).collect(); + objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" ")).into_bytes()); + for _ in 0..n { + objs.push(b"<< /Type /Page /Parent 2 0 R >>".to_vec()); + } + let mut out = b"%PDF-1.7 +" + .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 +", + i + 1 + ) + .as_bytes(), + ); + out.extend_from_slice(o); + out.extend_from_slice( + b" +endobj +", + ); + } + let xref = out.len(); + out.extend_from_slice( + format!( + "xref +0 {} +0000000000 65535 f +", + objs.len() + 1 + ) + .as_bytes(), + ); + for o in offsets { + out.extend_from_slice( + format!( + "{o:010} 00000 n +" + ) + .as_bytes(), + ); + } + out.extend_from_slice( + format!( + "trailer +<< /Size {} /Root 1 0 R >> +startxref +{xref} +%%EOF +", + objs.len() + 1 + ) + .as_bytes(), + ); + out +} + +/// A fresh directory holding `five.pdf` (5 pages). +fn workdir(test: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("pdfcraft-cli-extract-{test}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + std::fs::write(d.join("five.pdf"), fixture(5)).unwrap(); + d +} + +fn extract(dir: &std::path::Path, pages: &str) -> Output { + let out = dir.join(format!("out-{}.pdf", pages.replace(['-', ','], "_"))); + Command::new(BIN).args(["extract", dir.join("five.pdf").to_str().unwrap(), "--pages", pages, "--out", out.to_str().unwrap()]).output().unwrap() +} + +/// The page count `info` reports for the extracted file. +fn pages_of(dir: &std::path::Path, pages: &str) -> usize { + let out = dir.join(format!("out-{}.pdf", pages.replace(['-', ','], "_"))); + let info = Command::new(BIN).args(["info", out.to_str().unwrap()]).output().unwrap(); + let text = String::from_utf8_lossy(&info.stdout); + let n = text.split("\"pages\":").nth(1).and_then(|s| s.trim().split(|c: char| !c.is_ascii_digit()).next()).unwrap_or(""); + n.parse().unwrap_or_else(|_| panic!("no page count in {text}")) +} + +#[test] +fn pages_and_ranges_are_accepted_in_the_order_given() { + let dir = workdir("ranges"); + for (pages, count) in [("1-3", 3), ("5,1-2", 3), ("2-2", 1), ("1,3,5", 3), (" 4 - 5 , 1 ", 3)] { + let out = extract(&dir, pages); + assert!(out.status.success(), "{pages:?}: {}", String::from_utf8_lossy(&out.stderr)); + assert_eq!(pages_of(&dir, pages), count, "{pages:?}"); + } +} + +#[test] +fn bad_pages_and_ranges_are_named_as_typed() { + let dir = workdir("bad"); + for (pages, what) in [ + ("3-1", "bad page range \"3-1\""), + ("0", "bad page number \"0\""), + ("1-x", "bad page number \"1-x\""), + ("2-", "bad page number \"2-\""), + ("1-9999999", "bad page range \"1-9999999\""), + ] { + let out = extract(&dir, pages); + assert!(!out.status.success(), "{pages:?} succeeded"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains(what), "{pages:?}: {stderr}"); + } + // A page past the end is named as the user counts it (1-based), as a single page and as the + // first page a range reaches past the end. + for (pages, missing) in [("6", "page 6 does not exist"), ("9", "page 9 does not exist"), ("4-7", "page 6 does not exist")] { + let out = extract(&dir, pages); + assert!(!out.status.success(), "{pages:?} succeeded"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains(missing), "{pages:?}: {stderr}"); + } +} diff --git a/apps/pdfcraft-cli/tests/run_report.rs b/apps/pdfcraft-cli/tests/run_report.rs new file mode 100644 index 0000000000..cf1e6b650d --- /dev/null +++ b/apps/pdfcraft-cli/tests/run_report.rs @@ -0,0 +1,71 @@ +//! `pdfcraft-cli run --script --report`: one JSON document for the whole run (#1891). + +use std::process::{Command, Output}; + +fn run_script(test: &str, steps: &serde_json::Value, extra: &[&str]) -> Output { + let dir = std::env::temp_dir().join(format!("pdfcraft-cli-{test}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let script = dir.join("steps.json"); + std::fs::write(&script, steps.to_string()).unwrap(); + let out = + Command::new(env!("CARGO_BIN_EXE_pdfcraft-cli")).current_dir(&dir).arg("run").arg("--script").arg(&script).args(extra).output().unwrap(); + let _ = std::fs::remove_dir_all(&dir); + out +} + +fn blank() -> serde_json::Value { + serde_json::json!({ "tool": "doc_create", "args": { "from": "blank", "width": 100, "height": 100 } }) +} + +#[test] +fn a_successful_run_is_one_document_with_a_result_per_step() { + let steps = serde_json::json!([blank(), { "tool": "page_render", "args": { "doc": 1, "page": 1, "dpi": 10 } }]); + let o = run_script("report-ok", &steps, &["--report"]); + assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); + let report: serde_json::Value = serde_json::from_slice(&o.stdout).expect("stdout is one JSON document"); + assert_eq!(report["ok"], true); + let steps = report["steps"].as_array().unwrap(); + assert_eq!(steps.len(), 2); + assert_eq!((steps[0]["step"].clone(), steps[0]["tool"].clone(), steps[0]["status"].clone()), (1.into(), "doc_create".into(), "ok".into())); + assert_eq!(steps[1]["result"][0]["image"], "png", "an image with no `out` is summarised: {report}"); +} + +#[test] +fn a_failing_step_is_named_and_the_rest_are_skipped() { + let steps = serde_json::json!([blank(), { "tool": "page_rotate", "args": { "doc": 1, "pages": [999], "degrees": 90 } }, { "tool": "doc_close", "args": { "doc": 1 } }]); + let o = run_script("report-fail", &steps, &["--report"]); + assert_eq!(o.status.code(), Some(1), "exit status is unchanged"); + let stderr = String::from_utf8_lossy(&o.stderr); + assert!(stderr.contains("step 2 (page_rotate)"), "{stderr}"); + let report: serde_json::Value = serde_json::from_slice(&o.stdout).expect("stdout is one JSON document"); + assert_eq!(report["ok"], false); + let steps = report["steps"].as_array().unwrap(); + assert_eq!(steps.iter().map(|s| s["status"].as_str().unwrap()).collect::>(), ["ok", "error", "skipped"]); + assert!(steps[1]["error"].as_str().unwrap().contains("step 2 (page_rotate)"), "{report}"); + assert_eq!(steps[2]["tool"], "doc_close"); +} + +#[test] +fn a_step_without_a_tool_is_an_error_step() { + let o = run_script("report-notool", &serde_json::json!([{ "args": {} }]), &["--report"]); + assert_eq!(o.status.code(), Some(1)); + let report: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap(); + assert_eq!(report["steps"][0]["status"], "error"); + assert!(report["steps"][0]["tool"].is_null()); +} + +#[test] +fn without_the_flag_the_output_is_unchanged() { + let o = run_script("report-off", &serde_json::json!([blank()]), &[]); + assert!(o.status.success()); + let stdout = String::from_utf8_lossy(&o.stdout); + assert!(!stdout.contains("\"steps\""), "{stdout}"); +} + +#[test] +fn the_flag_needs_a_script() { + let o = Command::new(env!("CARGO_BIN_EXE_pdfcraft-cli")).args(["run", "doc_create", "--report"]).output().unwrap(); + assert_eq!(o.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&o.stderr).contains("--report needs --script")); +} diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index 4a185d1f92..a27401c700 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -29,6 +29,9 @@ log = { workspace = true } # crate registers the handler (Objective-C interop needs unsafe, which our workspace forbids) # behind a safe API. Same version as PhotoCraft. Pinned exactly: review the diff before bumping. fmv-macos-events = "=0.1.0" +# ⌘Q asks about unsaved changes like the close button (#1136): an app menu whose Quit asks the +# window to close instead of winit's, which ends the process without asking. +craft-quit = { workspace = true, features = ["menu"] } [target.'cfg(target_os = "linux")'.dependencies] # Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback), so the window is @@ -36,10 +39,19 @@ fmv-macos-events = "=0.1.0" wayland-client = "0.31" wayland-protocols = { version = "0.32", features = ["client"] } +[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] +# Whether the window is on Wayland, where it can't bring itself forward (src/single_instance.rs). +# Already in the tree through winit and eframe. +raw-window-handle = "0.6" + [dev-dependencies] # Waits for wgpu's adapter, device and error-scope futures in tests (already in the tree through wgpu). pollster = "1.0" +[target.'cfg(target_os = "linux")'.dev-dependencies] +# Exercise the vendored Wayland scroll policy with the actual winit event type. +winit = { version = "0.30", default-features = false, features = ["wayland"] } + [target.'cfg(target_os = "windows")'.dependencies] # Which GPU drives which display (EnumDisplayDevices), so the window is drawn on the GPU that # shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it). diff --git a/apps/pdfcraft/src/logging.rs b/apps/pdfcraft/src/logging.rs index a62d300f6b..71ed1d2d2f 100644 --- a/apps/pdfcraft/src/logging.rs +++ b/apps/pdfcraft/src/logging.rs @@ -3,7 +3,7 @@ //! Without a logger every `log::warn!`/`log::error!` in the workspace (a failed autosave, a page //! that would not render, a render worker that could not start, the panic hook's report) vanished. //! A launch from a desktop menu has no terminal, so the file is what a bug report can attach: -//! `/logs/pdfcraft.log`, next to `app.ron` (see `settings_dir` in `main.rs`). Each +//! `/logs/pdfcraft.log` (the PdfCraft eframe storage directory, or portable data). Each //! start moves the previous log to `pdfcraft.1.log` (and that one to `.2`), so the log of a run //! that crashed survives the next launch. //! diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index a1d2ad998d..7a0bb20432 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -37,6 +37,7 @@ mod logging; #[cfg(target_os = "windows")] mod motion; mod reopen; +mod settings; mod single_instance; mod text_scale; mod updates; @@ -57,14 +58,24 @@ const APP_ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256 /// The app was called PrintCraft before; settings saved then are under this key. const LEGACY_STORAGE_KEY: &str = "printcraft"; -/// The settings folder: `app.ron` and the `logs` folder (docs/development.md). In portable mode -/// it is `PdfCraftData` beside the executable (#157). eframe would otherwise derive it from the -/// app id; keep it under "PdfCraft". +/// The preferences folder (`app.ron`, docs/development.md). In portable mode +/// it is `PdfCraftData` beside the executable (#157). XDG desktops use the configuration base; +/// other platforms retain eframe's PdfCraft folder. Logs and recovery stay in the data folder. fn settings_dir() -> Option { - if let Some(dir) = pdfcraft_ui_egui::portable::data_dir() { - return Some(dir.to_path_buf()); + let legacy = eframe::storage_dir("PdfCraft"); + match settings::directory( + cfg!(all(unix, not(target_os = "macos"))), + pdfcraft_ui_egui::portable::data_dir(), + legacy.clone(), + std::env::var_os("XDG_CONFIG_HOME").map(std::path::PathBuf::from), + std::env::home_dir(), + ) { + Ok(dir) => dir, + Err(e) => { + log::warn!("could not migrate preferences to the XDG configuration folder; using the original location: {e}"); + legacy + } } - eframe::storage_dir("PdfCraft") } /// Move the settings and crash-recovery folders of the app's former name, PrintCraft, to the new @@ -75,7 +86,7 @@ fn migrate_legacy_folders() { if pdfcraft_ui_egui::portable::data_dir().is_some() { return; } - let mut moves = vec![(eframe::storage_dir("PrintCraft"), settings_dir())]; + let mut moves = vec![(eframe::storage_dir("PrintCraft"), eframe::storage_dir("PdfCraft"))]; // Recovery lives in the settings folder except on Windows, where it is under %LOCALAPPDATA%. if cfg!(windows) { let local = std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()).map(std::path::PathBuf::from); @@ -203,19 +214,22 @@ fn main() -> eframe::Result { // GNOME on Wayland leaves the window frame to the app (src/decorations.rs). let window_controls = if cfg!(any(target_os = "macos", target_os = "windows")) { None } else { decorations::app_drawn() }; migrate_legacy_folders(); - // The log file lives in the settings folder; opened after the arguments (so `--version` leaves + // The log file stays in the data folder; opened after the arguments (so `--version` leaves // no file behind) and after the PrintCraft migration (which a fresh folder would block). // Records logged until now are written to it first. - if let (Some(logger), Some(dir)) = (logger, settings_dir()) { + if let (Some(logger), Some(dir)) = + (logger, pdfcraft_ui_egui::portable::data_dir().map(std::path::Path::to_path_buf).or_else(|| eframe::storage_dir("PdfCraft"))) + { match logger.attach_dir(&dir.join("logs")) { Ok(path) => log::info!("PdfCraft {}, log file {}", pdfcraft_engine::build_info::version(), path.display()), // Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics. Err(e) => log::warn!("no log file: {e}"), } } - // Windows: a launch that only names files (an Outlook attachment, an Explorer double-click) - // hands them to the PdfCraft already running and exits (#282, #317). Any option means the - // caller wants this launch's own window. + // Windows, Linux and the BSDs: a launch that only names files (an Outlook attachment, an + // Explorer double-click, a file manager or `xdg-open`) hands them to the PdfCraft already + // running and exits (#282, #317, #1726). Any option means the caller wants this launch's own + // window. let may_hand_off = !new_window && !files.is_empty() && options.is_empty() && control_file.is_none() && !create_images; let instance = match single_instance::claim(&single_instance::absolute(&files), may_hand_off) { single_instance::Claim::HandedOff => return Ok(()), @@ -332,6 +346,18 @@ struct Launch { window_controls: Option, } +/// Whether the window is a native Wayland one (not X11 or XWayland). +#[cfg(all(unix, not(target_os = "macos")))] +fn native_wayland(cc: &eframe::CreationContext<'_>) -> bool { + use raw_window_handle::{HasDisplayHandle, RawDisplayHandle}; + cc.display_handle().is_ok_and(|handle| matches!(handle.as_raw(), RawDisplayHandle::Wayland(_))) +} + +#[cfg(not(all(unix, not(target_os = "macos"))))] +fn native_wayland(_: &eframe::CreationContext<'_>) -> bool { + false +} + fn app_creator<'a>( launch: Launch, started: Rc>, @@ -366,9 +392,14 @@ fn app_creator<'a>( #[cfg(target_os = "macos")] { app.os_events = Some(apple_events.connect(&cc.egui_ctx)); + // winit's Quit (⌘Q) ends the process without asking about unsaved changes (#1136). This + // menu's Quit closes the window instead, so `guard_quit` asks, as for the close button. + if let Err(e) = craft_quit::install_app_menu(&cc.egui_ctx, "PdfCraft") { + log::warn!("couldn't install the app menu, so ⌘Q quits without asking to save: {e}"); + } } if let Some(server) = instance { - app.os_events = Some(server.connect(&cc.egui_ctx)); + app.os_events = Some(server.connect(&cc.egui_ctx, native_wayland(cc))); } if let Some(file) = &control_file { let client = app.attach_control(&cc.egui_ctx); diff --git a/apps/pdfcraft/src/settings.rs b/apps/pdfcraft/src/settings.rs new file mode 100644 index 0000000000..950566a2e0 --- /dev/null +++ b/apps/pdfcraft/src/settings.rs @@ -0,0 +1,194 @@ +//! Platform preference location, separate from logs and crash recovery. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static STAGING_ID: AtomicU64 = AtomicU64::new(0); + +pub fn directory( + xdg: bool, + portable: Option<&Path>, + legacy: Option, + config_home: Option, + home: Option, +) -> std::io::Result> { + if let Some(portable) = portable { + return Ok(Some(portable.to_path_buf())); + } + if !xdg { + return Ok(legacy); + } + // XDG requires absolute environment paths; empty and relative overrides are ignored. + let preferred = config_home.filter(|p| p.is_absolute()).or_else(|| home.filter(|p| p.is_absolute()).map(|p| p.join(".config"))); + let Some(preferred) = preferred.map(|p| p.join("pdfcraft")) else { return Ok(legacy) }; + let target = preferred.join("app.ron"); + if std::fs::symlink_metadata(&target).is_ok() { + return Ok(Some(preferred)); + } + create_private_directory(&preferred)?; + if let Some(source) = legacy.map(|p| p.join("app.ron")) { + match std::fs::File::open(&source) { + Ok(mut source) => migrate(&mut source, &preferred, &target)?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e), + } + } + Ok(Some(preferred)) +} + +/// Create private configuration directories without changing existing directory permissions. +fn create_private_directory(directory: &Path) -> std::io::Result<()> { + let mut builder = std::fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder.create(directory) +} + +/// Publish a complete copy without replacing a concurrent launch's preferences. Keep the +/// original file and all other data (especially recovery and digital IDs) where they were. +fn migrate(source: &mut std::fs::File, directory: &Path, target: &Path) -> std::io::Result<()> { + create_private_directory(directory)?; + for _ in 0..32 { + let id = STAGING_ID.fetch_add(1, Ordering::Relaxed); + let staging = directory.join(format!(".app.ron-migrate-{}-{id}", std::process::id())); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = match options.open(&staging) { + Ok(file) => file, + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + }; + let result = (|| { + std::io::copy(source, &mut file)?; + file.sync_all()?; + // Both files are in the same directory/filesystem. Unlike rename, hard_link + // atomically refuses an existing destination on Unix as well as Windows. + match std::fs::hard_link(&staging, target) { + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), + result => result, + } + })(); + drop(file); + let _ = std::fs::remove_file(&staging); + return result; + } + Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "no free settings migration staging name")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn xdg_preferences_use_config_instead_of_data() { + let fixture = Fixture::new(); + let home = fixture.0.join("home"); + let data = home.join(".local/share/pdfcraft"); + assert_eq!(directory(true, None, Some(data), None, Some(home.clone())).unwrap(), Some(home.join(".config/pdfcraft"))); + } + + struct Fixture(PathBuf); + impl Fixture { + fn new() -> Self { + let id = STAGING_ID.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("pdfcraft-settings-{}-{id}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + Self(dir) + } + } + impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn xdg_override_and_invalid_overrides() { + let f = Fixture::new(); + let home = f.0.join("home"); + let config = f.0.join("custom-config"); + assert_eq!(directory(true, None, None, Some(config.clone()), Some(home.clone())).unwrap(), Some(config.join("pdfcraft"))); + for invalid in [PathBuf::new(), PathBuf::from("relative")] { + assert_eq!(directory(true, None, None, Some(invalid), Some(home.clone())).unwrap(), Some(home.join(".config/pdfcraft"))); + } + assert_eq!(directory(true, None, Some(f.0.clone()), None, None).unwrap(), Some(f.0.clone())); + } + + #[test] + fn migration_preserves_preferences_and_other_data() { + let f = Fixture::new(); + let data = f.0.join("data/pdfcraft"); + let config = f.0.join("config"); + std::fs::create_dir_all(data.join("recovery")).unwrap(); + std::fs::write(data.join("app.ron"), b"(theme:Dark,language:German)").unwrap(); + std::fs::write(data.join("recovery/unsaved.pdf"), b"UNSAVED").unwrap(); + std::fs::write(data.join("digital-id.p12"), b"KEY").unwrap(); + let selected = directory(true, None, Some(data.clone()), Some(config.clone()), None).unwrap().unwrap(); + assert_eq!(selected, config.join("pdfcraft")); + assert_eq!(std::fs::read(selected.join("app.ron")).unwrap(), std::fs::read(data.join("app.ron")).unwrap()); + assert_eq!(std::fs::read(data.join("recovery/unsaved.pdf")).unwrap(), b"UNSAVED"); + assert_eq!(std::fs::read(data.join("digital-id.p12")).unwrap(), b"KEY"); + // The migrated file is a copy, not a link to the original settings. + std::fs::write(selected.join("app.ron"), b"NEW").unwrap(); + assert_eq!(std::fs::read(data.join("app.ron")).unwrap(), b"(theme:Dark,language:German)"); + assert_eq!(directory(true, None, Some(data), Some(config), None).unwrap(), Some(selected.clone())); + assert_eq!(std::fs::read(selected.join("app.ron")).unwrap(), b"NEW"); + assert_eq!(std::fs::read_dir(selected).unwrap().count(), 1); + } + + #[test] + fn existing_or_concurrently_created_preferences_are_never_replaced() { + let f = Fixture::new(); + let source = f.0.join("old.ron"); + let config = f.0.join("config/pdfcraft"); + std::fs::write(&source, b"OLD").unwrap(); + std::fs::create_dir_all(&config).unwrap(); + let target = config.join("app.ron"); + std::fs::write(&target, b"CURRENT").unwrap(); + migrate(&mut std::fs::File::open(source).unwrap(), &config, &target).unwrap(); + assert_eq!(std::fs::read(&target).unwrap(), b"CURRENT"); + assert_eq!(std::fs::read_dir(config).unwrap().count(), 1); + } + + #[test] + fn failed_migration_keeps_original_available() { + let f = Fixture::new(); + std::fs::write(f.0.join("app.ron"), b"OLD").unwrap(); + let blocked = f.0.join("blocked"); + std::fs::write(&blocked, b"NOT A DIRECTORY").unwrap(); + assert!(directory(true, None, Some(f.0.clone()), Some(blocked), None).is_err()); + assert_eq!(std::fs::read(f.0.join("app.ron")).unwrap(), b"OLD"); + } + + #[test] + fn portable_and_non_xdg_platforms_keep_their_locations() { + let f = Fixture::new(); + let portable = f.0.join("PdfCraftData"); + for xdg in [true, false] { + assert_eq!(directory(xdg, Some(&portable), Some(f.0.clone()), Some(f.0.clone()), None).unwrap(), Some(portable.clone())); + } + assert_eq!(directory(false, None, Some(f.0.clone()), Some(portable), None).unwrap(), Some(f.0.clone())); + assert_eq!(std::fs::read_dir(&f.0).unwrap().count(), 0, "no migration outside XDG mode"); + } + + #[cfg(unix)] + #[test] + fn new_config_directory_is_private_and_existing_permissions_are_preserved() { + use std::os::unix::fs::PermissionsExt; + let f = Fixture::new(); + let selected = directory(true, None, None, Some(f.0.join("config")), None).unwrap().unwrap(); + assert_eq!(std::fs::metadata(&selected).unwrap().permissions().mode() & 0o777, 0o700); + std::fs::set_permissions(&selected, std::fs::Permissions::from_mode(0o750)).unwrap(); + create_private_directory(&selected).unwrap(); + assert_eq!(std::fs::metadata(selected).unwrap().permissions().mode() & 0o777, 0o750); + } +} diff --git a/apps/pdfcraft/src/single_instance.rs b/apps/pdfcraft/src/single_instance.rs index 9d15f5b26a..ecfa12f5c3 100644 --- a/apps/pdfcraft/src/single_instance.rs +++ b/apps/pdfcraft/src/single_instance.rs @@ -1,41 +1,50 @@ -//! One PdfCraft per Windows sign-in: a launch that only names files hands them to the PdfCraft -//! that is already running, where they open as tabs, and exits (#282, #317). +//! One PdfCraft per Windows sign-in or Linux/BSD display: a launch that only names files hands them +//! to the PdfCraft that is already running, where they open as tabs, and exits (#282, #317, #1726). //! -//! Outlook attachments, Explorer double-clicks and Open With all start `pdfcraft.exe ""`, so -//! until now every file got its own process and window. The first PdfCraft listens on a Unix -//! domain socket (Windows 10 1803 and later) in `%LOCALAPPDATA%\PdfCraft`, or in the data folder of -//! a portable copy. The socket is named after the Windows session (`instance-Console.sock`), so -//! two sign-ins of the same user never open files in each other's windows, and it lives in the -//! user's own profile, so other accounts can't create, replace or reach it: nobody else can -//! intercept a launch or open files in this user's window. The exchange is one line each way, a -//! JSON request naming absolute paths and `ok`, and the running app opens the paths exactly as -//! File ▸ Open would. +//! Outlook attachments, Explorer double-clicks and Open With all start `pdfcraft.exe ""`, and +//! a file manager or `xdg-open` starts `pdfcraft ` the same way, so until now every file got +//! its own process and window. The first PdfCraft listens on a Unix domain socket. On Windows (10 +//! 1803 and later) it is in `%LOCALAPPDATA%\PdfCraft`, or in the data folder of a portable copy, +//! and is named after the Windows session (`instance-Console.sock`), so two sign-ins of the same +//! user never open files in each other's windows. On Linux and the BSDs it is in +//! `$XDG_RUNTIME_DIR/pdfcraft` and is named after the display (`instance-wayland-0.sock`), so a +//! launch on another display or seat opens its own window there. Either way it lives in a folder +//! only the user can reach, so other accounts can't create, replace or reach it: nobody else can +//! intercept a launch or open files in this user's window. Inside a Flatpak, `$XDG_RUNTIME_DIR` is +//! the app's own and is shared by every run of it, so the handoff works there too. The exchange is +//! one line each way, a JSON request naming absolute paths and `ok`, and the running app opens the +//! paths exactly as File ▸ Open would. //! //! Every failure falls back to the old behaviour: a launch that can't hand its files over within //! a few seconds opens its own window. `--new-window`, any other option, or no files at all also -//! starts a window of its own, as before. Other platforms keep their current behaviour (macOS -//! already routes Finder opens to the running app through Apple events). +//! starts a window of its own, as before. macOS keeps its current behaviour (it already routes +//! Finder opens to the running app through Apple events). //! -//! The socket comes from `uds_windows`, already in the lockfile through zbus: `std` has no Unix -//! sockets on Windows, and calling Winsock directly would need `unsafe`, which the workspace -//! forbids. +//! On Windows the socket comes from `uds_windows`, already in the lockfile through zbus: `std` has +//! no Unix sockets on Windows, and calling Winsock directly would need `unsafe`, which the +//! workspace forbids. use std::sync::{Arc, Mutex, PoisonError}; -#[cfg(windows)] +#[cfg(any(windows, all(unix, not(target_os = "macos"))))] use std::time::Duration; +#[cfg(all(unix, not(target_os = "macos")))] +use std::os::unix::net::{UnixListener, UnixStream}; +#[cfg(windows)] +use uds_windows::{UnixListener, UnixStream}; + use pdfcraft_ui_egui::{OsEvent, OsEventsFn}; /// Longest request read: one line of JSON, far longer than any real list of paths. -#[cfg_attr(not(windows), allow(dead_code))] +#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))] const MAX_REQUEST: u64 = 1 << 20; /// How long either side waits for the other before giving up. -#[cfg(windows)] +#[cfg(any(windows, all(unix, not(target_os = "macos"))))] const TIMEOUT: Duration = Duration::from_secs(5); /// What this launch should do. -#[cfg_attr(not(windows), allow(dead_code))] +#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))] pub enum Claim { /// The running PdfCraft took the files: exit without a window. HandedOff, @@ -59,13 +68,13 @@ pub struct Server { } impl Server { - #[cfg_attr(not(windows), allow(dead_code))] + #[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))] fn new() -> Self { Self { inbox: Arc::default() } } /// Queue files from another launch and wake the UI. - #[cfg_attr(not(windows), allow(dead_code))] + #[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))] fn deliver(&self, paths: Vec) { let mut inbox = self.inbox.lock().unwrap_or_else(PoisonError::into_inner); inbox.paths.extend(paths); @@ -76,8 +85,10 @@ impl Server { /// The queue the UI drains every frame (`PdfCraftApp::os_events`). Files that arrived before /// the window existed are in the first batch. A batch also brings the window forward, as the - /// new window it replaces would have been. - pub fn connect(&self, ctx: &egui::Context) -> OsEventsFn { + /// new window it replaces would have been. A Wayland window can't bring itself forward (winit + /// ignores `Focus` there), so on `native_wayland` it asks for the user's attention instead, + /// which desktops show by highlighting it in the taskbar. + pub fn connect(&self, ctx: &egui::Context, native_wayland: bool) -> OsEventsFn { let wake = ctx.clone(); self.inbox.lock().unwrap_or_else(PoisonError::into_inner).wake = Some(Box::new(move || wake.request_repaint())); let inbox = Arc::clone(&self.inbox); @@ -89,13 +100,16 @@ impl Server { } ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + if native_wayland { + ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Informational)); + } vec![OsEvent::Open(paths)] }) } } /// The request a launch sends: `{"open":[…]}` and a newline. -#[cfg_attr(not(windows), allow(dead_code))] +#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))] pub fn request(paths: &[String]) -> String { let mut line = serde_json::json!({ "open": paths }).to_string(); line.push('\n'); @@ -104,7 +118,7 @@ pub fn request(paths: &[String]) -> String { /// The paths a request names, or `None` when it isn't a request: anything but one JSON object /// whose `open` is a non-empty list of non-empty strings. -#[cfg_attr(not(windows), allow(dead_code))] +#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))] pub fn parse_request(line: &str) -> Option> { let value: serde_json::Value = serde_json::from_str(line.trim_end()).ok()?; let open = value.get("open")?.as_array()?; @@ -121,14 +135,14 @@ pub fn absolute(paths: &[String]) -> Vec { /// Decide what this launch does. `files` are absolute ([`absolute`]); `may_hand_off` is false /// when the launch asked for anything besides opening files, so it gets its own window. pub fn claim(files: &[String], may_hand_off: bool) -> Claim { - #[cfg(windows)] + #[cfg(any(windows, all(unix, not(target_os = "macos"))))] { match socket_path() { Some(path) => claim_at(&path, files, may_hand_off), None => Claim::Alone, } } - #[cfg(not(windows))] + #[cfg(not(any(windows, all(unix, not(target_os = "macos")))))] { let _ = (files, may_hand_off); Claim::Alone @@ -148,6 +162,28 @@ fn socket_path() -> Option { std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()).map(|d| std::path::PathBuf::from(d).join("PdfCraft").join(name)) } +/// The socket in `$XDG_RUNTIME_DIR/pdfcraft`; none when `$XDG_RUNTIME_DIR` is unset. Never `/tmp` +/// instead: other accounts can create files there, and so could listen in this user's place. +#[cfg(all(unix, not(target_os = "macos")))] +fn socket_path() -> Option { + let var = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty()); + let name = display_socket_name(var("WAYLAND_DISPLAY").or_else(|| var("DISPLAY")).as_deref()); + // The XDG base directory spec makes it the user's own (mode 0700), so the folder inside it is + // out of other accounts' reach whatever the umask. + var("XDG_RUNTIME_DIR").map(|d| std::path::PathBuf::from(d).join("pdfcraft").join(name)) +} + +/// The socket's file name for a display (`WAYLAND_DISPLAY`, else `DISPLAY`): a launch opens its +/// files on the display it was started on, never in a window on another seat, a nested +/// compositor or a remote X display. Letters, digits, `-`, `_` and `.` are kept, and of those the +/// last 32, where an absolute `WAYLAND_DISPLAY` (`/run/user/1000/wayland-1`) differs. +#[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))] +fn display_socket_name(display: Option<&str>) -> String { + let kept: Vec = display.unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).collect(); + let display: String = kept.get(kept.len().saturating_sub(32)..).unwrap_or_default().iter().collect(); + if display.is_empty() { "instance.sock".to_string() } else { format!("instance-{display}.sock") } +} + /// The socket's file name for a Windows session (`SESSIONNAME`: `Console`, `RDP-Tcp#3`…). The /// same user signed in twice (at the console and over Remote Desktop) gets one socket per session, /// so a file never opens in a window on the other screen. Only letters, digits and `-` are kept. @@ -161,9 +197,9 @@ fn socket_name(session: Option<&str>) -> String { /// files selected in Explorer and opened at once) take turns: the first one is listening before /// the next one looks. Without it a launch could find the socket bound but not yet listening, /// fail to clear it ("Access is denied") and open a window of its own. The lock is on a file -/// beside the socket, which is never deleted; Windows releases it when the process ends, however -/// it ends. Best effort: without the lock the launch goes on as before. -#[cfg(windows)] +/// beside the socket, which is never deleted; the system releases it when the process ends, +/// however it ends. Best effort: without the lock the launch goes on as before. +#[cfg(any(windows, all(unix, not(target_os = "macos"))))] fn election(socket: &std::path::Path) -> Option { if let Some(dir) = socket.parent() { let _ = std::fs::create_dir_all(dir); @@ -183,9 +219,8 @@ fn election(socket: &std::path::Path) -> Option { } /// [`claim`] with the socket at `path`. -#[cfg(windows)] +#[cfg(any(windows, all(unix, not(target_os = "macos"))))] fn claim_at(path: &std::path::Path, files: &[String], may_hand_off: bool) -> Claim { - use uds_windows::{UnixListener, UnixStream}; // Released when this function returns: by then this launch listens, or has handed over. let _election = election(path); // Two launches can start together: the one that loses the race to bind hands over to the @@ -241,8 +276,8 @@ fn claim_at(path: &std::path::Path, files: &[String], may_hand_off: bool) -> Cla } /// Send `files` over `stream` and wait for the running app's `ok`. -#[cfg(windows)] -fn hand_off(stream: &uds_windows::UnixStream, files: &[String]) -> std::io::Result<()> { +#[cfg(any(windows, all(unix, not(target_os = "macos"))))] +fn hand_off(stream: &UnixStream, files: &[String]) -> std::io::Result<()> { use std::io::{BufRead, Read, Write}; stream.set_read_timeout(Some(TIMEOUT))?; stream.set_write_timeout(Some(TIMEOUT))?; @@ -254,8 +289,8 @@ fn hand_off(stream: &uds_windows::UnixStream, files: &[String]) -> std::io::Resu } /// Accept launches on a background thread for as long as the process runs. -#[cfg(windows)] -fn serve(listener: uds_windows::UnixListener) -> Claim { +#[cfg(any(windows, all(unix, not(target_os = "macos"))))] +fn serve(listener: UnixListener) -> Claim { let server = Server::new(); let inbox = Server { inbox: Arc::clone(&server.inbox) }; let started = std::thread::Builder::new().name("single-instance".into()).spawn(move || { @@ -278,8 +313,8 @@ fn serve(listener: uds_windows::UnixListener) -> Claim { } /// Read one launch's request, queue its files and confirm. -#[cfg(windows)] -fn receive(stream: &uds_windows::UnixStream, server: &Server) -> std::io::Result<()> { +#[cfg(any(windows, all(unix, not(target_os = "macos"))))] +fn receive(stream: &UnixStream, server: &Server) -> std::io::Result<()> { use std::io::{BufRead, Read, Write}; stream.set_read_timeout(Some(TIMEOUT))?; stream.set_write_timeout(Some(TIMEOUT))?; @@ -338,26 +373,40 @@ mod tests { // Before the window exists: queued, nothing to wake yet. server.deliver(vec!["a.pdf".into()]); let ctx = egui::Context::default(); - let mut poll = server.connect(&ctx); + let mut poll = server.connect(&ctx, false); server.deliver(vec!["b.pdf".into()]); let events = poll(); assert!(matches!(events.as_slice(), [OsEvent::Open(p)] if p == &["a.pdf", "b.pdf"])); assert!(poll().is_empty(), "each file is delivered once"); } - #[cfg(windows)] + #[test] + fn each_display_gets_its_own_socket_name() { + assert_eq!(display_socket_name(Some("wayland-0")), "instance-wayland-0.sock"); + assert_eq!(display_socket_name(Some(":0")), "instance-0.sock"); + assert_ne!(display_socket_name(Some(":1.0")), display_socket_name(Some(":10"))); + assert_ne!(display_socket_name(Some("wayland-0")), display_socket_name(Some("wayland-1"))); + // An absolute WAYLAND_DISPLAY keeps the end of its path, where nested compositors differ. + let nested = display_socket_name(Some("/home/someone/.cache/nested-compositor/run/wayland-1")); + assert!(nested.ends_with("wayland-1.sock") && !nested.contains('/'), "{nested}"); + assert_eq!(display_socket_name(Some("../../x/y")), "instance-....xy.sock", "never a path"); + assert_eq!(display_socket_name(None), "instance.sock"); + assert_eq!(display_socket_name(Some("")), "instance.sock"); + } + + #[cfg(any(windows, all(unix, not(target_os = "macos"))))] fn socket(test: &str) -> std::path::PathBuf { let dir = std::env::temp_dir().join(format!("pdfcraft-instance-{}-{test}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); dir.join(socket_name(None)) } - #[cfg(windows)] + #[cfg(any(windows, all(unix, not(target_os = "macos"))))] #[test] fn a_second_launch_hands_its_files_to_the_first() { let path = socket("hand-off"); let Claim::Primary(first) = claim_at(&path, &[], true) else { panic!("the first launch listens") }; - let mut poll = first.connect(&egui::Context::default()); + let mut poll = first.connect(&egui::Context::default(), false); assert!(matches!(claim_at(&path, &["C:\\a.pdf".into()], true), Claim::HandedOff)); assert!(matches!(poll().as_slice(), [OsEvent::Open(p)] if p == &["C:\\a.pdf"])); // A launch with options keeps its own window and doesn't take over the socket. @@ -365,10 +414,10 @@ mod tests { assert!(poll().is_empty()); } - #[cfg(windows)] + #[cfg(any(windows, all(unix, not(target_os = "macos"))))] #[test] fn launches_started_together_share_one_window() { - // Several files selected in Explorer and opened with Enter: one process per file, all at once. + // Several files selected in Explorer or a file manager and opened at once: one process per file. let path = socket("together"); let barrier = Arc::new(std::sync::Barrier::new(8)); let launches: Vec<_> = (0..8) @@ -386,12 +435,12 @@ mod tests { assert_eq!(claims.iter().filter(|c| matches!(c, Claim::HandedOff)).count(), 7, "the rest hand over"); } - #[cfg(windows)] + #[cfg(any(windows, all(unix, not(target_os = "macos"))))] #[test] fn a_socket_left_by_a_crashed_instance_is_replaced() { let path = socket("stale"); // Bind and drop: the file stays, nobody listens. - drop(uds_windows::UnixListener::bind({ + drop(UnixListener::bind({ std::fs::create_dir_all(path.parent().unwrap()).unwrap(); &path })); diff --git a/apps/pdfcraft/tests/winit_macos_title_bar_drag.rs b/apps/pdfcraft/tests/winit_macos_title_bar_drag.rs new file mode 100644 index 0000000000..5422af59b5 --- /dev/null +++ b/apps/pdfcraft/tests/winit_macos_title_bar_drag.rs @@ -0,0 +1,20 @@ +//! vendor/winit patch (12) (vendor/README.md). + +#[test] +fn winit_lets_macos_title_bar_drags_reach_the_app() { + // On macOS the tab strip is the title bar: a transparent title bar over a full-size content + // view. NSView answers YES to `mouseDownCanMoveWindow` for a view that isn't opaque, so AppKit + // moved the whole window for a drag that began on a document tab, and tabs could not be + // reordered. vendor/winit answers with the window's `movable_by_window_background` (NO unless + // asked); the empty strip still moves the window through `ViewportCommand::StartDrag`. A + // re-vendored copy without the patch would silently bring the bug back: re-apply the patch, or + // drop the copy once winit has the fix. + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let view = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/macos/view.rs")).unwrap(); + let (_, method) = view.split_once("#[method(mouseDownCanMoveWindow)]").expect("a title-bar drag moves the window again"); + let body = method.split_once("\n }\n").map_or(method, |(body, _)| body); + assert!(body.contains("movable_by_window_background"), "mouseDownCanMoveWindow no longer follows the window's setting"); + // The strip still moves the window itself, from its empty background. + let chrome = std::fs::read_to_string(root.join("crates/ui-egui/src/chrome.rs")).unwrap(); + assert!(chrome.contains("ViewportCommand::StartDrag"), "the empty tab strip no longer moves the window"); +} diff --git a/apps/pdfcraft/tests/winit_wayland_scroll.rs b/apps/pdfcraft/tests/winit_wayland_scroll.rs new file mode 100644 index 0000000000..ec76d2b4b9 --- /dev/null +++ b/apps/pdfcraft/tests/winit_wayland_scroll.rs @@ -0,0 +1,76 @@ +#![cfg(target_os = "linux")] + +use egui::{Event, Modifiers, MouseWheelUnit, RawInput, TouchPhase, vec2}; +use wayland_client::protocol::wl_pointer::AxisSource; + +// Compile the production policy against winit's real event type, without a compositor. +mod event { + pub use winit::event::TouchPhase; +} +#[path = "../../../vendor/winit/src/platform_impl/linux/wayland/seat/pointer/scroll.rs"] +mod scroll; + +fn egui_phase(phase: event::TouchPhase) -> TouchPhase { + match phase { + event::TouchPhase::Started => TouchPhase::Start, + event::TouchPhase::Moved => TouchPhase::Move, + event::TouchPhase::Ended => TouchPhase::End, + event::TouchPhase::Cancelled => TouchPhase::Cancel, + } +} + +fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> { + let ctx = egui::Context::default(); + let mut previous = event::TouchPhase::Ended; + let mut seen = Vec::new(); + let ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; + for (frame, modifiers) in [ctrl, ctrl, Modifiers::NONE].into_iter().enumerate() { + let phase = scroll::phase(previous, Some(source), discrete, false); + previous = phase; + let (unit, dy) = if discrete { (MouseWheelUnit::Line, 1.0) } else { (MouseWheelUnit::Point, 24.0) }; + let input = RawInput { + time: Some(frame as f64 / 60.0), + events: vec![Event::MouseWheel { unit, delta: vec2(0.0, dy), phase: egui_phase(phase), modifiers }], + ..Default::default() + }; + ctx.run_ui(input, |ui| seen.push(ui.input(|i| (i.zoom_delta(), i.smooth_scroll_delta)))).textures_delta.clear(); + } + seen +} + +#[test] +fn releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() { + for source in [AxisSource::Wheel, AxisSource::WheelTilt, AxisSource::Continuous] { + let seen = wheel_trace(source, false); + assert!(seen[1].0 > 1.0, "Ctrl must zoom: {source:?}"); + assert_eq!(seen[2].0, 1.0, "releasing Ctrl must stop zooming: {source:?}"); + assert!(seen[2].1.y > 0.0, "the next wheel event must scroll: {source:?}"); + } +} + +#[test] +fn discrete_wheels_still_follow_current_modifiers() { + let seen = wheel_trace(AxisSource::Wheel, true); + assert!(seen[1].0 > 1.0); + assert_eq!(seen[2].0, 1.0); + assert!(seen[2].1.y > 0.0); +} + +#[test] +fn finger_gestures_keep_their_modifier_until_the_gesture_ends() { + // A finger scroll's momentum may arrive after Ctrl is released. + let seen = wheel_trace(AxisSource::Finger, false); + assert!(seen[1].0 > 1.0); + assert!(seen[2].0 > 1.0); + assert_eq!(seen[2].1, egui::Vec2::ZERO); + assert_eq!(scroll::phase(event::TouchPhase::Moved, Some(AxisSource::Finger), false, true), event::TouchPhase::Ended); + assert_eq!(scroll::phase(event::TouchPhase::Ended, Some(AxisSource::Finger), false, false), event::TouchPhase::Started); +} + +#[test] +fn unknown_sources_keep_the_existing_phase_policy() { + assert_eq!(scroll::phase(event::TouchPhase::Ended, None, false, false), event::TouchPhase::Started); + assert_eq!(scroll::phase(event::TouchPhase::Started, None, false, false), event::TouchPhase::Moved); + assert_eq!(scroll::phase(event::TouchPhase::Ended, None, true, false), event::TouchPhase::Moved); + assert_eq!(scroll::phase(event::TouchPhase::Moved, None, false, true), event::TouchPhase::Ended); +} diff --git a/contributors/people.toml b/contributors/people.toml index aa8dd7a158..da3cfc6a77 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -25,6 +25,11 @@ real_name = "Brandon Thomas" display_name = "Brandon Thomas" sync_github_name = false +[people.edurbs] +real_name = "Eduardo Soares" +display_name = "Eduardo Soares" +sync_github_name = false + [people.jcl-bit] real_name = "James Locke" display_name = "James Locke" @@ -129,3 +134,14 @@ sync_github_name = false [people.webdevtodayjason] display_name = "Jason Brashear" + +[people.alexanderalber] +real_name = "Alexander Alber" + +[people.mtlcl410] +display_name = "mtlcl410" +sync_github_name = false + +[people.Silvertree2010] +display_name = "Netter" +sync_github_name = false diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index 1f2035405b..92a5ae8c20 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -27,6 +27,31 @@ pub enum AltError { Cos(#[from] pdfcraft_cos::CosError), } +/// Why a structure tag cannot safely become an artifact. +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum ArtifactError { + #[error("the document has no tags")] + Untagged, + #[error("object {0} is not a reachable indirect structure tag")] + NotATag(u32), + #[error("the tag has child structure elements; mark a leaf tag instead")] + HasChildren, + #[error("the tag has no direct marked content")] + NoMarkedContent, + #[error("the tag has no page")] + NoPage, + #[error("the tag's marked content was not found on its page")] + ContentNotFound, + #[error("the tag has no removable structure-tree parent")] + NoParent, + #[error("the parent must list the tag exactly once")] + AmbiguousParent, + #[error("couldn't update the parent tree: {0}")] + ParentTree(String), + #[error(transparent)] + Cos(#[from] pdfcraft_cos::CosError), +} + pub(crate) fn tree_root(doc: &Document) -> Option { let cat = doc.root().and_then(|r| doc.get(r).as_dict().cloned())?; cat.get(b"StructTreeRoot").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()) @@ -115,6 +140,22 @@ fn mcids(d: &Dict) -> Vec { out } +/// The page's named property lists (`/Resources /Properties`), which a `BDC` can name instead of +/// writing its property list inline. +fn page_properties(doc: &Document, page: &Dict) -> Dict { + let res = page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + res.get(b"Properties").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()).unwrap_or_default() +} + +/// The marked-content id a `BDC` opens, whether its property list is inline or named in `props`. +fn bdc_mcid(doc: &Document, props: &Dict, op: &pdfcraft_content::Op) -> Option { + match op.operands.get(1)? { + Object::Dict(d) => d.get(b"MCID").and_then(Object::as_int), + Object::Name(n) => props.get(n).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().and_then(|d| d.get(b"MCID").and_then(Object::as_int))), + _ => None, + } +} + pub(crate) fn page_content(doc: &Document, page: &Dict) -> Vec { let Some(c) = page.get(b"Contents") else { return Vec::new() }; match &*doc.resolve(c) { @@ -145,6 +186,7 @@ fn union(a: Option<[f64; 4]>, b: [f64; 4]) -> [f64; 4] { fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64; 4]> { let res = page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let xobjects = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + let props = page_properties(doc, page); let mut ctm = vec![Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])]; let mut marks: Vec = Vec::new(); let mut bbox = None; @@ -165,9 +207,7 @@ fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64; } } b"BMC" => marks.push(false), - b"BDC" => marks.push( - matches!(op.operands.get(1), Some(Object::Dict(d)) if d.get(b"MCID").and_then(Object::as_int).is_some_and(|n| ids.contains(&n))), - ), + b"BDC" => marks.push(bdc_mcid(doc, &props, &op).is_some_and(|n| ids.contains(&n))), b"EMC" => { marks.pop(); } @@ -262,6 +302,72 @@ pub fn set_alt(doc: &mut Document, obj: ObjRef, alt: Option<&str>) -> Result<(), Ok(()) } +/// The page's content entries: one stream, or the elements of a contents array. +fn content_entries(doc: &Document, page: &Dict) -> Option> { + let c = page.get(b"Contents")?; + Some(match &*doc.resolve(c) { + Object::Array(a) => a.clone(), + Object::Stream(_) => vec![c.clone()], + _ => return None, + }) +} + +fn marked(doc: &Document, props: &Dict, op: &pdfcraft_content::Op, ids: &HashSet) -> bool { + op.op == b"BDC" && bdc_mcid(doc, props, op).is_some_and(|n| ids.contains(&n)) +} + +/// Turn the figure's marked-content blocks into artifacts, rewriting only the streams that +/// contain them. Other content streams, and unknown entries on a rewritten stream, stay. +fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids: &HashSet) -> Result { + let Some(entries) = content_entries(doc, &page.dict) else { return Ok(false) }; + let props = page_properties(doc, &page.dict); + let mut next = Vec::with_capacity(entries.len()); + let mut changed = false; + for entry in entries { + let Some(stream) = (match &*doc.resolve(&entry) { + Object::Stream(s) => Some(s.clone()), + _ => None, + }) else { + next.push(entry); + continue; + }; + let mut ops = pdfcraft_content::parse(&stream.decoded().unwrap_or_default()).ops; + let mut ours = false; + for op in &mut ops { + if marked(doc, &props, op, ids) { + *op = pdfcraft_content::Op::new("BMC", vec![Object::name("Artifact")]); + ours = true; + } + } + if !ours { + next.push(entry); + continue; + } + changed = true; + let mut dict = stream.dict; + dict.remove(b"Length"); + dict.remove(b"Filter"); + dict.remove(b"DecodeParms"); + let stream = Object::Stream(Stream::flate(dict, &pdfcraft_content::serialize_ops(&ops))); + let stored = match entry { + Object::Ref(r) => { + doc.set(r, stream); + Object::Ref(r) + } + _ => Object::Ref(doc.add(stream)), + }; + next.push(stored); + } + if changed { + let contents = match next.as_slice() { + [one] => one.clone(), + _ => Object::Array(next), + }; + doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), contents))?; + } + Ok(changed) +} + /// Decorative figure: its marked content on the page becomes an artifact, and the element /// leaves the structure tree (and the parent tree). pub fn mark_decorative(doc: &mut Document, obj: ObjRef) -> Result<(), AltError> { @@ -269,24 +375,12 @@ pub fn mark_decorative(doc: &mut Document, obj: ObjRef) -> Result<(), AltError> let ids: HashSet = mcids(&d).into_iter().collect(); let pages = pdfcraft_model::pages(doc); let pg = d.get(b"Pg").and_then(Object::as_ref).or_else(|| elements(doc).into_iter().find(|e| e.object == obj).and_then(|e| e.page)); - if let Some(page) = pg.and_then(|p| pages.iter().find(|x| x.obj == p)).filter(|_| !ids.is_empty()) { - let mut ops = pdfcraft_content::parse(&page_content(doc, &page.dict)).ops; - let mut changed = false; - for op in ops.iter_mut() { - let ours = op.op == b"BDC" - && matches!(op.operands.get(1), Some(Object::Dict(m)) if m.get(b"MCID").and_then(Object::as_int).is_some_and(|n| ids.contains(&n))); - if ours { - *op = pdfcraft_content::Op::new("BMC", vec![Object::name("Artifact")]); - changed = true; - } - } - if changed { - let s = doc.add(Object::Stream(Stream::flate(Dict::new(), &pdfcraft_content::serialize_ops(&ops)))); - doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), Object::Ref(s)))?; - // The parent tree no longer points those ids at the figure. - if let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) { - clear_parent_tree(doc, key, obj)?; - } + if let Some(page) = pg.and_then(|p| pages.iter().find(|x| x.obj == p)).filter(|_| !ids.is_empty()) + && artifact_marked_content(doc, page, &ids)? + { + // The parent tree no longer points those ids at the figure. + if let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) { + clear_parent_tree(doc, key, obj)?; } } // Out of its parent's kids. @@ -305,17 +399,95 @@ pub fn mark_decorative(doc: &mut Document, obj: ObjRef) -> Result<(), AltError> Ok(()) } +/// Mark a leaf structure tag's directly owned marked content as an artifact. +/// +/// This deliberately refuses a tag with child structure elements. Converting a parent would +/// otherwise silently discard semantic descendants. The visible page content is preserved: each +/// matching marked-content sequence changes from `BDC` with an MCID to `/Artifact BMC`. +pub fn mark_tag_artifact(doc: &mut Document, obj: ObjRef) -> Result<(), ArtifactError> { + tree_root(doc).ok_or(ArtifactError::Untagged)?; + let elements = elements(doc); + let Some(element) = elements.iter().find(|e| e.object == obj) else { return Err(ArtifactError::NotATag(obj.num)) }; + if has_child_structure(doc, &element.dict) { + return Err(ArtifactError::HasChildren); + } + let ids: HashSet = mcids(&element.dict).into_iter().collect(); + if ids.is_empty() { + return Err(ArtifactError::NoMarkedContent); + } + let pages = pdfcraft_model::pages(doc); + let page_ref = element.page.ok_or(ArtifactError::NoPage)?; + let page = pages.iter().find(|page| page.obj == page_ref).ok_or(ArtifactError::NoPage)?; + let mut ops = pdfcraft_content::parse(&page_content(doc, &page.dict)).ops; + let mut found = HashSet::new(); + for op in &mut ops { + let id = (op.op == b"BDC") + .then(|| match op.operands.get(1) { + Some(Object::Dict(mark)) => mark.get(b"MCID").and_then(Object::as_int), + _ => None, + }) + .flatten() + .filter(|id| ids.contains(id)); + if let Some(id) = id { + *op = pdfcraft_content::Op::new("BMC", vec![Object::name("Artifact")]); + found.insert(id); + } + } + if found != ids { + return Err(ArtifactError::ContentNotFound); + } + let stream = doc.add(Object::Stream(Stream::flate(Dict::new(), &pdfcraft_content::serialize_ops(&ops)))); + doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), Object::Ref(stream)))?; + if let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) { + clear_parent_tree(doc, key, obj).map_err(|e| ArtifactError::ParentTree(e.to_string()))?; + } + let parent = element.dict.get(b"P").and_then(Object::as_ref).ok_or(ArtifactError::NoParent)?; + remove_from_parent(doc, parent, obj)?; + // Keep the retired object valid if another object holds an out-of-tree reference to it. + doc.update_dict(obj, |d| { + d.remove(b"P"); + })?; + Ok(()) +} + +fn has_child_structure(doc: &Document, tag: &Dict) -> bool { + fn child(doc: &Document, item: &Object) -> bool { + match item { + Object::Array(items) => items.iter().any(|item| child(doc, item)), + Object::Ref(r) => doc.get(*r).as_dict().is_some_and(|d| d.contains(b"S")), + Object::Dict(d) => d.contains(b"S"), + _ => false, + } + } + tag.get(b"K").is_some_and(|items| child(doc, items)) +} + +fn remove_from_parent(doc: &mut Document, parent: ObjRef, obj: ObjRef) -> Result<(), ArtifactError> { + let kids = doc.get(parent).as_dict().and_then(|p| p.get(b"K").cloned()).ok_or(ArtifactError::NoParent)?; + let replacement = match kids { + Object::Array(items) => { + if items.iter().filter(|item| item.as_ref() == Some(obj)).count() != 1 { + return Err(ArtifactError::AmbiguousParent); + } + Object::Array(items.into_iter().filter(|item| item.as_ref() != Some(obj)).collect()) + } + Object::Ref(r) if r == obj => Object::Array(Vec::new()), + _ => return Err(ArtifactError::NoParent), + }; + doc.update_dict(parent, |p| p.set(b"K".to_vec(), replacement))?; + Ok(()) +} + /// Replace `obj` by null in the page's parent-tree array (`key`), in a flat /Nums tree. fn clear_parent_tree(doc: &mut Document, key: i64, obj: ObjRef) -> Result<(), AltError> { let Some(pt) = tree_root(doc).and_then(|r| r.get(b"ParentTree").and_then(Object::as_ref)) else { return Ok(()) }; let Some(nums) = doc.get(pt).as_dict().and_then(|d| d.get(b"Nums").and_then(|n| n.as_array().cloned())) else { return Ok(()) }; let mut nums = nums; - for pair in nums.chunks_mut(2) { - if pair[0].as_int() == Some(key) { - match &pair[1] { - Object::Array(a) => { - pair[1] = Object::Array(a.iter().map(|x| if x.as_ref() == Some(obj) { Object::Null } else { x.clone() }).collect()) - } + // Whole pairs only: a trailing key without a value maps nothing, so it is left as it is. + for [k, v] in nums.as_chunks_mut::<2>().0 { + if k.as_int() == Some(key) { + match &*v { + Object::Array(a) => *v = Object::Array(a.iter().map(|x| if x.as_ref() == Some(obj) { Object::Null } else { x.clone() }).collect()), Object::Ref(r) => { if let Some(a) = doc.get(*r).as_array().cloned() { let a: Vec = a.iter().map(|x| if x.as_ref() == Some(obj) { Object::Null } else { x.clone() }).collect(); diff --git a/crates/a11y/src/autotag_fields.rs b/crates/a11y/src/autotag_fields.rs new file mode 100644 index 0000000000..3203aca376 --- /dev/null +++ b/crates/a11y/src/autotag_fields.rs @@ -0,0 +1,138 @@ +//! Conservative tagging for AcroForm widgets. +//! +//! It creates `/Form` structure elements and `/OBJR` children so assistive technology can find +//! widgets. An existing direct `/Document` root and direct `/ParentTree /Nums` are extended; +//! unusual tree layouts are refused before any edit is made. + +use pdfcraft_cos::{Dict, Document, ObjRef, Object}; + +fn reference(object: Option<&Object>, what: &str) -> Result { + object.and_then(Object::as_ref).ok_or_else(|| format!("the {what} must be an indirect reference")) +} + +fn nums(doc: &Document, parent_tree: ObjRef) -> Result, String> { + let tree = doc.get(parent_tree); + let tree = tree.as_dict().ok_or("the structure parent tree is invalid")?; + tree.get(b"Nums") + .map(|value| doc.resolve(value)) + .as_deref() + .and_then(Object::as_array) + .cloned() + .ok_or_else(|| "the structure parent tree must use a direct Nums array".to_string()) +} + +/// Add `/Form` tags for every page widget that does not already have a structure parent. +/// +/// A new structure tree is written for an untagged form. A conventional existing tree is +/// extended by adding the form tags to its direct `/Document` element and fresh entries to its +/// direct `/ParentTree /Nums` array. Text, figures, links, and non-widget annotations remain +/// outside this operation and need separate review. +pub fn tag_form_fields(doc: &mut Document) -> Result { + let root = doc.root().ok_or("the PDF has no catalog")?; + let catalog_object = doc.get(root); + let catalog = catalog_object.as_dict().ok_or("the PDF catalog is invalid")?; + if !catalog.contains(b"AcroForm") { + return Err("the PDF has no AcroForm fields".into()); + } + let existing_root = catalog.get(b"StructTreeRoot").cloned(); + let mark_info = catalog.get(b"MarkInfo").map(|v| doc.resolve(v)).as_deref().and_then(Object::as_dict).cloned().unwrap_or_default(); + let pages = pdfcraft_model::pages(doc); + let mut widgets = Vec::new(); + // A widget listed in more than one page's /Annots is tagged once (one key, one /Form). + let mut seen = std::collections::HashSet::new(); + for page in &pages { + let annots = page.dict.get(b"Annots").map(|a| doc.resolve(a)).as_deref().and_then(Object::as_array).cloned().unwrap_or_default(); + for annot in annots { + let Some(reference) = annot.as_ref() else { continue }; + let resolved = doc.get(reference); + let Some(dict) = resolved.as_dict() else { continue }; + if dict.name(b"Subtype") == Some(b"Widget") && !dict.contains(b"StructParent") && seen.insert(reference) { + widgets.push((page.obj, reference)); + } + } + } + if widgets.is_empty() { + return Err("the AcroForm has no untagged page widgets".into()); + } + + let (struct_root, document_ref, parent_tree, mut parent_nums, first_key, new_tree) = if let Some(existing_root) = existing_root { + let struct_root = reference(Some(&existing_root), "structure tree root")?; + let structure_object = doc.get(struct_root); + let structure = structure_object.as_dict().ok_or("the structure tree root is invalid")?; + let document_ref = reference(structure.get(b"K"), "structure tree root K")?; + let document = doc.get(document_ref); + if document.as_dict().is_none_or(|d| d.name(b"S") != Some(b"Document")) { + return Err("the structure tree root must point directly to a Document element".into()); + } + let parent_tree = reference(structure.get(b"ParentTree"), "structure parent tree")?; + let parent_nums = nums(doc, parent_tree)?; + if parent_nums.len() % 2 != 0 { + return Err("the structure parent tree has an odd number of entries".into()); + } + let mut greatest = -1_i64; + for pair in parent_nums.as_chunks::<2>().0 { + let Some(key) = pair[0].as_int() else { return Err("the structure parent tree has a non-integer key".into()) }; + if key < 0 { + return Err("the structure parent tree has a negative key".into()); + } + greatest = greatest.max(key); + } + let first_key = greatest.checked_add(1).ok_or("the structure parent tree has no available key")?; + (struct_root, document_ref, parent_tree, parent_nums, first_key, false) + } else { + let mut root_dict = Dict::new(); + root_dict.set(b"Type".to_vec(), Object::Name(b"StructTreeRoot".to_vec())); + let struct_root = doc.add(Object::Dict(root_dict)); + let mut document = Dict::new(); + document.set(b"S".to_vec(), Object::Name(b"Document".to_vec())); + document.set(b"P".to_vec(), Object::Ref(struct_root)); + let document_ref = doc.add(Object::Dict(document)); + let parent_tree = doc.add(Object::Dict(Dict::new())); + (struct_root, document_ref, parent_tree, Vec::new(), 0, true) + }; + let mut kids = Vec::with_capacity(widgets.len()); + for (offset, (page, widget)) in widgets.iter().copied().enumerate() { + let key = first_key.checked_add(i64::try_from(offset).map_err(|_| "too many form widgets")?).ok_or("too many form widgets")?; + let mut objr = Dict::new(); + objr.set(b"Type".to_vec(), Object::Name(b"OBJR".to_vec())); + objr.set(b"Obj".to_vec(), Object::Ref(widget)); + objr.set(b"Pg".to_vec(), Object::Ref(page)); + let mut form = Dict::new(); + form.set(b"S".to_vec(), Object::Name(b"Form".to_vec())); + form.set(b"P".to_vec(), Object::Ref(document_ref)); + form.set(b"Pg".to_vec(), Object::Ref(page)); + form.set(b"K".to_vec(), Object::Dict(objr)); + let form_ref = doc.add(Object::Dict(form)); + doc.update_dict(widget, |d| d.set(b"StructParent".to_vec(), Object::Int(key))).map_err(|e| e.to_string())?; + parent_nums.extend([Object::Int(key), Object::Ref(form_ref)]); + kids.push(Object::Ref(form_ref)); + } + doc.update_dict(document_ref, |d| { + let old = d.remove(b"K").unwrap_or(Object::Array(Vec::new())); + let mut all = match old { + Object::Array(items) => items, + item => vec![item], + }; + all.extend(kids); + d.set(b"K".to_vec(), Object::Array(all)); + }) + .map_err(|e| e.to_string())?; + doc.update_dict(parent_tree, |d| d.set(b"Nums".to_vec(), Object::Array(parent_nums))).map_err(|e| e.to_string())?; + if new_tree { + doc.update_dict(struct_root, |d| { + d.set(b"K".to_vec(), Object::Ref(document_ref)); + d.set(b"ParentTree".to_vec(), Object::Ref(parent_tree)); + }) + .map_err(|e| e.to_string())?; + } + let mut mark_info = mark_info; + mark_info.set(b"Marked".to_vec(), Object::Bool(true)); + doc.update_dict(root, |d| { + d.set(b"MarkInfo".to_vec(), Object::Dict(mark_info)); + if new_tree { + d.set(b"StructTreeRoot".to_vec(), Object::Ref(struct_root)); + } + }) + .map_err(|e| e.to_string())?; + Ok(widgets.len()) +} diff --git a/crates/a11y/src/headings.rs b/crates/a11y/src/headings.rs index 7a35deb469..c7e911fde2 100644 --- a/crates/a11y/src/headings.rs +++ b/crates/a11y/src/headings.rs @@ -3,6 +3,7 @@ use std::collections::{HashMap, HashSet}; +use pdfcraft_content::Matrix; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; use pdfcraft_fonts::pdf::Metrics; use pdfcraft_model::Page; @@ -171,13 +172,24 @@ fn sub(doc: &Document, d: &Dict, key: &[u8]) -> Dict { } /// The text each marked-content id shows on a page (text drawn directly in the page content). -fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { +/// +/// Word breaks come from where the glyphs are, as on the page: a glyph that starts on another +/// line, more than 0.2 em past the end of the previous glyph of the same id, or back over more +/// than an em from it, starts a new word (the em is the font size times |`Tz`|; see [`new_word`]). +/// Text-positioning operators alone don't break words: producers that place every glyph with its +/// own `Td` (Chromium) would otherwise read as "C h a p t e r". +pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { let res = sub(doc, page, b"Resources"); let (fonts, props) = (sub(doc, &res, b"Font"), sub(doc, &res, b"Properties")); let mut metrics: HashMap, Metrics> = HashMap::new(); - let mut font: Option> = None; let mut stack: Vec> = Vec::new(); let mut out: HashMap = HashMap::new(); + // Where each id's last glyph ended, in user space. + let mut ends: HashMap = HashMap::new(); + let mut gs = TextState::default(); + let mut saved: Vec = Vec::new(); + let mut unsaved = 0usize; + let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); for op in pdfcraft_content::parse(&page_content(doc, page)).ops { let id = stack.iter().rev().find_map(|m| *m); match op.op.as_slice() { @@ -192,8 +204,22 @@ fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { b"EMC" => { stack.pop(); } + b"q" if saved.len() < MAX_SAVED => saved.push(gs.clone()), + b"q" => unsaved += 1, + b"Q" if unsaved > 0 => unsaved -= 1, + b"Q" => { + if let Some(saved) = saved.pop() { + gs = saved; + } + } + b"cm" => { + if let Some(m) = op.nums::<6>() { + gs.ctm = Matrix(m).then(&gs.ctm); + } + } b"Tf" => { - font = op.name(0).map(<[u8]>::to_vec); + gs.size = op.num(1).unwrap_or(gs.size); + gs.font = op.name(0).map(<[u8]>::to_vec); if let Some(name) = op.name(0) && !metrics.contains_key(name) && let Some(f) = fonts.get(name).map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()) @@ -201,34 +227,113 @@ fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { metrics.insert(name.to_vec(), Metrics::from_dict(doc, &f)); } } - _ => {} - } - let (Some(id), Some(m)) = (id, font.as_ref().and_then(|f| metrics.get(f))) else { continue }; - let text = out.entry(id).or_default(); - if text.len() >= MAX_TITLE * 4 { - continue; - } - match op.op.as_slice() { - b"Tj" | b"'" | b"\"" => { - if op.op != b"Tj" { - text.push(' '); - } - if let Some(s) = op.operands.iter().rev().find_map(Object::as_string) { - text.push_str(&m.decode(&s.bytes)); + b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0), + b"Tw" => gs.word_spacing = op.num(0).unwrap_or(0.0), + b"Tz" => gs.scale = op.num(0).unwrap_or(100.0) / 100.0, + b"TL" => gs.leading = op.num(0).unwrap_or(0.0), + b"BT" => (tm, tlm) = (Matrix::IDENTITY, Matrix::IDENTITY), + b"Td" | b"TD" => { + if let Some([x, y]) = op.nums::<2>() { + if op.op == b"TD" { + gs.leading = -y; + } + tlm = Matrix::translate(x, y).then(&tlm); + tm = tlm; } } - b"TJ" => { - for item in op.operands.first().and_then(Object::as_array).into_iter().flatten() { - match item { - Object::String(s) => text.push_str(&m.decode(&s.bytes)), - n if n.as_f64().is_some_and(|n| n < -200.0) => text.push(' '), - _ => {} + b"Tm" => { + if let Some(m) = op.nums::<6>() { + (tm, tlm) = (Matrix(m), Matrix(m)); + } + } + b"T*" => { + tlm = Matrix::translate(0.0, -gs.leading).then(&tlm); + tm = tlm; + } + _ => {} + } + let shows = matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\""); + let Some(m) = gs.font.as_ref().and_then(|f| metrics.get(f)).filter(|_| shows) else { continue }; + if op.op != b"Tj" && op.op != b"TJ" { + if op.op == b"\"" { + gs.word_spacing = op.num(0).unwrap_or(gs.word_spacing); + gs.char_spacing = op.num(1).unwrap_or(gs.char_spacing); + } + tlm = Matrix::translate(0.0, -gs.leading).then(&tlm); + tm = tlm; + } + let pieces: Vec<&Object> = match op.op.as_slice() { + b"TJ" => op.operands.first().and_then(Object::as_array).map(|a| a.iter().collect()).unwrap_or_default(), + _ => op.operands.last().into_iter().collect(), + }; + for piece in pieces { + let s = match piece { + Object::String(s) => s, + other => { + // A TJ adjustment moves the next glyph; whether it breaks a word is decided there. + let dx = -other.as_f64().unwrap_or(0.0) / 1000.0 * gs.size * gs.scale; + tm = Matrix::translate(dx, 0.0).then(&tm); + continue; + } + }; + for (code, len) in m.codes(&s.bytes) { + let trm = tm.then(&gs.ctm); + if let Some(id) = id + && let Some(glyph) = m.text_of(code) + { + let text = out.entry(id).or_default(); + if text.len() < MAX_TITLE * 4 { + let breaks = ends.get(&id).is_some_and(|end| new_word(&trm, *end, gs.size, gs.scale)); + if breaks && !text.is_empty() && !text.ends_with(' ') && !glyph.starts_with(' ') { + text.push(' '); + } + text.push_str(&glyph); } } + let advance = m.width(code) * gs.size + gs.char_spacing + if m.is_space(code, len) { gs.word_spacing } else { 0.0 }; + tm = Matrix::translate(advance * gs.scale, 0.0).then(&tm); + if let Some(id) = id { + ends.insert(id, tm.then(&gs.ctm).apply(0.0, 0.0)); + } } - b"Td" | b"TD" | b"T*" | b"Tm" => text.push(' '), - _ => {} } } out } + +/// How deep `q` nests before the text state stops being saved (a hostile stream can nest millions). +const MAX_SAVED: usize = 256; + +/// The graphics state that places glyphs: the CTM, the font and the text state parameters. +#[derive(Clone)] +struct TextState { + ctm: Matrix, + font: Option>, + size: f64, + char_spacing: f64, + word_spacing: f64, + scale: f64, + leading: f64, +} + +impl Default for TextState { + fn default() -> Self { + TextState { ctm: Matrix::IDENTITY, font: None, size: 0.0, char_spacing: 0.0, word_spacing: 0.0, scale: 1.0, leading: 0.0 } + } +} + +/// Does a glyph drawn with text rendering matrix `trm` (text space → user space) start a new word +/// after a glyph that ended at `end` (user space)? Yes on another line (more than half the absolute +/// font size up or down), more than 0.2 horizontal em ahead, or back over more than a horizontal em. +/// The horizontal em is the absolute value of font size times horizontal scale (`Tz`); "ahead" follows +/// the glyph advance, backwards when the size or `Tz` is negative. `Tz` never scales the vertical threshold. +fn new_word(trm: &Matrix, end: (f64, f64), font_size: f64, horizontal_scale: f64) -> bool { + let advance = font_size * horizontal_scale; + let em = advance.abs(); + let start = trm.apply(0.0, 0.0); + let [a, b, c, d, _, _] = trm.0; + let Some(back) = Matrix([a, b, c, d, 0.0, 0.0]).invert() else { return true }; + let (dx, dy) = back.apply(start.0 - end.0, start.1 - end.1); + let ahead = if advance < 0.0 { -dx } else { dx }; + !(ahead.is_finite() && dy.is_finite()) || dy.abs() > font_size.abs() * 0.5 || ahead > em * 0.2 || ahead < -em +} diff --git a/crates/a11y/src/lib.rs b/crates/a11y/src/lib.rs index 81fbade91d..ff81cc4c97 100644 --- a/crates/a11y/src/lib.rs +++ b/crates/a11y/src/lib.rs @@ -14,17 +14,19 @@ use pdfcraft_cos::Document; mod alt; mod autotag; +mod autotag_fields; mod content; mod headings; mod report; mod structure; mod tags; -pub use alt::{AltError, Figure, figures, mark_decorative, set_alt}; +pub use alt::{AltError, ArtifactError, Figure, figures, mark_decorative, mark_tag_artifact, set_alt}; pub use autotag::{AutoTagError, AutoTagResult, autotag_text}; +pub use autotag_fields::tag_form_fields; pub use headings::{Heading, headings}; pub use report::{REPORT_WORDS, report_html}; -pub use tags::{TagError, TagInfo, TagPlacement, TagProperty, move_tag, set_tag_property, tags}; +pub use tags::{TagContentItem, TagError, TagInfo, TagPlacement, TagProperty, move_tag, set_tag_property, tag_content, tags}; #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Category { diff --git a/crates/a11y/src/tags.rs b/crates/a11y/src/tags.rs index 926350c3d9..565f6620d1 100644 --- a/crates/a11y/src/tags.rs +++ b/crates/a11y/src/tags.rs @@ -21,6 +21,14 @@ pub struct TagInfo { pub page: Option, } +/// A marked-content reference directly owned by a structure tag. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TagContentItem { + pub page: Option, + pub mcid: i64, + pub text: String, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TagProperty { Type, @@ -140,6 +148,58 @@ pub fn tags(doc: &Document) -> Vec { .collect() } +/// List marked-content references directly owned by an indirect tag. +pub fn tag_content(doc: &Document, object: ObjRef) -> Result, TagError> { + let Some(element) = alt::elements(doc).into_iter().find(|e| e.object == object) else { return Err(TagError::NotATag(object)) }; + let pages = pdfcraft_model::pages(doc); + let page_index: std::collections::HashMap = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); + let mut marks = Vec::new(); + if let Some(kids) = element.dict.get(b"K") { + direct_marks(doc, kids, element.page, &mut marks); + } + // Each page's content is parsed once, however many of its MCIDs the tag owns. + let mut texts: std::collections::HashMap> = std::collections::HashMap::new(); + Ok(marks + .into_iter() + .map(|(page, mcid)| { + let page = page.and_then(|p| page_index.get(&p).copied()); + let text = page + .and_then(|i| { + let page_texts = match texts.entry(i) { + std::collections::hash_map::Entry::Occupied(e) => e.into_mut(), + std::collections::hash_map::Entry::Vacant(e) => { + e.insert(pages.get(i).map(|p| crate::headings::mcid_texts(doc, &p.dict)).unwrap_or_default()) + } + }; + page_texts.get(&mcid).cloned() + }) + .unwrap_or_default(); + TagContentItem { page, mcid, text } + }) + .collect()) +} + +fn direct_marks(doc: &Document, item: &Object, page: Option, out: &mut Vec<(Option, i64)>) { + match item { + Object::Int(mcid) => out.push((page, *mcid)), + Object::Array(items) => items.iter().for_each(|item| direct_marks(doc, item, page, out)), + Object::Dict(dict) if dict.name(b"Type") == Some(b"MCR") => { + if let Some(mcid) = dict.get(b"MCID").and_then(Object::as_int) { + out.push((dict.get(b"Pg").and_then(Object::as_ref).or(page), mcid)); + } + } + Object::Ref(reference) => { + if let Some(dict) = doc.get(*reference).as_dict() + && dict.name(b"Type") == Some(b"MCR") + && let Some(mcid) = dict.get(b"MCID").and_then(Object::as_int) + { + out.push((dict.get(b"Pg").and_then(Object::as_ref).or(page), mcid)); + } + } + _ => {} + } +} + /// Change one property of a reachable tag; `None` removes a text property. pub fn set_tag_property(doc: &mut Document, object: ObjRef, property: TagProperty, value: Option<&str>) -> Result<(), TagError> { if !alt::elements(doc).iter().any(|e| e.object == object) { diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index e406569a8c..fcd681efc9 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -96,6 +96,78 @@ fn autotag_text_refuses_paint_and_does_not_modify_the_document() { assert_eq!(autotag_text(&mut pdf(&good(), "")), Err(AutoTagError::AlreadyTagged)); } +#[test] +fn autotag_form_fields_writes_widget_structure_and_parent_tree() { + let mut doc = pdf( + &[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R 6 0 R] >>".into(), + "<< /Length 0 >>\nstream\n\nendstream".into(), + "<< /FT /Tx /T (name) /Kids [6 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [20 60 120 80] >>".into(), + ], + "", + ); + assert_eq!(tag_form_fields(&mut doc).unwrap(), 1); + let report = check(&doc, &Options { rules: [Rule::TaggedPdf, Rule::TaggedFormFields].into(), pages: None }); + assert_eq!(status(&report, Rule::TaggedPdf), Status::Passed); + assert_eq!(status(&report, Rule::TaggedFormFields), Status::Passed); + let tree = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"StructTreeRoot").unwrap(); + assert!(doc.get(tree).as_dict().unwrap().contains(b"ParentTree")); + let widget = pdfcraft_model::pages(&doc)[0].dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap()[1].as_ref().unwrap(); + assert_eq!(doc.get(widget).as_dict().unwrap().get(b"StructParent").and_then(Object::as_int), Some(0)); + let written = pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(written)).unwrap(); + assert_eq!(status(&check(&reopened, &Options { rules: [Rule::TaggedFormFields].into(), pages: None }), Rule::TaggedFormFields), Status::Passed); +} + +#[test] +fn autotag_form_fields_tags_a_widget_listed_on_two_pages_once() { + let mut doc = pdf( + &[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >>".into(), + "<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R] >>".into(), + "<< /Length 0 >>\nstream\n\nendstream".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 60 120 80] >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R] >>".into(), + ], + "", + ); + assert_eq!(tag_form_fields(&mut doc).unwrap(), 1); + let tree = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"StructTreeRoot").unwrap(); + let parent_tree = doc.get(tree).as_dict().unwrap().reference(b"ParentTree").unwrap(); + let nums = doc.get(parent_tree).as_dict().unwrap().get(b"Nums").and_then(|n| n.as_array().cloned()).unwrap(); + assert_eq!(nums.len(), 2, "one key and one /Form element for the shared widget"); +} + +#[test] +fn autotag_form_fields_extends_a_direct_existing_structure_tree() { + let mut doc = pdf( + &[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> /MarkInfo << /Marked true >> /StructTreeRoot 6 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R] /StructParents 0 >>".into(), + "<< /Length 0 >>\nstream\n\nendstream".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 60 120 80] >>".into(), + "<< /Type /StructTreeRoot /K 7 0 R /ParentTree 8 0 R >>".into(), + "<< /S /Document /P 6 0 R /K 9 0 R >>".into(), + "<< /Nums [0 [9 0 R]] >>".into(), + "<< /S /P /P 7 0 R /Pg 3 0 R /K 0 >>".into(), + ], + "", + ); + assert_eq!(tag_form_fields(&mut doc).unwrap(), 1); + let widget = pdfcraft_model::pages(&doc)[0].dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap()[0].as_ref().unwrap(); + assert_eq!(doc.get(widget).as_dict().unwrap().get(b"StructParent").and_then(Object::as_int), Some(1)); + let report = check(&doc, &Options { rules: [Rule::TaggedFormFields].into(), pages: None }); + assert_eq!(status(&report, Rule::TaggedFormFields), Status::Passed); + let document_object = doc.get(pdfcraft_cos::ObjRef::new(7, 0)); + let document = document_object.as_dict().unwrap(); + assert_eq!(document.get(b"K").and_then(Object::as_array).unwrap().len(), 2, "the existing paragraph and new form remain siblings"); +} + #[test] fn an_untagged_document_fails_the_document_rules() { let doc = pdf( @@ -526,6 +598,16 @@ fn figures_are_listed_with_their_alt_text_and_place() { assert_eq!((finding.object, finding.page, finding.bbox), (Some(f[0].obj), Some(0), f[0].bbox)); } +#[test] +fn tag_content_lists_direct_marked_content() { + let doc = figures_doc(); + let tags = tags(&doc); + let first = tag_content(&doc, tags[1].object).unwrap(); + let second = tag_content(&doc, tags[2].object).unwrap(); + assert_eq!((first[0].page, first[0].mcid, first[0].text.as_str()), (Some(0), 0, "")); + assert_eq!((second[0].page, second[0].mcid, second[0].text.as_str()), (Some(0), 1, "")); +} + #[test] fn sibling_tags_move_in_reading_order_without_changing_content() { let mut doc = figures_doc(); @@ -591,6 +673,95 @@ fn setting_alt_text_and_marking_decorative() { assert!(text.contains("/Artifact BMC") && !text.contains("/MCID 0"), "{text}"); } +#[test] +fn marking_a_leaf_tag_as_an_artifact_preserves_appearance_and_structure_integrity() { + let mut doc = figures_doc(); + let page = pdfcraft_model::pages(&doc)[0].obj; + mark_tag_artifact(&mut doc, pdfcraft_cos::ObjRef::new(7, 0)).unwrap(); + assert_eq!(tags(&doc).into_iter().map(|tag| tag.object.num).collect::>(), [6, 8]); + let c = doc.resolve(doc.get(page).as_dict().unwrap().get(b"Contents").unwrap()); + let pdfcraft_cos::Object::Stream(c) = &*c else { panic!("one content stream") }; + let text = String::from_utf8(c.decoded().unwrap()).unwrap(); + assert!(text.contains("/Artifact BMC") && !text.contains("/MCID 0"), "{text}"); + let report = check(&doc, &Options { rules: [Rule::TaggedContent, Rule::FiguresAltText].into(), pages: None }); + assert_eq!(failed(&report), Vec::::new(), "{:?}", report.results); + assert_eq!(mark_tag_artifact(&mut doc, pdfcraft_cos::ObjRef::new(6, 0)), Err(ArtifactError::HasChildren)); + assert_eq!(mark_tag_artifact(&mut doc, pdfcraft_cos::ObjRef::new(7, 0)), Err(ArtifactError::NotATag(7))); +} + +#[test] +fn marking_decorative_survives_a_parent_tree_with_an_unpaired_key() { + // A trailing key without a value, matching the page's StructParents (#1809), and the same + // with no valid pair at all. + let tree = |nums: &str| pdf(&[format!("<< /Nums {nums} >>")], "").get(pdfcraft_cos::ObjRef::new(1, 0)).as_ref().clone(); + for (nums, cleared) in [("[0 [7 0 R 8 0 R] 0]", "[0 [null 8 0 R] 0]"), ("[0]", "[0]")] { + let mut doc = figures_doc(); + let pt = pdfcraft_cos::ObjRef::new(9, 0); + doc.set(pt, tree(nums)); + let f = figures(&doc); + mark_decorative(&mut doc, f[0].obj).unwrap(); + assert_eq!(figures(&doc).len(), 1, "{nums}"); + // The pair is cleared and the unpaired key is kept as it was. + assert_eq!(*doc.get(pt), tree(cleared), "{nums}"); + } +} + +/// `figures_doc` with its property lists named in the page resources (one inline, one indirect) +/// instead of written inline in the content. +fn named_figures_doc() -> Document { + pdf( + &[ + "<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /StructParents 0 \ + /Resources << /XObject << /Im 10 0 R >> /Properties << /P0 << /MCID 0 >> /P1 11 0 R /Other << /Note (kept) >> >> >> >>" + .into(), + stream( + "/Figure /P0 BDC q 40 0 0 20 10 150 cm /Im Do Q EMC \ + /Photo /P1 BDC 100 100 50 30 re f EMC", + ), + "<< /Type /StructTreeRoot /K 6 0 R /ParentTree 9 0 R /RoleMap << /Photo /Figure >> >>".into(), + "<< /S /Document /P 5 0 R /K [7 0 R 8 0 R] >>".into(), + "<< /S /Figure /P 6 0 R /Pg 3 0 R /K 0 >>".into(), + "<< /S /Photo /P 6 0 R /Pg 3 0 R /Alt (A bar) /K 1 >>".into(), + "<< /Nums [0 [7 0 R 8 0 R]] >>".into(), + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{0}\nendstream" + .into(), + "<< /MCID 1 >>".into(), + ], + "", + ) +} + +#[test] +fn named_property_lists_are_found_and_marked_decorative_like_inline_ones() { + let (mut inline, mut named) = (figures_doc(), named_figures_doc()); + let place = |doc: &Document| figures(doc).into_iter().map(|f| (f.obj, f.page, f.alt, f.bbox)).collect::>(); + assert_eq!(place(&named), place(&inline), "both figures, placed where their content is drawn"); + for doc in [&mut inline, &mut named] { + mark_decorative(doc, pdfcraft_cos::ObjRef::new(8, 0)).unwrap(); + let left = figures(doc); + assert_eq!((left.len(), left[0].obj.num, left[0].bbox), (1, 7, Some([10.0, 150.0, 50.0, 170.0]))); + let pages = pdfcraft_model::pages(doc); + let c = doc.resolve(pages[0].dict.get(b"Contents").unwrap()); + let Object::Stream(c) = &*c else { panic!("one content stream") }; + let text = String::from_utf8(c.decoded().unwrap()).unwrap(); + assert!(text.contains("/Artifact BMC") && !text.contains("/P1") && !text.contains("/MCID 1"), "{text}"); + assert!(text.contains("/Im Do"), "the drawing itself stays: {text}"); + let nums = doc.get(pdfcraft_cos::ObjRef::new(9, 0)).as_dict().and_then(|d| d.get(b"Nums").cloned()); + assert_eq!( + nums, + Some(Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Ref(pdfcraft_cos::ObjRef::new(7, 0)), Object::Null])])), + "the parent tree no longer points MCID 1 at the removed figure" + ); + let r = check(doc, &Options { rules: [Rule::FiguresAltText, Rule::TaggedContent].into(), pages: None }); + assert_eq!(r.result(Rule::TaggedContent).unwrap().status, Status::Passed, "{:?}", r.results); + } + let props = pdfcraft_model::pages(&named)[0].dict.get(b"Resources").and_then(|r| named.resolve(r).as_dict().cloned()); + let props = props.and_then(|r| r.get(b"Properties").and_then(|p| named.resolve(p).as_dict().cloned())).unwrap(); + assert!(props.contains(b"P0") && props.contains(b"P1") && props.contains(b"Other"), "resources are left as they were"); +} + fn ref_(n: u32) -> Option { Some(pdfcraft_cos::ObjRef::new(n, 0)) } @@ -640,6 +811,114 @@ fn headings_are_found_with_their_levels_titles_and_pages() { ); } +#[test] +fn tag_content_reads_the_text_of_each_marked_content_reference() { + let doc = pdf(&headings_doc(), ""); + let items = |n| tag_content(&doc, ref_(n).unwrap()).unwrap().into_iter().map(|i| (i.page, i.mcid, i.text)).collect::>(); + assert_eq!(items(12), [(Some(0), 0, "Chapter One".to_string())]); + assert_eq!(items(13), [(Some(0), 2, "Sec A".to_string())]); + assert_eq!(items(15), [(Some(1), 1, "AB".to_string())]); +} + +/// Helvetica advances (standard 14 metrics, per 1000 em) of the characters the tests below draw. +fn helvetica_width(c: char) -> f64 { + match c { + 'C' | 'w' => 722.0, + 'P' => 667.0, + 'T' | 'F' => 611.0, + 'v' => 500.0, + 'r' => 333.0, + 't' | ' ' => 278.0, + 'i' => 222.0, + _ => 556.0, // a e h o p + } +} + +/// `text` drawn one glyph at a time from (72, y), each glyph moved into place with its own `Td`, +/// as Chromium writes text. With `spaces`, the space is drawn too; without, the next glyph is +/// just moved past it. +fn glyph_by_glyph(text: &str, size: f64, y: f64, spaces: bool) -> String { + let mut ops = format!("BT /F1 {size} Tf 72 {y} Td"); + let mut step = 0.0; + for c in text.chars() { + if c == ' ' && !spaces { + step += helvetica_width(c) / 1000.0 * size; + continue; + } + ops.push_str(&format!(" {step:.3} 0 Td ({c}) Tj")); + step = helvetica_width(c) / 1000.0 * size; + } + ops + " ET" +} + +/// `text` drawn one glyph at a time with `/F1 1 Tf` and a `Tm` per glyph scaled to `size`; the +/// space is skipped over, not drawn. +fn glyph_by_tm(text: &str, size: f64, y: f64) -> String { + let mut ops = "BT /F1 1 Tf".to_string(); + let mut x = 72.0; + for c in text.chars() { + if c != ' ' { + ops.push_str(&format!(" {size} 0 0 {size} {x:.3} {y} Tm ({c}) Tj")); + } + x += helvetica_width(c) / 1000.0 * size; + } + ops + " ET" +} + +/// One tagged page whose H1 headings show `contents` (one marked-content sequence each). +fn heading_page(contents: &[String]) -> Document { + let marked: Vec = contents.iter().enumerate().map(|(i, c)| format!("/H1 << /MCID {i} >> BDC {c} EMC")).collect(); + let kids: Vec = (0..contents.len()).map(|i| format!("{} 0 R", 7 + i)).collect(); + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 6 0 R >> >> >>".into(), + stream(&marked.join("\n")), + format!("<< /Type /StructTreeRoot /K [{}] >>", kids.join(" ")), + FONT.into(), + ]; + objs.extend((0..contents.len()).map(|i| format!("<< /S /H1 /Pg 3 0 R /K {i} >>"))); + pdf(&objs, "") +} + +/// Chromium (Skia) places each glyph with its own `Td`: a heading must read as its words, not as +/// "C h a p t e r T w o", whether the space is drawn or only skipped over. A heading set with a +/// negative font size or `Tz` keeps its words too. +#[test] +fn headings_drawn_glyph_by_glyph_keep_their_words() { + let doc = heading_page(&[ + "BT /F1 20 Tf 72 700 Td (Chapter One) Tj ET".into(), + glyph_by_glyph("Chapter Two", 20.0, 650.0, true), + glyph_by_glyph("Chapter Three", 20.0, 600.0, false), + // Each glyph placed with its own Tm, at font size 1 scaled by the matrix. + glyph_by_tm("Part Five", 18.0, 550.0), + // Kerning inside a word, then a word space as a TJ adjustment, then a wrapped line. + "BT /F1 16 Tf 72 450 Td [(Ch) 40 (apter) -350 (Four)] TJ 0 -20 Td (continued) Tj ET".into(), + // A negative font size runs the glyphs backwards in text space: alone, in a mirrored + // text matrix (upright on the page), with a word space as a TJ adjustment, and the same + // with a negative horizontal scale instead (reset after, as Tz outlives the text object). + "BT /F1 -20 Tf 72 380 Td (Chapter Six) Tj ET".into(), + "BT /F1 -20 Tf -1 0 0 -1 300 330 Tm (Chapter Seven) Tj ET".into(), + "BT /F1 -20 Tf 72 280 Td [(Chapter) -300 (Eight)] TJ ET".into(), + "BT /F1 20 Tf -100 Tz 300 230 Td [(Chapter) -300 (Nine)] TJ ET 100 Tz".into(), + ]); + let titles: Vec = headings(&doc).into_iter().map(|h| h.title).collect(); + assert_eq!( + titles, + [ + "Chapter One", + "Chapter Two", + "Chapter Three", + "Part Five", + "Chapter Four continued", + "Chapter Six", + "Chapter Seven", + "Chapter Eight", + "Chapter Nine" + ] + ); +} + #[test] fn an_untagged_document_has_no_headings() { let mut objs = headings_doc(); @@ -736,3 +1015,11 @@ fn a_nested_finding_says_itself() { // Both wordings are translated, each on its own, and the document's words are left alone. assert_eq!(f.said(&|w: &str| format!("«{w}»")), "«Table element on page 3: «rows have between 1 and 2 columns»»"); } + +/// Horizontal stretching must not raise the vertical line-break threshold. +#[test] +fn horizontally_scaled_wrapped_heading_keeps_a_space_between_lines() { + let doc = heading_page(&["BT /F1 20 Tf 400 Tz 72 700 Td (I) Tj 0 -30 Td (Overview) Tj ET".into()]); + let titles: Vec = headings(&doc).into_iter().map(|h| h.title).collect(); + assert_eq!(titles, ["I Overview"]); +} diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index a5394038d0..0f0706428f 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -269,6 +269,12 @@ fn stamp_turn(d: &Dict) -> i64 { d.get(b"PCStampRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90 } +/// The page `/Rotate` a text box's or Fill & Sign text's lines are turned back by +/// (`/PCTextRotate`, written when it is added to a turned page). +fn text_turn(d: &Dict) -> i64 { + d.get(b"PCTextRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90 +} + /// The first text [`build`] would draw in the standard fonts (Helvetica, WinAnsiEncoding) that /// they can't, which would come out as `?`: what it is (for the message) and the character. The /// text of a text box, callout or Fill & Sign text, a measurement's label, a dynamic stamp's @@ -666,6 +672,19 @@ pub fn build(d: &Dict) -> Option { let bw = if d.contains(b"BS") || d.contains(b"Border") { border_width(d) } else { 0.0 }; // A callout: the leader line (arrowhead at its first point), and the text box inside // `/Rect` by `/RD`. + // Text added to a turned page is drawn upright in its displayed box, which the form + // `/Matrix` then maps into `/Rect` (as for stamps). Callouts keep user-space axes. + let turn = if d.contains(b"CL") { 0 } else { text_turn(d) }; + let (rect, matrix) = if turn == 0 { + (rect, None) + } else { + let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); + let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; + if dw < 1.0 || dh < 1.0 { + return None; + } + ([0.0, 0.0, dw, dh], Some(pdfcraft_model::view_matrix_for(turn, rect))) + }; let full = rect; let mut rect = rect; if d.contains(b"CL") { @@ -751,7 +770,11 @@ pub fn build(d: &Dict) -> Option { let mut fonts = Dict::new(); fonts.set(b"Helv".to_vec(), Object::Dict(font)); res.set(b"Font".to_vec(), Object::Dict(fonts)); - return Some(form(full, &out, res)); + let mut stream = form(full, &out, res); + if let Some(m) = matrix { + stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); + } + return Some(stream); } _ => return None, } diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index a25b35c3b9..f4973aa021 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -1206,6 +1206,13 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R } border(&mut d); rich_text(&mut d); + // The text is laid out for the page as displayed, like a stamp's label. + if !matches!(new.shape, Shape::Callout { .. }) { + let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); + if matches!(turn, 90 | 180 | 270) { + d.set(b"PCTextRotate".to_vec(), Object::Int(turn)); + } + } } _ => {} } @@ -1939,6 +1946,47 @@ pub fn set_style( redraw_or_restore(doc, r, before) } +/// Change the font size of a text box, typewriter text or callout (`/DA` keeps its colour; the +/// rich-text strings follow) and redraw it. The box grows to hold the wrapped text at the new +/// size and never shrinks, so a box the user drew large stays large. Sizes are 1 to 400 pt. +pub fn set_font_size(doc: &mut Document, page: usize, index: usize, size: f64, meta: &Meta) -> Result<(), AnnotError> { + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let d = annot_dict(doc, r); + if d.name(b"Subtype") != Some(b"FreeText") { + return Err(AnnotError::Invalid("only text boxes, typewriter text and callouts have a font size".into())); + } + if !size.is_finite() || !(1.0..=400.0).contains(&size) { + return Err(AnnotError::Invalid("font size must be 1\u{2013}400 pt".into())); + } + refuse_foreign_text(doc, &d)?; + let before = d; + doc.update_dict(r, |d| { + let (col, _) = appearance::parse_da(d); + d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(col[0]), n(col[1]), n(col[2]), n(size)).into_bytes())); + rich_text(d); + touch(d, meta); + })?; + let text = text_value(doc, &annot_dict(doc, r), b"Contents").unwrap_or_default(); + let result = match fitted_box(doc, r, &text) { + Some(fit) => { + let cur = annot_dict(doc, r); + let nums: Vec = + cur.get(b"Rect").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default(); + // Keep the top and sides; only the bottom edge moves, and only downwards. + match four(&nums).map(normalize) { + Some(old) if !cur.contains(b"CL") => apply_text_box(doc, r, [fit[0], fit[1].min(old[1]), fit[2], fit[3]], meta, false), + _ => apply_text_box(doc, r, fit, meta, false), + } + } + None => redraw_or_restore(doc, r, before.clone()), + }; + if result.is_err() { + doc.set(r, Object::Dict(before)); + } + result +} + /// Whether a comment of this subtype has an interior that can be filled (`/IC`: rectangles, /// ovals, polygons and clouds, §12.5.6.8–9). pub fn fillable(subtype: &str) -> bool { @@ -2173,6 +2221,8 @@ pub struct Props { pub fillable: bool, /// `/IC`, the interior colour (`None`: no fill). pub fill: Option, + /// Text boxes, typewriter text and callouts: the `/DA` font size in points. + pub font_size: Option, } /// The current properties of the comment at `(page, index)`. @@ -2198,6 +2248,7 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { locked: annotation_flags(doc, d) & FLAG_LOCKED != 0, fillable: fillable(&subtype), fill: fill_of(d), + font_size: (subtype == "FreeText").then(|| appearance::parse_da(d).1), subtype, endings: line_endings_of(d), }) diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 2ae46a2467..faed409abb 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1273,3 +1273,38 @@ fn cached_appearances_are_reused_while_annotations_are_unchanged() { assert!(!a.is_empty()); assert_eq!(a, b, "the reused appearance is byte-identical"); } + +/// #1908: a text box's font size changes in `/DA` (colour kept), the rich text follows, the box +/// grows to hold the bigger text but never shrinks, and bad sizes and other subtypes are refused. +#[test] +fn text_box_font_size_changes_the_da_and_grows_the_box() { + let mut doc = fixture(); + let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [100.0, 680.0, 220.0, 692.0], font_size: 12.0 }), &meta("t")).unwrap(); + set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap(); + set_contents(&mut doc, 0, t, "one two three four five six seven eight nine ten eleven twelve", &meta("")).unwrap(); + let height = |doc: &Document| { + let rect: Vec = list(doc, 0)[t].get(b"Rect").unwrap().as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); + rect[3] - rect[1] + }; + let before = height(&doc); + set_font_size(&mut doc, 0, t, 24.0, &meta("")).unwrap(); + let doc = reopen(&doc); + let d = &list(&doc, 0)[t]; + assert_eq!(text(d, b"DA"), "1 0 0 rg /Helv 24 Tf"); + assert!(text(d, b"DS").contains("24pt") && text(d, b"RC").contains("font-size:24pt")); + assert_eq!(props(&doc, 0, t).unwrap().font_size, Some(24.0)); + assert!(height(&doc) > before, "the box grows"); + let mut doc = doc; + let grown = height(&doc); + set_font_size(&mut doc, 0, t, 6.0, &meta("")).unwrap(); + assert!(height(&doc) >= grown, "the box never shrinks"); + for bad in [0.0, 0.5, 401.0, f64::NAN, f64::INFINITY] { + assert!(matches!(set_font_size(&mut doc, 0, t, bad, &meta("")), Err(AnnotError::Invalid(_))), "{bad}"); + } + assert_eq!(props(&doc, 0, t).unwrap().font_size, Some(6.0)); + let note = add_annotation(&mut doc, &new(0, Shape::Note { at: [50.0, 50.0], icon: NoteIcon::Note }), &meta("t")); + if let Ok(i) = note { + assert!(set_font_size(&mut doc, 0, i, 12.0, &meta("")).is_err()); + assert_eq!(props(&doc, 0, i).unwrap().font_size, None); + } +} diff --git a/crates/audit/README.md b/crates/audit/README.md index c8a6a3249f..5dd15de3af 100644 --- a/crates/audit/README.md +++ b/crates/audit/README.md @@ -16,6 +16,11 @@ It reports two kinds of finding: A properly applied redaction removes the text, so it never flags. Page backgrounds, table shading and highlights don't either: they're painted before the text, or light against it. +A rectangle covers a text line when it hides at least 40% of the area of the words the line +draws, or at least 40% of the line's box together with a positive-area intersection with one +of its words. The gap an applied redaction leaves (a `TJ` adjustment where the text was) is in the line's box but +holds no word, so a redaction box over it alone does not count. + Limits, by design and documented rather than hidden: - only filled rectangles count as covers, drawn with `re` or as a single `m`/`l` subpath whose @@ -23,9 +28,13 @@ Limits, by design and documented rather than hidden: translucent one (`/ca` below 1) is not flagged; - covers inside form XObjects are found, but only text drawn directly in the page content is checked under them; -- annotation rectangles are compared in unrotated user space, so `/Rotate` pages may misalign. +- annotation rectangles are compared in unrotated user space, so `/Rotate` pages may misalign; +- a cover that hides less than 40% of a line's box and less than 40% of its words (a short + value in the middle of a long line) is not flagged. It fails closed: an undecodable content stream, malformed `/Annots`, form XObjects nested deeper than 32 or in a cycle, or a walk past the operator bound is an error, never "no findings". -Surfaces: the `redact_audit` automation tool and `pdfcraft-cli`. +Surfaces: the `doc_audit_redactions` automation tool and `pdfcraft-cli audit`. Both report each +rectangle in points from the top-left of the page as displayed (y down, after `/Rotate`), like +the other tools; `AuditFinding::rect` itself stays in PDF user space. diff --git a/crates/audit/src/cover.rs b/crates/audit/src/cover.rs index be8afe0a85..3df666c190 100644 --- a/crates/audit/src/cover.rs +++ b/crates/audit/src/cover.rs @@ -219,10 +219,13 @@ impl<'a> Walker<'a> { b"h" => { path = match path { Path::Lines(pts, _) => Path::Lines(pts, true), + // `re` already closed its subpath, so a further `h` changes nothing. + Path::Rect(r) => Path::Rect(r), _ => Path::Other, }; } - b"f" | b"F" | b"f*" | b"B" | b"B*" => { + // `b`/`b*` close the path before filling it, which the fill does anyway. + b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => { if let Some(r) = std::mem::replace(&mut path, Path::Empty).rect() && gs.alpha >= 1.0 { diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index ad26c25596..204d237b3d 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -9,12 +9,13 @@ //! unreadable (WCAG contrast below 3.0). Page backgrounds, table shading and highlight //! marks are therefore not flagged. //! -//! Detection limits (documented, not hidden): only `re` rectangles closed by a fill operator -//! are considered — arbitrary filled paths, shadings and patterns are not; a rectangle drawn -//! with a translucent ExtGState (`/ca < 1`) is not flagged; annotation rectangles are compared -//! in unrotated user space, so pages with `/Rotate` may misalign; covers inside form XObjects -//! are found, but only text drawn directly in the page content is checked under them, so text -//! inside a form XObject is not audited. +//! Detection limits (documented, not hidden): `re` rectangles and single closed `m`/`l` +//! subpaths whose four corners form an axis-aligned rectangle in user space (#1510) are +//! considered when closed by a fill operator — other filled paths, shadings and patterns are +//! not; a rectangle drawn with a translucent ExtGState (`/ca < 1`) is not flagged; annotation +//! rectangles are compared in unrotated user space, so pages with `/Rotate` may misalign; +//! covers inside form XObjects are found, but only text drawn directly in the page content +//! is checked under them, so text inside a form XObject is not audited. //! //! The audit fails closed: any content that cannot be read (an undecodable content stream, //! malformed annotations, form XObjects nested deeper than 32 or in a cycle) is an error, never @@ -40,6 +41,11 @@ pub enum FindingKind { } impl FindingKind { + /// The stable machine-readable ID for this finding class. + /// + /// These strings are the crate's public vocabulary: they appear in CLI output and are + /// the IDs a future machine-readable format would serialize. Do not rename them; + /// add a new variant instead. pub fn id(self) -> &'static str { match self { FindingKind::CoveredText => "covered-text", @@ -54,9 +60,11 @@ pub struct AuditFinding { /// 0-based page index. pub page: usize, pub kind: FindingKind, - /// The covering rectangle (or the mark's rectangle), in page user space. + /// The covering rectangle (or the mark's rectangle), in page user space, normalized so + /// `rect[0] <= rect[2]` and `rect[1] <= rect[3]`. pub rect: [f64; 4], - /// The extractable text the cover hides (possibly empty when nothing decodable remains). + /// The extractable text the cover hides, lines joined with single spaces and trimmed. + /// Possibly empty when nothing decodable remains under the cover. pub covered_text: String, } @@ -76,7 +84,8 @@ pub enum AuditError { Edit(#[from] pdfcraft_edit::EditError), } -/// Fraction of a text line's area that must lie under a cover to count as hidden. +/// Fraction of a text line's ink (the area of its words), or of its box, that must lie under a +/// cover to count as hidden (see [`covers`]). const COVER_FRACTION: f64 = 0.4; /// WCAG contrast ratio below which the text is unreadable against the cover. const COVER_CONTRAST: f64 = 3.0; @@ -106,7 +115,7 @@ pub fn audit_redactions(doc: &Document) -> Result, AuditError> for annot in annots(doc, &page.dict)? { let mut covered = String::new(); for line in &lines { - if covers(&annot, &line.rect) { + if covers(&annot, line) { if !covered.is_empty() { covered.push(' '); } @@ -126,23 +135,35 @@ pub fn audit_redactions(doc: &Document) -> Result, AuditError> /// make the text unreadable. Light covers (table shading, highlights) over dark text fail the /// contrast check and are not flagged. fn is_cover(rect: &cover::FilledRect, line: &TextLine) -> bool { - rect.pos > line.paint_pos() && covers(&rect.rect, &line.rect) && contrast(line.color, rect.rgb) < COVER_CONTRAST + rect.pos > line.paint_pos() && covers(&rect.rect, line) && contrast(line.color, rect.rgb) < COVER_CONTRAST } -/// Does `cover` hide a substantial part of `line`? -fn covers(cover: &[f64; 4], line: &[f64; 4]) -> bool { - let ix0 = cover[0].max(line[0]); - let iy0 = cover[1].max(line[1]); - let ix1 = cover[2].min(line[2]); - let iy1 = cover[3].min(line[3]); - if ix1 <= ix0 || iy1 <= iy0 { - return false; - } - let line_area = (line[2] - line[0]).max(0.0) * (line[3] - line[1]).max(0.0); - if line_area <= 0.0 { - return false; - } - (ix1 - ix0) * (iy1 - iy0) / line_area >= COVER_FRACTION +/// Does `cover` hide a substantial part of what `line` draws? Two ways, either is enough: +/// +/// - 40% of the area of the line's words (its `ink`) lies under the cover. Measured on the words, +/// not the box: an applied redaction leaves a gap that draws nothing (a `TJ` adjustment where +/// the text was), and the box still spans it, so a redaction box at the end of a line used to +/// cover most of the box and flag the words left beside it. +/// - 40% of the line's box lies under the cover (the measure used before `ink`) and intersects the ink +/// of a word still drawn. This keeps a cover over a short value after a long label +/// flagged, while a cover over the gap alone, with no word under it, stops counting. +/// +/// A line without word boxes is measured on its box alone. +fn covers(cover: &[f64; 4], line: &TextLine) -> bool { + let hidden = |b: &[f64; 4]| area(&[cover[0].max(b[0]), cover[1].max(b[1]), cover[2].min(b[2]), cover[3].min(b[3])]); + let fraction = |boxes: &[[f64; 4]]| { + let total: f64 = boxes.iter().map(area).sum(); + if total > 0.0 { boxes.iter().map(hidden).sum::() / total } else { 0.0 } + }; + let inked = fraction(&line.ink) >= COVER_FRACTION; + let boxed = fraction(std::slice::from_ref(&line.rect)) >= COVER_FRACTION; + let word_intersects = || line.ink.iter().any(|w| hidden(w) > 0.0); + inked || (boxed && (line.ink.is_empty() || word_intersects())) +} + +/// The area of a rectangle, 0 when it is empty or inverted. +fn area(r: &[f64; 4]) -> f64 { + (r[2] - r[0]).max(0.0) * (r[3] - r[1]).max(0.0) } /// WCAG 2.x relative luminance of an sRGB triple. diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index be98d72842..d7d83a7db3 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use pdfcraft_cos::Document; -use crate::{FindingKind, audit_redactions}; +use crate::{AuditFinding, FindingKind, audit_redactions}; /// Minimal PDF assembler: objects are numbered 1..=n in order. struct Builder { @@ -228,12 +228,105 @@ fn self_drawing_form_fails_the_audit() { assert!(matches!(result, Err(crate::AuditError::TooDeep)), "{result:?}"); } +/// An applied redaction leaves the words around it and a `TJ` adjustment over the gap where the +/// text was, then the redaction box. At the end of a line, the box covers most of the line's box +/// (which spans the gap) but none of its words: nothing is hidden, so nothing is flagged. +#[test] +fn applied_redaction_at_the_end_of_a_line_is_not_flagged() { + let content = b"BT /F1 11 Tf 72 742 Td [(Mail the team:) -11354] TJ ET\n0 g\n144.138 739.8 121.814 11 re f\n"; + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert!(findings.is_empty(), "{findings:?}"); + // The same gap between two words, wider than the words beside it. + let content = b"BT /F1 11 Tf 72 742 Td [(Card:) -14000 (ok)] TJ ET\n0 g\n101 739.8 150 11 re f\n"; + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert!(findings.is_empty(), "{findings:?}"); +} + +/// The control: the same box over text still drawn under it is a faux redaction, at the end of a +/// line as in the middle of a short one. +#[test] +fn faux_redaction_at_the_end_of_a_line_is_found() { + let content = b"BT /F1 11 Tf 72 742 Td (Mail the team: someone@example.com) Tj ET\n0 g\n144.138 739.8 121.814 11 re f\n"; + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].covered_text, "Mail the team: someone@example.com"); + let content = b"BT /F1 11 Tf 72 742 Td [(Card:) -300 (4111111111111111) -300 (ok)] TJ ET\n0 g\n101 739.8 100 11 re f\n"; + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); +} + +/// A cover over a short value after a long label hides less than 40% of the line's words, but the +/// whole of the words it covers, and most of the line's box: still a faux redaction. The value +/// follows the label in the same `TJ` after a wide gap, or in a second run moved by `Td`. +#[test] +fn faux_redaction_over_a_short_value_after_a_long_label_is_found() { + let content = b"BT /F1 11 Tf 72 742 Td [(Social insurance number:) -8000 (123 456 789)] TJ ET\n0 g\n200 739.8 150 11 re f\n"; + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].covered_text, "Social insurance number: 123 456 789"); + let content = b"BT /F1 11 Tf 72 742 Td (Account:) Tj 70 0 Td (4111) Tj ET\n0 g\n115 739.8 55 11 re f\n"; + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert!(findings[0].covered_text.contains("4111"), "{findings:?}"); +} + +/// Text set with a negative font size in a mirrored text matrix runs its glyphs backwards: a +/// cover over the address it draws is still found. +#[test] +fn faux_redaction_over_mirrored_text_is_found() { + let content = b"BT /F1 -11 Tf -1 0 0 1 300 742 Tm (Mail the team: someone@example.com) Tj ET\n0 g\n365 733 130 14 re f\n"; + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].covered_text, "Mail the team: someone@example.com"); +} + +/// A cover over less than half a value must retain the historical line-box detection. +#[test] +fn faux_redaction_over_a_partial_value_after_a_long_label_is_found() { + partial_value_is_found(false); +} + +#[test] +fn faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found() { + partial_value_is_found(true); +} + +fn partial_value_is_found(rotated: bool) { + let bytes = one_page( + b"BT /F1 11 Tf 72 742 Td [(Social insurance number:) -8000 (123456789)] TJ ET\n0 g\n193 738 111 16 re f\n", + if rotated { "/Rotate 90" } else { "" }, + ); + let findings = audit_redactions(&open(bytes)).expect("audit must run"); + assert_eq!(findings.len(), 1, "partial value, rotated={rotated}"); +} + +/// Keep the historic detections with several covers, lines and text-spacing states. +#[test] +fn faux_redactions_with_multiple_covers_lines_and_spacing_are_found() { + let cases: [&[u8]; 4] = [ + b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj ET\n0 g\n72 738 65 16 re f\n140 738 40 16 re f\n", + b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj 0 -15 Td (Account: 987654321) Tj ET\n0 g\n115 723 100 30 re f\n", + b"BT /F1 11 Tf 8 Tc 72 742 Td (Account: 1234) Tj ET\n0 g\n145 738 90 16 re f\n", + b"BT /F1 11 Tf 250 Tz 72 742 Td (Account: 1234) Tj ET\n0 g\n100 738 160 16 re f\n", + ]; + for content in cases { + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert_eq!(findings.len(), 1, "{}: {findings:?}", String::from_utf8_lossy(content)); + } +} + #[test] fn rectangle_drawn_as_a_path_covers_text_like_re() { // The same cover as `re`, built from `m`/`l` (closed with `h`, a repeated start point, or // only by the fill itself), or in a scaled user space: all are faux redactions (#1510). - let covers: [&[u8]; 5] = [ + // So are `re` with a redundant `h`, and either path painted with `b`/`b*` (#1817). + let covers: [&[u8]; 10] = [ b"0 g\n100 688 220 32 re f\n", + b"0 g\n100 688 220 32 re h f\n", + b"0 g\n100 688 220 32 re b\n", + b"0 g\n100 688 220 32 re h b*\n", + b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l b\n", + b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l h b*\n", b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l h f\n", b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l 100 688 l h f\n", b"0 g\n100 688 m 100 720 l 320 720 l 320 688 l f\n", @@ -253,9 +346,12 @@ fn rectangle_drawn_as_a_path_covers_text_like_re() { #[test] fn non_rectangular_path_is_not_a_cover() { // Each path's bounding box covers the text, but the shape itself does not hide it all. - let covers: [&[u8]; 5] = [ + let covers: [&[u8]; 7] = [ // A triangle. b"0 g\n100 688 m 320 688 l 320 720 l h f\n", + // A triangle painted with `b`, and a trapezoid with `b*`. + b"0 g\n100 688 m 320 688 l 320 720 l b\n", + b"0 g\n100 688 m 320 688 l 320 720 l 140 720 l b*\n", // A triangle, then a line from its start point after `h`: not a fourth corner. b"0 g\n100 688 m 320 688 l 320 720 l h 100 720 l f\n", // A trapezoid: four corners, one slanted side. @@ -272,3 +368,92 @@ fn non_rectangular_path_is_not_a_cover() { assert!(findings.is_empty(), "{:?}: {findings:?}", String::from_utf8_lossy(cover)); } } + +#[test] +fn finding_kind_ids_are_stable() { + // The string IDs are the crate's public vocabulary (CLI output, future machine-readable + // formats). They must not change; add a variant instead of renaming. + assert_eq!(FindingKind::CoveredText.id(), "covered-text"); + assert_eq!(FindingKind::UnappliedMark.id(), "unapplied-mark"); +} + +#[test] +fn covered_text_finding_is_fully_determined() { + // Golden test: the whole finding, not just its presence, is pinned. + let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n0 g\n100 688 220 32 re f\n"; + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert_eq!( + findings, + vec![AuditFinding { + page: 0, + kind: FindingKind::CoveredText, + rect: [100.0, 688.0, 320.0, 720.0], + covered_text: "SECRET DATA 123".to_string(), + }] + ); +} + +#[test] +fn unapplied_mark_finding_is_fully_determined() { + // Golden test for the second finding class, with its exact mark rectangle. + let content = b"BT /F1 24 Tf 100 700 Td (DO NOT SHARE) Tj ET\n"; + let page_extra = "/Annots [6 0 R]"; + let mut b = Builder::new(); + b.add("<< /Type /Catalog /Pages 2 0 R >>"); + b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + b.add(&format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> {page_extra} >>")); + b.add_stream(content); + b.add(FONT); + b.add("<< /Type /Annot /Subtype /Redact /Rect [95 685 305 725] /C [1 0 0] >>"); + let findings = audit_redactions(&open(b.build())).expect("audit must run"); + assert_eq!( + findings, + vec![AuditFinding { page: 0, kind: FindingKind::UnappliedMark, rect: [95.0, 685.0, 305.0, 725.0], covered_text: "DO NOT SHARE".to_string() }] + ); +} + +/// A page whose content draws a chain of `n` nested form XObjects. The innermost form draws +/// nothing, so a successful audit reports no findings. +fn nested_forms(n: usize) -> Vec { + let mut b = Builder::new(); + b.add("<< /Type /Catalog /Pages 2 0 R >>"); + b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + // Object 3: page; object 4: content; objects 5..5+n: the form chain. + b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /XObject << /F1 5 0 R >> >> >>"); + b.add_stream(b"/F1 Do"); + for i in 1..=n { + let obj = 4 + i; + if i < n { + let next = obj + 1; + let name = format!("/F{}", i + 1); + b.add_stream_with( + format!("/{name} Do", name = &name[1..]).as_bytes(), + &format!( + "/Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources << /XObject << /F{i} {next} 0 R >> >>", + i = i + 1, + next = next + ), + ); + } else { + b.add_stream_with(b"q Q", "/Type /XObject /Subtype /Form /BBox [0 0 612 792]"); + } + debug_assert_eq!(b.objects.len(), obj); + } + b.build() +} + +#[test] +fn forms_nested_at_depth_limit_audit_clean() { + // 32 nested forms is the deepest walk the audit follows: it must succeed and report + // the page clean rather than fail. + let findings = audit_redactions(&open(nested_forms(32))).expect("audit must run"); + assert!(findings.is_empty(), "{findings:?}"); +} + +#[test] +fn forms_nested_past_depth_limit_fail_closed() { + // The 33rd nesting level cannot be followed: the audit fails rather than report the + // page clean with a possible cover left unseen. + let result = audit_redactions(&open(nested_forms(33))); + assert!(matches!(result, Err(crate::AuditError::TooDeep)), "{result:?}"); +} diff --git a/crates/automation/README.md b/crates/automation/README.md index d702b7ed4f..4e27702a32 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -27,9 +27,14 @@ mcp::McpServer::new(a).serve(stdin, stdout)? // newline-delimited JS - For proportional image signature resizing, use the embedded image layer's width/height ratio rather than a previously stretched annotation rectangle. The GUI preserves this ratio at corners with the opposite corner anchored; edge midpoint handles change only their own axis. `comment_edit` accepts the resulting rectangle. - `doc_close` refuses to drop unsaved changes unless `discard_changes: true`. `doc_save` writes atomically, incrementally in place, and in full for a new path. Atomic output writes call `sync_all` on the staging file before replacing the destination. A write or sync error leaves the destination intact, removes staging and leaves the document dirty for retry. A filesystem that does not support syncing returns an error; there is no unsynced fallback. This flushes file contents, but does not guarantee persistence of the parent-directory rename across power loss. - With a root set, every read and write path must resolve inside it (symlinks and `..` included). Relative paths resolve inside the root, and `..` is resolved by name before the check. Every path outside the root gets the same refusal (` is outside the allowed directory `), whether or not it exists, so a confined agent can't probe the rest of the disk; on Windows another network share or device path (`\\host\share`, `\\?\UNC\…`, `\\.\…`) is refused without being contacted. "Not found" and other filesystem errors are reported only for paths inside the root. Tools that write several files into a folder name them after the document or input file, with separators, colons and control characters replaced by `_`, so a name can't lead them out of the folder. The root itself must be a folder. +- `Automation::with_automation_roots` holds a read folder and a write folder (`pdfcraft-cli mcp --automation-read-root DIR --automation-write-root DIR`, see [docs/mcp.md](../../docs/mcp.md)). Reads must lie inside the read root and writes inside the write root, links followed. A root left out grants none of its access. Refusals read `automation path rejected: outside the read root : `. It applies on top of `with_root`. ## Text search and cache limits +`text_word_count` counts the current snapshot without filling the text cache. It returns totals +and per-page Unicode word/character counts, with optional tagged-table, tagged-code and +image/OCR exclusions. See [word count](../../docs/word-count.md) for counting rules and limits. + Extracted page text is reused in a session-wide LRU cache: at most **64 MiB** of estimated owned text storage and **4,096 pages**, shared by all open documents. Accounting includes glyph-vector capacity, each glyph string's capacity, line indexes and word-break flags. Cache hits share the @@ -152,6 +157,10 @@ For inline editor previews, `page_render` accepts `hide_text_block` (a 1-based i `text_paragraphs`). It renders the page without that paragraph’s source glyphs and leaves the working document unchanged. +`page_render.hide_added_text` does the same for an added text item's 1-based index from +`content_list` or `object_list`. It removes only that item in a read-only preview and is +mutually exclusive with `hide_text_block`. + ## Existing text and document fonts @@ -184,3 +193,19 @@ unapplied with a warning. `doc_batch` runs conversion, OCR and compression over files and optionally recursive folders. The CLI also exposes it as `pdfcraft-cli batch`. See the [batch guide](../../docs/cli-batch.md) for examples, output protection, errors and limits. + +## Viewer navigation history + +A running PdfCraft instance launched with `--control FILE` exposes its per-tab history through +`pdfcraft-cli ui --control FILE command id=view.previous_view` and +`pdfcraft-cli ui --control FILE command id=view.next_view`. `commands` reports whether each action +is enabled for the active document. These are the same actions as the rail buttons, View → Page +navigation, and Ctrl+[ / Ctrl+] (⌘[ / ⌘] on macOS). + +Explicit page jumps, including search-result selection and Next/Previous match, enter history +when the page changes. Automatic search refresh, continuous scrolling, and within-page scroll +movement do not. Existing page-step commands and single-page wheel turns retain their history +behavior. Back/Forward +returns to page tops, does not restore zoom, does not switch documents, and is not saved across +restarts. Standalone engine/MCP sessions have no viewport, so they do not have a separate +navigation-history tool; use the opt-in UI control channel to navigate the displayed document. diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 71e96131e2..1e8d71bd1c 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -202,6 +202,10 @@ fn review_html(record: &Value) -> Result { Ok(html) } +/// Tags in one page of `accessibility_tags`, by default and at most. +const TAG_PAGE: usize = 200; +const MAX_TAG_PAGE: usize = 1000; + impl Automation { pub(crate) fn accessibility_autotag_apply(&mut self, a: &Args) -> Result { let id = self.doc(a)?.id; @@ -213,6 +217,19 @@ impl Automation { })) } + pub(crate) fn accessibility_autotag_form_fields(&mut self, a: &Args) -> Result { + let id = self.doc(a)?.id; + self.session.apply(id, pdfcraft_engine::Edit::AutoTagFormFields).map_err(failed)?; + let report = self + .doc(a)? + .accessibility_check(&Options { rules: [Rule::TaggedFormFields].into(), pages: None }) + .ok_or_else(|| failed("the document can't be read"))?; + let result = report.result(Rule::TaggedFormFields).ok_or_else(|| failed("the form field check is unavailable"))?; + Ok( + json!({ "status": status_id(result.status), "remaining_findings": result.findings.len(), "note": "Only form widgets were tagged. Review text, figures, links, reading order, and other document content separately." }), + ) + } + pub(crate) fn accessibility_autotag_preview(&self, a: &Args) -> Result { let page = self.page(a)?; let preview = self.doc(a)?.autotag_preview(page).map_err(failed)?; @@ -269,6 +286,14 @@ impl Automation { Ok(report_json(&r)) } + /// Report only untagged page content, annotations, and AcroForm widgets. + pub(crate) fn a11y_find_untagged(&self, a: &Args) -> Result { + let pages = a.opt_ints("pages")?.map(|_| self.pages(a, "pages")).transpose()?; + let rules = [Rule::TaggedContent, Rule::TaggedAnnotations, Rule::TaggedFormFields].into_iter().collect(); + let report = self.doc(a)?.accessibility_check(&Options { rules, pages }).ok_or_else(|| failed("the document can't be read for checking"))?; + Ok(report_json(&report)) + } + pub(crate) fn a11y_report(&self, a: &Args) -> Result { let o = self.a11y_options(a)?; let doc = self.doc(a)?; @@ -441,11 +466,39 @@ impl Automation { self.accessibility_figures(a) } + /// The structure tags, one page at a time (`offset`, `limit`), optionally only some types, pages + /// (1-based) or depths. `count` is how many match; `next` is where the following page starts. pub(crate) fn accessibility_tags(&self, a: &Args) -> Result { - let tags: Vec = self + let offset = usize::try_from(a.opt_int("offset")?.unwrap_or(0)).map_err(|_| ToolError::InvalidArgs("offset must be 0 or more".into()))?; + let limit = a.opt_int("limit")?.unwrap_or(TAG_PAGE as i64); + let limit = usize::try_from(limit) + .ok() + .filter(|l| (1..=MAX_TAG_PAGE).contains(l)) + .ok_or_else(|| ToolError::InvalidArgs(format!("limit must be from 1 to {MAX_TAG_PAGE}")))?; + let types: Option> = a.get("types").map(|_| a.strs("types")).transpose()?; + let pages: Option> = a + .opt_ints("pages")? + .map(|p| p.into_iter().map(|n| usize::try_from(n).ok().filter(|n| *n >= 1).map(|n| n - 1)).collect::>>()) + .map(|p| p.ok_or_else(|| ToolError::InvalidArgs("pages must be 1-based page numbers".into()))) + .transpose()?; + let max_depth = a + .opt_int("max_depth")? + .map(|d| usize::try_from(d).map_err(|_| ToolError::InvalidArgs("max_depth must be 0 or more".into()))) + .transpose()?; + let matching: Vec<_> = self .doc(a)? .tags() .into_iter() + .filter(|t| types.as_ref().is_none_or(|ty| ty.contains(&t.kind.as_str()))) + .filter(|t| pages.as_ref().is_none_or(|p| t.page.is_some_and(|page| p.contains(&page)))) + .filter(|t| max_depth.is_none_or(|d| t.depth <= d)) + .collect(); + let count = matching.len(); + let next = offset.checked_add(limit).filter(|n| *n < count); + let tags: Vec = matching + .into_iter() + .skip(offset) + .take(limit) .map(|t| { json!({ "object": t.object.num, "generation": t.object.generation, "type": t.kind, @@ -455,7 +508,17 @@ impl Automation { }) }) .collect(); - Ok(json!({ "count": tags.len(), "tags": tags })) + Ok(json!({ "count": count, "tags": tags, "next": next })) + } + + pub(crate) fn accessibility_tag_content(&self, a: &Args) -> Result { + let object = u32::try_from(a.int("object")?).map_err(|_| ToolError::InvalidArgs("object must be a positive tag object number".into()))?; + let generation = + u16::try_from(a.int("generation")?).map_err(|_| ToolError::InvalidArgs("generation must be a PDF object generation".into()))?; + let content = self.doc(a)?.tag_content(pdfcraft_cos::ObjRef { num: object, generation }).map_err(failed)?; + Ok( + json!({ "count": content.len(), "content": content.into_iter().map(|item| json!({ "page": item.page.map(|p| p + 1), "mcid": item.mcid, "text": item.text })).collect::>() }), + ) } pub(crate) fn accessibility_set_tag_property(&mut self, a: &Args) -> Result { @@ -476,6 +539,18 @@ impl Automation { self.accessibility_tags(a) } + pub(crate) fn accessibility_mark_tag_artifact(&mut self, a: &Args) -> Result { + let object = u32::try_from(a.int("object")?).map_err(|_| ToolError::InvalidArgs("object must be a positive tag object number".into()))?; + if object == 0 { + return Err(ToolError::InvalidArgs("object must be a positive tag object number".into())); + } + let generation = u16::try_from(a.int("generation")?) + .map_err(|_| ToolError::InvalidArgs("generation must be a PDF object generation from accessibility_tags".into()))?; + let id = self.doc(a)?.id; + self.session.apply(id, pdfcraft_engine::Edit::MarkTagArtifact { object: pdfcraft_cos::ObjRef::new(object, generation) }).map_err(failed)?; + self.accessibility_tags(a) + } + pub(crate) fn accessibility_move_tag(&mut self, a: &Args) -> Result { let reference = |number: &str, generation: &str| -> Result { let n = u32::try_from(a.int(number)?).map_err(|_| ToolError::InvalidArgs(format!("{number} must be a positive tag object number")))?; @@ -524,7 +599,7 @@ impl Automation { // other tools (the write check passes missing files, which stay per-file errors below). let paths = a.strs("paths")?; for p in &paths { - self.resolve(p, true)?; + self.resolve_for(p, crate::file_access::Access::Read, true)?; } std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?; let ocr = pdfcraft_engine::ocr::engine().map_err(failed)?; diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index d98c48723e..302688f729 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -511,7 +511,8 @@ impl Automation { let text_at = |t: &str| { let w = (pdfcraft_engine::annot_text::text_width(t, size) + 8.0).clamp(20.0, 600.0); let h = size * 1.2 + 6.0; - Shape::Typewriter { rect: [at[0], at[1] - h, at[0] + w, at[1]], font_size: size } + let rect = pdfcraft_engine::text_box_at(i64::from(info.rotation), at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]); + Shape::Typewriter { rect, font_size: size } }; let (shape, contents) = match kind { "text" => { @@ -618,6 +619,12 @@ impl Automation { let fill = if f.eq_ignore_ascii_case("none") { None } else { Some(parse_color(f)?) }; edits.push(Edit::FillAnnotation { page, index, fill }); } + if let Some(size) = a.opt_num("font_size")? { + if !(1.0..=400.0).contains(&size) { + return Err(ToolError::InvalidArgs("font_size must be 1–400 pt".into())); + } + edits.push(Edit::FontSizeAnnotation { page, index, size }); + } if let Some(r) = a.nums::<4>("rect")? { edits.push(Edit::ResizeAnnotation { page, index, rect: rect_to_user(&info, r) }); } @@ -626,7 +633,7 @@ impl Automation { edits.push(Edit::MoveAnnotation { page, index, dx: d[0] - o[0], dy: d[1] - o[1] }); } if edits.is_empty() { - return Err(ToolError::InvalidArgs("nothing to change: pass contents, color, opacity, width, rect or move".into())); + return Err(ToolError::InvalidArgs("nothing to change: pass contents, color, opacity, width, fill, font_size, rect or move".into())); } let edit = if edits.len() == 1 { edits.remove(0) } else { Edit::Batch { label: "Edit comment".into(), edits } }; self.apply(a, edit) diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs index 0e97aa8b60..710952d7c2 100644 --- a/crates/automation/src/content.rs +++ b/crates/automation/src/content.rs @@ -69,7 +69,7 @@ impl Automation { let doc = self.doc(a)?; let mut per_page: std::collections::HashMap = Default::default(); let items: Vec = doc - .added + .added() .iter() .map(|it| { let index = per_page.entry(it.page).or_default(); @@ -110,7 +110,7 @@ impl Automation { let mut t = AddedText { rect, text: a.str("text")?.to_string(), ..AddedText::default() }; style(a, &mut t)?; let mut out = self.apply(a, Edit::AddText { page, text: t })?; - out["index"] = json!(self.doc(a)?.added.iter().filter(|x| x.page == page).count()); + out["index"] = json!(self.doc(a)?.added().iter().filter(|x| x.page == page).count()); Ok(out) } @@ -123,7 +123,7 @@ impl Automation { let rect = rect_arg(a, "rect")?.map(|r| to_display(r, ph)); let mut out = self.apply(a, Edit::AddImage { page, rect, name, bytes: std::sync::Arc::new(bytes) })?; let doc = self.doc(a)?; - let mine: Vec<_> = doc.added.iter().filter(|x| x.page == page).collect(); + let mine: Vec<_> = doc.added().iter().filter(|x| x.page == page).collect(); out["index"] = json!(mine.len()); out["rect"] = json!(mine.last().map(|x| to_view(x.content.rect(), ph))); Ok(out) @@ -135,7 +135,7 @@ impl Automation { let index = a.int("index")?; let ph = doc.info.pages[page].height as f64; let it = doc - .added + .added() .iter() .filter(|x| x.page == page) .nth((index - 1).max(0) as usize) diff --git a/crates/automation/src/conventions.rs b/crates/automation/src/conventions.rs index af8cf671ac..ac670109fb 100644 --- a/crates/automation/src/conventions.rs +++ b/crates/automation/src/conventions.rs @@ -2,15 +2,31 @@ use serde_json::{Value, json}; use crate::{Args, Automation, Content, Result, ToolError, encode_png, failed, info, tools}; +use pdfcraft_engine::commands; use pdfcraft_render::{RenderRequest, RequestKind}; /// The most steps one `command_batch` runs. pub(crate) const MAX_BATCH_STEPS: usize = 1000; +/// Why `id` has no tool to run: a tool name passed as a command id, an id the registry doesn't +/// know, or a real command that nothing automates yet. They need different fixes from the caller. +fn no_tool_for(id: &str) -> ToolError { + if commands::command(id).is_some() { + return failed(format!("command {id:?} has no headless tool; see command_list")); + } + match tools::find(id) { + Some(def) => match def.command { + Some(command) => failed(format!("{id:?} is a tool, not a command id; use the command id {command:?}, or call the tool directly")), + None => failed(format!("{id:?} is a tool, not a command id; call it directly (command ids are in command_list)")), + }, + None => failed(format!("unknown command id {id:?}; see command_list")), + } +} + impl Automation { pub(crate) fn command_run(&mut self, args: &Args) -> Result> { let id = args.str("id")?; - let tool = tools::tool_for_command(id).ok_or_else(|| failed(format!("command {id:?} has no headless tool; see command_list")))?; + let tool = tools::tool_for_command(id).ok_or_else(|| no_tool_for(id))?; let def = tools::find(tool).ok_or_else(|| failed("command tool is unavailable"))?; let mut params = match args.get("params") { None | Some(Value::Null) => serde_json::Map::new(), diff --git a/crates/automation/src/file_access.rs b/crates/automation/src/file_access.rs new file mode 100644 index 0000000000..55b10662f0 --- /dev/null +++ b/crates/automation/src/file_access.rs @@ -0,0 +1,381 @@ +//! The folders automation may read and write. +//! +//! `pdfcraft-cli mcp --automation-read-root --automation-write-root ` confines the +//! files an agent's tools reach, with the names and wording VectorCraft uses. [`AutomationRoots`] +//! holds the two folders. Every path a tool reads is checked with [`Access::Read`] and every path +//! it writes with [`Access::Write`], in `Automation::resolve`, which all of them go through: +//! +//! - The path is made absolute against the working directory, as the file system reads it. Its +//! links are followed: for a file that doesn't exist yet, the links of its nearest existing +//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so +//! is a link that leads nowhere). +//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside +//! `/work`), without regard to case on Windows. +//! - Read and write access are separate: a root given alone grants only its own kind of access. +//! +//! Without roots nothing is checked. A path is checked, then opened: a process that can change the +//! folders in between (swapping in a link) can still win that race. The roots keep an agent to the +//! files it was given; they don't guard against other programs on the computer. + +use std::ffi::OsString; +use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; + +/// Read access or write access. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Access { + Read, + Write, +} + +/// The folders automation may read and write; a root left out grants no access of its kind. +#[derive(Debug)] +pub struct AutomationRoots { + read: Option, + write: Option, +} + +#[derive(Debug)] +struct Root { + /// The folder made absolute, links followed: what paths are compared with. + resolved: PathBuf, + /// The same without Windows' `\\?\` prefix, for people. + shown: PathBuf, +} + +impl Root { + /// The folder at `path`, which must exist. + fn open(path: &Path, access: Access) -> Result { + let kind = match access { + Access::Read => "read", + Access::Write => "write", + }; + let resolved = std::fs::canonicalize(path) + .and_then(|p| if p.is_dir() { Ok(p) } else { Err(std::io::ErrorKind::NotADirectory.into()) }) + .map_err(|e| format!("cannot open the automation {kind} root `{}`: {e}", path.display()))?; + Ok(Self { shown: plain(resolved.clone()), resolved }) + } +} + +impl AutomationRoots { + /// The roots `read` and `write`, which must be existing folders; `None` when neither is given + /// (nothing is confined then). + pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result>, String> { + if read.is_none() && write.is_none() { + return Ok(None); + } + let read = read.map(|p| Root::open(p, Access::Read)).transpose()?; + let write = write.map(|p| Root::open(p, Access::Write)).transpose()?; + Ok(Some(Arc::new(Self { read, write }))) + } + + /// The read root, absolute with its links followed. + pub fn read_root(&self) -> Option<&Path> { + self.read.as_ref().map(|r| r.shown.as_path()) + } + + /// The write root, absolute with its links followed. + pub fn write_root(&self) -> Option<&Path> { + self.write.as_ref().map(|r| r.shown.as_path()) + } + + /// May the file or folder at `path` be read (or written)? `Err` says why not. + pub fn check(&self, path: &str, access: Access) -> Result<(), String> { + self.check_path(path, Path::new(path), access) + } + + /// [`check`](Self::check) for a path that was already put together from `shown` (the way the + /// caller wrote it, which is what refusals quote). + pub fn check_path(&self, shown: &str, target: &Path, access: Access) -> Result<(), String> { + let root = match access { + Access::Read => self.read.as_ref(), + Access::Write => self.write.as_ref(), + }; + let Some(Root { resolved: folder, shown: root_shown }) = root else { + return match access { + Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {shown}")), + Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {shown}")), + }; + }; + if inside(&resolve(shown, target)?, folder) { + return Ok(()); + } + let root = root_shown.display(); + match access { + Access::Read => Err(format!("automation path rejected: outside the read root {root}: {shown}")), + Access::Write => Err(format!("automation path rejected: outside the write root {root}: {shown}")), + } + } +} + +/// `path` without Windows' `\\?\` prefix (which `canonicalize` adds), for people to read. +fn plain(path: PathBuf) -> PathBuf { + if !cfg!(windows) { + return path; + } + let text = path.to_string_lossy(); + if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { + return PathBuf::from(format!(r"\\{rest}")); + } + match text.strip_prefix(r"\\?\") { + Some(rest) => PathBuf::from(rest), + None => path, + } +} + +/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest +/// existing folder's links followed and the rest appended. +fn resolve(shown: &str, path: &Path) -> Result { + if path.as_os_str().is_empty() { + return Err("automation path rejected: path is empty".into()); + } + // Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows. + if cfg!(windows) && path.components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) { + return Err(format!("automation path rejected: reserved device names are not allowed: {shown}")); + } + let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {shown}: {e}"))?; + // The names below the nearest existing folder, last first. + let mut missing: Vec = vec![]; + let mut at = absolute.as_path(); + loop { + match std::fs::canonicalize(at) { + Ok(mut found) => { + found.extend(missing.iter().rev()); + return Ok(found); + } + Err(e) => match std::fs::symlink_metadata(at) { + Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {shown}")), + // There, yet its real path can't be had (no permission): nothing to compare. + Ok(_) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")), + }, + } + let parent = at.parent(); + match (at.components().next_back(), parent) { + (Some(Component::Normal(name)), Some(parent)) => { + missing.push(name.to_os_string()); + at = parent; + } + (Some(Component::ParentDir), _) => { + return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {shown}")); + } + // Down to the root (or drive) and it doesn't exist either. + _ => { + let e = std::io::Error::from(std::io::ErrorKind::NotFound); + return Err(format!("automation path rejected: cannot resolve {shown}: {e}")); + } + } + } +} + +/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on +/// Windows, whose file systems ignore it)? +fn inside(path: &Path, root: &Path) -> bool { + let mut path = path.components(); + root.components().all(|r| path.next().is_some_and(|p| same_component(p, r))) +} + +fn same_component(a: Component, b: Component) -> bool { + if a == b { + return true; + } + if !cfg!(windows) { + return false; + } + match (a.as_os_str().to_str(), b.as_os_str().to_str()) { + (Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(), + _ => false, + } +} + +/// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file. +fn is_device_name(component: &str) -> bool { + let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase(); + let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")); + matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fresh folder holding `in` (read root), `out` (write root) and `outside`. + fn folders(name: &str) -> (PathBuf, Arc) { + let base = std::env::temp_dir().join(format!("pc-file-access-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for d in ["in/sub", "out", "outside", "in2"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + std::fs::write(base.join("in/a.pdf"), "x").unwrap(); + std::fs::write(base.join("outside/secret.pdf"), "x").unwrap(); + let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap(); + (base, roots) + } + + fn s(p: PathBuf) -> String { + p.to_string_lossy().into_owned() + } + + #[test] + fn paths_inside_the_roots_pass_and_others_dont() { + let (base, roots) = folders("inside"); + assert_eq!(roots.check(&s(base.join("in/a.pdf")), Access::Read), Ok(())); + assert_eq!(roots.check(&s(base.join("in")), Access::Read), Ok(()), "the root itself"); + assert_eq!(roots.check(&s(base.join("in/sub/new.pdf")), Access::Read), Ok(()), "missing files are checked by their folder"); + assert_eq!(roots.check(&s(base.join("out/new/deeper/x.png")), Access::Write), Ok(()), "and by their nearest folder"); + let e = roots.check(&s(base.join("outside/secret.pdf")), Access::Read).unwrap_err(); + assert!(e.starts_with("automation path rejected: outside the read root"), "{e}"); + // Read and write access are separate. + let e = roots.check(&s(base.join("in/a.pdf")), Access::Write).unwrap_err(); + assert!(e.contains("outside the write root"), "{e}"); + assert!(roots.check(&s(base.join("out/x.png")), Access::Read).unwrap_err().contains("outside the read root")); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn a_sibling_with_the_same_prefix_is_outside() { + let (base, roots) = folders("prefix"); + std::fs::write(base.join("in2/b.pdf"), "x").unwrap(); + assert!(roots.check(&s(base.join("in2/b.pdf")), Access::Read).is_err(), "in2 is not inside in"); + assert!(roots.check(&format!("{}2/c.pdf", s(base.join("in"))), Access::Read).is_err()); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn parent_traversal_is_resolved_or_refused() { + let (base, roots) = folders("dotdot"); + let sep = std::path::MAIN_SEPARATOR; + let up = format!("{}{sep}..{sep}outside{sep}secret.pdf", s(base.join("in"))); + assert!(roots.check(&up, Access::Read).unwrap_err().contains("outside the read root"), "`..` out of the root"); + let back = format!("{}{sep}..{sep}a.pdf", s(base.join("in/sub"))); + assert_eq!(roots.check(&back, Access::Read), Ok(()), "`..` that stays inside"); + let missing = format!("{}{sep}nope{sep}..{sep}..{sep}outside{sep}x.pdf", s(base.join("out"))); + let e = roots.check(&missing, Access::Write).unwrap_err(); + // Windows resolves `..` by name first (so does opening the file there): then it's outside. + assert!(e.contains("parent traversal below a folder that doesn't exist") || e.contains("outside the write root"), "{e}"); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn relative_paths_resolve_against_the_working_directory() { + let (base, roots) = folders("relative"); + // The tests' working directory is the crate's folder: not inside the roots. + assert!(roots.check("a.pdf", Access::Read).unwrap_err().contains("outside the read root")); + assert!(roots.check("", Access::Read).unwrap_err().contains("path is empty")); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn a_missing_root_is_an_error_and_none_is_no_confinement() { + let (base, _) = folders("missing"); + let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err(); + assert!(e.starts_with("cannot open the automation read root"), "{e}"); + let e = AutomationRoots::new(None, Some(&base.join("in/a.pdf"))).unwrap_err(); + assert!( + e.starts_with("cannot open the automation write root") + && e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()), + "{e}" + ); + assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``")); + assert!(AutomationRoots::new(None, None).unwrap().is_none()); + // One root alone grants only its own access. + let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap(); + let e = read_only.check(&s(base.join("in/x.pdf")), Access::Write).unwrap_err(); + assert!(e.contains("write authority is absent"), "{e}"); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn links_are_followed_before_comparing() { + let (base, roots) = folders("links"); + #[cfg(unix)] + let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok(); + #[cfg(windows)] + let made = |target: &Path, link: &Path| { + let made = + if target.is_dir() { std::os::windows::fs::symlink_dir(target, link) } else { std::os::windows::fs::symlink_file(target, link) }; + made.is_ok() + }; + #[cfg(not(any(unix, windows)))] + let made = |_: &Path, _: &Path| false; + // Windows needs Developer Mode or elevation to make links: nothing to test without them. + if !made(&base.join("outside/secret.pdf"), &base.join("in/leak.pdf")) { + eprintln!("skipped: symbolic links can't be made here"); + let _ = std::fs::remove_dir_all(base); + return; + } + assert!(roots.check(&s(base.join("in/leak.pdf")), Access::Read).unwrap_err().contains("outside the read root"), "a file link out"); + assert!(made(&base.join("outside"), &base.join("out/away"))); + let e = roots.check(&s(base.join("out/away/new.png")), Access::Write).unwrap_err(); + assert!(e.contains("outside the write root"), "a folder link out: {e}"); + assert!(made(&base.join("outside/gone.pdf"), &base.join("out/dangling.png"))); + let e = roots.check(&s(base.join("out/dangling.png")), Access::Write).unwrap_err(); + assert!(e.contains("a link that leads nowhere"), "writing would create the file outside: {e}"); + // A link to a folder inside the root stays inside. + assert!(made(&base.join("in/sub"), &base.join("in/alias"))); + assert_eq!(roots.check(&s(base.join("in/alias/x.pdf")), Access::Read), Ok(())); + // A root given through a link is the folder it leads to. + assert!(made(&base.join("in"), &base.join("in-link"))); + let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap(); + assert_eq!(linked.check(&s(base.join("in/a.pdf")), Access::Read), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + #[cfg(windows)] + #[test] + fn windows_ignores_case_and_refuses_devices() { + let (base, roots) = folders("case"); + let upper = s(base.join("IN/A.PDF")); + assert_eq!(roots.check(&upper, Access::Read), Ok(()), "{upper}"); + assert_eq!(roots.check(&s(base.join("In/Sub/New.pdf")), Access::Read), Ok(())); + let e = roots.check(&s(base.join("out/NUL")), Access::Write).unwrap_err(); + assert!(e.contains("reserved device names"), "{e}"); + assert!(roots.check(&s(base.join("out/com1.txt")), Access::Write).is_err()); + assert_eq!(roots.check(&s(base.join("out/console.txt")), Access::Write), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + /// Every file a tool reads or writes goes through `Automation::resolve`, which holds it to the + /// roots. A function that reaches for the file system without resolving a path first would + /// bypass them, so each use of `std::fs` or `File::open` must come after a `resolve` call in + /// the same function (or be one of the helpers that only ever get a resolved path). + #[test] + fn tools_do_not_touch_the_file_system_without_resolving_the_path() { + // Helpers that are only called with a resolved path. `doc_batch` (batch.rs) resolves its + // inputs and out_dir, walks only inside them (skipping links) and resolves every + // discovered file again before `process` reads it or `publish` writes it. + const RESOLVED_BY_CALLER: &[&str] = &["write_atomic", "write_atomic_with", "read_review_sidecar", "process", "linked", "walk", "publish"]; + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut offenders = vec![]; + for entry in std::fs::read_dir(&dir).unwrap() { + let path = entry.unwrap().path(); + if path.extension().is_none_or(|e| e != "rs") + || path.file_name().is_some_and(|n| n == "file_access.rs" || n.to_string_lossy().starts_with("tests")) + { + continue; + } + let text = std::fs::read_to_string(&path).unwrap(); + // Test modules (which build their own fixtures) come last in each file. + let code = text.split("\n#[cfg(test)]").next().unwrap_or(""); + let lines: Vec<&str> = code.lines().collect(); + for (n, line) in lines.iter().enumerate() { + let trimmed = line.trim_start(); + if trimmed.starts_with("//") + || !(trimmed.contains("std::fs") + || trimmed.contains("File::open") + || trimmed.contains("File::create") + || trimmed.contains("OpenOptions")) + { + continue; + } + let start = (0..=n).rev().find(|&i| lines[i].trim_start().contains("fn ") && lines[i].trim_start().ends_with('{')).unwrap_or(0); + let name = lines[start].split("fn ").nth(1).and_then(|r| r.split(['(', '<']).next()).unwrap_or(""); + if !RESOLVED_BY_CALLER.contains(&name) && !lines[start..=n].iter().any(|l| l.contains("resolve")) { + offenders.push(format!("{}:{}: in `{name}`: {}", path.display(), n + 1, trimmed)); + } + } + } + assert!(offenders.is_empty(), "resolve the path with `self.resolve` first: {offenders:#?}"); + } +} diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index acda47104d..859f96b495 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -7,7 +7,10 @@ //! server ([`mcp`]), `pdfcraft-cli run` and (later) the UI control channel. //! - Pages are **1-based** in every tool, as people number them. Rectangles are in PDF points //! with the origin at the top-left of the displayed page. -//! - An optional root directory confines every path a tool reads or writes. +//! - An optional root directory confines every path a tool reads or writes. Separately, +//! [`file_access::AutomationRoots`] (`pdfcraft-cli mcp --automation-read-root` and +//! `--automation-write-root`) holds a read folder and a write folder, each left out meaning no +//! access of its kind. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -16,6 +19,7 @@ mod batch; mod comments; mod content; mod conventions; +pub mod file_access; mod forms; mod links; #[cfg(feature = "mcp")] @@ -33,10 +37,11 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use pdfcraft_engine::{CombineSource, DocId, Document, Edit, Session, commands}; -use pdfcraft_platform::staging::{StagingName, create_staging, staging_suffixes}; +use pdfcraft_platform::staging::{StagingName, staging_suffixes}; use pdfcraft_render::{PageRenderer, PageText, RenderConfig, RenderRequest, RequestKind}; use serde_json::{Value, json}; +pub use pdfcraft_render::Pixels; pub use tools::{ToolDef, tools}; /// One piece of a tool's result. @@ -107,6 +112,8 @@ const FIND_MAX_WARNING_BYTES: usize = 1024; pub struct Automation { session: Session, root: Option, + /// The read and write folders of `--automation-read-root` / `--automation-write-root`. + roots: Option>, /// Synchronous renderers, rebuilt when a document's working bytes change. renderers: HashMap>, PageRenderer)>, /// Extracted page text, under one memory budget across all open documents. @@ -121,7 +128,7 @@ impl Default for Automation { impl Automation { pub fn new() -> Self { - Self { session: Session::new(), root: None, renderers: HashMap::new(), texts: TextCache::default() } + Self { session: Session::new(), root: None, roots: None, renderers: HashMap::new(), texts: TextCache::default() } } /// Confine every path the tools read or write to `root` (relative paths resolve inside it). @@ -134,26 +141,42 @@ impl Automation { Ok(self) } + /// Confine the files the tools read to the read root and the files they write to the write + /// root (`None`: no confinement). Applies on top of [`with_root`](Self::with_root) when both + /// are set. + pub fn with_automation_roots(mut self, roots: Option>) -> Self { + self.roots = roots; + self + } + /// Use a fixed clock for saves (deterministic output in tests). pub fn with_clock(mut self, clock: fn() -> i64) -> Self { self.session = std::mem::take(&mut self.session).with_clock(clock); self } + /// The clock and time zone of a host without its own. See [`Session::with_wall_clock`]. + pub fn with_wall_clock(mut self, clock: fn() -> i64, utc_offset: fn() -> i64) -> Self { + self.session = std::mem::take(&mut self.session).with_wall_clock(clock, utc_offset); + self + } + pub fn session(&self) -> &Session { &self.session } /// Save `bytes` that a caller writes on the session's behalf, such as `pdfcraft-cli run` /// saving a rendered page. With a root, it is confined like a tool's own output (a relative - /// path resolves inside it) and written atomically. Without one, the path is written as - /// given, so `/dev/stdout` and the like still work. Returns where the file went. + /// path resolves inside it). Written atomically either way (#2166), except to something + /// that is not a regular file, such as `/dev/stdout` or a pipe, which is written as given. + /// Returns where the file went. pub fn write_output(&self, path: &str, bytes: &[u8]) -> Result { let target = self.resolve(path, true)?; - if self.root.is_some() { - write_atomic(&target, bytes)?; - } else { + let special = self.root.is_none() && self.roots.is_none() && std::fs::metadata(&target).is_ok_and(|m| !m.is_file() && !m.is_dir()); + if special { std::fs::write(&target, bytes).map_err(|e| failed(format!("{path}: {e}")))?; + } else { + write_atomic(&target, bytes)?; } Ok(target) } @@ -186,6 +209,7 @@ impl Automation { "page_render" => return self.page_render(&a).map(|c| vec![c]), "comment_image_preview" => return self.comment_image_preview(&a), "text_extract" => self.text_extract(&a)?, + "text_word_count" => self.text_word_count(&a)?, "text_find" => self.text_find(&a)?, "page_rotate" => { let degrees = a.int("degrees")?; @@ -352,7 +376,9 @@ impl Automation { self.apply(&a, Edit::DuplicatePages { pages })? } "page_set_box" => self.page_set_box(&a)?, + "page_transition_set" => self.page_transition_set(&a)?, "doc_create" => self.doc_create(&a)?, + "xfa_to_static" => self.apply(&a, Edit::XfaToStatic)?, "doc_flatten" => { let (comments, fields) = (a.opt_bool("comments")?.unwrap_or(true), a.opt_bool("fields")?.unwrap_or(true)); if !comments && !fields { @@ -466,6 +492,15 @@ impl Automation { ImageEdit::Move([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) } "rotate" => ImageEdit::Rotate(a.opt_int("quarters")?.unwrap_or(1) as i32), + "crop" => { + let r: Vec = + a.get("rect").and_then(Value::as_array).map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); + let r = + <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("crop needs rect: 4 numbers, the part to keep".into()))?; + let info = &self.doc(&a)?.info.pages[page]; + let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32)); + ImageEdit::Crop([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) + } "flip_horizontal" => ImageEdit::Flip { horizontal: true }, "flip_vertical" => ImageEdit::Flip { horizontal: false }, "delete" => ImageEdit::Delete, @@ -474,14 +509,6 @@ impl Automation { "bring_forward" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::BringForward), "send_backward" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::SendBackward), "remove_crop" => ImageEdit::RemoveCrop, - "crop" => { - let r: Vec = - a.get("rect").and_then(Value::as_array).map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); - let r = <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("crop needs rect: 4 numbers".into()))?; - let info = &self.doc(&a)?.info.pages[page]; - let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32)); - ImageEdit::Crop([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) - } "replace" => { let path = self.resolve(a.str("path")?, false)?; let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; @@ -530,6 +557,12 @@ impl Automation { .iter() .map(|p| self.resolve(p.as_str().ok_or_else(|| ToolError::InvalidArgs("directory must be a path".into()))?, false)) .collect::>>()? + } else if let Some(roots) = &self.roots { + // The installed fonts are outside the roots: look in the read root instead. + let read = roots + .read_root() + .ok_or_else(|| failed("automation filesystem access is not granted: read authority is absent: installed fonts"))?; + vec![self.resolve(&read.to_string_lossy(), false)?] } else if let Some(root) = &self.root { vec![root.clone()] } else { @@ -735,8 +768,10 @@ impl Automation { "comment_set_status" => self.comment_set_status(&a)?, "sign_list" => self.sign_list(&a)?, "accessibility_check" => self.a11y_check(&a)?, + "accessibility_find_untagged" => self.a11y_find_untagged(&a)?, "accessibility_autotag_preview" => self.accessibility_autotag_preview(&a)?, "accessibility_autotag_apply" => self.accessibility_autotag_apply(&a)?, + "accessibility_autotag_form_fields" => self.accessibility_autotag_form_fields(&a)?, "ocr_recognize" => self.ocr_recognize(&a)?, "js_run" => self.js_run(&a)?, "js_document_scripts" => self.js_document_scripts(&a)?, @@ -767,7 +802,9 @@ impl Automation { "accessibility_figures" => self.accessibility_figures(&a)?, "accessibility_set_alt" => self.accessibility_set_alt(&a)?, "accessibility_tags" => self.accessibility_tags(&a)?, + "accessibility_tag_content" => self.accessibility_tag_content(&a)?, "accessibility_set_tag_property" => self.accessibility_set_tag_property(&a)?, + "accessibility_mark_tag_artifact" => self.accessibility_mark_tag_artifact(&a)?, "accessibility_move_tag" => self.accessibility_move_tag(&a)?, "sign_id_create" => self.sign_id_create(&a)?, "sign_document" => self.sign_document(&a)?, @@ -832,7 +869,8 @@ impl Automation { .session .bookmark_page(id, offset, limit) .ok_or_else(|| ToolError::Failed(format!("no open document with id {} (see doc_list)", id.0)))?; - Ok(json!({ "bookmarks": nest_rows(&page.rows), "next": page.next, "truncated": page.truncated })) + let pages = &self.doc(a)?.info.pages; + Ok(json!({ "bookmarks": nest_rows(&page.rows, pages), "next": page.next, "truncated": page.truncated })) } fn doc(&self, a: &Args) -> Result<&Document> { @@ -879,7 +917,14 @@ impl Automation { let id = doc.id; let target = match a.opt_str("path")? { Some(p) => self.resolve(p, true)?, - None => PathBuf::from(doc.path.clone().ok_or_else(|| failed("the document has never been saved: pass a path"))?), + None => { + // Saving in place writes where the file was opened from, which the read root + // allows and the write root may not. + let own = doc.path.clone().ok_or_else(|| failed("the document has never been saved: pass a path"))?; + let own_path = PathBuf::from(&own); + self.check_roots(&own, &own_path, file_access::Access::Write)?; + own_path + } }; let same_file = doc.path.as_deref().is_some_and(|p| Path::new(p) == target); // Saving to a new file is a full rewrite unless asked otherwise, like Save As. @@ -1006,11 +1051,14 @@ impl Automation { } fn doc_optimize(&mut self, a: &Args) -> Result { - use pdfcraft_engine::optimize::{Compression, ImageSettings, Settings}; + use pdfcraft_engine::optimize::{Compression, ImageSettings, Preset}; let id = self.doc(a)?.id; let before = self.doc(a)?.bytes.len(); let path = self.resolve(a.str("path")?, true)?; - let mut settings = Settings::default(); + let mut settings = match a.opt_str("preset")? { + None => Preset::Standard.settings(), + Some(p) => Preset::from_name(p).ok_or_else(|| ToolError::InvalidArgs(format!("unknown preset {p:?} (standard, mobile)")))?.settings(), + }; let images = |key: &str, base: ImageSettings| -> Result { let Some(v) = a.get(key) else { return Ok(base) }; let mut s = base; @@ -1038,6 +1086,7 @@ impl Automation { settings.color = images("color", settings.color)?; settings.gray = images("gray", settings.gray)?; for (key, flag) in [ + ("merge_fragments", &mut settings.merge_fragments), ("discard_thumbnails", &mut settings.discard_thumbnails), ("discard_alternate_images", &mut settings.discard_alternate_images), ("discard_tags", &mut settings.discard_tags), @@ -1070,6 +1119,8 @@ impl Automation { "path": path.to_string_lossy(), "bytes_before": before, "bytes_after": bytes.len(), + "merged_images": o.merged_images, + "fragments_merged": o.fragments_merged, "images": o.images, "images_resampled": o.images_resampled, "images_recompressed": o.images_recompressed, @@ -1210,7 +1261,7 @@ impl Automation { let out = pdfcraft_engine::export::extract_images(&doc.export_source(), &pages, min).map_err(failed)?; let mut files = Vec::new(); for (k, img) in out.images.iter().enumerate() { - let path = child(&folder, &pdfcraft_engine::export::image_file_name(&stem, img, k + 1)); + let path = child(&folder, &pdfcraft_engine::export::image_file_name(&stem, img, k + 1, doc.info.pages.len())); write_atomic(&path, &img.data)?; files.push(json!({ "path": path.to_string_lossy(), "page": img.page + 1, "width": img.width, "height": img.height })); } @@ -1292,6 +1343,58 @@ impl Automation { Ok(summary(self.session.get(id).ok_or_else(|| failed("the document vanished"))?)) } + fn page_transition_set(&mut self, a: &Args) -> Result { + use pdfcraft_engine::{Transition, TransitionSpec}; + let n = self.doc(a)?.info.pages.len(); + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..n).collect(), + }; + let transition = match a.opt_str("style")? { + None | Some("none") => None, + Some(style) => { + let mut t = Transition::new(style); + if let Some(d) = a.opt_num("duration")? { + t.duration = d as f32; + } + if let Some(dm) = a.opt_str("dimension")? { + t.horizontal = match dm { + "horizontal" => true, + "vertical" => false, + other => return Err(ToolError::InvalidArgs(format!("dimension must be horizontal or vertical, not {other:?}"))), + }; + } + if let Some(m) = a.opt_str("motion")? { + t.inward = match m { + "inward" => true, + "outward" => false, + other => return Err(ToolError::InvalidArgs(format!("motion must be inward or outward, not {other:?}"))), + }; + } + match a.get("direction") { + None => {} + Some(Value::String(s)) if s == "none" => t.direction = None, + Some(v) => { + t.direction = Some( + v.as_u64() + .and_then(|d| u16::try_from(d).ok()) + .ok_or_else(|| ToolError::InvalidArgs("direction must be 0, 90, 180, 270, 315 or \"none\"".into()))?, + ); + } + } + if let Some(s) = a.opt_num("scale")? { + t.scale = s as f32; + } + if let Some(b) = a.opt_bool("opaque")? { + t.opaque = b; + } + Some(t) + } + }; + let display = a.opt_num("display")?.map(|s| s as f32); + self.apply(a, Edit::SetTransition { pages, spec: TransitionSpec { transition, display } }) + } + fn page_set_box(&mut self, a: &Args) -> Result { use pdfcraft_engine::{BoxSpec, PageBox}; let doc = self.doc(a)?; @@ -1700,12 +1803,23 @@ impl Automation { return Err(ToolError::InvalidArgs(format!("dpi must be between 1 and {MAX_DPI}"))); } let scale = (dpi / 72.0) as f32; - let out = if let Some(block) = a.opt_int("hide_text_block")? { + let block = a.opt_int("hide_text_block")?; + let added = a.opt_int("hide_added_text")?; + if block.is_some() && added.is_some() { + return Err(ToolError::InvalidArgs("choose only one of hide_text_block and hide_added_text".into())); + } + let out = if let Some(block) = block { let index = usize::try_from(block) .ok() .and_then(|n| n.checked_sub(1)) .ok_or_else(|| ToolError::InvalidArgs("hide_text_block must be a positive paragraph index from text_paragraphs".into()))?; self.doc(a)?.text_edit_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)? + } else if let Some(added) = added { + let index = usize::try_from(added) + .ok() + .and_then(|n| n.checked_sub(1)) + .ok_or_else(|| ToolError::InvalidArgs("hide_added_text must be a positive added text index from object_list".into()))?; + self.doc(a)?.added_text_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)? } else { self.renderer(id)?.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false }) }; @@ -1716,6 +1830,27 @@ impl Automation { Ok(Content::Png { data, width: out.width, height: out.height }) } + /// A page as RGBA pixels, without encoding a PNG. `page` is 1-based and `scale` is pixels + /// per point (1.0 = 72 dpi). For an embedder that paints the result straight onto a canvas: + /// the `page_render` tool only returns PNG, and encoding one to decode it again is wasted + /// work on every scroll. It uses the same renderer, and the same per-document cache, as + /// `page_render`. + pub fn render_pixels(&mut self, doc: u64, page: usize, scale: f32) -> Result<(u32, u32, pdfcraft_render::Pixels)> { + if !(scale.is_finite() && scale > 0.0 && scale <= (MAX_DPI / 72.0) as f32) { + return Err(ToolError::InvalidArgs(format!("scale must be between 0 and {}", MAX_DPI / 72.0))); + } + let id = DocId(doc); + let n = self.session.get(id).ok_or_else(|| failed(format!("no open document with id {doc} (see doc_list)")))?.info.pages.len(); + if page < 1 || page > n { + return Err(ToolError::InvalidArgs(format!("page {page} is out of range: the document has {n} pages"))); + } + let out = self.renderer(id)?.render(RenderRequest { page: page - 1, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false }); + if let Some(e) = out.error { + return Err(failed(format!("page {page} could not be rendered: {e}"))); + } + Ok((out.width, out.height, out.rgba)) + } + fn page(&self, a: &Args) -> Result { let n = self.doc(a)?.info.pages.len(); let p = a.int("page")?; @@ -1770,6 +1905,28 @@ impl Automation { Ok(pages.iter().map(|p| (*p, results.get(p).cloned().unwrap_or_else(|| Err("text extraction returned no result".into())))).collect()) } + fn text_word_count(&self, a: &Args) -> Result { + use pdfcraft_engine::word_count::{self, Counts, Options}; + let doc = self.doc_to_copy(a)?; + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..doc.info.pages.len()).collect(), + }; + let options = Options { + exclude_tables: a.opt_bool("exclude_tables")?.unwrap_or(false), + exclude_code: a.opt_bool("exclude_code")?.unwrap_or(false), + exclude_images: a.opt_bool("exclude_images")?.unwrap_or(false), + }; + let report = word_count::count(doc.export_source(), &pages, options, |_| true).map_err(failed)?; + let counts = |c: Counts| json!({ "words": c.words, "characters": c.characters, "characters_without_spaces": c.characters_without_spaces }); + Ok(json!({ + "total": counts(report.total), + "pages": report.pages.into_iter().map(|(p, c)| json!({ "page": p + 1, "counts": counts(c) })).collect::>(), + "pages_without_text": report.pages_without_text, + "unclassified_structure": report.unclassified_structure, + })) + } + fn text_extract(&mut self, a: &Args) -> Result { let doc = self.doc_to_copy(a)?; let id = doc.id; @@ -1895,6 +2052,29 @@ impl Automation { /// namespace is refused without touching it, and the deepest existing ancestor (links /// followed) must be inside the root before anything about the rest is reported. fn resolve(&self, path: &str, for_write: bool) -> Result { + let access = if for_write { file_access::Access::Write } else { file_access::Access::Read }; + self.resolve_for(path, access, for_write) + } + + /// [`resolve`](Self::resolve) with the kind of access named, for the places where a file that + /// is only read may still be missing (a path checked up front, whose absence is reported per + /// file later). The automation roots, if any, get the last word. + fn resolve_for(&self, path: &str, access: file_access::Access, may_be_missing: bool) -> Result { + let target = self.resolve_in_root(path, may_be_missing)?; + self.check_roots(path, &target, access)?; + Ok(target) + } + + /// Hold `target`, which `path` named, to the automation read or write root. Always passes + /// without roots. + fn check_roots(&self, path: &str, target: &Path, access: file_access::Access) -> Result<()> { + match &self.roots { + Some(roots) => roots.check_path(path, target, access).map_err(failed), + None => Ok(()), + } + } + + fn resolve_in_root(&self, path: &str, for_write: bool) -> Result { let p = Path::new(path); let Some(root) = &self.root else { return Ok(p.to_path_buf()) }; let outside = || failed(format!("{path} is outside the allowed directory {}", root.display())); @@ -1921,8 +2101,17 @@ impl Automation { return Err(outside()); } if !rest.is_empty() && !for_write { - // Missing, below a folder inside the root: say why, as the system reports it. - real = joined.canonicalize().map_err(|e| failed(format!("{path}: {e}")))?; + // Missing, below a folder inside the root: say where it was looked for, so a path + // written for another working directory can be corrected (#1890), and otherwise + // why, as the system reports it. `joined` is under the root, so naming it reveals + // nothing outside. + real = joined.canonicalize().map_err(|e| { + let (looked, root) = (joined.display(), root.display()); + match e.kind() { + std::io::ErrorKind::NotFound => failed(format!("{path} not found (looked for {looked}, --root {root})")), + _ => failed(format!("{path}: {e} (looked for {looked}, --root {root})")), + } + })?; if !real.starts_with(root) { return Err(outside()); // it appeared, as a link out, since the check above } @@ -2081,7 +2270,7 @@ impl Args<'_> { /// Bookmark rows in listing order as JSON: a row holds the rows that follow it on the page at the /// next level down in `children`, and paths and pages are 1-based. -fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow]) -> Vec { +fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow], pages: &[pdfcraft_render::PageInfo]) -> Vec { // The open rows, outermost first, with their depth and the children gathered so far. let mut open: Vec<(usize, Value, Vec)> = Vec::new(); let mut roots = Vec::new(); @@ -2091,7 +2280,10 @@ fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow]) -> Vec { close_row(&mut open, &mut roots); } let path: Vec = row.path.iter().map(|p| p.saturating_add(1)).collect(); - let fields = json!({ "path": path, "title": row.title, "page": row.page.map(|p| p.saturating_add(1)), "open": row.open }); + let mut fields = json!({ "path": path, "title": row.title, "page": row.page.map(|p| p.saturating_add(1)), "open": row.open }); + if let Some(destination) = row.page.and_then(|p| pages.get(p)).and_then(|p| destination(&row.view, p)) { + fields["destination"] = destination; + } open.push((depth, fields, Vec::new())); } while !open.is_empty() { @@ -2100,6 +2292,36 @@ fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow]) -> Vec { roots } +/// Where on its page a bookmark or link goes, in points from the top-left of the displayed page +/// like every `rect`: the destination type (`XYZ`, `Fit`, `FitH`, `FitV`, `FitR`) and its +/// position, `null` where the destination leaves it unspecified (`/XYZ null null null` keeps the +/// reader's position and zoom). `None` for a destination without a type. +pub(crate) fn destination(view: &pdfcraft_render::DestView, page: &pdfcraft_render::PageInfo) -> Option { + use pdfcraft_render::DestView; + let points = |left: Option, top: Option| { + let [x, y] = page.dest_fraction(left, top, 0); + let round = |v: f32| f64::from((v * 100.0).round() / 100.0); + (x.map(|x| round(x * page.width)), y.map(|y| round(y * page.height))) + }; + Some(match view { + DestView::Top => return None, + DestView::Xyz { left, top, zoom } => { + let (x, y) = points(*left, *top); + json!({ "fit": "XYZ", "left": x, "top": y, "zoom": zoom.map(f64::from) }) + } + DestView::Fit => json!({ "fit": "Fit" }), + DestView::FitH { top } => { + let (x, y) = points(None, *top); + json!({ "fit": "FitH", "left": x, "top": y }) + } + DestView::FitV { left } => { + let (x, y) = points(*left, None); + json!({ "fit": "FitV", "left": x, "top": y }) + } + DestView::FitR { rect } => json!({ "fit": "FitR", "rect": comments::rect_to_view(page, *rect) }), + }) +} + /// Closes the innermost open row: it joins the row above it as a child, or the top level. fn close_row(open: &mut Vec<(usize, Value, Vec)>, roots: &mut Vec) { if let Some((_, mut fields, children)) = open.pop() { @@ -2222,6 +2444,13 @@ fn info(d: &Document) -> Value { }, })).collect::>(), "layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible })).collect::>(), + // Full-screen presentation settings a page carries (ISO 32000-2 §12.4.4): only pages with + // a `/Trans` or `/Dur` appear; `style` is null for a display time alone. + "transitions": i.transitions.iter().map(|t| json!({ + "page": page1(t.page), "style": t.style.map(pdfcraft_render::TransitionStyle::name), "duration": t.duration, + "horizontal": t.horizontal, "inward": t.inward, "direction": t.direction, "scale": t.scale, "opaque": t.opaque, + "display": t.display, + })).collect::>(), "attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::>(), // `substitute` names the face standing in for a program the document does not embed, and // `substitution` says why. Both are null for an embedded font: nothing stands in. A null @@ -2236,9 +2465,11 @@ fn info(d: &Document) -> Value { }) } -/// A user-space rectangle on 0-based `page` in displayed-page coordinates; unchanged when the -/// page is unknown (a malformed annotation that points at no page). -fn view_rect(i: &pdfcraft_render::DocInfo, page: usize, rect: [f32; 4]) -> [f32; 4] { +/// A user-space rectangle on 0-based `page` in displayed-page coordinates (points from the +/// top-left of the page as displayed, y down, after `/Rotate`), the way every tool reports +/// rectangles; unchanged when the page is unknown (a malformed annotation that points at no page). +/// Public so `pdfcraft-cli` reports the same rectangles as the tools. +pub fn view_rect(i: &pdfcraft_render::DocInfo, page: usize, rect: [f32; 4]) -> [f32; 4] { i.pages.get(page).map_or(rect, |p| comments::rect_to_view(p, rect)) } @@ -2342,32 +2573,20 @@ fn write_atomic_synced( suffixes: impl IntoIterator, sync: impl FnOnce(&std::fs::File) -> std::io::Result<()>, ) -> Result<()> { - use std::io::Write; // A folder can't be replaced, and staging beside it could land outside the root: "." names // the root itself, whose parent isn't ours to write in. if path.is_dir() { return Err(failed(format!("{}: is a folder, not a file", path.display()))); } let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new(".")); - let name = path.file_name().ok_or_else(|| failed(format!("{}: not a file path", path.display())))?; + path.file_name().ok_or_else(|| failed(format!("{}: not a file path", path.display())))?; std::fs::create_dir_all(dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; - let (tmp, file) = - create_staging(dir, &name.to_string_lossy(), StagingName::SuffixThenTag, suffixes).map_err(|e| failed(format!("{}: {e}", dir.display())))?; - // Closed at the end of the block, before the rename. - let written = { - let mut file = file; - // Surface delayed writeback failures before replacing the destination or marking - // a document clean. The handle is closed before rename (required on Windows). - file.write_all(bytes).and_then(|()| sync(&file)) - }; - if let Err(e) = written { - let _ = std::fs::remove_file(&tmp); - return Err(failed(format!("{}: {e}", tmp.display()))); - } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - failed(format!("{}: {e}", path.display())) - }) + // The writer itself is the platform crate's, shared with the desktop app and the CLI + // (#2166): the staging file is written, flushed (delayed writeback failures surface before + // the destination is replaced or a document marked clean), closed and renamed; a failure at + // any step removes it and leaves the destination as it was. + pdfcraft_platform::staging::write_atomically_with(path, bytes, StagingName::SuffixThenTag, suffixes, sync) + .map_err(|e| failed(format!("{}: {e}", path.display()))) } #[cfg(test)] diff --git a/crates/automation/src/links.rs b/crates/automation/src/links.rs index c8ef9fc9d4..cd0fb9da09 100644 --- a/crates/automation/src/links.rs +++ b/crates/automation/src/links.rs @@ -65,18 +65,34 @@ impl Automation { pub(crate) fn link_list(&self, a: &Args) -> Result { let doc = self.doc(a)?; + let targets: std::collections::HashMap<(usize, usize), &LinkTarget> = doc.info.links.iter().map(|t| ((t.page, t.index), &t.target)).collect(); let items: Vec = doc - .links + .links() .iter() .map(|l| { let p = &doc.info.pages[l.page]; let (v0, v1) = (p.user_to_view(l.rect[0] as f32, l.rect[1] as f32), p.user_to_view(l.rect[2] as f32, l.rect[3] as f32)); let rect = [v0[0].min(v1[0]), v0[1].min(v1[1]), v0[0].max(v1[0]), v0[1].max(v1[1])].map(|x| (x * 100.0).round() / 100.0); let mut v = json!({ "page": l.page + 1, "index": l.index + 1, "rect": rect, "visible": l.style.visible }); - match &l.action { - LinkAction::Page(p) => v["to_page"] = json!(p + 1), - LinkAction::Uri(u) => v["url"] = json!(u), - LinkAction::Other(o) => v["other"] = json!(o), + // Where an in-document link goes, as the viewer resolves it: named destinations + // included, with the position on the page. + let target = targets.get(&(l.page, l.index)).and_then(|t| match t { + LinkTarget::Page(page, view) => Some((*page, view)), + _ => None, + }); + match (&l.action, target) { + (LinkAction::Uri(u), _) => v["url"] = json!(u), + (action, Some((page, view))) => { + v["to_page"] = json!(page + 1); + if let Some(d) = doc.info.pages.get(page).and_then(|p| crate::destination(view, p)) { + v["destination"] = d; + } + if matches!(action, LinkAction::Other(_)) { + v["named"] = json!(true); + } + } + (LinkAction::Page(p), None) => v["to_page"] = json!(p + 1), + (LinkAction::Other(o), None) => v["other"] = json!(o), } v }) @@ -163,7 +179,7 @@ impl Automation { let index = a.int("index")?; let doc = self.doc(a)?; let l = doc - .links + .links() .iter() .find(|l| l.page == page && l.index as i64 + 1 == index) .ok_or_else(|| failed(format!("page {} has no link {index} (see link_list)", page + 1)))?; @@ -201,8 +217,8 @@ impl Automation { None => None, }; let before = match &pages { - Some(ps) => self.doc(a)?.links.iter().filter(|l| ps.contains(&l.page)).count(), - None => self.doc(a)?.links.len(), + Some(ps) => self.doc(a)?.links().iter().filter(|l| ps.contains(&l.page)).count(), + None => self.doc(a)?.links().len(), }; let mut out = self.apply(a, Edit::RemoveLinks { pages })?; out["removed"] = json!(before); diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index e9b16fc8ad..19cd48ef43 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -125,7 +125,18 @@ impl Automation { bad(format!("unknown paper {p:?}; choose one of {}", PAPERS.iter().map(|(name, _)| *name).collect::>().join(", "))) })?, }; - let settings = print::Settings { pages, paper, orientation, layout, content }; + // 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) { + let dpi = a.opt_num("dpi")?.unwrap_or(300.0); + if !(72.0..=600.0).contains(&dpi) { + return Err(bad("dpi must be between 72 and 600")); + } + Some(print::Raster { dpi, grayscale: a.opt_bool("grayscale")?.unwrap_or(false) }) + } else { + None + }; + let settings = print::Settings { pages, paper, orientation, layout, content, raster, crops: Vec::new() }; let sizes: Vec<(f64, f64)> = self.doc(a)?.info.pages.iter().map(|p| (p.width as f64, p.height as f64)).collect(); let sheets = print::layout(&sizes, &settings).map_err(|e| bad(e.to_string()))?.len(); let bytes = self.session.print_pdf(id, &settings).map_err(failed)?; diff --git a/crates/automation/src/redact.rs b/crates/automation/src/redact.rs index 60d8c8b77f..1b6283c0ad 100644 --- a/crates/automation/src/redact.rs +++ b/crates/automation/src/redact.rs @@ -173,14 +173,16 @@ impl Automation { } pub(crate) fn doc_audit_redactions(&self, a: &Args) -> Result { - let findings = self.doc(a)?.audit_redactions().map_err(ToolError::Failed)?; + let d = self.doc(a)?; + let findings = d.audit_redactions().map_err(ToolError::Failed)?; let items: Vec = findings .into_iter() .map(|f| { json!({ "page": f.page + 1, "kind": f.kind.id(), - "rect": f.rect, + // Points from the top-left of the displayed page, like `comment_list` and `doc_info`. + "rect": crate::view_rect(&d.info, f.page, f.rect.map(|v| v as f32)), "covered_text": f.covered_text, }) }) diff --git a/crates/automation/src/signing.rs b/crates/automation/src/signing.rs index 2d0281fc00..94fc4bdec8 100644 --- a/crates/automation/src/signing.rs +++ b/crates/automation/src/signing.rs @@ -17,6 +17,62 @@ fn time(secs: i64) -> sign::Time { sign::Time::from_unix(secs) } +/// The longest label `sign_document` takes (characters): a label is a few words. +const MAX_LABEL_CHARS: usize = 200; + +/// `appearance`: which items the visible signature shows; those not given keep the default +/// (name, date and labels on; reason, location and distinguished name off). +fn appearance_arg(v: &Value) -> Result { + const KEYS: &str = "name, date, reason, location, distinguished_name, labels"; + let o = v.as_object().ok_or_else(|| bad(format!("appearance must be an object of true/false items ({KEYS})")))?; + let mut shown = sign::Appearance::default(); + for (key, value) in o { + let on = value.as_bool().ok_or_else(|| bad(format!("appearance.{key} must be true or false")))?; + let item = match key.as_str() { + "name" => &mut shown.name, + "date" => &mut shown.date, + "reason" => &mut shown.reason, + "location" => &mut shown.location, + "distinguished_name" => &mut shown.distinguished_name, + "labels" => &mut shown.labels, + other => return Err(bad(format!("unknown appearance item {other:?} ({KEYS})"))), + }; + *item = on; + } + Ok(shown) +} + +/// `labels`: the words of the visible signature's labels; those not given keep the English. Each +/// must be drawable in the signature's font. Nobody sees a preview here, so a label it can't draw +/// is refused even when this signature doesn't show it. +fn labels_arg(v: &Value) -> Result { + const KEYS: &str = "signed_by, distinguished_name, reason, location, date"; + let o = v.as_object().ok_or_else(|| bad(format!("labels must be an object of strings ({KEYS})")))?; + let mut labels = sign::AppearanceLabels::default(); + for (key, value) in o { + let text = value.as_str().ok_or_else(|| bad(format!("labels.{key} must be a string")))?; + if text.chars().count() > MAX_LABEL_CHARS { + return Err(bad(format!("labels.{key} is longer than {MAX_LABEL_CHARS} characters"))); + } + let label = match key.as_str() { + "signed_by" => &mut labels.signed_by, + "distinguished_name" => &mut labels.distinguished_name, + "reason" => &mut labels.reason, + "location" => &mut labels.location, + "date" => &mut labels.date, + other => return Err(bad(format!("unknown label {other:?} ({KEYS})"))), + }; + *label = text.to_string(); + } + if let Some((field, c)) = labels.first_undrawable() { + return Err(bad(format!( + "labels.{field}: {}", + pdfcraft_engine::undrawable_message(&format!("the {field} label of the visible signature"), c) + ))); + } + Ok(labels) +} + pub(crate) fn cert_json(c: &Certificate) -> Value { json!({ "name": c.display_name(), @@ -89,8 +145,8 @@ impl Automation { return Err(bad("name must not be empty")); } let password = a.str("password")?; - if password.chars().count() < 6 { - return Err(bad("the password must have at least 6 characters")); + if password.chars().count() < sign::pkcs12::MIN_PASSWORD_CHARS { + return Err(bad(format!("the password must have at least {} characters", sign::pkcs12::MIN_PASSWORD_CHARS))); } let path = self.resolve(a.str("path")?, true)?; let key = match a.opt_str("key")?.unwrap_or("rsa2048") { @@ -177,6 +233,17 @@ impl Automation { Some(g) if rect.is_none() && field.is_none() => return Err(bad(format!("graphic {g} needs a visible signature: pass rect or field"))), Some(g) => Some(self.signature_graphic(g)?), }; + let visible = rect.is_some() || field.is_some(); + let appearance = match a.get("appearance") { + None | Some(Value::Null) => sign::Appearance::default(), + Some(_) if !visible => return Err(bad("appearance needs a visible signature: pass rect or field")), + Some(v) => appearance_arg(v)?, + }; + let appearance_labels = match a.get("labels") { + None | Some(Value::Null) => sign::AppearanceLabels::default(), + Some(_) if !visible => return Err(bad("labels needs a visible signature: pass rect or field")), + Some(v) => labels_arg(v)?, + }; let out = self.resolve(a.str("out")?, true)?; let opts = SignOptions { field, @@ -186,6 +253,8 @@ impl Automation { location: a.opt_str("location")?.map(str::to_string), contact: a.opt_str("contact")?.map(str::to_string), certify, + appearance, + appearance_labels, graphic, ..SignOptions::default() }; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 137cba344b..9c9389f007 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -133,7 +133,7 @@ pub fn tools() -> Vec { .cmd("file.open") .with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])), t("doc_list", "List open documents", "List the open documents with their ids, page counts and unsaved state.").ro().with(schema(json!({}), &[])), - t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, attachments, fonts, security and repair notes.") + t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, page transitions and display times, attachments, fonts, security and repair notes.") .ro() .cmd("file.properties") .with(schema(json!({ "doc": doc() }), &["doc"])), @@ -161,7 +161,8 @@ pub fn tools() -> Vec { t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600).") .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 (index from text_paragraphs). Does not modify the document." } + "hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (index from text_paragraphs). Does not modify the document." }, + "hide_added_text": { "type": "integer", "minimum": 1, "description": "Render a retyping background without this added text item (added index from object_list). Mutually exclusive with hide_text_block. Does not modify the document." } }), &["doc", "page"])), t("object_list", "List editable objects", "List existing paragraphs, Image/Form artwork and added content once, with source kind/index references, displayed rectangles and document generation. Indexes change when content changes; use generation in object_move to reject stale references.") .ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])), @@ -171,6 +172,12 @@ pub fn tools() -> Vec { "type":"object","properties":{"kind":{"type":"string","enum":["added","text","image"]},"index":{"type":"integer","minimum":1}},"required":["kind","index"],"additionalProperties":false}}, "offset":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":2}, "generation":{"type":"integer","minimum":0,"description":"Use generation returned by object_list to reject a stale selection."}}), &["doc","page","objects","offset"])), + t("text_word_count", "Word count", "Count Unicode words and characters on selected pages (default all), with totals and per-page counts. Table/code exclusions use PDF structure tags, not visual guesses; unclassified text is retained. Image exclusion removes Figure-tagged and invisible/OCR text. Does not run OCR. Copy permission is required. Fails rather than returning a partial total when a page cannot be read.") + .ro() + .cmd("file.word_count") + .with(schema(json!({ "doc": doc(), "pages": pages("to count (default: all)"), + "exclude_tables": { "type": "boolean" }, "exclude_code": { "type": "boolean" }, "exclude_images": { "type": "boolean" } + }), &["doc"])), t("text_extract", "Extract text", "Extract the text of some or all pages, in reading order. With rect, only the text inside that rectangle on each page, as Column select takes it: one row per visual line, side-by-side pieces (table cells) separated by a tab.") .ro() .cmd("edit.column_select") @@ -223,7 +230,7 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "at": { "type": "integer", "minimum": 1 }, "width": { "type": "number", "description": "Points." }, "height": { "type": "number", "description": "Points." } }), &["doc", "at"], )), - t("page_insert_file", "Insert pages from a file", "Insert pages of another PDF so the first becomes page `at`. Undoable.") + t("page_insert_file", "Insert pages from a file", "Insert pages of another PDF so the first becomes page `at`. Its bookmarks come along, collapsed under one bookmark named after the file, placed among the document's bookmarks where the pages went. Undoable.") .cmd("page.insert") .with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "at": { "type": "integer", "minimum": 1 }, "pages": pages("of the source file (default: all)") }), &["doc", "path", "at"])), t("page_extract", "Extract pages", "Copy pages into a new PDF (links, bookmarks, fields and layers that belong to them come along). separate: true writes each page as its own file into out_dir; delete: true removes the pages from this document afterwards (undoable).") @@ -366,8 +373,7 @@ pub fn tools() -> Vec { "Field properties", "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position) and rotation require a single field. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.", ) - .with({ - let mut input = schema( + .with(schema( json!({ "doc": doc(), "field": { "type": "string", "description": "The field's current name." }, @@ -393,10 +399,7 @@ pub fn tools() -> Vec { "calculate": { "description": "Calculate tab: {\"op\": \"sum\"|\"product\"|\"average\"|\"min\"|\"max\", \"fields\": [\"a\", \"b\"]}, {\"notation\": \"Price * Qty\"} (simplified field notation), or \"none\"." }, }), &["doc"], - ); - input["oneOf"] = json!([{ "required": ["field"] }, { "required": ["fields"] }]); - input - }), + )), t("form_tab_order", "Set the tab order", "Set the tab order of pages (default all): row (top to bottom, left to right), column, structure, or annotations (unspecified). Or order tabs manually: `field` with `move` earlier|later moves that field one place on its page. Returns the resulting order of fields. Undoable.") .with(schema( json!({ "doc": doc(), "order": { "type": "string", "enum": ["row", "column", "structure", "annotations"] }, "pages": pages("to set (default: all)"), "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] } }), @@ -468,7 +471,7 @@ pub fn tools() -> Vec { t( "doc_audit_redactions", "Audit faux redactions", - "Find faux redactions: opaque filled rectangles painted over text that is still extractable, and /Redact annotations that were marked but never applied. Paint-order and colour-aware: a rectangle counts only when painted after the text and dark enough to hide it (page backgrounds and light shading are not flagged). Each finding reports the 1-based page, the kind (covered-text or unapplied-mark), the rectangle, and the hidden text. Fails closed: unreadable content is an error, never a clean bill. A properly applied redaction removes the text and never flags.", + "Find faux redactions: opaque filled rectangles painted over text that is still extractable, and /Redact annotations that were marked but never applied. Paint-order and colour-aware: a rectangle counts only when painted after the text and dark enough to hide it (page backgrounds and light shading are not flagged). Each finding reports the 1-based page, the kind (covered-text or unapplied-mark), the rectangle (points from the top-left of the displayed page, like comment_list), and the hidden text. Fails closed: unreadable content is an error, never a clean bill. A properly applied redaction removes the text and never flags.", ) .ro() .with(schema(json!({ "doc": doc() }), &["doc"])), @@ -479,7 +482,7 @@ pub fn tools() -> Vec { 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; default all); subset odd/even; reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, 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), 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, or A0/A1/A2/A3/A4/A5 (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; default all); subset odd/even; reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, 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), 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, or A0/A1/A2/A3/A4/A5/A6 (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).", ) .with(schema( json!({ @@ -506,6 +509,8 @@ pub fn tools() -> Vec { "collate": { "type": "boolean" }, "duplex": { "type": "string", "enum": ["off", "long-edge", "short-edge"] }, "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." }, + "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." }, }), &["doc"], @@ -516,7 +521,7 @@ pub fn tools() -> Vec { t( "page_add_text", "Add text to a page", - "Add text as page content (not a comment). Place it with at [x, y] (top-left, points from the top-left of the displayed page) and width (wrap width, default 200), or rect. Newlines start new lines; long lines wrap. Style: font helvetica/times/courier, size, bold, italic, color (#RRGGBB or a name), align left/center/right. Text must be Western European (WinAnsi) characters; others are refused. It stays editable with content_update. Undoable.", + "Add text as page content (not a comment). Place it with at [x, y] (top-left, points from the top-left of the displayed page) and width (wrap width, default 200), or rect. Newlines start new lines; long lines wrap. Style: font helvetica/times/courier, size, bold, italic, color (#RRGGBB or a name), align left/center/right. Text must be Western European (WinAnsi) characters, or Arabic, Hebrew or Thai when the build has their craft-fonts faces (drawn right to left where the text is); others are refused. It stays editable with content_update. Undoable.", ) .cmd("edit.text") .with(schema( @@ -673,7 +678,7 @@ pub fn tools() -> Vec { "country": { "type": "string", "description": "Two-letter country code." }, "key": { "type": "string", "enum": ["rsa2048", "rsa3072", "rsa4096", "p256"] }, "years": { "type": "integer", "minimum": 1, "maximum": 50 }, - "password": { "type": "string", "minLength": 6 }, + "password": { "type": "string", "minLength": pdfcraft_engine::sign::pkcs12::MIN_PASSWORD_CHARS }, "path": { "type": "string", "description": "The .p12 file to write." }, }), &["name", "password", "path"], @@ -686,7 +691,7 @@ pub fn tools() -> Vec { .ro() .cmd("sign.digital") .with(schema(json!({}), &[])), - t("sign_document", "Sign a document", "Sign with a digital ID (a .p12/.pfx path, or on macOS a Keychain identity: \"keychain:\" from sign_keychain_ids, or on Windows a store identity: \"windows:\" from sign_windows_ids) and save the signed file to `out` (signing always saves, as in Acrobat; the document then shows the signed file). Sign an existing empty signature field (`field`), or a new one on `page` at `rect` (omit rect for an invisible signature). certify: no_changes, form_fill or comments makes a certification signature. graphic shows a picture where the signer's name would be large (Acrobat: Configure Signature Appearance): {\"image\": path} (PNG or JPEG), {\"text\": name} (typed in the Fill & Sign script font) or {\"strokes\": [[[x, y], …], …]} (y up, the drawing pad 1 wide); it needs a visible signature. PAdES B-B, SHA-256 (SHA-384 for P-384 keys).") + t("sign_document", "Sign a document", "Sign with a digital ID (a .p12/.pfx path, or on macOS a Keychain identity: \"keychain:\" from sign_keychain_ids, or on Windows a store identity: \"windows:\" from sign_windows_ids) and save the signed file to `out` (signing always saves, as in Acrobat; the document then shows the signed file). Sign an existing empty signature field (`field`), or a new one on `page` at `rect` (omit rect for an invisible signature). certify: no_changes, form_fill or comments makes a certification signature. graphic shows a picture where the signer's name would be large (Acrobat: Configure Signature Appearance): {\"image\": path} (PNG or JPEG), {\"text\": name} (typed in the Fill & Sign script font) or {\"strokes\": [[[x, y], …], …]} (y up, the drawing pad 1 wide); it needs a visible signature. appearance chooses what a visible signature shows (default: name, date and labels; reason, location and distinguished_name are off unless turned on). labels gives the words of its labels, e.g. in another language (default English: \"Digitally signed by\", \"DN: \", \"Reason: \", \"Location: \", \"Date: \"; include the separator); each must be in Western European characters (Windows-1252), the only ones the signature's font draws, or the call is refused naming the label and character. PAdES B-B, SHA-256 (SHA-384 for P-384 keys).") .cmd("sign.digital") .with(schema( json!({ @@ -711,6 +716,31 @@ pub fn tools() -> Vec { "minProperties": 1, "maxProperties": 1, }, + "appearance": { + "type": "object", + "description": "What a visible signature shows; items not given keep their default.", + "properties": { + "name": { "type": "boolean", "description": "Default true." }, + "date": { "type": "boolean", "description": "Default true." }, + "reason": { "type": "boolean", "description": "Default false." }, + "location": { "type": "boolean", "description": "Default false." }, + "distinguished_name": { "type": "boolean", "description": "Default false." }, + "labels": { "type": "boolean", "description": "Default true." }, + }, + "additionalProperties": false, + }, + "labels": { + "type": "object", + "description": "The words of a visible signature's labels (at most 200 characters each, Western European characters only); labels not given stay English.", + "properties": { + "signed_by": { "type": "string", "description": "Above the name. Default \"Digitally signed by\"." }, + "distinguished_name": { "type": "string", "description": "Before the certificate's subject. Default \"DN: \"." }, + "reason": { "type": "string", "description": "Default \"Reason: \"." }, + "location": { "type": "string", "description": "Default \"Location: \"." }, + "date": { "type": "string", "description": "Default \"Date: \"." }, + }, + "additionalProperties": false, + }, "out": { "type": "string", "description": "Where to save the signed document." }, }), &["doc", "id", "out"], @@ -749,13 +779,14 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "sort": { "type": "string", "enum": ["page", "author", "date", "type"] }, "out": save_out, "open": open }), &["doc"], )), - t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, fill (rectangle/oval/polygon; \"none\" removes it), rectangle (rectangle/oval/text box/stamp) or position (`move` [dx, dy] in points). Stamps keep their original appearance when resized. One undo step.").with(schema( + t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon; \"none\" removes it), rectangle (rectangle/oval/text box/stamp) or position (`move` [dx, dy] in points). Stamps keep their original appearance when resized. One undo step.").with(schema( comment_ref(json!({ "contents": { "type": "string" }, "color": color(), "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, "width": { "type": "number", "minimum": 0 }, "fill": { "type": "string", "description": "Interior colour of a rectangle, oval or polygon: #RRGGBB, a colour name, or \"none\" for no fill." }, + "font_size": { "type": "number", "minimum": 1, "maximum": 400, "description": "Font size in points of a text box, typewriter text or callout; the box grows to hold the text." }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "move": point(), })), @@ -794,6 +825,26 @@ pub fn tools() -> Vec { t("page_duplicate", "Duplicate pages", "Insert copies of pages after the last of them (fonts and images are shared, not copied). Undoable.") .cmd("page.duplicate") .with(schema(json!({ "doc": doc(), "pages": pages("to duplicate") }), &["doc", "pages"])), + t( + "page_transition_set", + "Set page transitions", + "Set or remove the transition and display time pages play in a full-screen presentation (ISO 32000-2 §12.4.4; Acrobat's Organize ▸ Page Transitions). style: Split, Blinds, Box, Wipe, Dissolve, Glitter, R (replace, no effect), Fly, Push, Cover, Uncover or Fade, or none to remove the transition; duration in seconds (default 1); dimension horizontal/vertical (Split, Blinds); motion inward/outward (Split, Box, Fly); direction 0, 90, 180, 270, 315 or none (Wipe, Glitter, Fly, Cover, Uncover, Push); scale and opaque for Fly. display: seconds the page stays before advancing; left out, the page waits for the presenter. doc_info lists the result under transitions.", + ) + .with(schema( + json!({ + "doc": doc(), + "pages": pages("to change (default: all)"), + "style": { "type": "string", "enum": ["none", "Split", "Blinds", "Box", "Wipe", "Dissolve", "Glitter", "R", "Fly", "Push", "Cover", "Uncover", "Fade"] }, + "duration": { "type": "number", "minimum": 0 }, + "dimension": { "type": "string", "enum": ["horizontal", "vertical"] }, + "motion": { "type": "string", "enum": ["inward", "outward"] }, + "direction": { "description": "0, 90, 180, 270, 315 or \"none\"" }, + "scale": { "type": "number", "exclusiveMinimum": 0 }, + "opaque": { "type": "boolean" }, + "display": { "type": "number", "minimum": 0 }, + }), + &["doc"], + )), t( "page_set_box", "Set page boxes / crop", @@ -878,6 +929,9 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])), t("accessibility_autotag_apply", "Automatically tag text-only PDF", "Write marked content, a Document structure element, paragraph tags and a parent tree for every text object in an untagged, text-only PDF. Refuses graphics, images, annotations, forms, existing tags and malformed streams before changing anything. Review all resulting roles; this does not certify accessibility. Undoable.") .with(schema(json!({ "doc": doc() }), &["doc"])), + t("accessibility_autotag_form_fields", "Tag form fields", "Write `/Form` tags, widget structure-parent keys, and a parent tree for all page widgets in an untagged AcroForm. Existing tag trees are refused for review. Text, figures, links, and other content are not tagged.") + .cmd("a11y.autotag_fields") + .with(schema(json!({ "doc": doc() }), &["doc"])), t("accessibility_check", "Check for accessibility", "Run the Accessibility Checker's full check (32 rules in 7 categories: document, page_content, forms, alternate_text, tables, lists, headings). Each rule is passed, failed (with findings and pages), manual (needs a person) or skipped. Findings on locatable figures, annotations and form widgets include bbox_user_space in PDF coordinates. Colour contrast is off unless all is true; rules (ids such as tagged-pdf, figures-alt-text) or categories narrow the check; pages limit the page rules.") .ro() .cmd("a11y.check") @@ -891,6 +945,10 @@ pub fn tools() -> Vec { }), &["doc"], )), + t("accessibility_find_untagged", "Find untagged content", "Find untagged painted page content, annotations, and form widgets. This focused check does not modify the PDF or claim that its other accessibility rules pass.") + .ro() + .cmd("a11y.find_untagged") + .with(schema(json!({ "doc": doc(), "pages": pages("to search (default: all)") }), &["doc"])), t("accessibility_report", "Accessibility report", "Run the full check and write the accessibility report (HTML) to path; returns the results too. Takes the same options as accessibility_check.") .destructive() .cmd("a11y.report") @@ -942,10 +1000,24 @@ pub fn tools() -> Vec { t("accessibility_set_alt", "Set alternate text", "Set a figure's alternate text (alt; empty or omitted clears it), or mark it decorative (decorative: true: its content becomes an artifact and the figure leaves the tags). figure is a number from accessibility_figures. Returns the figures. Undoable.") .cmd("a11y.alt_text") .with(schema(json!({ "doc": doc(), "figure": { "type": "integer", "minimum": 1 }, "alt": { "type": "string" }, "decorative": { "type": "boolean" } }), &["doc", "figure"])), - t("accessibility_tags", "List structure tags", "List reachable indirect structure tags in document order with PDF object number, generation, nearest indirect parent, depth, page, type, title, actual text, alternate text and language. Inline tag dictionaries have no stable object reference and are not listed.") + t("accessibility_tags", "List structure tags", "List reachable indirect structure tags in document order with PDF object number, generation, nearest indirect parent, depth, page, type, title, actual text, alternate text and language, one page at a time: count is how many tags match, next the offset of the following page (null at the end). types, pages and max_depth keep only some tags. Inline tag dictionaries have no stable object reference and are not listed.") .ro() .cmd("a11y.tags") - .with(schema(json!({ "doc": doc() }), &["doc"])), + .with(schema( + json!({ + "doc": doc(), + "offset": { "type": "integer", "minimum": 0, "description": "Where the page starts, counted from 0 (the previous page's next)" }, + "limit": { "type": "integer", "minimum": 1, "maximum": 1000, "description": "Tags in the page (default 200)" }, + "types": { "type": "array", "items": { "type": "string" }, "description": "Only tags of these types, as listed in type (such as H1, H2, Table)" }, + "pages": { "type": "array", "items": { "type": "integer", "minimum": 1 }, "description": "Only tags on these pages" }, + "max_depth": { "type": "integer", "minimum": 0, "description": "Only tags at most this deep (0 = top level)" } + }), + &["doc"], + )), + t("accessibility_tag_content", "List tag content", "List the marked-content references directly owned by one reachable indirect structure tag, including page, MCID and extracted text.") + .ro() + .cmd("a11y.tags") + .with(schema(json!({ "doc": doc(), "object": { "type": "integer", "minimum": 1 }, "generation": { "type": "integer", "minimum": 0, "maximum": 65535 } }), &["doc", "object", "generation"])), t("accessibility_set_tag_property", "Edit tag property", "Set or clear one property of a reachable indirect structure tag. Use object and generation from accessibility_tags. Properties: type, title, actual_text, alternate_text, language. Empty or omitted value clears text properties; type requires a nonempty PDF name. Undoable.") .with(schema(json!({ "doc": doc(), "object": { "type": "integer", "minimum": 1 }, @@ -953,6 +1025,11 @@ pub fn tools() -> Vec { "property": { "type": "string", "enum": ["type", "title", "actual_text", "alternate_text", "language"] }, "value": { "type": "string" }, }), &["doc", "object", "generation", "property"])), + t("accessibility_mark_tag_artifact", "Mark tag content as artifact", "Mark a reachable leaf structure tag's direct marked content as an artifact and remove the tag from the reading structure. Use object and generation from accessibility_tags. Tags with child structure elements, no direct marked content, or content not found on its page are rejected. Undoable.") + .with(schema(json!({ + "doc": doc(), "object": { "type": "integer", "minimum": 1 }, + "generation": { "type": "integer", "minimum": 0, "maximum": 65535 }, + }), &["doc", "object", "generation"])), t("accessibility_move_tag", "Move structure tag", "Move a reachable indirect structure tag before or after a direct sibling in their parent's /K array, changing tagged reading order without changing page appearance. Use object and generation values from accessibility_tags. Rejects distinct parents, duplicate references and positions that are already current. Undoable.") .with(schema(json!({ "doc": doc(), "object": { "type": "integer", "minimum": 1 }, @@ -1021,7 +1098,7 @@ pub fn tools() -> Vec { t("doc_compare_mark", "Mark differences as comments", "Add the differences from other (older) to doc (newer) as comments in doc: highlights over replaced (blue) and inserted (green) text, notes where text was deleted (red), authored Compare. Undoable.") .cmd("doc.compare") .with(schema(json!({ "doc": doc(), "other": { "type": "integer" } }), &["doc", "other"])), - t("form_detect_fields", "Detect form fields", "Prepare a form ▸ automatic field detection: find the blanks a printed form asks to be filled (underscore runs, lines, empty boxes, small squares for check boxes) and name each field from its label. With add (default true) the fields are created as one undoable step; otherwise they are only proposed. Returns page (1-based), kind, name and rect (points, origin bottom-left).") + t("form_detect_fields", "Detect form fields", "Prepare a form ▸ automatic field detection: find the blanks a printed form asks to be filled (underscore runs, lines, empty boxes, small squares for check boxes; on scanned pages read from the pixels) and name each field from its label. With add (default true) the fields are created as one undoable step; otherwise they are only proposed. Returns page (1-based), kind, name and rect (points, origin bottom-left).") .cmd("form.detect") .with(schema(json!({ "doc": doc(), "pages": pages("to look at (default: all)"), "add": { "type": "boolean" } }), &["doc"])), t("form_actions", "Field actions", "Field Properties ▸ Actions: the field's action for each trigger (mouse_up, mouse_down, mouse_enter, mouse_exit, on_focus, on_blur).") @@ -1068,7 +1145,7 @@ pub fn tools() -> Vec { .ro() .cmd("ocr.recognize") .with(schema(json!({}), &[])), - t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`_Page__Image_.jpg|png`), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, JBIG2, CCITT, separations) are listed under skipped. Includes unsaved edits.") + t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`_Page__Image_.jpg|png`, n zero-padded to the page count's digits), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, JBIG2, CCITT, separations) are listed under skipped. Includes unsaved edits.") .cmd("export.all_images") .with(schema( json!({ @@ -1164,14 +1241,14 @@ pub fn tools() -> Vec { .ro() .cmd("edit.edit_text") .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])), - t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), rotate (quarters clockwise, default 1), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.") + t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), rotate (quarters clockwise, default 1), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.") .cmd("edit.edit_text") .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "image": { "type": "integer", "minimum": 1 }, - "action": { "type": "string", "enum": ["move", "rotate", "flip_horizontal", "flip_vertical", "replace", "delete", "bring_to_front", "send_to_back", "bring_forward", "send_backward", "remove_crop", "crop"] }, + "action": { "type": "string", "enum": ["move", "crop", "rotate", "flip_horizontal", "flip_vertical", "replace", "delete", "bring_to_front", "send_to_back", "bring_forward", "send_backward", "remove_crop"] }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "quarters": { "type": "integer" }, "path": { "type": "string" }, @@ -1226,12 +1303,14 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.") .with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])), - t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") + t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") .cmd("optimize.advanced") .with(schema( json!({ "doc": doc(), "path": { "type": "string" }, + "preset": { "type": "string", "enum": ["standard", "mobile"] }, + "merge_fragments": { "type": "boolean" }, "color": { "type": "object" }, "gray": { "type": "object" }, "discard_thumbnails": { "type": "boolean" }, @@ -1245,9 +1324,16 @@ pub fn tools() -> Vec { }), &["doc", "path"], )), - t("doc_flatten", "Flatten", "Merge comment and/or form field appearances into the page content, so they print and display everywhere but can no longer be edited. Undoable until saved.") + t("doc_flatten", "Flatten", "Merge comment and/or form field appearances into the page content, so they print and display everywhere but can no longer be edited. Flattening the fields of an XFA form also takes the XFA out (its template, data and scripts), so Adobe's viewers show the baked pages rather than laying the form out again. Undoable until saved.") .destructive() .with(schema(json!({ "doc": doc(), "comments": { "type": "boolean", "description": "Default true." }, "fields": { "type": "boolean", "description": "Default true." } }), &["doc"])), + t( + "xfa_to_static", + "Convert an XFA form to a static form", + "Make an XFA form an ordinary PDF form: the XFA packets (template, data, scripts) and PdfCraft's private keys go; the pages as laid out and the fields with their values stay, so every viewer shows the same form and none lays it out again. Its scripts no longer run. Undoable until saved.", + ) + .destructive() + .with(schema(json!({ "doc": doc() }), &["doc"])), t("edit_undo", "Undo", "Undo the last edit of a document.").cmd("edit.undo").with(schema(json!({ "doc": doc() }), &["doc"])), t("edit_redo", "Redo", "Redo the last undone edit of a document.").cmd("edit.redo").with(schema(json!({ "doc": doc() }), &["doc"])), t("command_list", "List commands", "Every registered PdfCraft command with its menu, shortcut, whether it is enabled now, and the tool that automates it.") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 28ce3e9b79..4d9134eb9e 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -115,6 +115,105 @@ fn auto(dir: &Path) -> Automation { Automation::new().with_root(dir).unwrap().with_clock(|| 1_700_000_000) } +/// Full-screen presentation settings (`/Trans`, `/Dur`) reach `doc_info`, 1-based pages, only +/// for the pages that carry them. +#[test] +fn doc_info_lists_page_transitions_and_display_times() { + let dir = workdir("transitions"); + let objects: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 300] >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Trans << /S /Fade /D 0.5 >> /Dur 4 >>".to_vec(), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + bytes.extend_from_slice(object); + bytes.extend_from_slice(b"\nendobj\n"); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + std::fs::write(dir.join("slides.pdf"), bytes).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "slides.pdf" }))["doc"].as_u64().unwrap(); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + let t = info["transitions"].as_array().unwrap(); + assert_eq!(t.len(), 1, "{}", info["transitions"]); + assert_eq!( + (t[0]["page"].as_u64(), t[0]["style"].as_str(), t[0]["duration"].as_f64(), t[0]["display"].as_f64()), + (Some(2), Some("Fade"), Some(0.5), Some(4.0)), + "{}", + t[0] + ); +} + +/// Page transitions through the tools: set on chosen pages, written to the file as `/Trans` and +/// `/Dur`, removed again, bad values refused, and one undo step. (Read through `pdfcraft_cos`, +/// so this does not lean on `doc_info` listing them.) +#[test] +fn page_transitions_are_set_and_removed_through_the_tools() { + use pdfcraft_cos::Object; + /// `key` of 0-based `page` in the saved file `name`, resolved. + fn entry(dir: &Path, name: &str, page: usize, key: &[u8]) -> Option { + let d = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join(name)).ok()?)).ok()?; + let root = d.root()?; + let pages = d.get(root).as_dict()?.reference(b"Pages")?; + let kids = d.get(pages).as_dict()?.get(b"Kids")?.clone(); + let kid = d.resolve(&kids).as_array()?.get(page)?.clone(); + let o = d.resolve(&kid).as_dict()?.get(key)?.clone(); + Some((*d.resolve(&o)).clone()) + } + let dir = workdir("transitions-set"); + std::fs::write(dir.join("a.pdf"), fixture(3)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_transition_set", json!({ "doc": doc, "pages": [2, 3], "style": "Wipe", "duration": 2, "direction": 270, "display": 5 })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out/t1.pdf" })); + let t = entry(&dir, "out/t1.pdf", 1, b"Trans").expect("/Trans on page 2"); + let t = t.as_dict().expect("a dictionary"); + assert_eq!( + (t.name(b"S"), t.get(b"D").and_then(Object::as_f64), t.get(b"Di").and_then(Object::as_f64)), + (Some(&b"Wipe"[..]), Some(2.0), Some(270.0)), + "{t:?}" + ); + assert_eq!(entry(&dir, "out/t1.pdf", 1, b"Dur").and_then(|o| o.as_f64()), Some(5.0)); + assert!(entry(&dir, "out/t1.pdf", 2, b"Trans").is_some(), "page 3 too"); + assert!(entry(&dir, "out/t1.pdf", 0, b"Trans").is_none() && entry(&dir, "out/t1.pdf", 0, b"Dur").is_none(), "page 1 was not asked for"); + ok( + &mut a, + "page_transition_set", + json!({ "doc": doc, "pages": [3], "style": "Fly", "motion": "outward", "direction": "none", "scale": 0.5, "opaque": true }), + ); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out/t2.pdf" })); + let t = entry(&dir, "out/t2.pdf", 2, b"Trans").expect("/Trans on page 3"); + let t = t.as_dict().expect("a dictionary"); + assert_eq!( + (t.name(b"S"), t.name(b"M"), t.name(b"Di"), t.get(b"SS").and_then(Object::as_f64), t.get(b"B")), + (Some(&b"Fly"[..]), Some(&b"O"[..]), Some(&b"None"[..]), Some(0.5), Some(&Object::Bool(true))), + "{t:?}" + ); + assert!(entry(&dir, "out/t2.pdf", 2, b"Dur").is_none(), "display time left out removes /Dur"); + assert!(a.call("page_transition_set", &json!({ "doc": doc, "style": "Swirl" })).is_err(), "an unknown style is refused"); + assert!(a.call("page_transition_set", &json!({ "doc": doc, "style": "Wipe", "direction": 45 })).is_err()); + assert!(a.call("page_transition_set", &json!({ "doc": doc, "pages": [9], "style": "Fade" })).is_err(), "no such page"); + ok(&mut a, "page_transition_set", json!({ "doc": doc, "style": "none" })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out/t3.pdf" })); + assert!((0..3).all(|p| entry(&dir, "out/t3.pdf", p, b"Trans").is_none() && entry(&dir, "out/t3.pdf", p, b"Dur").is_none()), "all removed"); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out/t4.pdf" })); + assert!( + entry(&dir, "out/t4.pdf", 1, b"Trans").is_some() && entry(&dir, "out/t4.pdf", 2, b"Trans").is_some(), + "one undo step brings both pages back" + ); +} + #[test] fn autotag_preview_proposes_text_without_changing_the_pdf() { let dir = workdir("autotag-preview"); @@ -145,6 +244,30 @@ fn autotag_preview_proposes_text_without_changing_the_pdf() { assert!(preview["candidates"].as_array().unwrap().iter().any(|c| c["text"] == "First paragraph" && c["type"] == "P")); } +#[test] +fn render_pixels_agrees_with_page_render_and_skips_the_png() { + let dir = workdir("render-pixels"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + + let (width, height, pixels) = a.render_pixels(doc, 1, 1.0).unwrap(); + let png = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap(); + let Content::Png { width: png_width, height: png_height, .. } = png[0] else { panic!("expected an image") }; + assert_eq!((width, height), (png_width, png_height), "scale 1.0 is 72 dpi, like the tool"); + assert_eq!(pixels.as_ref().len(), width as usize * height as usize * 4, "RGBA, four bytes a pixel"); + assert!(pixels.as_ref().as_chunks::<4>().0.iter().any(|p| p[..3] != [255, 255, 255]), "the page text is drawn, not a blank sheet"); + + let (double_width, _, _) = a.render_pixels(doc, 1, 2.0).unwrap(); + assert_eq!(double_width, width * 2, "the scale is pixels per point"); + + for scale in [0.0_f32, -1.0, f32::NAN, f32::INFINITY, 1.0e6] { + assert!(matches!(a.render_pixels(doc, 1, scale), Err(ToolError::InvalidArgs(_))), "scale {scale} must be refused"); + } + assert!(matches!(a.render_pixels(doc, 0, 1.0), Err(ToolError::InvalidArgs(_))), "pages are 1-based"); + assert!(matches!(a.render_pixels(doc, 99, 1.0), Err(ToolError::InvalidArgs(_))), "a page past the end"); + assert!(matches!(a.render_pixels(9_999, 1, 1.0), Err(ToolError::Failed(_))), "a document that is not open"); +} + #[test] fn autotag_apply_writes_reviewable_tags_and_survives_save_reopen() { let dir = workdir("autotag-apply"); @@ -168,7 +291,22 @@ fn autotag_apply_writes_reviewable_tags_and_survives_save_reopen() { let pages = pdfcraft_model::pages(&reopened); assert_eq!(pages.len(), 2); assert!(pages.iter().all(|page| page.dict.contains(b"StructParents"))); - assert!(reopened.get(reopened.root().unwrap()).as_dict().unwrap().contains(b"StructTreeRoot")); +} + +#[test] +fn autotag_form_fields_writes_reviewable_widget_tags() { + let dir = workdir("autotag-form-fields"); + std::fs::write(dir.join("field.pdf"), form_pdf("name")).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "field.pdf" }))["doc"].as_u64().unwrap(); + let tagged = ok(&mut a, "accessibility_autotag_form_fields", json!({ "doc": doc })); + assert_eq!(tagged["status"], "passed"); + assert_eq!(tagged["remaining_findings"], 0); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "tagged.pdf" })); + let reopened = ok(&mut a, "doc_open", json!({ "path": "tagged.pdf" }))["doc"].as_u64().unwrap(); + let check = ok(&mut a, "accessibility_check", json!({ "doc": reopened, "rules": ["tagged-form-fields"] })); + let result = check["results"].as_array().unwrap().iter().find(|result| result["rule"] == "tagged-form-fields").unwrap(); + assert_eq!(result["status"], "passed"); } /// A one-page PDF whose form holds one text field named `name`. @@ -238,6 +376,9 @@ fn tool_table_is_well_formed() { assert!(props.contains_key(r.as_str().unwrap()), "{}: required {r} is not a property", t.name); } assert!(!(t.read_only && t.destructive), "{} is both read-only and destructive", t.name); + for k in ["oneOf", "anyOf", "allOf"] { + assert!(t.input_schema.get(k).is_none(), "{}: top-level {k} in input_schema is rejected by MCP clients such as Claude", t.name); + } if let Some(c) = t.command { assert!(pdfcraft_engine::commands::command(c).is_some(), "{} names unregistered command {c}", t.name); } @@ -433,6 +574,29 @@ fn open_inspect_render_and_find() { assert_eq!((info.width, info.height), (200, 300)); } +#[test] +fn word_count_reads_selected_pages_and_unsaved_edits_without_mutating() { + let dir = workdir("word-count"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let before = ok(&mut a, "doc_list", json!({})); + let report = ok(&mut a, "text_word_count", json!({ "doc": doc })); + assert_eq!(report["total"]["words"], 6); + assert_eq!(report["pages"].as_array().unwrap().len(), 3); + let subset = ok(&mut a, "text_word_count", json!({ "doc": doc, "pages": [3, 1], "exclude_tables": true })); + assert_eq!(subset["total"]["words"], 4); + assert_eq!(subset["pages"][0]["page"], 3); + assert_eq!(subset["unclassified_structure"], true); + assert_eq!(ok(&mut a, "doc_list", json!({})), before); + assert!(a.call("text_word_count", &json!({ "doc": doc, "pages": [4] })).is_err()); + ok(&mut a, "page_delete", json!({ "doc": doc, "pages": [1] })); + assert_eq!(ok(&mut a, "text_word_count", json!({ "doc": doc }))["total"]["words"], 4); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + assert_eq!(ok(&mut a, "text_word_count", json!({ "doc": doc }))["total"]["words"], 6); + let def = tools().into_iter().find(|t| t.name == "text_word_count").unwrap(); + assert!(def.read_only && !def.destructive); +} + #[test] fn text_editor_background_is_read_only_and_removes_source_glyphs() { let dir = workdir("text-preview"); @@ -457,6 +621,29 @@ fn text_editor_background_is_read_only_and_removes_source_glyphs() { } } +#[test] +fn added_text_editor_background_hides_only_the_item_without_editing() { + let dir = workdir("added-text-preview"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let render = json!({"doc":doc,"page":1,"dpi":72}); + let before = a.call("page_render", &render).unwrap(); + ok(&mut a, "page_add_text", json!({"doc":doc,"page":1,"text":"Retype this label","at":[20,20],"width":160})); + let drawn = a.call("page_render", &render).unwrap(); + assert_ne!(drawn, before); + let state = ok(&mut a, "doc_info", json!({"doc":doc})); + let items = ok(&mut a, "content_list", json!({"doc":doc})); + let preview = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72,"hide_added_text":1})).unwrap(); + assert_eq!(preview, before, "native text and the page background stay intact"); + assert_eq!(a.call("page_render", &render).unwrap(), drawn); + assert_eq!(ok(&mut a, "doc_info", json!({"doc":doc})), state); + assert_eq!(ok(&mut a, "content_list", json!({"doc":doc})), items); + for index in [-1, 0, 2, i64::MAX] { + assert!(a.call("page_render", &json!({"doc":doc,"page":1,"hide_added_text":index})).is_err()); + } + assert!(a.call("page_render", &json!({"doc":doc,"page":1,"hide_added_text":1,"hide_text_block":1})).is_err()); +} + #[test] fn edit_undo_redo_and_save_round_trip() { let dir = workdir("edit"); @@ -530,11 +717,12 @@ fn an_in_place_save_reports_a_required_rewrite_as_not_incremental() { #[test] fn a_save_that_does_not_reopen_leaves_the_file_as_it_was() { let dir = workdir("save-verify-before-write"); - let objs: [&[u8]; 4] = [ + let objs: [&[u8]; 5] = [ b"<< /Type /Catalog /Pages 3 0 R >>", b"<< /Type /Page /MediaBox [0 0 200 300] /Contents 4 0 R >>", b"[ 0 ]", b"<< /Length 18 >>\nstream\n0 0 m 100 100 l S\nendstream", + b"<< /Type /Page /MediaBox [0 0 200 200] >>", ]; let mut original = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); @@ -545,11 +733,11 @@ fn a_save_that_does_not_reopen_leaves_the_file_as_it_was() { original.extend_from_slice(b"\nendobj\n"); } let xref = original.len(); - original.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n"); + original.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); for o in offsets { original.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); } - original.extend_from_slice(format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + original.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); std::fs::write(dir.join("damaged.pdf"), &original).unwrap(); let mut a = auto(&dir); @@ -834,7 +1022,10 @@ fn root_refusals_do_not_reveal_what_exists_outside() { // Inside the root nothing changes: missing files say so, and existing ones open and save, // however the path is spelled. let missing = a.call("doc_open", &json!({ "path": "missing-inside.pdf" })).unwrap_err(); - assert!(matches!(&missing, ToolError::Failed(m) if m.starts_with("missing-inside.pdf: ") && !m.contains("outside")), "{missing:?}"); + assert!( + matches!(&missing, ToolError::Failed(m) if m.starts_with("missing-inside.pdf not found (looked for ") && !m.contains("outside")), + "{missing:?}" + ); let not_dir = a.call("doc_open", &json!({ "path": "inside.pdf/x" })).unwrap_err(); assert!(matches!(¬_dir, ToolError::Failed(m) if !m.contains("outside")), "{not_dir:?}"); #[cfg_attr(not(windows), allow(unused_mut))] @@ -1413,6 +1604,54 @@ fn bookmark_list_pages_reach_bookmarks_past_the_hundred_thousandth() { assert!(matches!(a.call("bookmark_list", &json!({ "doc": doc, "offset": -1 })), Err(ToolError::InvalidArgs(_)))); } +/// Bookmarks and links say where on the page they go, in the same top-left points as rect, and a +/// link through a named destination says which page that is. +#[test] +fn destinations_are_listed_with_their_position() { + let dir = workdir("destination-positions"); + let objs: Vec<&str> = vec![ + "<< /Type /Catalog /Pages 2 0 R /Outlines 5 0 R /Names << /Dests << /Names [(sec-b) [4 0 R /XYZ 72 500 0]] >> >> >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] >>", + "<< /Type /Page /Parent 2 0 R /Annots [8 0 R 9 0 R 10 0 R] >>", + "<< /Type /Page /Parent 2 0 R >>", + "<< /Type /Outlines /First 6 0 R /Last 7 0 R /Count 2 >>", + "<< /Title (Section B) /Parent 5 0 R /Next 7 0 R /Dest [4 0 R /XYZ 72 500 0] >>", + "<< /Title (Wide view) /Parent 5 0 R /Prev 6 0 R /Dest [4 0 R /FitH 400] >>", + "<< /Type /Annot /Subtype /Link /Rect [72 700 300 716] /Border [0 0 0] /Dest [4 0 R /XYZ 72 500 2] >>", + "<< /Type /Annot /Subtype /Link /Rect [72 670 300 686] /Border [0 0 0] /Dest (sec-b) >>", + "<< /Type /Annot /Subtype /Link /Rect [72 640 300 656] /Border [0 0 0] /A << /S /URI /URI (https://example.com) >> >>", + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + std::fs::write(dir.join("dests.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "dests.pdf" }))["doc"].as_u64().unwrap(); + let marks = ok(&mut a, "bookmark_list", json!({ "doc": doc })); + // 792 − 500 = 292 points from the top; a zoom of 0 keeps the reader's zoom. + assert_eq!(marks["bookmarks"][0]["destination"], json!({ "fit": "XYZ", "left": 72.0, "top": 292.0, "zoom": null }), "{marks}"); + assert_eq!(marks["bookmarks"][1]["destination"], json!({ "fit": "FitH", "left": null, "top": 392.0 }), "{marks}"); + let links = ok(&mut a, "link_list", json!({ "doc": doc })); + let explicit = &links["links"][0]; + assert_eq!( + (explicit["to_page"].clone(), explicit["destination"].clone()), + (json!(2), json!({ "fit": "XYZ", "left": 72.0, "top": 292.0, "zoom": 2.0 })) + ); + let named = &links["links"][1]; + assert_eq!((named["to_page"].clone(), named["named"].clone(), named.get("other")), (json!(2), json!(true), None), "{links}"); + assert_eq!(named["destination"]["top"], 292.0); + assert_eq!((links["links"][2]["url"].clone(), links["links"][2].get("destination")), (json!("https://example.com"), None)); +} + #[test] fn bookmarks_from_structure_through_tools() { let dir = workdir("bookmarks-structure"); @@ -1599,6 +1838,7 @@ fn protecting_through_tools() { // Copying wasn't allowed either (#568): no text or images come out, though search works. for (tool, args) in [ ("text_extract", json!({ "doc": re })), + ("text_word_count", json!({ "doc": re })), ("text_paragraphs", json!({ "doc": re, "page": 1 })), ("doc_export_text", json!({ "doc": re, "path": "out.txt" })), ("doc_export_images", json!({ "doc": re, "folder": "pages" })), @@ -1629,7 +1869,7 @@ fn protecting_through_tools() { fn signing_an_encrypted_form_through_tools() { let dir = workdir("sign-encrypted"); let mut a = auto(&dir); - ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret1", "path": "ada.p12" })); + ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret12", "path": "ada.p12" })); let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); let field = ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "signature", "rect": [20, 200, 180, 250] }))["field"] .as_str() @@ -1642,12 +1882,12 @@ fn signing_an_encrypted_form_through_tools() { assert_eq!(ok(&mut b, "doc_info", json!({ "doc": form }))["security"]["protected"], true); match b.call( "sign_document", - &json!({ "doc": form, "id": "ada.p12", "password": "secret1", "page": 1, "rect": [200, 200, 380, 250], "out": "new.pdf" }), + &json!({ "doc": form, "id": "ada.p12", "password": "secret12", "page": 1, "rect": [200, 200, 380, 250], "out": "new.pdf" }), ) { Err(ToolError::Failed(m)) => assert!(m.contains("adding new signature fields"), "{m}"), other => panic!("a new field needs the permissions password: {other:?}"), } - let r = ok(&mut b, "sign_document", json!({ "doc": form, "id": "ada.p12", "password": "secret1", "field": field, "out": "signed.pdf" })); + let r = ok(&mut b, "sign_document", json!({ "doc": form, "id": "ada.p12", "password": "secret12", "field": field, "out": "signed.pdf" })); assert_eq!((r["signature"]["signer"].as_str(), r["signature"]["status"].as_str()), (Some("Ada Lovelace"), Some("unknown"))); let original = std::fs::read(dir.join("form.pdf")).unwrap(); let signed = std::fs::read(dir.join("signed.pdf")).unwrap(); @@ -1655,7 +1895,7 @@ fn signing_an_encrypted_form_through_tools() { let mut c = auto(&dir); let re = ok(&mut c, "doc_open", json!({ "path": "signed.pdf" }))["doc"].as_u64().unwrap(); assert_eq!(ok(&mut c, "doc_info", json!({ "doc": re }))["security"]["protected"], true, "still encrypted"); - ok(&mut c, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret1" })); + ok(&mut c, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret12" })); let list = ok(&mut c, "sign_list", json!({ "doc": re })); assert_eq!((list["all_valid"].as_bool(), list["signatures"][0]["status"].as_str()), (Some(true), Some("valid")), "{list}"); } @@ -2047,6 +2287,40 @@ trailer << /Root 1 0 R >> assert!(matches!(a.call("accessibility_set_alt", &json!({ "doc": fd, "figure": 6, "alt": "x" })), Err(ToolError::Failed(_)))); } +#[test] +fn marking_a_leaf_tag_as_an_artifact_through_automation_is_undoable_and_saved() { + let dir = workdir("a11y-artifact"); + std::fs::write( + dir.join("tagged.pdf"), + b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /StructParents 0 >> endobj +4 0 obj << /Length 48 >> stream +/Figure << /MCID 0 >> BDC 10 150 40 20 re f EMC +endstream endobj +5 0 obj << /Type /StructTreeRoot /K 6 0 R /ParentTree 8 0 R >> endobj +6 0 obj << /S /Document /P 5 0 R /K [7 0 R] >> endobj +7 0 obj << /S /Figure /P 6 0 R /Pg 3 0 R /K 0 >> endobj +8 0 obj << /Nums [0 [7 0 R]] >> endobj +trailer << /Root 1 0 R >> +%%EOF", + ) + .unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "tagged.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "accessibility_tags", json!({ "doc": doc }))["count"], 2); + let tagged = ok(&mut a, "accessibility_mark_tag_artifact", json!({ "doc": doc, "object": 7, "generation": 0 })); + assert_eq!(tagged["count"], 1); + assert_eq!(ok(&mut a, "accessibility_check", json!({ "doc": doc, "rules": ["tagged-content"] }))["failed"], 0); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "artifact.pdf" })); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("artifact.pdf")).unwrap())).unwrap(); + assert_eq!(pdfcraft_engine::a11y::tags(&saved).len(), 1); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Mark content as artifact"); + assert_eq!(ok(&mut a, "accessibility_tags", json!({ "doc": doc }))["count"], 2); + assert!(matches!(a.call("accessibility_mark_tag_artifact", &json!({ "doc": doc, "object": 6, "generation": 0 })), Err(ToolError::Failed(_)))); +} + #[test] fn accessibility_review_record_compares_two_versions_without_changing_them() { let dir = workdir("a11y-review-record"); @@ -2262,6 +2536,12 @@ fn editing_page_images_through_tools() { assert_eq!(r["undo"], "Move image"); let moved = ok(&mut a, "page_images", json!({ "doc": made, "page": 1 }))["images"][0]["rect"].clone(); assert_eq!(moved, json!([5.0, 5.0, 25.0, 35.0])); + // Crop keeps part of it in place: the placement stays, visible_rect says what shows. + let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "crop", "rect": [10, 10, 25, 30] })); + assert_eq!(r["undo"], "Crop image"); + let cropped = ok(&mut a, "page_images", json!({ "doc": made, "page": 1 }))["images"][0].clone(); + assert_eq!(cropped["rect"], json!([5.0, 5.0, 25.0, 35.0])); + assert_eq!(cropped["visible_rect"], json!([10.0, 10.0, 25.0, 30.0])); ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "rotate" })); ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "flip_horizontal" })); let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "bring_to_front" })); @@ -2872,6 +3152,28 @@ fn flattening_through_tools() { assert!(matches!(a.call("doc_flatten", &json!({ "doc": doc, "comments": false, "fields": false })), Err(ToolError::InvalidArgs(_)))); } +/// Inserting a file's pages brings its bookmarks too, under one named after the file, at the +/// pages' place among the document's own bookmarks. +#[test] +fn inserting_a_file_brings_its_bookmarks() { + let dir = workdir("insert-bookmarks"); + let mut a = auto(&dir); + let annex = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "bookmark_add", json!({ "doc": annex, "title": "Annex intro", "page": 1 })); + ok(&mut a, "bookmark_add", json!({ "doc": annex, "title": "Annex table", "page": 2 })); + ok(&mut a, "doc_save", json!({ "doc": annex, "path": "annex.pdf" })); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "Guide start", "page": 1 })); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "Guide end", "page": 3 })); + ok(&mut a, "page_insert_file", json!({ "doc": doc, "path": "annex.pdf", "at": 2 })); + let list = ok(&mut a, "bookmark_list", json!({ "doc": doc })); + let top: Vec<(&str, Value)> = list["bookmarks"].as_array().unwrap().iter().map(|b| (b["title"].as_str().unwrap(), b["page"].clone())).collect(); + assert_eq!(top, [("Guide start", json!(1)), ("annex", json!(2)), ("Guide end", json!(5))], "{list}"); + let kids: Vec<(&str, Value)> = + list["bookmarks"][1]["children"].as_array().unwrap().iter().map(|b| (b["title"].as_str().unwrap(), b["page"].clone())).collect(); + assert_eq!(kids, [("Annex intro", json!(2)), ("Annex table", json!(3))], "{list}"); +} + #[test] fn replacing_pages_through_tools() { let dir = workdir("replace"); @@ -3089,7 +3391,11 @@ fn removing_hidden_information_through_tools() { /// A one-page PDF whose text "TOP SECRET" sits under an opaque black rectangle. fn faux_redacted_fixture() -> Vec { - let body = "BT /F1 24 Tf 100 700 Td (TOP SECRET) Tj ET\n0 g\n90 688 220 32 re f"; + helvetica_page("BT /F1 24 Tf 100 700 Td (TOP SECRET) Tj ET\n0 g\n90 688 220 32 re f") +} + +/// A one-page US Letter PDF drawing `body` with Helvetica as /F1. +fn helvetica_page(body: &str) -> Vec { let objs: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [4 0 R] /Count 1 >>".to_vec(), @@ -3126,11 +3432,94 @@ fn auditing_faux_redactions_through_tools() { assert_eq!(f["page"], 1); assert_eq!(f["kind"], "covered-text"); assert!(f["covered_text"].as_str().unwrap().contains("TOP SECRET"), "{f}"); + // Points from the top-left of the page, like every other tool (the cover is `90 688 220 32 re` + // on a 792 pt high page). + assert_eq!(f["rect"], json!([90.0, 72.0, 310.0, 104.0]), "{f}"); // A clean document audits clean. let clean = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); assert_eq!(ok(&mut a, "doc_audit_redactions", json!({ "doc": clean }))["count"], 0); } +/// An applied redaction at the end of a line leaves the words before it, then a gap the text +/// matrix jumps over and the redaction box: nothing is under the box any more, so the audit must +/// not report the words beside it as covered. In the middle of a line it never did. +#[test] +fn an_applied_redaction_at_the_end_of_a_line_audits_clean() { + let dir = workdir("audit-applied-end-of-line"); + let body = "BT /F1 11 Tf 72 692 Td (Mail the team: someone@example.com) Tj ET\nBT /F1 11 Tf 72 662 Td (Account 0042-7788 is in the middle of this line.) Tj ET"; + std::fs::write(dir.join("mail.pdf"), helvetica_page(body)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "mail.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "redact_mark", json!({ "doc": doc, "pattern": "email" })); + ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "0042-7788" })); + ok(&mut a, "redact_apply", json!({ "doc": doc })); + let text = ok(&mut a, "text_extract", json!({ "doc": doc }))["pages"][0]["text"].as_str().unwrap().to_string(); + assert!(text.contains("Mail the team:") && !text.contains("someone") && !text.contains("0042"), "{text}"); + let r = ok(&mut a, "doc_audit_redactions", json!({ "doc": doc })); + assert_eq!(r["count"], 0, "{r}"); +} + +/// A long tagged document lists its tags a page at a time, and only the types, pages or depths +/// asked for: a 20-page guide has hundreds of tags, more than one tool result should hold. +#[test] +fn structure_tags_are_listed_a_page_at_a_time() { + let dir = workdir("tags-paging"); + // A Document holding, on each of 3 pages, one H1 and 149 P: 451 tags. + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 6 0 R /MarkInfo << /Marked true >> >>".into(), + "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 200 200] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + String::new(), // 6: the root, below + String::new(), // 7: the Document, below + ]; + let mut kids = Vec::new(); + for page in 0..3 { + for i in 0..150 { + let n = objs.len() + 1; + kids.push(format!("{n} 0 R")); + objs.push(format!("<< /S /{} /P 7 0 R /Pg {} 0 R >>", if i == 0 { "H1" } else { "P" }, 3 + page)); + } + } + objs[5] = "<< /Type /StructTreeRoot /K 7 0 R >>".into(); + objs[6] = format!("<< /S /Document /P 6 0 R /K [{}] >>", kids.join(" ")); + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + std::fs::write(dir.join("long.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "long.pdf" }))["doc"].as_u64().unwrap(); + let first = ok(&mut a, "accessibility_tags", json!({ "doc": doc })); + assert_eq!((first["count"].as_u64(), first["tags"].as_array().map(Vec::len), first["next"].as_u64()), (Some(451), Some(200), Some(200))); + assert_eq!(first["tags"][0]["type"], "Document"); + let last = ok(&mut a, "accessibility_tags", json!({ "doc": doc, "offset": 400, "limit": 100 })); + assert_eq!((last["tags"].as_array().map(Vec::len), last["next"].clone()), (Some(51), Value::Null)); + let headings = ok(&mut a, "accessibility_tags", json!({ "doc": doc, "types": ["H1"] })); + let pages: Vec = headings["tags"].as_array().unwrap().iter().map(|t| t["page"].as_u64().unwrap()).collect(); + assert_eq!((headings["count"].as_u64(), pages), (Some(3), vec![1, 2, 3])); + let page2 = ok(&mut a, "accessibility_tags", json!({ "doc": doc, "pages": [2], "types": ["H1", "P"], "limit": 1000 })); + assert_eq!((page2["count"].as_u64(), page2["next"].clone()), (Some(150), Value::Null)); + assert_eq!(ok(&mut a, "accessibility_tags", json!({ "doc": doc, "max_depth": 0 }))["count"], 1, "the Document alone"); + for bad in [ + json!({ "doc": doc, "limit": 0 }), + json!({ "doc": doc, "limit": 1001 }), + json!({ "doc": doc, "offset": -1 }), + json!({ "doc": doc, "pages": [0] }), + ] { + assert!(matches!(a.call("accessibility_tags", &bad), Err(ToolError::InvalidArgs(_))), "{bad}"); + } +} + #[test] fn printing_through_tools() { let dir = workdir("print"); @@ -3217,7 +3606,7 @@ fn text_styling_on_an_image_is_refused_without_an_edit() { ok(&mut a, "doc_save", json!({ "doc": doc, "path": "saved.pdf" })); let state = |a: &Automation| { let d = a.session().docs().iter().find(|d| d.id.0 == doc).unwrap(); - (format!("{:?}", d.added), d.dirty, d.edit_generation(), d.can_undo().map(str::to_owned), d.can_redo().map(str::to_owned)) + (format!("{:?}", d.added()), d.dirty, d.edit_generation(), d.can_undo().map(str::to_owned), d.can_redo().map(str::to_owned)) }; let before = state(&a); assert!(!before.1 && before.4.is_some(), "a clean document with a step to redo: {before:?}"); @@ -3850,6 +4239,33 @@ fn text_the_standard_fonts_cannot_draw_is_never_silent_through_tools() { assert!(!text.contains("?? OK"), "{text}"); } +/// #1908: `comment_edit font_size` resizes the text of a text box, in one undo step, and refuses +/// sizes outside 1-400 pt and comments without text. +#[test] +fn text_box_font_size_through_the_tool() { + let dir = workdir("fontsize"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [20, 40, 200, 80], "contents": "Hello" })); + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [20, 120, 80, 160] })); + ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 1, "index": 1, "font_size": 30 })); + for bad in [0, 401] { + assert!(matches!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 1, "font_size": bad })), Err(ToolError::InvalidArgs(_)))); + } + assert!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 2, "font_size": 12 })).is_err()); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "sized.pdf" })); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("sized.pdf")).unwrap())).unwrap(); + let sizes: Vec = saved + .object_numbers() + .iter() + .filter_map(|n| saved.try_get(*n).ok()) + .filter_map(|o| o.as_dict().and_then(|d| d.get(b"DA")?.as_string().map(|s| String::from_utf8_lossy(&s.bytes).into_owned()))) + .collect(); + assert_eq!(sizes.len(), 1, "{sizes:?}"); + assert!(sizes[0].ends_with("/Helv 30 Tf"), "{sizes:?}"); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Change comment properties"); +} + #[test] fn line_endings_through_the_tool() { let dir = workdir("endings"); @@ -3975,12 +4391,12 @@ fn signing_with_an_image_typed_or_drawn_graphic_through_tools() { std::fs::write(dir.join("logo.png"), png).unwrap(); std::fs::write(dir.join("broken.png"), b"broken").unwrap(); let mut a = auto(&dir); - ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret1", "path": "ada.p12" })); + ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret12", "path": "ada.p12" })); let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); let sign = |a: &mut Automation, rect: [f64; 4], graphic: Value, out: &str| { a.call( "sign_document", - &json!({ "doc": doc, "id": "ada.p12", "password": "secret1", "page": 1, "rect": rect, "graphic": graphic, "out": out }), + &json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "page": 1, "rect": rect, "graphic": graphic, "out": out }), ) }; for (graphic, why) in [ @@ -4000,7 +4416,7 @@ fn signing_with_an_image_typed_or_drawn_graphic_through_tools() { // A missing file fails as it does for every tool that reads one. assert!(matches!(sign(&mut a, [20.0, 200.0, 180.0, 250.0], json!({ "image": "missing.png" }), "bad.pdf"), Err(ToolError::Failed(_)))); // An invisible signature shows nothing, so a graphic for one is a mistake. - let r = a.call("sign_document", &json!({ "doc": doc, "id": "ada.p12", "password": "secret1", "graphic": { "text": "Ada" }, "out": "bad.pdf" })); + let r = a.call("sign_document", &json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "graphic": { "text": "Ada" }, "out": "bad.pdf" })); assert!(matches!(r, Err(ToolError::InvalidArgs(_))), "{r:?}"); assert!(!dir.join("bad.pdf").exists()); @@ -4036,11 +4452,13 @@ fn digital_ids_signing_and_validation_through_tools() { let id = ok( &mut a, "sign_id_create", - json!({ "name": "Ada Lovelace", "organization": "Analytical Engines", "email": "ada@example.com", "country": "GB", "key": "p256", "password": "secret1", "path": "ada.p12" }), + json!({ "name": "Ada Lovelace", "organization": "Analytical Engines", "email": "ada@example.com", "country": "GB", "key": "p256", "password": "secret12", "path": "ada.p12" }), ); assert_eq!(id["certificate"]["subject"], "C=GB, O=Analytical Engines, CN=Ada Lovelace, E=ada@example.com"); assert_eq!(id["certificate"]["self_signed"], true); assert!(matches!(a.call("sign_id_create", &json!({ "name": "X", "password": "short", "path": "x.p12" })), Err(ToolError::InvalidArgs(_)))); + // Eight characters at least (#1847): seven is refused. + assert!(matches!(a.call("sign_id_create", &json!({ "name": "X", "password": "seven77", "path": "x.p12" })), Err(ToolError::InvalidArgs(_)))); let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["count"], 0); @@ -4063,7 +4481,7 @@ fn digital_ids_signing_and_validation_through_tools() { let r = ok( &mut a, "sign_document", - json!({ "doc": doc, "id": "ada.p12", "password": "secret1", "page": 2, "rect": [20, 200, 180, 250], "reason": "Approved", "location": "London", "out": "signed.pdf" }), + json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "page": 2, "rect": [20, 200, 180, 250], "reason": "Approved", "location": "London", "out": "signed.pdf" }), ); assert_eq!(r["signature"]["status"], "unknown"); assert_eq!(r["signature"]["signer"], "Ada Lovelace"); @@ -4073,7 +4491,7 @@ fn digital_ids_signing_and_validation_through_tools() { assert!(dir.join("signed.pdf").exists()); // Trust the ID: valid; comment afterwards: still valid, change allowed. - let t = ok(&mut a, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret1" })); + let t = ok(&mut a, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret12" })); assert_eq!(t["trusted"].as_array().unwrap().len(), 1); let list = ok(&mut a, "sign_list", json!({ "doc": doc })); assert_eq!((list["all_valid"].as_bool(), list["signatures"][0]["status"].as_str()), (Some(true), Some("valid"))); @@ -4095,6 +4513,89 @@ fn digital_ids_signing_and_validation_through_tools() { assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "unknown"); } +/// The decoded text of every visible signature appearance (Helvetica forms) in a signed file. +fn signature_appearances(path: &Path) -> String { + use pdfcraft_cos::{Document, ObjRef, Object}; + let doc = Document::open(std::sync::Arc::new(std::fs::read(path).unwrap())).unwrap(); + let mut text = String::new(); + for num in doc.object_numbers() { + if let Object::Stream(s) = &*doc.get(ObjRef { num, generation: 0 }) + && s.dict.name(b"Subtype") == Some(b"Form") + { + let content = String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(); + if content.contains("/Helv") { + text.push_str(&content); + } + } + } + text +} + +/// `sign_document` takes the words of a visible signature's labels (say, in German) and which +/// items it shows, as the interface does. A label the signature's font can't draw is refused, +/// naming the label, the character and what is allowed, before anything is written. +#[test] +fn signing_with_translated_labels_through_tools() { + let dir = workdir("signing-labels"); + let mut a = auto(&dir); + ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret12", "path": "ada.p12" })); + let open = |a: &mut Automation| ok(a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let doc = open(&mut a); + let args = |doc: u64, extra: Value, out: &str| { + let mut args = json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "page": 1, "rect": [10, 150, 190, 290], "reason": "Genehmigt", "location": "Berlin", "out": out }); + for (k, v) in extra.as_object().unwrap() { + args[k] = v.clone(); + } + args + }; + for (extra, why, says) in [ + ( + json!({ "labels": { "signed_by": "数字签名者" } }), + "a label Helvetica can't draw", + "labels.signed_by: the standard fonts can't draw \"数\" (U+6570)", + ), + (json!({ "labels": { "reason": "Причина: " } }), "even one this signature doesn't show", "labels.reason"), + (json!({ "labels": { "signer": "Von" } }), "an unknown label", "unknown label \"signer\""), + (json!({ "labels": { "date": 1 } }), "not a string", "labels.date must be a string"), + (json!({ "labels": "Datum" }), "not an object", "labels must be an object"), + (json!({ "labels": { "date": "x".repeat(201) } }), "too long", "longer than 200"), + (json!({ "appearance": { "reason": "yes" } }), "not true or false", "appearance.reason must be true or false"), + (json!({ "appearance": { "logo": true } }), "an unknown item", "unknown appearance item"), + (json!({ "rect": null, "labels": { "date": "Datum: " } }), "labels for an invisible signature", "needs a visible signature"), + (json!({ "rect": null, "appearance": { "reason": true } }), "an appearance for an invisible one", "needs a visible signature"), + ] { + let r = a.call("sign_document", &args(doc, extra, "bad.pdf")); + assert!(matches!(&r, Err(ToolError::InvalidArgs(m)) if m.contains(says)), "{why}: {r:?}"); + } + let r = a.call("sign_document", &args(doc, json!({ "labels": { "signed_by": "数字签名者" } }), "bad.pdf")); + assert!(matches!(&r, Err(ToolError::InvalidArgs(m)) if m.contains("signed_by label") && m.contains("Western European")), "{r:?}"); + assert!(!dir.join("bad.pdf").exists(), "nothing is written for a refused call"); + + // German labels, with the reason and the location shown: stamped as given. + let german = json!({ + "labels": { "signed_by": "Digital signiert von", "reason": "Grund: ", "location": "Ort: ", "date": "Datum: " }, + "appearance": { "reason": true, "location": true }, + }); + let r = ok(&mut a, "sign_document", args(doc, german, "de.pdf")); + assert_eq!(r["signature"]["reason"], "Genehmigt"); + let stamped = signature_appearances(&dir.join("de.pdf")); + // Each line starts with its label (a long one wraps after it). + for word in ["(Digital", "(Grund:", "Genehmigt", "(Ort:", "Berlin", "(Datum:"] { + assert!(stamped.contains(word), "{word}: {stamped}"); + } + assert!(!stamped.contains("Reason") && !stamped.contains("Date:"), "{stamped}"); + + // Labels omitted keep the English; the labels turned off show the values alone. + let doc = open(&mut a); + ok(&mut a, "sign_document", args(doc, json!({ "labels": { "date": "Datum: " }, "appearance": { "reason": true } }), "mixed.pdf")); + let stamped = signature_appearances(&dir.join("mixed.pdf")); + assert!(stamped.contains("(Reason:") && stamped.contains("(Datum:") && !stamped.contains("Berlin"), "{stamped}"); + let doc = open(&mut a); + ok(&mut a, "sign_document", args(doc, json!({ "appearance": { "reason": true, "labels": false } }), "bare.pdf")); + let stamped = signature_appearances(&dir.join("bare.pdf")); + assert!(stamped.contains("(Genehmigt)") && !stamped.contains("Reason") && !stamped.contains("Date"), "{stamped}"); +} + #[test] fn optimizing_through_tools() { let dir = workdir("optimize"); @@ -4135,6 +4636,125 @@ fn optimizing_through_tools() { assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "discard": ["everything"] })), Err(ToolError::InvalidArgs(_)))); } +/// A one-page PDF whose page picture is drawn as `rows` grey images one pixel row high, 600 px +/// wide on a 72 pt square (600 ppi), as some PDF printers write it. +fn printed_in_strips(rows: usize) -> Vec { + let mut objs: Vec> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()]; + let names: String = (0..rows).map(|r| format!("/R{r} {} 0 R ", 5 + r)).collect(); + objs.push(b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec()); + objs.push(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 72] /Resources << /XObject << {names}>> >> /Contents 4 0 R >>").into_bytes()); + let k = 72.0 / rows as f64; + let content: String = (0..rows) + .map(|r| { + format!( + "q 72 0 0 {k} 0 {} cm /R{r} Do Q +", + (rows - 1 - r) as f64 * k + ) + }) + .collect(); + objs.push( + format!( + "<< /Length {} >> +stream +{content} +endstream", + content.len() + 1 + ) + .into_bytes(), + ); + for r in 0..rows { + let px: Vec = (0..600).map(|x| ((x + r * 7) % 256) as u8).collect(); + let mut o = format!( + "<< /Type /XObject /Subtype /Image /Width 600 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length {} >> +stream +", + px.len() + ) + .into_bytes(); + o.extend_from_slice(&px); + o.extend_from_slice( + b" +endstream", + ); + objs.push(o); + } + let mut out = b"%PDF-1.5 +" + .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 +", + i + 1 + ) + .as_bytes(), + ); + out.extend_from_slice(o); + out.extend_from_slice( + b" +endobj +", + ); + } + let x = out.len(); + out.extend_from_slice( + format!( + "xref +0 {} +0000000000 65535 f +", + objs.len() + 1 + ) + .as_bytes(), + ); + for o in offs { + out.extend_from_slice( + format!( + "{o:010} 00000 n +" + ) + .as_bytes(), + ); + } + out.extend_from_slice( + format!( + "trailer +<< /Size {} /Root 1 0 R >> +startxref +{x} +%%EOF +", + objs.len() + 1 + ) + .as_bytes(), + ); + out +} + +#[test] +fn a_page_printed_in_strips_gets_small_through_tools() { + let dir = workdir("optimize-strips"); + std::fs::write(dir.join("strips.pdf"), printed_in_strips(300)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "strips.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "mobile.pdf", "preset": "mobile" })); + assert_eq!((r["merged_images"].as_u64(), r["fragments_merged"].as_u64(), r["unused_xobjects"].as_u64()), (Some(1), Some(300), Some(300)), "{r}"); + assert!(r["bytes_after"].as_u64().unwrap() * 10 < r["bytes_before"].as_u64().unwrap(), "{r}"); + // Reduce File Size merges too; with the option off, the strips stay. + let reduced = ok(&mut a, "doc_reduce", json!({ "doc": doc, "path": "reduced.pdf" })); + assert!(reduced["bytes_after"].as_u64().unwrap() * 10 < reduced["bytes_before"].as_u64().unwrap(), "{reduced}"); + let off = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "off.pdf", "merge_fragments": false })); + assert_eq!(off["merged_images"].as_u64(), Some(0), "{off}"); + // The merged page opens and renders. + let mobile = ok(&mut a, "doc_open", json!({ "path": "mobile.pdf" }))["doc"].as_u64().unwrap(); + assert!(a.call("page_render", &json!({ "doc": mobile, "page": 1, "dpi": 72 })).is_ok()); + assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "preset": "tiny" })), Err(ToolError::InvalidArgs(_)))); +} + #[test] fn initial_view_through_tools() { let dir = workdir("initial-view"); @@ -4533,6 +5153,33 @@ fn hundreds_of_xfa_calculations_open_and_recalculate_within_their_budgets() { eprintln!("{} calculate scripts: open {open_took:?}, recalculation after a change {change_took:?}", sections * rows + sections); } +#[test] +fn xfa_forms_flatten_and_convert_to_static_through_tools() { + let dir = workdir("xfa-flat"); + let data = "
JP
"; + std::fs::write(dir.join("fields.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(data))).unwrap(); + let mut a = auto(&dir); + // Convert to static: the fields stay, the XFA goes. + let doc = ok(&mut a, "doc_open", json!({ "path": "fields.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["xfa"], "dynamic"); + let before = ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"].as_array().unwrap().len(); + ok(&mut a, "xfa_to_static", json!({ "doc": doc })); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + assert!(info["xfa"].is_null(), "{info}"); + assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"].as_array().unwrap().len(), before); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "static.pdf" })); + let again = ok(&mut a, "doc_open", json!({ "path": "static.pdf" }))["doc"].as_u64().unwrap(); + assert!(ok(&mut a, "doc_info", json!({ "doc": again }))["xfa"].is_null()); + assert!(a.call("xfa_to_static", &json!({ "doc": again })).is_err(), "not an XFA form"); + // Flatten the fields: a plain PDF with the values as page content. + let doc = ok(&mut a, "doc_open", json!({ "path": "fields.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_flatten", json!({ "doc": doc, "comments": false, "fields": true })); + assert!(ok(&mut a, "doc_info", json!({ "doc": doc }))["xfa"].is_null()); + assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"].as_array().unwrap().len(), 0); + let text = ok(&mut a, "text_extract", json!({ "doc": doc, "pages": [1] })); + assert!(text.to_string().contains("Japan"), "{text}"); +} + #[test] fn xfa_data_files_export_and_import_through_tools() { let dir = workdir("xfa-data"); @@ -5155,6 +5802,24 @@ fn command_batch_refuses_more_than_a_thousand_steps() { assert!(err.to_string().contains("at most 1000 steps"), "{err}"); } +#[test] +fn command_batch_tells_a_tool_name_from_an_unknown_or_toolless_command() { + let dir = workdir("batch-ids"); + let mut a = auto(&dir); + let steps = json!([{"id":"page_rotate"}, {"id":"doc_inspect"}, {"id":"no.such.command"}, {"id":"help.about"}]); + let r = ok(&mut a, "command_batch", json!({"steps": steps, "stop_on_error": false})); + let errors: Vec<&str> = r["results"].as_array().unwrap().iter().map(|s| s["error"].as_str().unwrap()).collect(); + assert_eq!( + errors, + [ + r#""page_rotate" is a tool, not a command id; use the command id "page.rotate", or call the tool directly"#, + r#""doc_inspect" is a tool, not a command id; call it directly (command ids are in command_list)"#, + r#"unknown command id "no.such.command"; see command_list"#, + r#"command "help.about" has no headless tool; see command_list"#, + ] + ); +} + #[test] fn thai_text_through_tools_saves_reopens_extracts_and_renders() { let dir = workdir("thai-content"); @@ -5179,6 +5844,33 @@ fn thai_text_through_tools_saves_reopens_extracts_and_renders() { assert!(page_text(&mut a, reopened)[0].contains("แก้ไข น้ำ")); } +#[test] +fn hebrew_text_through_tools_saves_reopens_extracts_and_renders() { + let dir = workdir("hebrew-content"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path": "a.pdf"}))["doc"].as_u64().unwrap(); + let text = "שם משפחה: שרון, טל׳ 052-1234567 (נייד)"; + let args = json!({"doc": doc, "page": 1, "text": text, "rect": [10, 20, 400, 40], "size": 11, "align": "right"}); + if pdfcraft_fonts::document_hebrew_font(false).is_none() { + assert!(a.call("page_add_text", &args).unwrap_err().to_string().contains("U+05E9")); + assert_eq!(ok(&mut a, "content_list", json!({"doc": doc}))["count"], 0); + return; + } + ok(&mut a, "page_add_text", args); + // Extraction gives back the typing order, not the drawing order. + assert!(page_text(&mut a, doc)[0].contains(text), "{:?}", page_text(&mut a, doc)); + ok(&mut a, "doc_save", json!({"doc": doc, "path": "hebrew.pdf"})); + let reopened = ok(&mut a, "doc_open", json!({"path": "hebrew.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "content_list", json!({"doc": reopened}))["items"][0]["text"], text); + assert!(page_text(&mut a, reopened)[0].contains(text)); + let found = ok(&mut a, "text_find", json!({"doc": reopened, "query": "שרון"})); + assert_eq!(found["matches"].as_array().map(Vec::len), Some(1), "{found}"); + let rendered = a.call("page_render", &json!({"doc": reopened, "page": 1, "dpi": 144})).unwrap(); + assert!(rendered.iter().any(|c| matches!(c, Content::Png { data, width, height } if !data.is_empty() && *width > 0 && *height > 0))); + ok(&mut a, "content_update", json!({"doc": reopened, "page": 1, "index": 1, "text": "מיכאל שָׁלוֹם"})); + assert!(page_text(&mut a, reopened)[0].contains("מיכאל")); +} + #[test] fn mixed_objects_move_through_tools_with_generation_and_one_undo() { let dir = workdir("mixed-object-move"); @@ -5476,6 +6168,7 @@ fn architectural_and_iso_papers_through_tools() { ("A2", 420.0 * mm, 594.0 * mm), ("A1", 594.0 * mm, 841.0 * mm), ("A0", 841.0 * mm, 1189.0 * mm), + ("A6", 105.0 * mm, 148.0 * mm), ("Letter", 612.0, 792.0), ("US Legal", 612.0, 1008.0), ("Tabloid", 792.0, 1224.0), @@ -5727,3 +6420,81 @@ fn absolute_gotor_consent_does_not_bypass_validation_or_passwords() { assert_eq!(opened["document"]["encrypted"], true); assert_eq!(opened["destination_applied"], true); } + +#[test] +fn finding_untagged_content_limits_results_to_content_annotations_and_widgets() { + let dir = workdir("find-untagged"); + std::fs::write(dir.join("field.pdf"), form_pdf("name")).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "field.pdf" }))["doc"].as_u64().unwrap(); + let found = ok(&mut a, "accessibility_find_untagged", json!({ "doc": doc })); + let active: Vec<&str> = found["results"] + .as_array() + .unwrap() + .iter() + .filter(|result| result["status"] != "skipped") + .map(|result| result["rule"].as_str().unwrap()) + .collect(); + assert_eq!(active, ["tagged-content", "tagged-annotations", "tagged-form-fields"]); + let fields = found["results"].as_array().unwrap().iter().find(|result| result["rule"] == "tagged-form-fields").unwrap(); + assert_eq!(fields["status"], "failed"); + assert_eq!(fields["findings"][0]["page"], 1); +} + +#[test] +fn a_missing_path_under_the_root_names_where_it_was_looked_for() { + // #1890: a script written from another working directory repeats the root's folder in its + // paths. The refusal names the resolved path and the root, so there is something to correct. + let (base, root) = sandbox("resolved-path"); + let mut a = auto(&root); + let looked = |rel: &str| root.join(rel).display().to_string(); + for (path, rel) in + [("root/inside.pdf", "root/inside.pdf"), ("missing/deeper/x.pdf", "missing/deeper/x.pdf"), ("./sub/../root/inside.pdf", "root/inside.pdf")] + { + let ToolError::Failed(m) = err(&mut a, "doc_open", json!({ "path": path })) else { panic!("{path}: not a failure") }; + assert_eq!(m, format!("{path} not found (looked for {}, --root {})", looked(rel), root.display())); + } + // The same mistake with a file that does exist outside still gets the plain refusal (#136). + let ToolError::Failed(m) = err(&mut a, "doc_open", json!({ "path": "../outside/secret.pdf" })) else { panic!("not a failure") }; + assert!(!m.contains("looked for"), "{m}"); + ok(&mut a, "doc_open", json!({ "path": "inside.pdf" })); + let _ = std::fs::remove_dir_all(base); +} + +/// `doc_print` prints as image: pictures at `dpi`, grey with `grayscale`; a resolution outside +/// 72–600 is refused. +#[test] +fn doc_print_as_image() { + let dir = workdir("print-image"); + 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", "as_image": true, "dpi": 150, "grayscale": true, "path": "image.pdf" })); + assert_eq!(r["sheets"], 1); + let bytes = std::fs::read(dir.join("image.pdf")).unwrap(); + let out = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap(); + let pictures = + out.scan_objects().filter(|(_, o)| matches!(&**o, pdfcraft_cos::Object::Stream(st) if st.dict.name(b"Subtype") == Some(b"Image"))).count(); + assert_eq!(pictures, 1); + match a.call("doc_print", &json!({ "doc": doc, "as_image": true, "dpi": 20, "path": "x.pdf" })) { + Err(ToolError::InvalidArgs(m)) => assert!(m.contains("dpi"), "{m}"), + other => panic!("{other:?}"), + } +} + +/// Without a root, `write_output` still writes through a staging file (#2166): the folder is +/// made, nothing is left beside the file, and a folder at the path is refused. +#[test] +fn a_root_less_write_output_is_atomic_too() { + let dir = std::env::temp_dir().join(format!("pdfcraft-automation-rootless-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let free = Automation::new(); + let target = dir.join("new").join("page.png"); + let written = free.write_output(&target.to_string_lossy(), &[1, 2, 3]).unwrap(); + assert_eq!(written, target); + assert_eq!(std::fs::read(&target).unwrap(), [1, 2, 3]); + let names: Vec<_> = std::fs::read_dir(dir.join("new")).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + assert_eq!(names, ["page.png"], "no staging file left beside it"); + let e = free.write_output(&dir.join("new").to_string_lossy(), &[4]).unwrap_err(); + assert!(matches!(&e, ToolError::Failed(m) if m.contains("is a folder")), "{e:?}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/automation/tests/automation_roots.rs b/crates/automation/tests/automation_roots.rs new file mode 100644 index 0000000000..94958ea2fb --- /dev/null +++ b/crates/automation/tests/automation_roots.rs @@ -0,0 +1,376 @@ +//! `pdfcraft-cli mcp --automation-read-root --automation-write-root `: through the MCP +//! protocol, files open and load only from inside the read root and are written only inside the +//! write root. Paths that leave a root (`..`, another folder, a sibling with the same prefix, a +//! link) are refused with the wording VectorCraft uses, nothing is read into the result and +//! nothing is written outside. +#![cfg(feature = "mcp")] + +use std::path::{Path, PathBuf}; + +use pdfcraft_automation::Automation; +use pdfcraft_automation::file_access::AutomationRoots; +use pdfcraft_automation::mcp::McpServer; +use serde_json::{Value, json}; + +/// A 1x1 PNG, so the tools that take a picture have something real to read. +const PNG: &[u8] = &[ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0xf0, + 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, +]; + +/// A one-page PDF reading `text`, with a 1x1 picture on it. +fn pdf(text: &str) -> Vec { + let body = format!("q 100 0 0 100 20 20 cm /Im1 Do Q BT /F1 24 Tf 20 150 Td ({text}) Tj ET"); + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> /XObject << /Im1 6 0 R >> >> >>".to_vec(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes(), + b"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length 3 >>\nstream\n\xff\x00\x00\nendstream" + .to_vec(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// A fresh folder holding `in` (the read root, with `a.pdf`, `b.pdf` and `pic.png`), `out` (the +/// write root), `outside` (with `secret.pdf` and a picture) and `in2`. +fn folders(tag: &str) -> PathBuf { + let base = std::env::temp_dir().join(format!("pdfcraft-mcp-roots-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for d in ["in", "out", "outside", "in2"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + std::fs::write(base.join("in/a.pdf"), pdf("Inside page")).unwrap(); + std::fs::write(base.join("in/b.pdf"), pdf("Second inside")).unwrap(); + std::fs::write(base.join("in/pic.png"), PNG).unwrap(); + std::fs::write(base.join("outside/secret.pdf"), pdf("TOP SECRET")).unwrap(); + std::fs::write(base.join("outside/pic.png"), PNG).unwrap(); + std::fs::write(base.join("in2/c.pdf"), pdf("Sibling")).unwrap(); + base +} + +/// A server confined to `in` and `out` (either may be left out). +fn confined(tag: &str, read: bool, write: bool) -> (McpServer, PathBuf) { + let base = folders(tag); + let (in_dir, out_dir) = (base.join("in"), base.join("out")); + let roots = AutomationRoots::new(read.then_some(in_dir.as_path()), write.then_some(out_dir.as_path())).unwrap(); + (McpServer::new(Automation::new().with_automation_roots(roots).with_clock(|| 1_700_000_000)), base) +} + +fn s(p: &Path) -> String { + p.to_string_lossy().into_owned() +} + +fn rpc(server: &mut McpServer, method: &str, params: Value) -> Value { + let line = json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}).to_string(); + serde_json::from_str(&server.handle_line(&line).unwrap()).unwrap() +} + +/// `tools/call` -> (isError, all the text of the reply). +fn call(server: &mut McpServer, name: &str, args: Value) -> (bool, String) { + let reply = rpc(server, "tools/call", json!({"name": name, "arguments": args})); + let result = &reply["result"]; + let mut text = + result["content"].as_array().map(|c| c.iter().filter_map(|c| c["text"].as_str()).collect::>().join("\n")).unwrap_or_default(); + text.push_str(&result["structuredContent"].to_string()); + text.push_str(&reply["error"].to_string()); + (result["isError"] == true || reply["error"].is_object(), text) +} + +fn ok(server: &mut McpServer, name: &str, args: Value) -> Value { + let reply = rpc(server, "tools/call", json!({"name": name, "arguments": args})); + assert!(reply["result"]["isError"] == false, "{name} {args}: {reply}"); + reply["result"]["structuredContent"].clone() +} + +fn refused(server: &mut McpServer, name: &str, args: Value, why: &str) { + let (error, text) = call(server, name, args.clone()); + assert!(error && text.contains(why), "{name} {args} should be refused ({why}): {text}"); + assert!(!text.contains("TOP SECRET"), "{name} {args} let the file's text through: {text}"); +} + +/// The text of document 1, through the resource a client can read it with. +fn text_of(server: &mut McpServer, doc: u64) -> String { + rpc(server, "resources/read", json!({"uri": format!("pdfcraft://doc/{doc}/text")}))["result"]["contents"][0]["text"] + .as_str() + .unwrap_or_default() + .to_owned() +} + +fn names(dir: &Path) -> Vec { + let mut names: Vec = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + names.sort(); + names +} + +#[test] +fn files_open_only_from_inside_the_read_root() { + let (mut server, base) = confined("open", true, true); + let sep = std::path::MAIN_SEPARATOR; + ok(&mut server, "doc_open", json!({"path": s(&base.join("in/a.pdf"))})); + assert!(text_of(&mut server, 1).contains("Inside page")); + + refused(&mut server, "doc_open", json!({"path": s(&base.join("outside/secret.pdf"))}), "outside the read root"); + refused(&mut server, "doc_open", json!({"path": format!("{}{sep}..{sep}outside{sep}secret.pdf", s(&base.join("in")))}), "outside the read root"); + // A sibling whose name starts with the root's name is not inside it. + refused(&mut server, "doc_open", json!({"path": s(&base.join("in2/c.pdf"))}), "outside the read root"); + // The write root is not readable: the two kinds of access are separate. + std::fs::write(base.join("out/d.pdf"), pdf("Written")).unwrap(); + refused(&mut server, "doc_open", json!({"path": s(&base.join("out/d.pdf"))}), "outside the read root"); + // A relative path is the working directory's (the crate's folder here): outside. + refused(&mut server, "doc_open", json!({"path": "Cargo.toml"}), "outside the read root"); + // Nothing was opened by the refused calls. + assert_eq!(ok(&mut server, "doc_list", json!({}))["documents"].as_array().unwrap().len(), 1); +} + +#[test] +fn tools_that_read_a_path_are_held_to_the_read_root() { + let (mut server, base) = confined("reads", true, true); + ok(&mut server, "doc_open", json!({"path": s(&base.join("in/a.pdf"))})); + let (secret, pic, gone) = (s(&base.join("outside/secret.pdf")), s(&base.join("outside/pic.png")), s(&base.join("outside/gone.bin"))); + let out = s(&base.join("out/result.pdf")); + for (tool, args) in [ + ("page_insert_file", json!({"doc": 1, "path": secret, "at": 1})), + ("page_replace", json!({"doc": 1, "pages": [1], "path": secret})), + ("doc_combine", json!({"paths": [s(&base.join("in/a.pdf")), secret], "out": out})), + ("doc_create_multiple", json!({"paths": [secret], "mode": "combine", "out": out})), + ("doc_create", json!({"from": "images", "paths": [pic]})), + ("stamp_custom", json!({"doc": 1, "page": 1, "path": secret, "at": [10, 10]})), + ("page_add_image", json!({"doc": 1, "page": 1, "path": pic})), + ("image_edit", json!({"doc": 1, "page": 1, "image": 1, "action": "replace", "path": pic})), + ("comment_add", json!({"doc": 1, "page": 1, "type": "attachment", "at": [10, 10], "path": secret})), + ("fill_sign_add", json!({"doc": 1, "page": 1, "type": "signature", "at": [10, 10], "path": pic})), + ("form_set_image", json!({"doc": 1, "field": "f", "path": pic})), + ("doc_import_data", json!({"doc": 1, "path": secret})), + ("doc_watermark", json!({"doc": 1, "file": pic})), + ("doc_background", json!({"doc": 1, "file": pic})), + ("form_merge_data", json!({"paths": [secret], "path": out})), + ("ocr_recognize_files", json!({"paths": [secret], "folder": s(&base.join("out/ocr"))})), + ("sign_trust", json!({"paths": [secret]})), + ("sign_document", json!({"doc": 1, "id": secret, "out": out})), + ("text_font_import", json!({"doc": 1, "page": 1, "path": gone})), + ("text_font_files", json!({"directories": [s(&base.join("outside"))]})), + ] { + refused(&mut server, tool, args, "outside the read root"); + } + // The refused calls changed neither the document nor the disk. + assert!(!text_of(&mut server, 1).contains("TOP SECRET")); + assert_eq!(ok(&mut server, "doc_list", json!({}))["documents"].as_array().unwrap().len(), 1); + assert_eq!(names(&base.join("out")), Vec::::new()); + + // One file per output: a file that can't be read is reported in its own entry, with the same refusal. + let (_, text) = call(&mut server, "doc_create_multiple", json!({"paths": [secret], "mode": "separate", "out_dir": s(&base.join("out/sep"))})); + assert!(text.contains("outside the read root") && !text.contains("TOP SECRET"), "{text}"); + assert!(names(&base.join("out/sep")).is_empty()); + let (_, text) = call(&mut server, "action_run", json!({"paths": [secret], "folder": s(&base.join("out/act")), "action": "Add Page Numbers"})); + assert!(text.contains("outside the read root") && !text.contains("TOP SECRET"), "{text}"); + assert!(names(&base.join("out/act")).is_empty()); + std::fs::remove_dir_all(base.join("out/act")).unwrap(); + + // The same tools work on files inside the root. + std::fs::remove_dir_all(base.join("out/sep")).unwrap(); + ok(&mut server, "page_insert_file", json!({"doc": 1, "path": s(&base.join("in/b.pdf")), "at": 2})); + assert!(text_of(&mut server, 1).contains("Second inside")); + ok(&mut server, "page_add_image", json!({"doc": 1, "page": 1, "path": s(&base.join("in/pic.png"))})); + ok(&mut server, "doc_combine", json!({"paths": [s(&base.join("in/a.pdf")), s(&base.join("in/b.pdf"))], "out": out})); + assert_eq!(names(&base.join("out")), ["result.pdf"]); + // Without a folder to list, the installed fonts are outside the read root: it is searched instead. + ok(&mut server, "text_font_files", json!({})); + ok(&mut server, "text_font_files", json!({"directories": [s(&base.join("in"))]})); +} + +#[test] +fn tools_that_write_a_path_are_held_to_the_write_root() { + let (mut server, base) = confined("writes", true, true); + let input = |name: &str| s(&base.join("in").join(name)); + ok(&mut server, "doc_open", json!({"path": input("a.pdf")})); + ok(&mut server, "doc_open", json!({"path": input("b.pdf")})); + let writes = |dir: &str| -> Vec<(&'static str, Value)> { + let file = |name: &str| s(&base.join(dir).join(name)); + vec![ + ("doc_save", json!({"doc": 1, "path": file("saved.pdf")})), + ("page_extract", json!({"doc": 1, "pages": [1], "out": file("extract.pdf")})), + ("page_extract", json!({"doc": 1, "pages": [1], "separate": true, "out_dir": file("pages")})), + ("doc_combine", json!({"paths": [input("a.pdf"), input("b.pdf")], "out": file("combined.pdf")})), + ("doc_create_multiple", json!({"paths": [input("a.pdf")], "mode": "combine", "out": file("multiple.pdf")})), + ("doc_create_multiple", json!({"paths": [input("a.pdf")], "mode": "separate", "out_dir": file("separate")})), + ("doc_split", json!({"doc": 1, "every": 1, "out_dir": file("parts")})), + ("doc_reduce", json!({"doc": 1, "path": file("reduced.pdf")})), + ("doc_optimize", json!({"doc": 1, "path": file("optimized.pdf")})), + ("doc_print", json!({"doc": 1, "path": file("printed.pdf")})), + ("doc_export_text", json!({"doc": 1, "path": file("text.txt")})), + ("doc_export_office", json!({"doc": 1, "path": file("export.docx")})), + ("doc_export_data", json!({"doc": 1, "path": file("data.xfdf")})), + ("doc_export_images", json!({"doc": 1, "folder": file("renders")})), + ("doc_export_all_images", json!({"doc": 1, "folder": file("images")})), + ("image_save", json!({"doc": 1, "page": 1, "image": 1, "path": file("image.png")})), + ("measure_export", json!({"doc": 1, "out": file("measurements.csv")})), + ("comments_summarize", json!({"doc": 1, "out": file("summary.pdf")})), + ("accessibility_report", json!({"doc": 1, "path": file("report.html")})), + ("accessibility_review_record", json!({"before": 1, "after": 2, "path": file("record.json")})), + ("doc_compare_report", json!({"doc": 1, "other": 2, "path": file("compare.pdf")})), + ("form_merge_data", json!({"paths": [input("a.pdf")], "path": file("merged.csv")})), + ("action_run", json!({"paths": [input("a.pdf")], "folder": file("actions"), "action": "Add Page Numbers"})), + ("ocr_recognize_files", json!({"paths": [input("a.pdf")], "folder": file("ocr")})), + ("sign_id_create", json!({"name": "Test", "password": "secret12", "path": file("id.p12"), "key": "p256"})), + ] + }; + + for (tool, args) in writes("outside") { + refused(&mut server, tool, args, "outside the write root"); + } + // The read root is not writable either. + for (tool, args) in writes("in") { + refused(&mut server, tool, args, "outside the write root"); + } + // `..` out of the write root, and a sibling that shares its prefix. + let sep = std::path::MAIN_SEPARATOR; + refused( + &mut server, + "doc_save", + json!({"doc": 1, "path": format!("{}{sep}..{sep}outside{sep}up.pdf", s(&base.join("out")))}), + "outside the write root", + ); + std::fs::create_dir_all(base.join("out2")).unwrap(); + refused(&mut server, "doc_save", json!({"doc": 1, "path": s(&base.join("out2/x.pdf"))}), "outside the write root"); + // Saving in place writes to the folder the file was opened from: the read root, not the write root. + refused(&mut server, "doc_save", json!({"doc": 1}), "outside the write root"); + + assert_eq!(names(&base.join("outside")), ["pic.png", "secret.pdf"], "nothing was written outside the roots"); + assert_eq!(names(&base.join("in")), ["a.pdf", "b.pdf", "pic.png"], "nothing was written to the read root"); + assert!(names(&base.join("out2")).is_empty()); + assert!(names(&base.join("out")).is_empty()); + + // The same calls land in the write root. + for (tool, args) in writes("out") { + let (error, text) = call(&mut server, tool, args.clone()); + // (Printing to a PDF and the like may not apply to this fixture: a refusal for the roots is the failure.) + assert!(!text.contains("automation path rejected") && !text.contains("automation filesystem access"), "{tool} {args}: {text}"); + let _ = error; + } + assert!(base.join("out/saved.pdf").is_file() && base.join("out/combined.pdf").is_file() && base.join("out/text.txt").is_file()); + assert_eq!(names(&base.join("outside")), ["pic.png", "secret.pdf"]); +} + +#[test] +fn signing_writes_and_reads_only_inside_the_roots() { + // One folder for both roots, so a digital ID can be made and then read back. + let base = folders("signing"); + let work = base.join("work"); + std::fs::create_dir_all(&work).unwrap(); + std::fs::write(work.join("a.pdf"), pdf("Work page")).unwrap(); + let roots = AutomationRoots::new(Some(&work), Some(&work)).unwrap(); + let mut server = McpServer::new(Automation::new().with_automation_roots(roots).with_clock(|| 1_700_000_000)); + ok(&mut server, "doc_open", json!({"path": s(&work.join("a.pdf"))})); + ok(&mut server, "sign_id_create", json!({"name": "Test", "password": "secret12", "path": s(&work.join("id.p12")), "key": "p256"})); + refused( + &mut server, + "sign_id_create", + json!({"name": "Test", "password": "secret12", "path": s(&base.join("outside/id.p12")), "key": "p256"}), + "outside the write root", + ); + refused( + &mut server, + "sign_document", + json!({"doc": 1, "id": s(&work.join("id.p12")), "password": "secret12", "out": s(&base.join("outside/signed.pdf"))}), + "outside the write root", + ); + ok(&mut server, "sign_document", json!({"doc": 1, "id": s(&work.join("id.p12")), "password": "secret12", "out": s(&work.join("signed.pdf"))})); + assert!(work.join("signed.pdf").is_file()); + assert_eq!(names(&base.join("outside")), ["pic.png", "secret.pdf"]); +} + +#[test] +fn a_root_left_out_grants_none_of_its_access() { + let (mut server, base) = confined("read-only", true, false); + ok(&mut server, "doc_open", json!({"path": s(&base.join("in/a.pdf"))})); + for target in ["out/doc.pdf", "in/doc.pdf"] { + refused(&mut server, "doc_save", json!({"doc": 1, "path": s(&base.join(target))}), "write authority is absent"); + } + refused(&mut server, "doc_save", json!({"doc": 1}), "write authority is absent"); + refused(&mut server, "doc_export_text", json!({"doc": 1, "path": s(&base.join("out/t.txt"))}), "write authority is absent"); + refused(&mut server, "page_extract", json!({"doc": 1, "pages": [1], "out": s(&base.join("out/e.pdf"))}), "write authority is absent"); + refused(&mut server, "doc_split", json!({"doc": 1, "every": 1, "out_dir": s(&base.join("out/parts"))}), "write authority is absent"); + assert!(names(&base.join("out")).is_empty() && names(&base.join("in")) == ["a.pdf", "b.pdf", "pic.png"]); + // A tool that gives its result back instead of writing it still can. + ok(&mut server, "page_extract", json!({"doc": 1, "pages": [1]})); + + let (mut server, base) = confined("write-only", false, true); + refused(&mut server, "doc_open", json!({"path": s(&base.join("in/a.pdf"))}), "read authority is absent"); + refused( + &mut server, + "doc_combine", + json!({"paths": [s(&base.join("in/a.pdf")), s(&base.join("in/b.pdf"))], "out": s(&base.join("out/c.pdf"))}), + "read authority is absent", + ); + refused(&mut server, "text_font_files", json!({}), "read authority is absent"); + ok(&mut server, "doc_create", json!({"from": "blank", "pages": 1, "width": 200, "height": 300})); + ok(&mut server, "doc_save", json!({"doc": 1, "path": s(&base.join("out/blank.pdf"))})); + assert!(base.join("out/blank.pdf").is_file()); +} + +#[test] +fn links_out_of_a_root_are_refused() { + let (mut server, base) = confined("links", true, true); + #[cfg(unix)] + let linked = std::os::unix::fs::symlink(base.join("outside"), base.join("out/away")).is_ok() + && std::os::unix::fs::symlink(base.join("outside/secret.pdf"), base.join("in/leak.pdf")).is_ok() + && std::os::unix::fs::symlink(base.join("outside/nowhere.pdf"), base.join("out/dangling.pdf")).is_ok(); + #[cfg(windows)] + let linked = std::os::windows::fs::symlink_dir(base.join("outside"), base.join("out/away")).is_ok() + && std::os::windows::fs::symlink_file(base.join("outside/secret.pdf"), base.join("in/leak.pdf")).is_ok() + && std::os::windows::fs::symlink_file(base.join("outside/nowhere.pdf"), base.join("out/dangling.pdf")).is_ok(); + #[cfg(not(any(unix, windows)))] + let linked = false; + // Windows needs Developer Mode or elevation to make links. + if !linked { + eprintln!("skipped: symbolic links can't be made here"); + return; + } + ok(&mut server, "doc_open", json!({"path": s(&base.join("in/a.pdf"))})); + refused(&mut server, "doc_open", json!({"path": s(&base.join("in/leak.pdf"))}), "outside the read root"); + refused(&mut server, "doc_save", json!({"doc": 1, "path": s(&base.join("out/away/doc.pdf"))}), "outside the write root"); + refused(&mut server, "doc_save", json!({"doc": 1, "path": s(&base.join("out/away/deeper/doc.pdf"))}), "outside the write root"); + refused(&mut server, "doc_save", json!({"doc": 1, "path": s(&base.join("out/dangling.pdf"))}), "a link that leads nowhere"); + assert_eq!(names(&base.join("outside")), ["pic.png", "secret.pdf"]); +} + +#[test] +fn without_roots_nothing_is_confined() { + let base = folders("none"); + let mut server = McpServer::new(Automation::new()); + ok(&mut server, "doc_open", json!({"path": s(&base.join("outside/secret.pdf"))})); + assert!(text_of(&mut server, 1).contains("TOP SECRET")); + ok(&mut server, "doc_save", json!({"doc": 1, "path": s(&base.join("in2/copy.pdf"))})); + assert!(base.join("in2/copy.pdf").is_file()); +} + +#[test] +fn the_roots_apply_on_top_of_a_root() { + let base = folders("both"); + let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap(); + // `--root` resolves relative paths inside `in`, which the read root allows and the write root does not. + let mut server = McpServer::new(Automation::new().with_root(base.join("in")).unwrap().with_automation_roots(roots)); + ok(&mut server, "doc_open", json!({"path": "a.pdf"})); + refused(&mut server, "doc_save", json!({"doc": 1, "path": "copy.pdf"}), "outside the write root"); + refused(&mut server, "doc_open", json!({"path": "../outside/secret.pdf"}), "outside the allowed directory"); + assert!(!base.join("in/copy.pdf").exists()); + // The write root alone is not enough: the other root still has its say. + refused(&mut server, "doc_save", json!({"doc": 1, "path": "../out/copy.pdf"}), "outside the allowed directory"); +} diff --git a/crates/content/README.md b/crates/content/README.md index d9d8af10cd..6ab984dd49 100644 --- a/crates/content/README.md +++ b/crates/content/README.md @@ -12,3 +12,8 @@ let m = Matrix::from_operands(&op.operands); // cm / Tm arithmetic, invert, bbo The parser is tolerant (unreadable bytes are skipped and counted, never fatal) and never panics. `redact` and (later) `edit` build their interpreters on it; the graphics-state tracker and the `Patch` API of architecture §6 grow here. + +Unfiltered inline images with built-in or Indexed colour spaces, and image masks, use their +sample layout (including per-row byte padding) to locate `EI`. This preserves pixel bytes +that look like the terminator. Filtered images and resource-defined colour spaces retain +the tolerant delimiter heuristic; damaged or truncated layouts fall back to it too. diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index 4a8f077fe3..32334e047b 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -175,12 +175,83 @@ fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec, usize), usize } // One whitespace byte separates ID from the data. let begin = (lx.pos + 1).min(data.len()); - Ok(match end_of_image(data, begin) { + let end = unfiltered_image_end(data, begin, &dict).or_else(|| end_of_image(data, begin)); + Ok(match end { Some((end_data, end)) => (dict, data[begin..end_data].to_vec(), end), None => (dict, data[begin..].to_vec(), data.len()), }) } +/// An unfiltered image's samples determine its length, including byte padding per row. +/// Binary samples may themselves contain whitespace + `EI` + whitespace, so use the known +/// length when the actual terminator follows it. Unknown or damaged layouts keep the heuristic. +fn unfiltered_image_end(data: &[u8], begin: usize, dict: &Dict) -> Option<(usize, usize)> { + for key in [b"Filter".as_slice(), b"F"] { + if let Some(filter) = dict.get(key) + && !matches!(filter, Object::Null) + && !matches!(filter, Object::Array(filters) if filters.is_empty()) + { + return None; + } + } + for (full, short) in + [(b"Width".as_slice(), b"W".as_slice()), (b"Height", b"H"), (b"ColorSpace", b"CS"), (b"BitsPerComponent", b"BPC"), (b"ImageMask", b"IM")] + { + if let (Some(full), Some(short)) = (dict.get(full), dict.get(short)) + && full != short + { + return None; + } + } + let get = |full: &[u8], short: &[u8]| dict.get(full).or_else(|| dict.get(short)); + let width = usize::try_from(get(b"Width", b"W")?.as_int()?).ok().filter(|n| *n > 0)?; + let height = usize::try_from(get(b"Height", b"H")?.as_int()?).ok().filter(|n| *n > 0)?; + let mask = match get(b"ImageMask", b"IM") { + None | Some(Object::Null | Object::Bool(false)) => false, + Some(Object::Bool(true)) => true, + _ => return None, + }; + let bits = match get(b"BitsPerComponent", b"BPC") { + None | Some(Object::Null) => None, + Some(value) => Some(value.as_int()?), + }; + let (components, bits) = if mask { + if bits.is_some_and(|bits| bits != 1) { + return None; + } + (1usize, 1usize) + } else { + let (components, max_bits) = match get(b"ColorSpace", b"CS")? { + Object::Name(name) => match name.as_slice() { + b"DeviceGray" | b"G" => (1, 16), + b"DeviceRGB" | b"RGB" => (3, 16), + b"DeviceCMYK" | b"CMYK" => (4, 16), + _ => return None, + }, + Object::Array(space) if space.len() == 4 && matches!(space.first().and_then(Object::as_name), Some(b"Indexed" | b"I")) => (1, 8), + _ => return None, + }; + let bits = match bits? { + bits @ (1 | 2 | 4 | 8 | 16) => bits as usize, + _ => return None, + }; + if bits > max_bits { + return None; + } + (components, bits) + }; + let row = width.checked_mul(components)?.checked_mul(bits)?.div_ceil(8); + let end_data = begin.checked_add(row.checked_mul(height)?)?; + let after = data.get(end_data..)?; + if !after.first().is_some_and(|b| is_ws(*b)) { + return None; + } + let separator = after.iter().position(|b| !is_ws(*b))?; + let ei = end_data.checked_add(separator)?; + let end = ei.checked_add(2)?; + if data.get(ei..end) == Some(b"EI") && data.get(end).is_none_or(|b| is_ws(*b) || is_delim(*b)) { Some((end_data, end)) } else { None } +} + /// Where inline image data starting at `begin` ends: (end of the data, position after `EI`). The /// data ends at whitespace + `EI` + (whitespace, a delimiter or the end). fn end_of_image(data: &[u8], begin: usize) -> Option<(usize, usize)> { diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs index 99f40d4e30..18e0b8cbcc 100644 --- a/crates/content/src/tests.rs +++ b/crates/content/src/tests.rs @@ -38,6 +38,171 @@ fn inline_images_stay_whole() { assert_eq!(again.ops[2].inline, p.ops[2].inline); } +#[test] +fn unfiltered_inline_image_keeps_whitespace_bounded_ei_pixels() { + let src = b"q 4 0 0 1 0 0 cm BI /W 4 /H 1 /BPC 8 /CS /G ID EI \nEI Q"; + let parsed = parse(src); + assert_eq!(parsed.ops[2].inline.as_ref().unwrap().1, b" EI "); + assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::>(), [b"q".as_slice(), b"cm", b"BI", b"Q"]); + let rewritten = serialize_ops(&parsed.ops); + assert_eq!(parse(&rewritten).ops[2].inline, parsed.ops[2].inline); +} + +fn inline_pixels(parameters: &[u8], pixels: &[u8]) -> Parsed { + let mut src = b"q BI ".to_vec(); + src.extend_from_slice(parameters); + src.extend_from_slice(b" ID "); + src.extend_from_slice(pixels); + src.extend_from_slice(b"\nEI Q"); + parse(&src) +} + +#[test] +fn unfiltered_inline_image_lengths_use_components_and_parameter_aliases() { + for (parameters, pixels) in [ + (b"/W 4 /H 1 /CS /G /BPC 8".as_slice(), b" EI ".as_slice()), + (b"/W 4 /H 1 /CS /G /BPC 8 /Filter null", b" EI "), + (b"/W 4 /H 1 /CS /G /BPC 8 /F []", b" EI "), + (b"/W 4 /Width 4 /H 1 /CS /G /BPC 8", b" EI "), + (b"/Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b" EI "), + (b"/W 2 /H 1 /CS /RGB /BPC 8", b"\0 EI \xff"), + (b"/Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8", b"\0 EI \xff"), + (b"/W 1 /H 1 /CS /CMYK /BPC 8", b" EI "), + (b"/Width 1 /Height 1 /ColorSpace /DeviceCMYK /BitsPerComponent 8", b" EI "), + ] { + let parsed = inline_pixels(parameters, pixels); + assert_eq!(parsed.skipped, 0); + assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::>(), [b"q".as_slice(), b"BI", b"Q"]); + assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, pixels); + assert_eq!(parse(&serialize_ops(&parsed.ops)).ops[1].inline, parsed.ops[1].inline); + } +} + +#[test] +fn unfiltered_inline_image_rows_are_byte_aligned_for_every_sample_depth() { + // Two rows of two bytes each. Padding belongs to each row, not only the final row. + for (bits, width) in [(1, 9), (2, 5), (4, 3), (8, 2), (16, 1)] { + let parameters = format!("/W {width} /H 2 /BPC {bits} /CS /G"); + let parsed = inline_pixels(parameters.as_bytes(), b" EI "); + assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b" EI ", "bits={bits}"); + assert_eq!(parsed.ops.len(), 3); + } +} + +#[test] +fn unfiltered_inline_masks_default_to_one_bit_and_indexed_images_have_one_component() { + for parameters in [b"/W 9 /H 2 /IM true".as_slice(), b"/Width 9 /Height 2 /ImageMask true /BitsPerComponent 1"] { + let parsed = inline_pixels(parameters, b" EI "); + assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b" EI "); + assert_eq!(parsed.ops.len(), 3); + } + for name in ["Indexed", "I"] { + // A valid 256-entry RGB lookup table; samples are indices regardless of the base space. + let parameters = format!("/W 4 /H 1 /BPC 8 /CS [/{name} /DeviceRGB 255 <{}>]", "00".repeat(768)); + let parsed = inline_pixels(parameters.as_bytes(), b" EI "); + assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b" EI "); + assert_eq!(parsed.ops.len(), 3); + // Indexed samples permit at most 8 bits, even though device spaces permit 16. + let invalid = parameters.replace("/W 4", "/W 2").replace("/BPC 8", "/BPC 16"); + let parsed = inline_pixels(invalid.as_bytes(), b" EI "); + assert!(parsed.ops[1].inline.as_ref().unwrap().1.is_empty()); + } +} + +#[test] +fn unfiltered_inline_image_terminator_must_follow_its_samples() { + for separator in [b" ".as_slice(), b"\r\n", b"\0\t\x0c\n"] { + let mut src = b"BI /W 4 /H 1 /BPC 8 /CS /G ID EI ".to_vec(); + src.extend_from_slice(separator); + src.extend_from_slice(b"EI/Q q"); + let parsed = parse(&src); + assert_eq!(parsed.ops[0].inline.as_ref().unwrap().1, b" EI "); + assert_eq!(&src[parsed.ops[0].span.clone()], &src[..src.len() - b"/Q q".len()]); + } + let parsed = parse(b"BI /W 4 /H 1 /BPC 8 /CS /G ID EI \nEI"); + assert_eq!(parsed.ops[0].inline.as_ref().unwrap().1, b" EI "); + // EI is a keyword: a prefix of a larger token is not a terminator. + let parsed = parse(b"BI /W 4 /H 1 /BPC 8 /CS /G ID abcd EIx\nEI Q"); + assert_eq!(parsed.ops[0].inline.as_ref().unwrap().1, b"abcd EIx"); +} + +#[test] +fn filtered_unknown_and_damaged_inline_images_keep_the_existing_fallback() { + for parameters in [ + b"/W 4 /H 1 /BPC 8 /CS /G /F /A85".as_slice(), + b"/W 4 /H 1 /BPC 8 /CS /ResourceColor", + b"/W 0 /H 1 /BPC 8 /CS /G", + b"/W -1 /H 1 /BPC 8 /CS /G", + b"/W 4 /H -1 /BPC 8 /CS /G", + b"/W 4 /H 1 /BPC 3 /CS /G", + b"/W 4 /H 1 /IM true /BPC 8", + b"/W 4 /H 1 /IM true /BPC /Bad", + b"/Width 4 /W 1 /H 1 /BPC 8 /CS /G", + b"/W 4 /H 1 /BPC 8 /CS /G /ImageMask false /IM true", + b"/W 9223372036854775807 /H 9223372036854775807 /BPC 16 /CS /CMYK", + b"/W 9223372036854775807 /H 2 /BPC 8 /CS /G", + ] { + let parsed = inline_pixels(parameters, b"! EI !!~>"); + assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b"!", "{}", String::from_utf8_lossy(parameters)); + } + // A short payload still uses the tolerant recovery, including when there is no EI. + for src in [b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc\nEI Q".as_slice(), b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc"] { + assert_eq!(parse(src).ops[0].inline.as_ref().unwrap().1, b"abc"); + } +} + +fn inline_pdf(content: &[u8], object_streams: bool) -> Vec { + use pdfcraft_cos::{Document, SaveOptions, Stream, write_full}; + + let mut doc = Document::new_empty(); + let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), content))); + let page = doc.add(Object::Dict( + [ + (b"Type".to_vec(), Object::name("Page")), + (b"Parent".to_vec(), Object::Ref(pages)), + (b"MediaBox".to_vec(), Object::Array([0, 0, 4, 1].map(Object::Int).to_vec())), + (b"Resources".to_vec(), Object::Dict(Dict::new())), + (b"Contents".to_vec(), Object::Ref(contents)), + ] + .into_iter() + .collect(), + )); + doc.update_dict(pages, |dict| { + dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + dict.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + write_full(&doc, &SaveOptions { object_streams, ..SaveOptions::default() }).unwrap() +} + +#[test] +fn rewritten_inline_image_pixels_survive_saving_and_reopening_a_pdf() { + use pdfcraft_cos::{Document, Object}; + use std::sync::Arc; + + let original = b"q 4 0 0 1 0 0 cm BI /W 4 /H 1 /BPC 8 /CS /G ID EI \nEI Q"; + let rewritten = serialize_ops(&parse(original).ops); + for object_streams in [false, true] { + let bytes = inline_pdf(&rewritten, object_streams); + let doc = Document::open(Arc::new(bytes.clone())).unwrap(); + assert!(doc.repair_log().is_empty()); + let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + let pages = doc.get(pages); + let page = doc.resolve(&pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap()[0]); + let content = doc.resolve(page.as_dict().unwrap().get(b"Contents").unwrap()); + let Object::Stream(stream) = &*content else { panic!("page content stream") }; + assert_eq!(parse(&stream.decoded_strict().unwrap()).ops[2].inline.as_ref().unwrap().1, b" EI "); + // Opt-in artifacts for independent readers/renderers; synthetic PDFs are never committed. + if let Some(dir) = std::env::var_os("PDFCRAFT_INLINE_FIXTURE_DIR") { + let dir = std::path::PathBuf::from(dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("original.pdf"), inline_pdf(original, false)).unwrap(); + std::fs::write(dir.join(if object_streams { "rewritten-packed.pdf" } else { "rewritten-classic.pdf" }), bytes).unwrap(); + } + } +} + #[test] fn damage_is_skipped_not_fatal() { let p = parse(b"1 0 0 RG ) ] 10 20 m 30 40 l S (unterminated"); diff --git a/crates/cos/Cargo.toml b/crates/cos/Cargo.toml index f02f63c527..9ef9126ca8 100644 --- a/crates/cos/Cargo.toml +++ b/crates/cos/Cargo.toml @@ -14,6 +14,8 @@ pdfcraft-filters = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +pdfcraft-oracle = { workspace = true } +pdfcraft-testkit = { workspace = true } serde_json = { workspace = true } proptest = { workspace = true } hayro-syntax = { workspace = true } diff --git a/crates/cos/README.md b/crates/cos/README.md index b3ac964d51..ab382219d5 100644 --- a/crates/cos/README.md +++ b/crates/cos/README.md @@ -53,7 +53,7 @@ another. Edits never touch the original bytes. headers. Repairs are *recorded*, never silent, so the UI can say the file was damaged. - **Tolerant, never fatal.** The lexer works on a byte slice and a position; it allocates nothing for skipped data, bounds-checks every index, and caps nesting depth. Malformed input yields - `CosError`, never a panic — this crate is the first thing a hostile file meets. + `CosError`, never a panic — this crate is the first thing a hostile file meets. Exhausted xref entries are skipped with a repair log; exhausted /Size hints allocate unused numbers without overflowing or replacing existing objects. - **Lazy.** Objects are parsed on first use and cached. (Whole-file buffering is still the case; `ByteSource` streaming is `core.lazy-loading`, not yet done.) - **Encryption** is handled on load and save through `pdfcraft-crypt`: every standard-security diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 0f05661b26..a0b1f56eeb 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -61,6 +61,245 @@ pub enum XrefEntry { InStream { stream: u32, index: u32 }, } +#[derive(Clone, Copy, Debug)] +struct XrefRange { + start: u32, + count: u32, + offset: usize, + stride: usize, +} + +#[derive(Clone, Debug)] +enum XrefSectionData { + Table, + Stream { bytes: Arc>, widths: [usize; 3] }, +} + +#[derive(Clone, Debug)] +struct XrefSection { + data: XrefSectionData, + ranges: Vec, + row_count: usize, + max_num: u32, + has_entries: bool, +} + +#[derive(Clone, Debug)] +struct XrefRevisionSection { + primary: XrefSection, + hybrid: Option, +} + +#[derive(Clone, Debug)] +enum XrefIndex { + Compact { revisions: Vec, max_num: u32, has_entries: bool }, + Materialized(Arc>), +} + +const MAX_COMPACT_XREF_ROWS: usize = 2_000_000; +const MAX_COMPACT_XREF_BYTES: usize = 64 * 1024 * 1024; + +impl XrefSection { + fn has_any_entry(&self) -> bool { + match &self.data { + XrefSectionData::Table => self.has_entries, + XrefSectionData::Stream { bytes, widths } => { + let row_width = widths.iter().sum::(); + if row_width == 0 { + return false; + } + for range in &self.ranges { + let available = bytes.len().saturating_sub(range.offset) / row_width; + for i in 0..available.min(range.count as usize) { + let Some(offset) = i.checked_mul(row_width).and_then(|delta| range.offset.checked_add(delta)) else { continue }; + let Some(row) = bytes.get(offset..offset.saturating_add(row_width)) else { continue }; + let read_field = |from: usize, width: usize, default: u64| -> Option { + if width == 0 { + return Some(default); + } + let end = from.checked_add(width)?; + Some(row.get(from..end)?.iter().fold(0u64, |acc, byte| acc.wrapping_shl(8) | u64::from(*byte))) + }; + let Some(kind) = read_field(0, widths[0], 1) else { continue }; + if kind == 0 || kind == 2 { + return true; + } + if kind == 1 && read_field(widths[0], widths[1], 0).is_some_and(|offset| offset != 0) { + return true; + } + } + } + false + } + } + } + + fn retained_bytes(&self) -> usize { + match &self.data { + XrefSectionData::Table => 0, + XrefSectionData::Stream { bytes, .. } => bytes.capacity(), + } + } + + fn lookup(&self, num: u32, file: &[u8]) -> Option { + let after = self.ranges.partition_point(|range| range.start <= num); + let range = self.ranges.get(after.checked_sub(1)?)?; + let end = u64::from(range.start).checked_add(u64::from(range.count))?; + if u64::from(num) >= end { + return None; + } + let row = usize::try_from(num.checked_sub(range.start)?).ok()?; + let offset = range.offset.checked_add(row.checked_mul(range.stride)?)?; + match &self.data { + XrefSectionData::Table => { + let mut lexer = Lexer::new(file, offset); + lexer.skip_ws(); + let object_offset = lexer.token(); + lexer.skip_ws(); + let generation = lexer.token(); + lexer.skip_ws(); + let kind = lexer.token(); + match kind { + b"n" => { + let object_offset = std::str::from_utf8(object_offset).ok()?.parse::().ok()?; + if object_offset == 0 { + return None; + } + let generation = std::str::from_utf8(generation).ok()?.parse::().unwrap_or(0).min(u16::MAX as u32) as u16; + Some(XrefEntry::InFile { offset: object_offset, generation }) + } + b"f" => { + let generation = std::str::from_utf8(generation).ok()?.parse::().unwrap_or(0).min(u16::MAX as u32) as u16; + Some(XrefEntry::Free { next_generation: generation }) + } + _ => None, + } + } + XrefSectionData::Stream { bytes, widths } => { + let row_width = widths.iter().sum::(); + let end = offset.checked_add(row_width)?; + let row = bytes.get(offset..end)?; + let field = |from: usize, width: usize, default: u64| -> Option { + if width == 0 { + return Some(default); + } + let end = from.checked_add(width)?; + Some(row.get(from..end)?.iter().fold(0u64, |acc, byte| acc.wrapping_shl(8) | u64::from(*byte))) + }; + let kind = field(0, widths[0], 1)?; + let a = field(widths[0], widths[1], 0)?; + let b = field(widths[0] + widths[1], widths[2], 0)?; + let entry = match kind { + 0 => XrefEntry::Free { next_generation: b.min(u16::MAX as u64) as u16 }, + 1 => XrefEntry::InFile { offset: a, generation: b.min(u16::MAX as u64) as u16 }, + 2 => XrefEntry::InStream { stream: a.min(u32::MAX as u64) as u32, index: b.min(u32::MAX as u64) as u32 }, + _ => return None, + }; + (!matches!(entry, XrefEntry::InFile { offset: 0, .. })).then_some(entry) + } + } + } + + fn extend_numbers(&self, numbers: &mut Vec) { + for range in &self.ranges { + let available = match &self.data { + XrefSectionData::Table => range.count as usize, + XrefSectionData::Stream { bytes, widths } => { + let row_width = widths.iter().sum::(); + bytes.len().saturating_sub(range.offset).checked_div(row_width).unwrap_or(0) + } + }; + for i in 0..available.min(range.count as usize) { + if let Ok(i) = u32::try_from(i) + && let Some(num) = range.start.checked_add(i) + { + numbers.push(num); + } + } + } + } +} + +impl XrefRevisionSection { + fn lookup(&self, num: u32, file: &[u8]) -> Option { + let primary = self.primary.lookup(num, file); + let hybrid = self.hybrid.as_ref().and_then(|section| section.lookup(num, file)); + match primary { + None | Some(XrefEntry::Free { .. }) => hybrid.or(primary), + entry => entry, + } + } + + fn extend_numbers(&self, numbers: &mut Vec) { + self.primary.extend_numbers(numbers); + if let Some(hybrid) = &self.hybrid { + hybrid.extend_numbers(numbers); + } + } +} + +impl XrefIndex { + fn empty() -> Self { + Self::Materialized(Arc::new(BTreeMap::new())) + } + + fn lookup(&self, num: u32, file: &[u8]) -> Option { + match self { + Self::Compact { revisions, .. } => revisions.iter().find_map(|revision| revision.lookup(num, file)), + Self::Materialized(entries) => entries.get(&num).copied(), + } + } + + fn is_empty(&self) -> bool { + match self { + Self::Compact { has_entries, .. } => !has_entries, + Self::Materialized(entries) => entries.is_empty(), + } + } + + /// Drop an entry for object number 4294967295, which no incremental `/Size` can cover. + /// Only a materialized index can hold one: the compact reader requires `max_num < /Size`. + fn remove_exhausted(&mut self) -> bool { + match self { + Self::Compact { .. } => false, + Self::Materialized(entries) => Arc::make_mut(entries).remove(&u32::MAX).is_some(), + } + } + + fn max_num(&self) -> u32 { + match self { + Self::Compact { max_num, .. } => *max_num, + Self::Materialized(entries) => entries.keys().next_back().copied().unwrap_or(0), + } + } + + fn object_numbers(&self, file: &[u8]) -> Vec { + match self { + Self::Materialized(entries) => { + entries.iter().filter_map(|(&num, entry)| (!matches!(entry, XrefEntry::Free { .. })).then_some(num)).collect() + } + Self::Compact { revisions, .. } => { + if let [revision] = revisions.as_slice() + && revision.hybrid.is_none() + { + let mut numbers = Vec::with_capacity(revision.primary.row_count); + revision.primary.extend_numbers(&mut numbers); + numbers.retain(|num| self.lookup(*num, file).is_some_and(|entry| !matches!(entry, XrefEntry::Free { .. }))); + return numbers; + } + let mut numbers = Vec::new(); + for revision in revisions { + revision.extend_numbers(&mut numbers); + } + numbers.sort_unstable(); + numbers.dedup(); + numbers.retain(|num| self.lookup(*num, file).is_some_and(|entry| !matches!(entry, XrefEntry::Free { .. }))); + numbers + } + } + } +} + /// One cross-reference section of the file (one per save; the first is the original). #[derive(Clone, Debug, PartialEq, Eq)] pub struct Revision { @@ -102,7 +341,7 @@ impl Eq for SourceIdentity {} pub struct Document { data: Arc>, source_identity: Arc<()>, - entries: Arc>, + entries: Arc, trailer: Dict, revisions: Arc>, /// See [`Self::xref_oddities`]. @@ -207,10 +446,17 @@ fn owned_bytes(object: &Object, data: &Arc>, depth: usize) -> usize { impl std::fmt::Debug for Document { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Document").field("bytes", &self.data.len()).field("objects", &self.entries.len()).field("edits", &self.overlay.len()).finish() + f.debug_struct("Document").field("bytes", &self.data.len()).field("xref", &self.entries).field("edits", &self.overlay.len()).finish() } } +/// How an encrypted document is opened: with a password, or with the file key of a document +/// that has the same encryption (an earlier revision of the same file). +enum Credentials<'a> { + Password(Option<&'a str>), + Handler { handler: &'a Arc, id0: Vec }, +} + impl Document { /// A new document with an empty page tree (for split / extract / combine). It has no /// original bytes, so saving always writes a full file. @@ -218,7 +464,7 @@ impl Document { let mut doc = Document { data: Arc::new(Vec::new()), source_identity: Arc::new(()), - entries: Arc::new(BTreeMap::new()), + entries: Arc::new(XrefIndex::empty()), trailer: Dict::new(), revisions: Arc::new(Vec::new()), xref_oddities: Arc::default(), @@ -267,6 +513,23 @@ impl Document { /// document is read-only: objects in a larger object stream read as missing, so the /// writers refuse to save it ([`CosError::ReadOnlyLimit`]). pub fn open_with_stream_limit(data: Arc>, password: Option<&str>, stream_limit: usize) -> Result { + Self::open_as(data, Credentials::Password(password), stream_limit) + } + + /// Open `data`, an earlier revision of this file (the bytes a signature covers) or another + /// save of it, with this document's credentials: an encrypted one must have the same + /// `/Encrypt` dictionary and `/ID` as this document, whose file key is then reused, so no + /// password is needed or kept; unencrypted data opens as [`open`](Self::open) does. Data + /// encrypted differently fails with `NeedsPassword`. + pub fn open_with_same_credentials(&self, data: Arc>) -> Result { + let credentials = match &self.security { + Some(handler) => Credentials::Handler { handler, id0: self.first_id() }, + None => Credentials::Password(None), + }; + Self::open_as(data, credentials, self.stream_limit) + } + + fn open_as(data: Arc>, credentials: Credentials<'_>, stream_limit: usize) -> Result { // Viewers accept files whose header is missing or damaged as long as the body looks // like PDF; so do we (a note goes to the repair log). let header = find(&data, b"%PDF-", 0, 1024); @@ -282,7 +545,7 @@ impl Document { let mut doc = Document { data, source_identity: Arc::new(()), - entries: Arc::new(BTreeMap::new()), + entries: Arc::new(XrefIndex::empty()), trailer: Dict::new(), revisions: Arc::new(Vec::new()), xref_oddities: Arc::default(), @@ -316,7 +579,7 @@ impl Document { // Authenticate before checking the catalog: it may sit in an encrypted object // stream, which reads as garbage until the key is known. if let Some(enc) = doc.trailer.get(b"Encrypt").cloned() { - doc.authenticate(&enc, password)?; + doc.authenticate(&enc, &credentials)?; authenticated = true; } doc.root_is_catalog() @@ -337,15 +600,21 @@ impl Document { if (!ok || !authenticated) && let Some(enc) = doc.trailer.get(b"Encrypt").cloned() { - doc.authenticate(&enc, password)?; + doc.authenticate(&enc, &credentials)?; } - let max = doc.entries.keys().next_back().copied().unwrap_or(0); - let size = doc.trailer.int(b"Size").unwrap_or(0).max(0) as u32; - doc.next_num = max.max(size.saturating_sub(1)) + 1; + if Arc::make_mut(&mut doc.entries).remove_exhausted() { + Arc::make_mut(&mut doc.repair_log).push("skipped exhausted xref object number 4294967295".into()); + } + let max = doc.entries.max_num(); + let size = u32::try_from(doc.trailer.int(b"Size").unwrap_or(0).max(0)).unwrap_or(u32::MAX); + if size == u32::MAX { + Arc::make_mut(&mut doc.repair_log).push("exhausted trailer /Size; new objects use unused object numbers".into()); + } + doc.next_num = max.saturating_add(1).max(size).max(1); Ok(doc) } - fn authenticate(&mut self, enc: &Object, password: Option<&str>) -> Result<(), CosError> { + fn authenticate(&mut self, enc: &Object, credentials: &Credentials<'_>) -> Result<(), CosError> { // Authentication can change how the same input is decoded. self.source_identity = Arc::new(()); let (num, dict) = match enc { @@ -363,12 +632,17 @@ impl Document { }; let params = crate::security::encrypt_dict(self, &dict); let id0 = self.first_id(); - let handler = pdfcraft_crypt::SecurityHandler::open(params, &id0, password).map_err(|e| match e { - pdfcraft_crypt::CryptError::WrongPassword if password.is_none() => CosError::NeedsPassword, - pdfcraft_crypt::CryptError::WrongPassword => CosError::WrongPassword, - other => CosError::Security(other.to_string()), - })?; - self.security = Some(Arc::new(handler)); + let handler = match credentials { + Credentials::Password(password) => Arc::new(pdfcraft_crypt::SecurityHandler::open(params, &id0, *password).map_err(|e| match e { + pdfcraft_crypt::CryptError::WrongPassword if password.is_none() => CosError::NeedsPassword, + pdfcraft_crypt::CryptError::WrongPassword => CosError::WrongPassword, + other => CosError::Security(other.to_string()), + })?), + // The same encryption as the document whose key this is: that key opens it. + Credentials::Handler { handler, id0: known } if *handler.dict() == params && *known == id0 => Arc::clone(handler), + Credentials::Handler { .. } => return Err(CosError::NeedsPassword), + }; + self.security = Some(handler); self.encrypt_num = num; // Objects read while locating the catalog were read before decryption was possible. self.cache = Arc::default(); @@ -589,7 +863,7 @@ impl Document { /// Where object `num` is stored in the file (ignoring unsaved edits). pub fn xref_entry(&self, num: u32) -> Option { - self.entries.get(&num).cloned() + self.entries.lookup(num, &self.data) } /// Cross-reference entries this reader did not take at face value, as (byte offset of the @@ -609,15 +883,15 @@ impl Document { /// position of its object stream. When it does not, [`Self::get`] still finds the object by /// searching, but another reader may resolve the number differently. Ignores unsaved edits. pub fn xref_entry_is_exact(&self, num: u32) -> bool { - match self.entries.get(&num) { + match self.xref_entry(num) { None | Some(XrefEntry::Free { .. }) => true, Some(XrefEntry::InFile { offset, .. }) => { let at = |off: usize| matches!(parse_indirect_shared(&self.data, off, &|_| None), Ok((id, _)) if id.num == num); // Offsets may count from a header that does not start the file. - usize::try_from(*offset).is_ok_and(|off| at(off) || (self.header_offset != 0 && at(off.saturating_add(self.header_offset)))) + usize::try_from(offset).is_ok_and(|off| at(off) || (self.header_offset != 0 && at(off.saturating_add(self.header_offset)))) } Some(XrefEntry::InStream { stream, index }) => { - self.objstm(*stream).is_ok_and(|stm| usize::try_from(*index).ok().and_then(|i| stm.index.get(i)).is_some_and(|(n, _)| *n == num)) + self.objstm(stream).is_ok_and(|stm| usize::try_from(index).ok().and_then(|i| stm.index.get(i)).is_some_and(|(n, _)| *n == num)) } } } @@ -798,7 +1072,7 @@ impl Document { if depth > 16 { return Err(CosError::Syntax { offset: 0, detail: "reference cycle while loading".into() }); } - match self.entries.get(&num) { + match self.entries.lookup(num, &self.data) { None | Some(XrefEntry::Free { .. }) => Ok(Object::Null), Some(XrefEntry::InFile { offset, .. }) => { let resolve = |r: ObjRef| { @@ -809,7 +1083,7 @@ impl Document { LENGTHS.with(|m| m.borrow_mut().insert(r.num, length)); length }; - let off = *offset as usize; + let off = offset as usize; let at = |off: usize| match parse_indirect_shared(&self.data, off, &resolve) { Ok((id, o)) if id.num == num => Some(self.decrypted(id, o)), _ => None, @@ -821,10 +1095,10 @@ impl Document { .ok_or(CosError::MissingObject(num)) } Some(XrefEntry::InStream { stream, index }) => { - let stm = self.objstm(*stream)?; + let stm = self.objstm(stream)?; let (n, off) = stm .index - .get(*index as usize) + .get(index as usize) .copied() .or_else(|| stm.index.iter().find(|(n, _)| *n == num).copied()) .ok_or(CosError::MissingObject(num))?; @@ -896,24 +1170,77 @@ impl Document { let obj = self.adopt(obj.into()); self.overlay.insert(r.num, Slot::Set(r.generation, Arc::new(obj))); if r.num >= self.next_num { - self.next_num = r.num + 1; + self.next_num = r.num.saturating_add(1); } } /// Add a new indirect object and return its reference. pub fn add(&mut self, obj: impl Into) -> ObjRef { - let r = ObjRef::new(self.next_num, 0); - self.next_num += 1; + let r = ObjRef::new(self.allocate_num(), 0); let obj = self.adopt(obj.into()); self.overlay.insert(r.num, Slot::Set(0, Arc::new(obj))); r } + /// Find a never-used number, retaining the high-water mark for incremental /Size. + /// Exhausted hints fall back to gaps without replacing source or overlay objects. + fn allocate_num(&mut self) -> u32 { + let mut num = if self.next_num == u32::MAX { 1 } else { self.next_num.max(1) }; + while self.entries.lookup(num, &self.data).is_some() || self.overlay.contains_key(&num) { + num = if num == u32::MAX - 1 { 1 } else { num + 1 }; + } + self.next_num = self.next_num.max(num.saturating_add(1)); + num + } + /// Delete an object (its number becomes free with the next generation). pub fn free(&mut self, r: ObjRef) { self.overlay.insert(r.num, Slot::Freed(r.generation.saturating_add(1))); } + /// Record `current`'s object graph as unsaved edits of this document. + /// + /// Used when the working snapshot was restored from an earlier revision and the next + /// incremental save must still append to this file (the last saved revision). Objects + /// that already match are left untouched. Reads go through private readers so neither + /// document's editing cache is filled. + pub fn overlay_changes_from(&mut self, current: &Document) -> Result<(), CosError> { + let saved_nums = self.object_numbers(); + let current_nums = current.object_numbers(); + let mut freed = Vec::new(); + let mut changed = Vec::new(); + { + let mut saved_reader = self.object_reader(); + let mut current_reader = current.object_reader(); + for &num in &saved_nums { + if current_nums.binary_search(&num).is_err() { + freed.push(num); + } + } + for &num in ¤t_nums { + let generation = current.generation(num); + let same = saved_nums.binary_search(&num).is_ok() + && self.generation(num) == generation + && saved_reader.try_get(ObjRef::new(num, 0))? == current_reader.try_get(ObjRef::new(num, 0))?; + if !same { + changed.push(num); + } + } + } + for num in freed { + let generation = self.generation(num); + self.free(ObjRef::new(num, generation)); + } + let mut current_reader = current.object_reader(); + for num in changed { + let generation = current.generation(num); + let object = current_reader.try_get(ObjRef::new(num, 0))?; + self.set(ObjRef::new(num, generation), (*object).clone()); + } + *self.trailer_mut() = current.trailer().clone(); + Ok(()) + } + /// Edit a dictionary (or stream dictionary) object in place. pub fn update_dict(&mut self, r: ObjRef, f: impl FnOnce(&mut Dict)) -> Result<(), CosError> { let mut obj = (*self.get(r)).clone(); @@ -928,9 +1255,9 @@ impl Document { match self.overlay.get(&num) { Some(Slot::Set(g, _)) => *g, Some(Slot::Freed(g)) => *g, - None => match self.entries.get(&num) { - Some(XrefEntry::InFile { generation, .. }) => *generation, - Some(XrefEntry::Free { next_generation }) => *next_generation, + None => match self.entries.lookup(num, &self.data) { + Some(XrefEntry::InFile { generation, .. }) => generation, + Some(XrefEntry::Free { next_generation }) => next_generation, _ => 0, }, } @@ -949,26 +1276,23 @@ impl Document { /// All object numbers known (file and overlay), excluding freed ones. pub fn object_numbers(&self) -> Vec { - // Both maps are ordered. Overlay slots replace source slots, including frees; - // merge them directly without a second set and a sort of every object number. - let mut numbers = Vec::with_capacity(self.entries.len().max(self.overlay.len())); - let mut source = self.entries.iter().peekable(); + let source_numbers = self.entries.object_numbers(&self.data); + let mut numbers = Vec::with_capacity(source_numbers.len().max(self.overlay.len())); + let mut source = source_numbers.into_iter().peekable(); for (&num, slot) in &self.overlay { - while source.peek().is_some_and(|(n, _)| **n < num) { - if let Some((&n, entry)) = source.next() - && !matches!(entry, XrefEntry::Free { .. }) - { + while source.peek().is_some_and(|n| *n < num) { + if let Some(n) = source.next() { numbers.push(n); } } - if source.peek().is_some_and(|(n, _)| **n == num) { + if source.peek().is_some_and(|n| *n == num) { source.next(); } if matches!(slot, Slot::Set(..)) { numbers.push(num); } } - numbers.extend(source.filter_map(|(&num, entry)| (!matches!(entry, XrefEntry::Free { .. })).then_some(num))); + numbers.extend(source); numbers } @@ -986,7 +1310,309 @@ impl Document { } #[allow(clippy::type_complexity)] - fn read_xref_chain(&self, log: &mut Vec) -> Result<(BTreeMap, Dict, Vec, Vec<(u64, u32)>), CosError> { + fn read_xref_chain(&self, log: &mut Vec) -> Result<(XrefIndex, Dict, Vec, Vec<(u64, u32)>), CosError> { + let mut compact_log = Vec::new(); + if let Ok(Some((index, trailer, revisions))) = self.read_compact_xref_chain(&mut compact_log) { + let size = trailer.int(b"Size").unwrap_or(0).max(0); + if size > 0 && size <= i64::from(u32::MAX) && u64::from(index.max_num()) < size as u64 { + log.extend(compact_log); + // The compact reader declines any section with set-aside entries, so there are none here. + return Ok((index, trailer, revisions, Vec::new())); + } + } + let (entries, trailer, revisions, oddities) = self.read_xref_chain_materialized(log)?; + Ok((XrefIndex::Materialized(Arc::new(entries)), trailer, revisions, oddities)) + } + + fn read_compact_xref_chain(&self, log: &mut Vec) -> Result)>, CosError> { + let data = &self.data; + let tail_start = data.len().saturating_sub(4096); + let sx = rfind(data, b"startxref", tail_start).ok_or(CosError::Syntax { offset: data.len(), detail: "no startxref".into() })?; + let mut lx = Lexer::new(data, sx + 9); + let first = match lx.object() { + Ok(Object::Int(n)) if n >= 0 => n as u64, + _ => return Err(CosError::Syntax { offset: sx, detail: "bad startxref value".into() }), + }; + let mut sections = Vec::new(); + let mut retained_rows = 0usize; + let mut retained_bytes = 0usize; + let mut trailer: Option = None; + let mut revisions = Vec::new(); + let mut visited = HashSet::new(); + let mut next = Some(first); + while let Some(off) = next.take() { + if !visited.insert(off) || visited.len() > 10_000 { + log.push(format!("cross-reference chain loops at offset {off}; stopped")); + break; + } + let (primary, sect_trailer, is_stream) = match self.read_compact_section(off as usize) { + Ok(Some(v)) => v, + Ok(None) => return Ok(None), + Err(e) if self.header_offset > 0 => { + log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header")); + match self.read_compact_section(off as usize + self.header_offset)? { + Some(v) => v, + None => return Ok(None), + } + } + // An unreadable older section loses every object only it locates: the + // materialized reader logs it and the file is rebuilt from object headers (#1656). + Err(e) => return Err(e), + }; + revisions.push(Revision { xref_offset: off, is_stream }); + let Some(rows) = retained_rows.checked_add(primary.row_count).filter(|rows| *rows <= MAX_COMPACT_XREF_ROWS) else { return Ok(None) }; + let Some(bytes) = retained_bytes.checked_add(primary.retained_bytes()).filter(|bytes| *bytes <= MAX_COMPACT_XREF_BYTES) else { + return Ok(None); + }; + retained_rows = rows; + retained_bytes = bytes; + let hybrid = if let Some(x) = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0) { + match self.read_compact_section(x as usize) { + Ok(Some((section, _, _))) => { + let Some(rows) = retained_rows.checked_add(section.row_count).filter(|rows| *rows <= MAX_COMPACT_XREF_ROWS) else { + return Ok(None); + }; + let Some(bytes) = retained_bytes.checked_add(section.retained_bytes()).filter(|bytes| *bytes <= MAX_COMPACT_XREF_BYTES) + else { + return Ok(None); + }; + retained_rows = rows; + retained_bytes = bytes; + Some(section) + } + Ok(None) => return Ok(None), + Err(e) => { + log.push(format!("hybrid /XRefStm at {x} unreadable: {e}")); + None + } + } + } else { + None + }; + if let Some(hybrid) = &hybrid { + // A hybrid entry that disagrees with the table is set aside by the materialized reader. + let mut numbers = Vec::new(); + hybrid.extend_numbers(&mut numbers); + for num in numbers { + let Some(entry) = hybrid.lookup(num, &self.data) else { continue }; + if primary.lookup(num, &self.data).is_some_and(|old| !matches!(old, XrefEntry::Free { .. }) && old != entry) { + return Ok(None); + } + } + } + sections.push(XrefRevisionSection { primary, hybrid }); + next = sect_trailer.int(b"Prev").filter(|p| *p >= 0).map(|p| p as u64); + if trailer.is_none() { + trailer = Some(sect_trailer); + } + } + revisions.reverse(); + let mut trailer = trailer.ok_or(CosError::Syntax { offset: 0, detail: "no trailer".into() })?; + for k in [&b"Prev"[..], b"XRefStm", b"Type", b"W", b"Index", b"Filter", b"DecodeParms", b"Length"] { + trailer.remove(k); + } + let mut max_num = 0; + let mut has_entries = false; + for revision in §ions { + max_num = max_num.max(revision.primary.max_num); + has_entries |= revision.primary.has_entries; + if let Some(hybrid) = &revision.hybrid { + max_num = max_num.max(hybrid.max_num); + has_entries |= hybrid.has_entries; + } + } + Ok(Some((XrefIndex::Compact { revisions: sections, max_num, has_entries }, trailer, revisions))) + } + + fn read_compact_section(&self, off: usize) -> Result, CosError> { + let data = &self.data; + let mut lx = Lexer::new(data, off); + if lx.eat_keyword(b"xref") { + let mut ranges = Vec::new(); + let mut layout_ok = true; + let mut max_num = 0; + let mut has_entries = false; + let mut previous_end = None; + let mut row_count = 0usize; + loop { + lx.skip_ws(); + if lx.eat_keyword(b"trailer") { + let t = lx.object()?; + let d = t.as_dict().cloned().ok_or(CosError::Syntax { offset: lx.pos, detail: "trailer is not a dictionary".into() })?; + if !layout_ok { + return Ok(None); + } + return Ok(Some((XrefSection { data: XrefSectionData::Table, ranges, row_count, max_num, has_entries }, d, false))); + } + let (Ok(Object::Int(start)), Ok(Object::Int(count))) = (lx.object(), lx.object()) else { + return Err(CosError::Syntax { offset: lx.pos, detail: "bad xref subsection header".into() }); + }; + if !valid_xref_range(start, count) || count > 10_000_000 { + return Err(CosError::Syntax { offset: lx.pos, detail: "invalid xref object range".into() }); + } + let Some(next_rows) = usize::try_from(count).ok().and_then(|count| row_count.checked_add(count)) else { return Ok(None) }; + if next_rows > MAX_COMPACT_XREF_ROWS { + return Ok(None); + } + row_count = next_rows; + let mut range_offset = None; + let mut stride = None; + let mut previous_record = None; + for i in 0..count { + lx.skip_ws(); + let record = lx.pos; + if range_offset.is_none() { + range_offset = Some(record); + } + if let Some(previous) = previous_record { + let Some(delta) = record.checked_sub(previous) else { + layout_ok = false; + continue; + }; + if stride.is_some_and(|known| known != delta) { + layout_ok = false; + } + stride.get_or_insert(delta); + } + previous_record = Some(record); + let o = lx.token(); + lx.skip_ws(); + let _generation = lx.token(); + lx.skip_ws(); + let kind = lx.token(); + let num = (start + i) as u32; + let offset = std::str::from_utf8(o).ok().and_then(|s| s.parse::().ok()); + match (kind, offset) { + (b"n", Some(offset)) if offset != 0 => { + has_entries = true; + max_num = max_num.max(num); + } + (b"n", Some(_)) => { + // Set aside by the materialized reader, which records it. + if num != 0 { + return Ok(None); + } + } + (b"f", _) => { + has_entries = true; + max_num = max_num.max(num); + } + _ => return Err(CosError::Syntax { offset: lx.pos, detail: "bad xref entry".into() }), + } + } + if count > 0 { + let range_start = start as u32; + let range_count = count as u32; + let end = u64::from(range_start) + u64::from(range_count); + if previous_end.is_some_and(|previous| u64::from(range_start) < previous) { + layout_ok = false; + } + previous_end = Some(end); + ranges.push(XrefRange { + start: range_start, + count: range_count, + offset: range_offset.unwrap_or(lx.pos), + stride: stride.unwrap_or(1), + }); + } + } + } + let (_, obj) = parse_indirect(data, off, &|_| None)?; + let Object::Stream(stream) = obj else { return Err(CosError::Syntax { offset: off, detail: "expected xref table or stream".into() }) }; + if stream.dict.name(b"Type") != Some(b"XRef") { + return Err(CosError::Syntax { offset: off, detail: "stream at startxref is not /Type /XRef".into() }); + } + let w: Vec = stream + .dict + .get(b"W") + .and_then(Object::as_array) + .map(|a| a.iter().map(|x| x.as_int().unwrap_or(0).clamp(0, 8) as usize).collect()) + .unwrap_or_default(); + if w.len() < 3 { + return Err(CosError::Syntax { offset: off, detail: "xref stream /W invalid".into() }); + } + let widths = [w[0], w[1], w[2]]; + let row = widths.iter().sum::(); + if row == 0 { + return Err(CosError::Syntax { offset: off, detail: "xref stream row width 0".into() }); + } + let size = stream.dict.int(b"Size").unwrap_or(0).max(0); + let index: Vec = match stream.dict.get(b"Index").and_then(Object::as_array) { + Some(a) => a.iter().filter_map(Object::as_int).collect(), + None => vec![0, size], + }; + let mut requested_rows = 0usize; + let mut previous_end = None; + for pair in index.chunks(2) { + let [start, count] = pair else { break }; + if !valid_xref_range(*start, *count) { + return Err(CosError::Syntax { offset: off, detail: "invalid xref object range".into() }); + } + let Ok(count) = usize::try_from(*count) else { return Ok(None) }; + let Some(next) = requested_rows.checked_add(count).filter(|rows| *rows <= MAX_COMPACT_XREF_ROWS) else { return Ok(None) }; + if count > 0 { + let end = (*start as u64).saturating_add(count as u64); + if previous_end.is_some_and(|previous| (*start as u64) < previous) { + return Ok(None); + } + previous_end = Some(end); + } + requested_rows = next; + } + if requested_rows.checked_mul(row).is_none_or(|bytes| bytes > MAX_COMPACT_XREF_BYTES) { + return Ok(None); + } + let raw = stream.decoded_within(MAX_XREF_OBJECTS.saturating_mul(row).min(self.stream_limit).min(MAX_COMPACT_XREF_BYTES))?; + if raw.capacity() > MAX_COMPACT_XREF_BYTES { + return Ok(None); + } + let mut ranges = Vec::new(); + let mut row_start = 0usize; + let available_rows = raw.len() / row; + for pair in index.chunks(2) { + let [start, count] = pair else { break }; + let range_start = *start as u32; + let Ok(requested) = usize::try_from(*count) else { return Ok(None) }; + let range_count = requested.min(available_rows.saturating_sub(row_start)); + if range_count > 0 { + let Ok(range_count_u32) = u32::try_from(range_count) else { return Ok(None) }; + let Some(byte_offset) = row_start.checked_mul(row) else { return Ok(None) }; + ranges.push(XrefRange { start: range_start, count: range_count_u32, offset: byte_offset, stride: row }); + } + let Some(next) = row_start.checked_add(range_count) else { return Ok(None) }; + row_start = next; + } + // Rows the materialized reader sets aside (reserved types, offset 0) are left to it. + for range in &ranges { + for i in 0..range.count as usize { + let at = range.offset + i * row; + let Some(r) = raw.get(at..at + row) else { break }; + let field = |from: usize, width: usize, default: u64| -> u64 { + if width == 0 { + return default; + } + r[from..from + width].iter().fold(0u64, |acc, byte| acc << 8 | u64::from(*byte)) + }; + let kind = field(0, widths[0], 1); + let offset = field(widths[0], widths[1], 0); + if kind > 2 || (kind == 1 && offset == 0 && range.start + i as u32 != 0) { + return Ok(None); + } + } + } + let max_num = ranges.iter().filter_map(|range| range.start.checked_add(range.count.saturating_sub(1))).max().unwrap_or(0); + let row_count = row_start; + let section = XrefSection { data: XrefSectionData::Stream { bytes: Arc::new(raw), widths }, ranges, row_count, max_num, has_entries: false }; + let has_entries = section.has_any_entry(); + let section = XrefSection { has_entries, ..section }; + Ok(Some((section, stream.dict.clone(), true))) + } + + #[allow(clippy::type_complexity)] + fn read_xref_chain_materialized( + &self, + log: &mut Vec, + ) -> Result<(BTreeMap, Dict, Vec, Vec<(u64, u32)>), CosError> { let data = &self.data; let tail_start = data.len().saturating_sub(4096); let sx = rfind(data, b"startxref", tail_start).ok_or(CosError::Syntax { offset: data.len(), detail: "no startxref".into() })?; @@ -1019,11 +1645,12 @@ impl Document { self.read_section(off as usize + self.header_offset, &mut sect, &mut odd)? } Err(e) => { - if revisions.is_empty() { - return Err(e); + // An unreadable older section loses every object only it locates (the page + // tree, often): rebuild from object headers, as readers do (#1656). + if !revisions.is_empty() { + log.push(format!("older cross-reference section at {off} is unreadable: {e}")); } - log.push(format!("older cross-reference section at {off} is unreadable: {e}")); - break; + return Err(e); } }; revisions.push(Revision { xref_offset: off, is_stream }); @@ -1212,7 +1839,7 @@ impl Document { if entries.is_empty() { return Err(CosError::Syntax { offset: 0, detail: "no objects found".into() }); } - self.entries = Arc::new(entries.clone()); + self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries.clone()))); self.cache.lock().map_err(|_| CosError::Poisoned)?.clear(); // Objects inside object streams. for num in objstms { @@ -1226,7 +1853,8 @@ impl Document { } } } - self.entries = Arc::new(entries); + let object_count = entries.len(); + self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries))); self.cache.lock().map_err(|_| CosError::Poisoned)?.clear(); // Trailer: the last `trailer` dictionary with a usable /Root, else the catalog itself. let mut trailer = None; @@ -1245,8 +1873,8 @@ impl Document { None => { let catalog = self .entries - .keys() - .copied() + .object_numbers(&self.data) + .into_iter() .find(|n| matches!(self.try_get(*n).as_deref(), Ok(Object::Dict(d)) if d.name(b"Type") == Some(b"Catalog"))); let Some(c) = catalog else { return Err(CosError::Syntax { offset: 0, detail: "no document catalog found".into() }) }; let mut t = Dict::new(); @@ -1260,7 +1888,7 @@ impl Document { } self.trailer = trailer; self.revisions = Arc::new(Vec::new()); - log.push(format!("rebuilt cross-reference index from {} object headers", self.entries.len())); + log.push(format!("rebuilt cross-reference index from {object_count} object headers")); Ok(()) } } @@ -1324,6 +1952,101 @@ mod tests { out } + fn assert_compact_matches_materialized(doc: &Document) { + assert!(matches!(doc.entries.as_ref(), XrefIndex::Compact { .. }), "valid xrefs should not retain a per-entry map"); + let (entries, _, revisions, _) = doc.read_xref_chain_materialized(&mut Vec::new()).unwrap(); + assert_eq!(revisions.as_slice(), doc.revisions()); + let max = entries.keys().next_back().copied().unwrap_or(0); + assert_eq!(doc.entries.max_num(), max); + for num in 0..=max { + assert_eq!(doc.xref_entry(num), entries.get(&num).copied(), "object {num}"); + } + let expected: Vec<_> = entries.iter().filter_map(|(&num, entry)| (!matches!(entry, XrefEntry::Free { .. })).then_some(num)).collect(); + assert_eq!(doc.object_numbers(), expected); + } + + #[test] + fn compact_xref_tables_streams_and_revisions_match_the_materialized_reader() { + let classic = Document::open(Arc::new(build( + &["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Value 9 >>"], + "/Root 1 0 R", + ))) + .unwrap(); + assert_compact_matches_materialized(&classic); + + let mut sparse = b"%PDF-1.7\n".to_vec(); + let root_offset = sparse.len(); + sparse.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 5 0 R >>\nendobj\n"); + let pages_offset = sparse.len(); + sparse.extend_from_slice(b"5 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n"); + let xref = sparse.len(); + sparse.extend_from_slice( + format!( + "xref\n0 1\n0000000000 65535 f \n1 1\n{root_offset:010} 00000 n \n5 1\n{pages_offset:010} 00000 n \ntrailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n" + ) + .as_bytes(), + ); + let sparse = Document::open(Arc::new(sparse)).unwrap(); + assert_eq!(sparse.object_numbers(), vec![1, 5]); + assert_compact_matches_materialized(&sparse); + + let mut sparse_stream = b"%PDF-1.7\n".to_vec(); + let mut offsets = [0u32; 8]; + offsets[1] = sparse_stream.len() as u32; + sparse_stream.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 5 0 R >>\nendobj\n"); + offsets[5] = sparse_stream.len() as u32; + sparse_stream.extend_from_slice(b"5 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n"); + offsets[6] = sparse_stream.len() as u32; + sparse_stream.extend_from_slice(b"6 0 obj\n<< /Unused true >>\nendobj\n"); + offsets[7] = sparse_stream.len() as u32; + let xref_row = |kind: u8, offset: u32, generation: u16| { + let mut row = Vec::with_capacity(7); + row.push(kind); + row.extend_from_slice(&offset.to_be_bytes()); + row.extend_from_slice(&generation.to_be_bytes()); + row + }; + let mut rows = Vec::new(); + rows.extend(xref_row(0, 0, u16::MAX)); + rows.extend(xref_row(1, offsets[1], 0)); + rows.extend(xref_row(1, offsets[5], 0)); + rows.extend(xref_row(1, offsets[6], 0)); + rows.extend(xref_row(1, offsets[7], 0)); + sparse_stream.extend_from_slice( + format!("7 0 obj\n<< /Type /XRef /Size 8 /Root 1 0 R /W [1 4 2] /Index [0 2 5 3] /Length {} >>\nstream\n", rows.len()).as_bytes(), + ); + sparse_stream.extend_from_slice(&rows); + sparse_stream.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{}\n%%EOF\n", offsets[7]).as_bytes()); + let sparse_stream = Document::open(Arc::new(sparse_stream)).unwrap(); + assert_eq!(sparse_stream.object_numbers(), vec![1, 5, 6, 7]); + assert_compact_matches_materialized(&sparse_stream); + + let stream_bytes = crate::write_full(&classic, &crate::SaveOptions::default()).unwrap(); + let stream = Document::open(Arc::new(stream_bytes)).unwrap(); + assert!(stream.revisions().last().is_some_and(|revision| revision.is_stream)); + assert_compact_matches_materialized(&stream); + + let mut edited = classic.clone(); + edited.update_dict(ObjRef::new(1, 0), |dict| dict.set(b"Lang".to_vec(), Object::String(crate::PdfString::literal("en")))).unwrap(); + edited.free(ObjRef::new(3, 0)); + let incremental = crate::write_incremental(&edited, &crate::SaveOptions::default()).unwrap(); + let updated = Document::open(Arc::new(incremental)).unwrap(); + assert_eq!(updated.revisions().len(), 2); + assert_compact_matches_materialized(&updated); + } + + #[test] + fn malformed_size_uses_the_materialized_compatibility_path() { + let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Value 9 >>"], "/Root 1 0 R"); + let marker = b"/Size 4"; + let at = bytes.windows(marker.len()).position(|window| window == marker).unwrap(); + bytes[at..at + marker.len()].copy_from_slice(b"/Size 2"); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert!(matches!(doc.entries.as_ref(), XrefIndex::Materialized(_))); + assert_eq!(doc.next_num, 4); + assert_eq!(doc.object_numbers(), vec![1, 2, 3]); + } + /// Entries the reader sets aside are remembered, so a caller comparing revisions can tell /// that another reader may resolve the object differently. #[test] @@ -1362,6 +2085,75 @@ mod tests { assert!(doc.xref_entry_is_exact(1) && doc.xref_entry_is_exact(2)); } + #[test] + fn opening_an_exhausted_classic_xref_repairs_and_allocates() { + // The extra in-use row need not be loaded to exhaust the next object number. + let bytes = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\nxref\n0 3\n0000000000 65535 f \n0000000009 00000 n \n0000000058 00000 n \n4294967295 1\n0000000058 00000 n \ntrailer\n<< /Size 3 /Root 1 0 R >>\nstartxref\n110\n%%EOF\n"; + assert_eq!(bytes.len(), 266); + let mut doc = Document::open(Arc::new(bytes.to_vec())).unwrap(); + assert_eq!(doc.root(), Some(ObjRef::new(1, 0))); + assert!(doc.repair_log().iter().any(|line| line.contains("exhausted xref"))); + assert_eq!(doc.add(Object::Null), ObjRef::new(3, 0)); + } + + #[test] + fn opening_an_exhausted_xref_stream_repairs_and_allocates() { + let mut bytes = + b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n".to_vec(); + let offset = bytes.len(); + bytes.extend_from_slice(b"3 0 obj\n<< /Type /XRef /Size 4 /Root 1 0 R /W [1 4 2] /Index [0 3 4294967295 1] /Length 28 >>\nstream\n"); + for (kind, position, generation) in [(0_u8, 0_u32, 65535_u16), (1, 9, 0), (1, 58, 0), (1, 58, 0)] { + bytes.push(kind); + bytes.extend_from_slice(&position.to_be_bytes()); + bytes.extend_from_slice(&generation.to_be_bytes()); + } + bytes.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{offset}\n%%EOF\n").as_bytes()); + let mut doc = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(doc.root(), Some(ObjRef::new(1, 0))); + assert!(doc.repair_log().iter().any(|line| line.contains("exhausted xref"))); + assert_eq!(doc.add(Object::Null), ObjRef::new(4, 0)); + } + + #[test] + fn exhausted_size_and_high_edits_allocate_without_overwriting_objects() { + let ordinary = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R"); + for size in ["4294967295", "4294967296", "9223372036854775807"] { + let bytes = String::from_utf8(ordinary.clone()).unwrap().replace("/Size 3", &format!("/Size {size}")); + let mut doc = Document::open(Arc::new(bytes.into_bytes())).unwrap(); + assert!(doc.repair_log().iter().any(|line| line.contains("exhausted trailer"))); + assert_eq!(doc.add(Object::Int(42)), ObjRef::new(3, 0)); + doc.set(ObjRef::new(u32::MAX, 0), Object::Int(99)); + doc.set(ObjRef::new(u32::MAX - 1, 0), Object::Int(98)); + assert_eq!(doc.add(Object::Int(43)), ObjRef::new(4, 0)); + assert_eq!(doc.get(ObjRef::new(3, 0)).as_int(), Some(42)); + assert_eq!(doc.get(ObjRef::new(u32::MAX, 0)).as_int(), Some(99)); + assert_eq!(doc.get(ObjRef::new(1, 0)).as_dict().unwrap().name(b"Type"), Some(b"Catalog".as_slice())); + } + let mut doc = Document::new_empty(); + doc.set(ObjRef::new(u32::MAX - 2, 0), Object::Null); + assert_eq!(doc.add(Object::Null), ObjRef::new(u32::MAX - 1, 0)); + assert_eq!(doc.add(Object::Null), ObjRef::new(3, 0)); + assert_eq!(doc.add(Object::Null), ObjRef::new(4, 0)); + } + + #[test] + fn exhausted_xref_controls_preserve_ordinary_ignored_and_repaired_entries() { + let ordinary = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R"); + for (extra, repaired) in + [("", false), ("4294967295 1\n0000000000 00000 n \n", false), ("4294967295 2\n0000000058 00000 n \n0000000058 00000 n \n", true)] + { + let bytes = String::from_utf8(ordinary.clone()).unwrap().replacen("trailer\n", &format!("{extra}trailer\n"), 1); + let mut doc = Document::open(Arc::new(bytes.into_bytes())).unwrap(); + assert_eq!(doc.root(), Some(ObjRef::new(1, 0))); + assert_eq!(doc.get(ObjRef::new(1, 0)).as_dict().unwrap().name(b"Type"), Some(b"Catalog".as_slice())); + assert_eq!(!doc.repair_log().is_empty(), repaired); + if repaired { + assert!(doc.repair_log().iter().any(|line| line.contains("xref") && line.contains("range"))); + } + assert_eq!(doc.add(Object::Null), ObjRef::new(3, 0)); + } + } + #[test] fn invalid_xref_object_ranges_are_reconstructed() { // Original in-memory PDF: an intact catalog followed by a damaged xref section. @@ -1393,6 +2185,32 @@ mod tests { } } + #[test] + fn objects_only_an_unreadable_older_section_locates_are_found_by_scanning() { + // The newest section lists the catalog only; its /Prev points past the end of the file, + // so the page tree is reachable only by scanning object headers, as readers do (#1656). + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (num, body) in + [(1, "<< /Type /Catalog /Pages 2 0 R >>"), (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"), (3, "<< /Type /Page /Parent 2 0 R >>")] + { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice( + format!( + "xref\n0 2\n0000000000 65535 f \n{:010} 00000 n \ntrailer\n<< /Root 1 0 R /Size 4 /Prev 999999 >>\nstartxref\n{xref}\n%%EOF\n", + offsets[0] + ) + .as_bytes(), + ); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(doc.get(ObjRef::new(2, 0)).as_dict().and_then(|d| d.name(b"Type")), Some(b"Pages".as_slice())); + assert_eq!(doc.get(ObjRef::new(3, 0)).as_dict().and_then(|d| d.name(b"Type")), Some(b"Page".as_slice())); + assert!(doc.repair_log().iter().any(|l| l.contains("999999")), "{:?}", doc.repair_log()); + } + #[test] fn self_referential_length_does_not_overflow() { let bytes = build( @@ -1439,7 +2257,7 @@ mod tests { fn object_numbers_merge_source_and_overlay_in_order() { let mut doc = Document::new_empty(); doc.overlay.clear(); - doc.entries = Arc::new(BTreeMap::from([ + doc.entries = Arc::new(XrefIndex::Materialized(Arc::new(BTreeMap::from([ (0, XrefEntry::Free { next_generation: 65535 }), (2, XrefEntry::InFile { offset: 10, generation: 7 }), (4, XrefEntry::Free { next_generation: 9 }), @@ -1447,7 +2265,7 @@ mod tests { (8, XrefEntry::InFile { offset: 20, generation: 5 }), (10, XrefEntry::InFile { offset: 30, generation: 3 }), (u32::MAX, XrefEntry::InFile { offset: 40, generation: 1 }), - ])); + ])))); doc.set(ObjRef::new(1, 2), Object::Int(1)); // before the first live source slot doc.set(ObjRef::new(2, 8), Object::Int(2)); // override, without duplication doc.set(ObjRef::new(4, 9), Object::Int(4)); // reuse a source free slot @@ -1463,7 +2281,7 @@ mod tests { assert_eq!(doc.generation(10), 4); doc.overlay.clear(); assert_eq!(doc.object_numbers(), vec![2, 6, 8, 10, u32::MAX]); - doc.entries = Arc::default(); + doc.entries = Arc::new(XrefIndex::empty()); assert!(doc.object_numbers().is_empty()); doc.set(ObjRef::new(5, 7), Object::Int(5)); assert_eq!(doc.object_numbers(), vec![5]); @@ -1694,6 +2512,7 @@ mod tests { out.extend_from_slice(format!("trailer << /Size 6 /Root 1 0 R /XRefStm {} >>\nstartxref\n{table}\n%%EOF\n", offs[5]).as_bytes()); let doc = Document::open(Arc::new(out)).unwrap(); assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log()); + assert_compact_matches_materialized(&doc); let hidden = doc.get(ObjRef::new(3, 0)); assert_eq!(hidden.as_dict().and_then(|d| d.get(b"Hidden").and_then(|h| h.as_string().map(|s| s.to_text()))), Some("yes".into())); } diff --git a/crates/cos/src/security.rs b/crates/cos/src/security.rs index 2605bb20f9..cd0d04a144 100644 --- a/crates/cos/src/security.rs +++ b/crates/cos/src/security.rs @@ -34,12 +34,14 @@ pub(crate) fn encrypt_dict(doc: &Document, d: &Dict) -> EncryptDict { } let v = int(b"V", 0); // V4: the key length comes from the standard crypt filter's /Length (bytes, or bits in - // some files); /Length on the dictionary is often a stale 40. + // some files); /Length on the dictionary is often a stale 40. A length that isn't + // positive means nothing and reads as if absent. let cf_len = d .get(b"CF") .map(|c| doc.resolve(c)) .and_then(|c| c.as_dict().and_then(|c| c.get(b"StdCF").map(|f| doc.resolve(f)))) - .and_then(|f| f.as_dict().and_then(|f| f.int(b"Length"))); + .and_then(|f| f.as_dict().and_then(|f| f.int(b"Length"))) + .filter(|n| *n > 0); let length_bits = match (v, cf_len) { (5.., _) => 256, (4, Some(n)) if n <= 32 => n * 8, diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index 34254bf374..f8d8fd3d80 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -4,7 +4,8 @@ //! as the file's last one (table or stream) and a trailer with `/Prev`. The original bytes are //! never modified, which keeps digital signatures over earlier revisions valid. //! * `write_full` writes only objects reachable from the trailer, renumbered densely, with a -//! cross-reference table or stream — the "Save As" / garbage-collecting path. +//! cross-reference table or stream — the "Save As" / garbage-collecting path. Unknown trailer +//! entries are copied, including objects reachable only from them. use std::borrow::Cow; use std::collections::{BTreeMap, HashMap, VecDeque}; @@ -257,6 +258,13 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro queue.push_back(r); } } + // Unknown trailer entries are document data. Objects they alone reference must be written + // too, or a full rewrite would keep the key and drop its value. + for (key, value) in trailer_in.iter() { + if !trailer_key_rewritten(key) { + collect_refs(value, &mut |r| queue.push_back(r)); + } + } while let Some(r) = queue.pop_front() { if map.contains_key(&r) { continue; @@ -337,6 +345,11 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro if let Some(id) = trailer_in.get(b"ID") { trailer.set(b"ID".to_vec(), id.clone()); } + for (key, value) in trailer_in.iter() { + if !trailer_key_rewritten(key) { + trailer.set(key.clone(), renumber(value, &map)); + } + } if reader.document.output_security().0.is_none() { ensure_id(&mut trailer, opts, reader.document.bytes()); } @@ -400,6 +413,15 @@ impl ObjectStreamBatch { } } +/// Trailer keys a full rewrite generates itself. Everything else is copied, with references +/// renumbered, so a save does not drop metadata the reader accepted. +fn trailer_key_rewritten(key: &[u8]) -> bool { + matches!( + key, + b"Size" | b"Prev" | b"XRefStm" | b"Type" | b"W" | b"Index" | b"Filter" | b"DecodeParms" | b"Length" | b"Root" | b"Info" | b"Encrypt" | b"ID" + ) +} + fn output_number(index: usize) -> Result { u32::try_from(index).ok().and_then(|n| n.checked_add(1)).ok_or_else(|| CosError::Syntax { offset: 0, detail: "too many objects to save".into() }) } diff --git a/crates/cos/tests/encryption.rs b/crates/cos/tests/encryption.rs index 6868a6dc21..af92cf67c7 100644 --- a/crates/cos/tests/encryption.rs +++ b/crates/cos/tests/encryption.rs @@ -202,24 +202,23 @@ fn unencrypted_metadata_is_left_readable() { let _ = ObjRef::new(1, 0); } -/// Second independent oracle when qpdf is installed: it must accept both passwords, identify -/// the owner password, and find no structural errors. +/// The second independent oracle: qpdf must accept both passwords, identify the owner password, +/// and find no structural errors. For R2-R4 this is the *only* check of owner-password +/// authentication (Algorithm 7) that does not go through our own code, so it is the one place a +/// silent skip would cost the most -- see `pdfcraft-oracle`, which makes absence loud. #[test] fn qpdf_agrees() { - if std::process::Command::new("qpdf").arg("--version").output().is_err() { - eprintln!("qpdf not installed; skipping"); - return; - } + let qpdf = pdfcraft_oracle::or_skip!(pdfcraft_oracle::qpdf()); let dir = std::env::temp_dir().join(format!("pdfcraft-enc-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); for alg in ALL { let path = dir.join(format!("{alg:?}.pdf")); std::fs::write(&path, protect(alg, "user", "owner", -1)).unwrap(); for (pw, expect) in [("user", "user password"), ("owner", "owner password")] { - let out = std::process::Command::new("qpdf").arg(format!("--password={pw}")).arg("--show-encryption").arg(&path).output().unwrap(); + let out = qpdf.command().arg(format!("--password={pw}")).arg("--show-encryption").arg(&path).output().unwrap(); let text = String::from_utf8_lossy(&out.stdout); assert!(text.contains(&format!("Supplied password is {expect}")), "{alg:?}/{pw}: {text}"); - let check = std::process::Command::new("qpdf").arg(format!("--password={pw}")).arg("--check").arg(&path).output().unwrap(); + let check = qpdf.command().arg(format!("--password={pw}")).arg("--check").arg(&path).output().unwrap(); assert!(check.status.success(), "{alg:?}/{pw}: {}", String::from_utf8_lossy(&check.stdout)); } } @@ -279,3 +278,61 @@ fn signature_contents_stay_unencrypted() { assert_eq!(string(2, b"Contents"), b"Private note", "{alg:?}"); } } + +#[test] +fn a_nonpositive_crypt_filter_length_reads_as_the_default_key_length() { + // A V4 crypt filter's /Length counts bytes (≤ 32) or bits. A zero or negative value is + // meaningless and is read as if absent (128 bits), not multiplied into an overflow (#1816). + let original = protect(Algorithm::Aes128, "", "owner", -1); + for length in [i64::MIN, i64::MIN / 8 - 1, i64::MIN / 8, -1, 0, 16, 128] { + let mut doc = Document::open(Arc::new(original.clone())).unwrap(); + let encrypt = doc.trailer().reference(b"Encrypt").unwrap(); + doc.update_dict(encrypt, |e| { + let Some(Object::Dict(cf)) = e.get(b"CF") else { panic!("no /CF") }; + let Some(Object::Dict(std_cf)) = cf.get(b"StdCF") else { panic!("no /StdCF") }; + let (mut cf, mut std_cf) = (cf.clone(), std_cf.clone()); + std_cf.set(b"Length".to_vec(), Object::Int(length)); + cf.set(b"StdCF".to_vec(), Object::Dict(std_cf)); + e.set(b"CF".to_vec(), Object::Dict(cf)); + }) + .unwrap(); + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let again = Document::open(Arc::new(saved)).unwrap(); + assert_eq!(content(&again), SECRET, "/Length {length}"); + } +} + +#[test] +fn an_earlier_revision_opens_with_the_documents_own_credentials() { + // The bytes a signature covers are an earlier revision of the file: the document that is + // open can read them with its own key, so no password has to be kept or asked for again. + for alg in ALL { + let original = protect(alg, "user", "owner", -1); + let mut doc = Document::open_with_password(Arc::new(original.clone()), Some("user")).unwrap(); + let info = doc.trailer().reference(b"Info").unwrap(); + doc.update_dict(info, |d| d.set(b"Title".to_vec(), Object::String(PdfString::text("Revised")))).unwrap(); + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let current = Document::open_with_password(Arc::new(saved), Some("user")).unwrap(); + assert!(matches!(Document::open(Arc::new(original.clone())), Err(CosError::NeedsPassword)), "{alg:?}: not without a password"); + let earlier = current.open_with_same_credentials(Arc::new(original.clone())).unwrap(); + assert_eq!(content(&earlier), SECRET, "{alg:?}: the earlier revision is readable"); + assert_eq!(title(&earlier).as_deref(), Some("Quarterly numbers"), "{alg:?}: and it is the earlier one"); + // Another file, encrypted with other keys: the document's key is no use, and it says so. + let other = { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + doc.set_encryption(&NewEncryption { + algorithm: alg, + user_password: "user", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [3; 32], + }) + .unwrap(); + write_full(&doc, &SaveOptions::default()).unwrap() + }; + assert!(matches!(current.open_with_same_credentials(Arc::new(other)), Err(CosError::NeedsPassword)), "{alg:?}"); + // An unencrypted file opens as it always does. + assert_eq!(content(¤t.open_with_same_credentials(Arc::new(fixture())).unwrap()), SECRET, "{alg:?}"); + } +} diff --git a/crates/cos/tests/writer.rs b/crates/cos/tests/writer.rs index b7514cc053..ec18b13f36 100644 --- a/crates/cos/tests/writer.rs +++ b/crates/cos/tests/writer.rs @@ -4,34 +4,23 @@ use std::sync::Arc; use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full, write_incremental}; +use pdfcraft_testkit::Pdf; /// Four pages, a shared font, document info with a non-ASCII title. fn fixture() -> Vec { - let mut objs: Vec = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()]; let kids: Vec = (0..4).map(|i| format!("{} 0 R", 4 + 2 * i)).collect(); - objs.push(format!("<< /Type /Pages /Kids [{}] /Count 4 /MediaBox [0 0 200 300] >>", kids.join(" "))); - objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into()); + let mut pdf = Pdf::new() + .version("1.4") + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj(format!("<< /Type /Pages /Kids [{}] /Count 4 /MediaBox [0 0 200 300] >>", kids.join(" "))) + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); for i in 0..4 { - objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i)); - let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1); - objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len())); + pdf = pdf + .obj(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i)) + .stream("", format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1)); } - objs.push("<< /Title (Caf\\351 \\(draft\\)) >>".into()); - let mut out = b"%PDF-1.4\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 /Info {} 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1, objs.len()).as_bytes(), - ); - out + let info = pdf.next_obj(); + pdf.obj("<< /Title (Caf\\351 \\(draft\\)) >>").trailer_entry(&format!("/Info {info} 0 R")).build() } fn title(doc: &Document) -> Vec { @@ -159,18 +148,14 @@ fn classic_table() -> Vec { bytes } -fn qpdf_check(bytes: &[u8]) -> Option { - let Ok(version) = std::process::Command::new("qpdf").arg("--version").output() else { - return None; - }; - assert!(version.status.success()); +fn qpdf_check(qpdf: &pdfcraft_oracle::Oracle, bytes: &[u8]) -> std::process::Output { static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let path = std::env::temp_dir().join(format!("pdfcraft-xref-{}-{n}.pdf", std::process::id())); std::fs::write(&path, bytes).unwrap(); - let check = std::process::Command::new("qpdf").arg("--check").arg(&path).output().unwrap(); + let check = qpdf.command().arg("--check").arg(&path).output().unwrap(); let _ = std::fs::remove_file(&path); - Some(check) + check } fn page(doc: &Document) -> Dict { @@ -181,10 +166,8 @@ fn page(doc: &Document) -> Dict { } fn assert_qpdf_clean(bytes: &[u8]) { - let Some(check) = qpdf_check(bytes) else { - eprintln!("qpdf not installed: skipped"); - return; - }; + let qpdf = pdfcraft_oracle::or_skip!(pdfcraft_oracle::qpdf()); + let check = qpdf_check(&qpdf, bytes); let report = format!("{}{}", String::from_utf8_lossy(&check.stdout), String::from_utf8_lossy(&check.stderr)); assert!(check.status.success() && !report.contains("WARNING"), "{report}"); } @@ -247,16 +230,12 @@ fn incremental_save_keeps_a_trailer_only_edit() { #[test] fn qpdf_accepts_object_stream_output() { - let Ok(out) = std::process::Command::new("qpdf").arg("--version").output() else { - eprintln!("qpdf not installed: skipped"); - return; - }; - assert!(out.status.success()); + let qpdf = pdfcraft_oracle::or_skip!(pdfcraft_oracle::qpdf()); let doc = Document::open(Arc::new(fixture())).unwrap(); let packed = write_full(&doc, &SaveOptions::default()).unwrap(); let path = std::env::temp_dir().join(format!("pdfcraft-objstm-{}.pdf", std::process::id())); std::fs::write(&path, &packed).unwrap(); - let check = std::process::Command::new("qpdf").arg("--check").arg(&path).output().unwrap(); + let check = qpdf.command().arg("--check").arg(&path).output().unwrap(); let _ = std::fs::remove_file(&path); let report = String::from_utf8_lossy(&check.stdout); assert!(check.status.success() && report.contains("No syntax or stream encoding errors"), "{report}"); @@ -348,3 +327,68 @@ fn page_tree_loops_in_object_streams_do_not_crash_readers() { assert!(String::from_utf8_lossy(&packed).contains("/ObjStm")); assert_eq!(hayro_pages(&packed), 1); } + +/// Catalog, empty page tree, and trailer entries a reader must not invent: a direct name, a +/// direct string, and a string object reachable only from the trailer. +fn trailer_fixture() -> Vec { + let mut out = b"%PDF-1.4\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(only from the trailer)"].iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(b"xref\n0 4\n0000000000 65535 f \n"); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice( + format!("trailer\n<< /Size 4 /Root 1 0 R /Version /1.7 /Custom (keep me) /Note 3 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes(), + ); + out +} + +fn trailer_custom(doc: &Document) -> Option> { + doc.trailer().get(b"Custom").and_then(Object::as_string).map(|s| s.bytes.clone()) +} + +fn trailer_note(doc: &Document) -> Option> { + doc.trailer().get(b"Note").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.bytes.clone())) +} + +#[test] +fn full_save_preserves_unknown_trailer_data() { + let input = trailer_fixture(); + let doc = Document::open(Arc::new(input)).unwrap(); + assert_eq!(trailer_custom(&doc), Some(b"keep me".to_vec())); + assert_eq!(trailer_note(&doc).as_deref(), Some(b"only from the trailer".as_slice())); + assert_eq!(doc.trailer().get(b"Version").and_then(Object::as_name), Some(&b"1.7"[..])); + assert!(doc.repair_log().is_empty()); + assert!(!doc.full_save_required()); + + let inc = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(trailer_custom(&inc), trailer_custom(&doc)); + assert_eq!(trailer_note(&inc), trailer_note(&doc)); + assert_eq!(inc.trailer().get(b"Version"), doc.trailer().get(b"Version")); + assert!(inc.repair_log().is_empty()); + + for object_streams in [true, false] { + let opts = SaveOptions { object_streams, ..SaveOptions::default() }; + let bytes = write_full(&doc, &opts).unwrap(); + let back = Document::open(Arc::new(bytes)).unwrap(); + assert!(back.root().is_some()); + assert!(back.repair_log().is_empty(), "{:?}", back.repair_log()); + assert_eq!(trailer_custom(&back), trailer_custom(&doc)); + assert_eq!(trailer_note(&back), trailer_note(&doc)); + assert_eq!(back.trailer().get(b"Version"), doc.trailer().get(b"Version")); + } + + let mut required = doc.clone(); + required.require_full_save(); + let back = Document::open(Arc::new(write_incremental(&required, &SaveOptions::default()).unwrap())).unwrap(); + assert!(back.root().is_some()); + assert!(back.repair_log().is_empty(), "{:?}", back.repair_log()); + assert_eq!(trailer_custom(&back), trailer_custom(&doc)); + assert_eq!(trailer_note(&back), trailer_note(&doc)); + assert_eq!(back.trailer().get(b"Version"), doc.trailer().get(b"Version")); +} diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index d9a96181ab..1034489458 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -179,8 +179,9 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> Space::Gray => rgb.extend_from_slice(&[v(0); 3]), Space::Rgb => rgb.extend_from_slice(&[v(0), v(1), v(2)]), Space::Cmyk => rgb.extend_from_slice(&cmyk(v(0), v(1), v(2), v(3))), - Space::Indexed(base, table) => { - let i = px.first().copied().unwrap_or(0) as usize; + Space::Indexed(base, hival, table) => { + // Indexed samples outside the declared range use the nearest endpoint (ISO 32000-1, 8.6.6.3). + let i = px.first().copied().unwrap_or(0).min(*hival) as usize; let k = base.components(); let e = table.get(i * k..i * k + k).unwrap_or(&[0, 0, 0, 0][..k]); rgb.extend_from_slice(&match **base { @@ -310,8 +311,8 @@ enum Space { Gray, Rgb, Cmyk, - /// Base space and lookup table (one byte per base component). - Indexed(Box, Vec), + /// Base space, highest valid palette index, and lookup table. + Indexed(Box, u8, Vec), } impl Space { @@ -352,12 +353,16 @@ fn space(doc: &Document, cs: Option<&Object>) -> Result { if matches!(base, Space::Indexed(..)) { return Err("nested indexed colour spaces aren't valid".into()); } + let hival = match a.get(2).map(|value| doc.resolve(value)).as_deref() { + Some(Object::Int(value)) if (0..=255).contains(value) => *value as u8, + _ => return Err("an indexed colour space with an invalid hival".into()), + }; let table = match a.get(3).map(|t| doc.resolve(t)).as_deref() { Some(Object::String(s)) => s.bytes.clone(), Some(Object::Stream(t)) => t.decoded().map_err(|e| e.to_string())?, _ => return Err("an indexed colour space without a lookup table".into()), }; - Ok(Space::Indexed(Box::new(base), table)) + Ok(Space::Indexed(Box::new(base), hival, table)) } Some(other) => unsupported(other), None => Err("a damaged colour space".into()), diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 2d76055ec9..993c38d9f3 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -744,6 +744,137 @@ fn damaged_masks_and_sizes_never_panic() { } } +#[test] +fn indexed_image_export_clamps_samples_to_hival() { + for palette in [vec![255, 0, 0, 0, 0, 255], vec![255, 0, 0, 0, 0, 255, 0, 255, 0, 255, 255, 255]] { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + let page = pdfcraft_model::pages(&doc)[0].clone(); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + let image = xo.iter().next().unwrap().1.as_ref().unwrap(); + let mut dict = Dict::new(); + dict.set(b"Type".to_vec(), Object::name("XObject")); + dict.set(b"Subtype".to_vec(), Object::name("Image")); + dict.set(b"Width".to_vec(), Object::Int(4)); + dict.set(b"Height".to_vec(), Object::Int(1)); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(2)); + dict.set( + b"ColorSpace".to_vec(), + Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB"), Object::Int(1), Object::String(PdfString::literal(palette))]), + ); + // Default Decode preserves sample values0,1,2,3. Values2 and3 clamp to hival1. + doc.set(image, Object::Stream(Stream::from_raw(dict, vec![0b0001_1011]))); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{:?}", out.skipped); + assert_eq!(out.images.len(), 1); + assert_eq!(exported_png_pixels(&out.images[0].data), vec![255, 0, 0, 0, 0, 255, 0, 0, 255, 0, 0, 255]); + } +} + +fn exported_png_pixels(bytes: &[u8]) -> Vec { + let decoder = png::Decoder::new(std::io::Cursor::new(bytes)); + let mut reader = decoder.read_info().unwrap(); + let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; + let info = reader.next_frame(&mut pixels).unwrap(); + pixels.truncate(info.buffer_size()); + pixels +} + +fn indexed_export_fixture( + hival: Object, + palette: Vec, + stream_table: bool, + indirect_hival: bool, + bpc: i64, + samples: Vec, +) -> (Document, pdfcraft_cos::ObjRef) { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + let page = pdfcraft_model::pages(&doc)[0].clone(); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + let image = xo.iter().next().unwrap().1.as_ref().unwrap(); + let table = if stream_table { + Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &palette)))) + } else { + Object::String(PdfString::literal(palette)) + }; + let hival = if indirect_hival { Object::Ref(doc.add(hival)) } else { hival }; + let mut dict = Dict::new(); + dict.set(b"Type".to_vec(), Object::name("XObject")); + dict.set(b"Subtype".to_vec(), Object::name("Image")); + dict.set(b"Width".to_vec(), Object::Int(4)); + dict.set(b"Height".to_vec(), Object::Int(1)); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(bpc)); + dict.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB"), hival, table])); + doc.set(image, Object::Stream(Stream::from_raw(dict, samples))); + (doc, image) +} + +#[test] +fn indexed_hival_resolves_indirect_values_and_stream_palettes() { + for (bpc, samples) in [(2, vec![0x1b]), (4, vec![0x01, 0x23]), (8, vec![0, 1, 2, 255])] { + let (doc, image) = indexed_export_fixture(Object::Int(1), vec![255, 0, 0, 0, 0, 255], true, true, bpc, samples); + let (ext, bytes) = crate::extract::image_file(&doc, image).unwrap(); + assert_eq!(ext, "png"); + assert_eq!(exported_png_pixels(&bytes), vec![255, 0, 0, 0, 0, 255, 0, 0, 255, 0, 0, 255]); + } +} + +#[test] +fn indexed_hival_boundary_values_keep_valid_palette_entries() { + let palette: Vec = (0..=255u8).flat_map(|i| [i, 255 - i, 17]).collect(); + for high in [0, 255] { + let (doc, image) = indexed_export_fixture(Object::Int(high), palette.clone(), false, false, 8, vec![0, 1, 254, 255]); + let (_, bytes) = crate::extract::image_file(&doc, image).unwrap(); + let expected: Vec = [0u8, 1, 254, 255] + .into_iter() + .flat_map(|i| { + let i = i.min(high as u8); + [i, 255 - i, 17] + }) + .collect(); + assert_eq!(exported_png_pixels(&bytes), expected); + } +} + +#[test] +fn indexed_hival_rejects_invalid_values_without_panicking() { + for high in [Object::Int(-1), Object::Int(256), Object::Real(1.0), Object::Null, Object::name("bad")] { + let (doc, image) = indexed_export_fixture(high, vec![255, 0, 0], false, false, 8, vec![0; 4]); + assert!(crate::extract::image_file(&doc, image).unwrap_err().contains("invalid hival")); + let out = extract_images(&doc, &[0], 0); + assert!(out.images.is_empty()); + assert_eq!(out.skipped.len(), 1); + assert!(out.skipped[0].2.contains("invalid hival")); + } +} + +#[test] +fn indexed_hival_clamping_preserves_raw_colour_key_mask_samples() { + let (mut doc, image) = indexed_export_fixture(Object::Int(1), vec![255, 0, 0, 0, 0, 255], false, false, 2, vec![0x1b]); + let Object::Stream(mut stream) = (*doc.get(image)).clone() else { panic!("image stream") }; + stream.dict.set(b"Mask".to_vec(), Object::Array(vec![Object::Int(2), Object::Int(2)])); + doc.set(image, Object::Stream(stream)); + let (_, bytes) = crate::extract::image_file(&doc, image).unwrap(); + assert_eq!(exported_png_pixels(&bytes), vec![255, 0, 0, 255, 0, 0, 255, 255, 0, 0, 255, 0, 0, 0, 255, 255]); +} + +#[test] +fn indexed_hival_missing_element_returns_export_error() { + let (mut doc, image) = indexed_export_fixture(Object::Int(1), vec![255, 0, 0], false, false, 8, vec![0; 4]); + let Object::Stream(mut stream) = (*doc.get(image)).clone() else { panic!("image stream") }; + stream.dict.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB")])); + doc.set(image, Object::Stream(stream)); + assert!(crate::extract::image_file(&doc, image).unwrap_err().contains("invalid hival")); +} + +#[test] +fn indexed_hival_keeps_existing_short_palette_fallback() { + let (doc, image) = indexed_export_fixture(Object::Int(1), vec![255, 0, 0], false, false, 2, vec![0x1b]); + let (_, bytes) = crate::extract::image_file(&doc, image).unwrap(); + assert_eq!(exported_png_pixels(&bytes), vec![255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); +} + /// An 8×8 baseline JPEG of one flat colour with four components, stored as `samples`, and an /// Adobe APP14 segment with `transform` (0: CMYK, 2: YCCK). fn flat_four_component_jpeg(samples: [u8; 4], transform: u8) -> Vec { diff --git a/crates/crypt/tests/proptests.rs b/crates/crypt/tests/proptests.rs new file mode 100644 index 0000000000..e4f1d78b57 --- /dev/null +++ b/crates/crypt/tests/proptests.rs @@ -0,0 +1,464 @@ +//! Property tests for what the security handler does to *content*, and to the ciphertext a +//! stranger's file hands back. +//! +//! `src/tests.rs` covers this handler carefully with fixed vectors — including the `/O`, `/U` and +//! file keys qpdf 12.4.2 produced for real files — plus one property over malformed dictionaries. +//! What it does not vary is the *payload*. Upstream's encrypted round trips run three shapes: a +//! 30-byte text, a 1000-byte ramp, and a 10-byte attachment in `identity_crypt_filters_leave_data_alone`. +//! The shapes nobody walks are the ones that break CBC and readers: empty content, one block minus +//! one, a ciphertext cut short by a damaged `/Length`, and keys of a length `create` never builds. +//! +//! - plaintext survives encryption at every length, for each algorithm and each crypt filter; +//! - truncated or mutated ciphertext produces bytes rather than a panic (`AGENTS.md` §4: PdfCraft +//! opens files from strangers, and encrypted streams in the wild do get truncated); +//! - object keys are scoped to their object, and AES-256's documented exception still holds; +//! - a file opens for exactly the passwords whose effective bytes match, stated as a rule over +//! generated Unicode passwords instead of the three examples in `src/tests.rs` (the #1185 rule); +//! - the AES-CBC and RC4 primitives invert each other for any key length, since `/Length` and +//! crypt filters from a damaged file reach them with shapes `create` would never build. + +use pdfcraft_crypt::{ + Algorithm, Auth, CryptError, Method, NewEncryption, PADDING, SecurityHandler, StreamKind, aes_cbc_decrypt, aes_cbc_encrypt, create, + effective_password, pdfdoc_byte, rc4, +}; +use proptest::prelude::*; + +/// The first element of `/ID`, which every R2–R4 derivation hashes in (Algorithm 2). +const ID: &[u8] = b"\x01\x23\x45\x67\x89\xab\xcd\xef\x0f\xed\xcb\xa9\x87\x65\x43\x21"; + +fn algorithms() -> impl Strategy { + prop::sample::select(vec![Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256]) +} + +/// Content at the lengths where CBC padding goes wrong, plus anything in between. +fn payloads() -> impl Strategy> { + prop_oneof![ + Just(Vec::new()), + Just(vec![0x5cu8; 1]), + Just(vec![0xa7u8; 15]), + Just(vec![0x3fu8; 16]), + Just(vec![0x9du8; 17]), + Just(vec![0x10u8; 32]), + prop::collection::vec(any::(), 1..96), + ] +} + +/// Passwords from an alphabet the two encodings disagree about: SASLprep deletes U+00AD, folds +/// U+FF21 to "A", U+FB01 to "fi" and U+3000 to a space (revisions 5–6), while PDFDocEncoding has +/// no byte for 'ğ' or CJK at all, which sends revisions 2–4 down their UTF-8 fallback. +const PASSWORD_CHARS: [char; 12] = ['a', 'B', '7', ' ', '-', '\u{AD}', '\u{FF21}', '\u{FB01}', 'ı', 'ğ', '日', '\u{A0}']; + +fn passwords() -> impl Strategy { + prop::collection::vec(prop::sample::select(PASSWORD_CHARS.to_vec()), 0..12).prop_map(|chars| chars.into_iter().collect()) +} + +/// `bytes` cut to 32 and padded with the standard padding string (Algorithm 2, step a). +fn padded(bytes: &[u8]) -> Vec { + let mut out = PADDING; + let n = bytes.len().min(32); + out[..n].copy_from_slice(&bytes[..n]); + out.to_vec() +} + +/// Every byte string the handler may authenticate `pw` as. Revisions 2–4 use PDFDocEncoding, and +/// for a password holding characters it lacks they also try the raw UTF-8 bytes (qpdf writes such +/// passwords that way); revisions 5–6 use the SASLprep bytes. `effective_password` publishes the +/// first of these per algorithm, and it is what callers such as Protect check before encrypting. +fn candidates(algorithm: Algorithm, pw: &str) -> Vec> { + let mut out = vec![effective_password(algorithm, pw)]; + if algorithm != Algorithm::Aes256 && pw.chars().any(|c| pdfdoc_byte(c).is_none()) { + out.push(padded(&pw.as_bytes().iter().copied().take(32).collect::>())); + } + out +} + +/// What `create` keys `/O` from: the owner password, except on revisions 2–4 with no owner +/// password given, where it falls back to the user password's bytes. +fn owner_target(algorithm: Algorithm, user: &str, owner: &str) -> Vec { + if algorithm != Algorithm::Aes256 && owner.is_empty() { effective_password(algorithm, user) } else { effective_password(algorithm, owner) } +} + +/// Which password `pw` is for this file. Owners are tried before users, so bytes that match both +/// authenticate as owner — the order viewers use. `None` means it opens nothing. +fn expected_auth(algorithm: Algorithm, user_target: &[u8], owner_target: &[u8], pw: &str) -> Option { + let tried = candidates(algorithm, pw); + if tried.iter().any(|c| c == owner_target) { + Some(Auth::Owner) + } else if tried.iter().any(|c| c == user_target) { + Some(Auth::User) + } else { + None + } +} + +fn make(algorithm: Algorithm, user: &str, owner: &str) -> SecurityHandler { + create(&NewEncryption { algorithm, user_password: user, owner_password: owner, permissions: -1, encrypt_metadata: true, seed: [0x5e; 32] }, ID) + .unwrap() +} + +proptest! { + /// Encrypting then decrypting returns the input at every length, as a string and as each kind + /// of stream — and is byte-for-byte reproducible, because re-running a save must not silently + /// produce different bytes. Payloads of at least one block must come back changed: RC4 would + /// need an all-zero keystream to leave them alone, and CBC cannot shrink a padded block. + #[test] + fn content_survives_the_trip_at_every_length(alg in algorithms(), data in payloads()) { + let h = make(alg, "user", "owner"); + + // The key width each algorithm promises: 5 bytes for export-grade 40-bit RC4, 16 for + // AES-128 and RC4-128, 32 for AES-256. `key_len` is the only place that decides this and + // nothing in either suite reads it back, so `if d.r == 2 { 5 }` becoming `{ 4 }` today + // produces a file key of the wrong size that every test still calls fine. + let want_len = match alg { Algorithm::Rc4_40 => 5, Algorithm::Aes128 | Algorithm::Rc4_128 => 16, Algorithm::Aes256 => 32 }; + prop_assert_eq!(h.file_key().len(), want_len, "{:?} must use a {} byte file key", alg, want_len); + + let h = make(alg, "user", "owner"); + for num in [0u32, 1, 4096, u32::MAX] { + let generation = (num % 65_535) as u16; + + let enc = h.encrypt_string(num, generation, &data); + prop_assert_eq!(h.decrypt_string(num, generation, &enc), &data[..], "string round trip at {} bytes ({:?})", data.len(), alg); + prop_assert_eq!(h.encrypt_string(num, generation, &data), enc, "string encryption must be reproducible ({:?})", alg); + + for kind in [StreamKind::Normal, StreamKind::EmbeddedFile, StreamKind::Named(b"StdCF")] { + let enc = h.encrypt_stream(num, generation, &data, kind); + prop_assert_eq!(h.decrypt_stream(num, generation, &enc, kind), &data[..], "stream round trip at {} bytes ({:?}, {:?})", data.len(), alg, kind); + prop_assert_eq!(h.encrypt_stream(num, generation, &data, kind), enc, "{:?} {:?} encryption must be reproducible", alg, kind); + } + + if data.len() >= 16 { + prop_assert_ne!(h.encrypt_string(num, generation, &data), &data[..], "one block left in plain sight ({:?})", alg); + } + } + } + + /// Ciphertext of any length — truncated by a damaged `/Length`, padded out with garbage, or + /// byte-flipped inside — must decrypt to bytes and never read past the end. AES discards a + /// trailing partial block and leaves invalid padding in place rather than failing, so its + /// output can only be a whole number of blocks minus at most one; RC4 keeps its length + /// whatever the bytes are. An input shorter than the IV has nothing left to decode. + #[test] + fn damaged_ciphertext_never_panics(alg in algorithms(), enc in prop::collection::vec(any::(), 0..64)) { + let h = make(alg, "user", "owner"); + for cut in 0..=enc.len() { + let broken = &enc[..cut]; + let out = h.decrypt_string(7, 3, broken); + if alg == Algorithm::Rc4_40 || alg == Algorithm::Rc4_128 { + prop_assert_eq!(out.len(), cut, "RC4 must be length-preserving ({:?})", alg); + } else { + let blocks = cut.saturating_sub(16) / 16; + prop_assert!(out.len() <= blocks * 16, "{:?}: {} bytes of ciphertext yielded {} decrypted bytes", alg, cut, out.len()); + prop_assert!(blocks * 16 - out.len() <= 16, "{:?}: padding removed more than one block", alg); + } + } + + // Flip every byte of a real ciphertext in turn: no position may crash the reader. + let full = h.encrypt_string(7, 3, b"twenty-four bytes of PDF content"); + for i in 0..full.len() { + let mut mutated = full.clone(); + mutated[i] ^= 0xFF; + let _ = h.decrypt_string(7, 3, &mutated); + } + } + + /// A crypt filter name decides whether content is encrypted at all: PDF 2.0 permits + /// `/StmF /Identity` with attachments still encrypted, and a name that appears in neither `/CF` + /// nor as `Identity` resolves to Identity rather than failing. The reported answer and the + /// bytes must agree, at every length including empty (where AES still adds a whole block). + /// `/EFF` is set here so embedded files and normal streams must take *different* paths: the + /// mix-up that leaves an attachment in plain sight, or encrypts a stream its own filter says + /// should not be. And sometimes `/CF` carries an entry *named* `Identity` that claims + /// `/CFM /AESV2`: a producer writing nonsense. The name wins and the bytes stay plain, since + /// encrypting what both sides agreed is plaintext would write a file other readers cannot open. + #[test] + fn crypt_filters_decide_what_gets_encrypted( + data in payloads(), + name in prop::sample::select(vec!["Identity", "StdCF", "Nope"]), + bogus_identity_in_cf in any::(), + ) { + let mut d = make(Algorithm::Aes256, "", "owner").dict().clone(); + d.str_f = name.as_bytes().to_vec(); + d.stm_f = b"Identity".to_vec(); + d.ef_f = b"StdCF".to_vec(); + if bogus_identity_in_cf { + d.crypt_filters.push((b"Identity".to_vec(), Method::Aes256)); + } + let h = SecurityHandler::open(d, ID, None).unwrap(); + + // Only `StdCF` really encrypts; `Identity` does not even when `/CF` defines it, and a name + // in neither place resolves to Identity rather than failing. + let strings_encrypted = name == "StdCF"; + prop_assert_eq!(h.encrypts_strings(), strings_encrypted, "/StrF /{} reported {}", name, strings_encrypted); + + let enc = h.encrypt_string(3, 0, &data); + if strings_encrypted { + prop_assert_ne!(&enc, &data, "/StrF /{} left strings in plain sight", name); + prop_assert!(enc.len() > data.len(), "AES always pads: {} bytes of ciphertext for {} plaintext", enc.len(), data.len()); + prop_assert_eq!(h.decrypt_string(3, 0, &enc), &data[..], "/StrF /{} round trip", name); + } else { + prop_assert_eq!(&enc, &data, "/StrF /{} must pass strings through", name); + } + + // A normal stream follows `/StmF /Identity` here, whatever the string filter says. + prop_assert_eq!(&h.encrypt_stream(3, 0, &data, StreamKind::Normal), &data[..], "normal streams follow /StmF Identity"); + // An embedded file follows `/EFF /StdCF`, so it is encrypted at every length — empty + // included, where AES still emits a padded block — and comes back intact. + let ef = h.encrypt_stream(3, 0, &data, StreamKind::EmbeddedFile); + prop_assert_ne!(&ef, &data[..], "/EFF /StdCF left an embedded file in plain sight at {} bytes", data.len()); + prop_assert_eq!(h.decrypt_stream(3, 0, &ef, StreamKind::EmbeddedFile), &data[..], "embedded file round trip"); + // An unknown named filter is Identity, not an error; a known one uses its real method. + prop_assert_eq!(&h.encrypt_stream(3, 0, &data, StreamKind::Named(b"Missing")), &data[..], "an unknown crypt filter must not encrypt"); + prop_assert_ne!(&h.encrypt_stream(3, 0, &data, StreamKind::Named(b"StdCF")), &data[..], "a filter named /StdCF must encrypt"); + } + + /// Object keys are scoped to the object (Algorithm 1, §7.6.3.3): RC4 and AES-128 fold the + /// object number and generation into the key, so another object must not read these bytes back. + /// AES-256 is the documented exception — revisions 5 and 6 use the file key for every object. + /// The comparisons that decrypt under a foreign number carry the weight: wrong keystream bytes + /// match the plaintext only by a chance of one in 256 per byte. Checking that *encryption* + /// differs would prove less, because our initial vector already covers `/Num`. + #[test] + fn object_keys_are_scoped_to_their_object(alg in prop::sample::select(vec![Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128]), data in prop::collection::vec(any::(), 8..40)) { + let h = make(alg, "user", "owner"); + + let enc = h.encrypt_string(100, 0, &data); + prop_assert_eq!(h.decrypt_string(100, 0, &enc), &data[..], "{:?} reads its own object back", alg); + prop_assert_ne!(h.decrypt_string(101, 0, &enc), &data[..], "{:?}: another object number must not decrypt these bytes", alg); + prop_assert_ne!(h.decrypt_string(100, 1, &enc), &data[..], "{:?}: another generation must not decrypt these bytes", alg); + + // Algorithm 1 folds the LOW THREE bytes of `/Num` into the key. Two directions are pinned: + // numbers that differ inside those three bytes must separate (a fold to `[..2]` would let + // object 0 and object 65_536 share a key, silently handing one object's plaintext to another), + // and numbers that differ only ABOVE them must share it — the fold is three bytes by design, + // so a widening to four would break this and nothing else in the crate notices. + let low = h.encrypt_string(0, 0, &data); + prop_assert_ne!(&h.decrypt_string(65_536, 0, &low)[..], &data[..], "{:?}: /Num is folded three bytes wide; 0 and 65_536 must not share a key", alg); + prop_assert_ne!(&h.decrypt_string(1 << 23, 0, &low)[..], &data[..], "{:?}: the third byte of /Num must still reach the key", alg); + prop_assert_eq!(&h.decrypt_string(1 << 24, 0, &low)[..], &data[..], "{:?}: /Num is folded three bytes wide, so a difference above bit 23 shares the key by design", alg); + + let r6 = make(Algorithm::Aes256, "user", "owner"); + let enc6 = r6.encrypt_string(100, 0, &data); + prop_assert_eq!(&r6.decrypt_string(101, 3, &enc6)[..], &data[..], "AES-256 uses the file key for every object (§7.6.3.3)"); + // ...which is exactly why the initial vector has to vary: revisions 5 and 6 reuse one key + // for the whole file, so two objects holding the same bytes must still produce different + // ciphertext. Identical bytes for a different `/Num` would be CBC initial-vector reuse — + // the flaw that leaks structure in encrypted data. + prop_assert_ne!(&r6.encrypt_string(101, 0, &data)[..], &enc6[..], "AES-256 reused the same initial vector for two objects"); + prop_assert_ne!(&r6.encrypt_string(100, 1, &data)[..], &enc6[..], "AES-256 reused the same initial vector across generations"); + } + + /// A file created for `user` and `owner` opens for exactly the passwords whose effective bytes + /// equal one of theirs, and reports the role that match earned — owner first, since viewers try + /// `/O` before `/U`. Everything else is `WrongPassword`: never an opening, and no panic on odd + /// Unicode. This is the rule #1185 became (bytes decide, so a soft hyphen alone is no password + /// and U+FF21 is "A"), stated for all inputs rather than the three examples in `src/tests.rs`. + /// It also pins #1762's upgrade path: a user session gains owner rights on the owner password + /// and on nothing else. + #[test] + fn authentication_accepts_exactly_the_matching_passwords( + alg in algorithms(), + mut user in passwords(), + owner in prop::sample::select(vec!["owner".to_string(), String::new(), "Owner\u{AD}".into(), "\u{FF21}".into()]), + extra in passwords(), + ) { + // `user` may have been grown past the 32-byte cut above; create with that spelling. + // Grow the legacy password PAST the 32-byte cut BEFORE the file is built from it, so a + // partner differing only beyond byte 32 is the same password to this file. + if alg != Algorithm::Aes256 { + user = format!("{user}{}", "abcdefghijklmnopqrstuvwxyz0123456789".repeat(2)); + } + + let h = make(alg, &user, &owner); + let d = h.dict().clone(); + let user_bytes = effective_password(alg, &user); + let owner_bytes = owner_target(alg, &user, &owner); + + // A collision engineered by construction, not by luck. Revisions 2-4 cut the password at + // 32 bytes (Algorithm 2), so any spelling that agrees on the first 32 is ONE password; + // revisions 5-6 run SASLprep, which deletes U+00AD. Growing past byte 32 guarantees the + // partner is a different string with identical bytes at every draw — earlier drafts here + // claimed `(` alone did it, which is false: appending "(" to a short password DISPLACES the + // padding (pad("a") is [61,28,BF..], pad("a(") is [61,28,28,BF..]), and this alphabet draws + // at most 11 characters, so chance produced zero legacy collisions in 1,566 measured cases. + + let mut suspects: Vec = vec![user.clone(), owner.clone(), String::new()]; + // Near misses are PREPENDED: appending to a legacy password already past the cut would be + // another collision rather than a refusal, which would silently thin the negative testing. + let mut near_misses: Vec = vec![format!("q{user}"), extra.clone()]; + if alg == Algorithm::Aes256 && !user.is_empty() { + near_misses.push(format!("{user}q")); + } + + let collision_partner = if alg == Algorithm::Aes256 { format!("{user}\u{AD}") } else { format!("{user}Z") }; + prop_assert_ne!(&collision_partner[..], &user[..], "the collision partner must be a different string"); + prop_assert_eq!(effective_password(alg, &collision_partner), effective_password(alg, &user), "{:?}: {:?} must become the same bytes as {:?}", alg, collision_partner, user); + + // The mistake itself, pinned so it cannot come back: a short legacy password plus "(" is NOT the + // same password, because it shifts the padding by one byte instead of filling a slot. + if alg != Algorithm::Aes256 { + prop_assert_ne!(effective_password(alg, "a("), effective_password(alg, "a"), "appending ( to a short password displaces the padding; that is not a collision"); + prop_assert_eq!(effective_password(alg, &"a".repeat(31)), effective_password(alg, &format!("{}(", "a".repeat(31))), "at exactly 31 characters ( does land in the padding slot, and THAT is a collision"); + } + + suspects.extend(near_misses); + suspects.push(collision_partner.clone()); + suspects.dedup(); + + for suspect in &suspects { + let want = expected_auth(alg, &user_bytes, &owner_bytes, suspect); + match (want, SecurityHandler::open(d.clone(), ID, Some(suspect))) { + (Some(role), Ok(got)) => { + prop_assert_eq!(got.auth(), role, "{:?}: {:?} should open as {:?}", alg, suspect, role); + prop_assert_eq!(got.file_key(), h.file_key(), "{:?}: whichever password opens the file, the key is the same", alg); + } + (Some(role), Err(e)) => prop_assert!(false, "{:?}: {:?} must open as {:?} but gave {:?}", alg, suspect, role, e), + (None, Ok(got)) => prop_assert!(false, "{:?}: {:?} is not a password for this file, yet it opened as {:?}", alg, suspect, got.auth()), + (None, Err(CryptError::WrongPassword)) => {} + (None, Err(e)) => prop_assert!(false, "{:?}: {:?} gave {}, expected WrongPassword", alg, suspect, e), + } + + // The owner password upgrades a user session; nothing else does. When the two share + // bytes, `create` gave /O the user's own bytes (revisions 2–4) or left it empty + // (AES-256, #1185's sharp edge), so the open password *is* an owner password here. + if let Ok(session) = SecurityHandler::open(d.clone(), ID, Some(&user)) { + let is_owner_password = expected_auth(alg, &user_bytes, &owner_bytes, suspect) == Some(Auth::Owner); + let upgrade = session.authenticate_owner(ID, suspect).map(|u| u.auth()); + if is_owner_password { + prop_assert_eq!(upgrade, Ok(Auth::Owner), "{:?}: {:?} must unlock owner rights", alg, suspect); + } else { + prop_assert_eq!(upgrade, Err(CryptError::WrongPassword), "{:?}: {:?} must not grant owner rights", alg, suspect); + } + } + } + + // `None` means no password was offered: the handler tries the empty string, and nothing + // else (a file with a real open password never opens silently). + let want_none = expected_auth(alg, &user_bytes, &owner_bytes, ""); + match (want_none, SecurityHandler::open(d.clone(), ID, None)) { + (Some(role), Ok(got)) => prop_assert_eq!(got.auth(), role, "{:?}: no password opened as {:?}", alg, got.auth()), + (None, Ok(got)) => prop_assert!(false, "{:?}: the empty password is not valid, yet no password opened as {:?}", alg, got.auth()), + (Some(role), Err(e)) => prop_assert!(false, "{:?}: the empty password should open as {:?} but gave {:?}", alg, role, e), + (None, Err(CryptError::WrongPassword)) => {} + (None, Err(e)) => prop_assert!(false, "{:?}: no password gave {}, expected WrongPassword", alg, e), + } + } + + /// The AES-CBC and RC4 primitives taken directly: a damaged file's `/Length` and crypt filters + /// hand them keys and data of shapes `create` would never build, so they must invert each other + /// for any key length (`Cipher::new` takes AES-256 at 32 bytes and zero-padded AES-128 below + /// it, identically on both sides of the trip) and every payload length. + #[test] + fn primitives_are_invertible_for_any_key_and_length( + key in prop::collection::vec(any::(), 1..40), + data in payloads(), + iv in prop::collection::vec(any::(), 16..24), + ) { + let iv: [u8; 16] = *iv.as_slice().first_chunk::<16>().expect("the iv strategy draws at least 16 bytes; a silent zero fallback would hide a generator change"); + + let enc = aes_cbc_encrypt(&key, &iv, &data, true); + prop_assert_eq!(aes_cbc_decrypt(&key, &iv, &enc, true), &data[..], "padded AES round trip at {} bytes", data.len()); + prop_assert!(enc.len() >= 16 && enc.len().is_multiple_of(16), "PKCS#5 makes whole blocks: {} bytes for {} plaintext", enc.len(), data.len()); + + // Without padding, a partial block is zero-padded rather than refused, and extra trailing + // bytes decrypt to more output instead of being dropped. + let raw = aes_cbc_encrypt(&key, &iv, &data, false); + prop_assert_eq!(raw.len(), data.len().div_ceil(16) * 16, "unpadded AES output is whole blocks"); + let mut longer = raw.clone(); + longer.extend_from_slice(b"0123456789abcdef"); + prop_assert_eq!(aes_cbc_decrypt(&key, &iv, &longer, false).len(), raw.len() + 16, "a whole extra block decrypts to a whole extra block"); + + // RC4 undoes itself at every length; an empty key encrypts nothing, which is why the + // handler never derives an empty object key. + prop_assert_eq!(rc4(&key, &rc4(&key, &data)), &data[..], "RC4 round trip at {} bytes", data.len()); + prop_assert_eq!(rc4(&[], &data), &data[..], "an empty RC4 key passes bytes through"); + } + + /// `/Perms` certifies the permission bits and `/EncryptMetadata`: decrypted with the file key it + /// must hold `/P` little-endian, four set bytes, 'T' or 'F', and the `adb` sentinel (Algorithm 10). + /// Nothing else in this crate reads it — `cos` writes it out and no code takes it back apart — so + /// without this property a flipped metadata byte would tell every other viewer the wrong thing about + /// our own file while all our tests stayed green. The older revisions we create emit no `/Perms` + /// at all, which is recorded here as well so a change in what goes out shows up rather than + /// shipping quietly. + #[test] + fn perms_value_agrees_with_the_permissions_it_certifies(permissions in any::(), encrypt_metadata in any::()) { + let h = create( + &NewEncryption { algorithm: Algorithm::Aes256, user_password: "user", owner_password: "owner", permissions, encrypt_metadata, seed: [0x5e; 32] }, + ID, + ) + .unwrap(); + + prop_assert_eq!(h.dict().perms.len(), 16, "/Perms is one encrypted block"); + let open = aes_cbc_decrypt(h.file_key(), &[0u8; 16], &h.dict().perms, false); + + // Bits 1-2 clear and the reserved bits set (§7.6.4.2 Table 22) is what `/P` must say, and the + // encrypted certificate has to repeat it byte for byte. + let normalised = (permissions | !0xFFF | 0xC0) & !0x3; + prop_assert_eq!(&open[..4], &normalised.to_le_bytes(), "/Perms must carry /P"); + prop_assert_eq!(h.dict().p, normalised, "the /P written must match the permission bits drawn"); + prop_assert_eq!(&open[4..8], &[0xFF; 4], "the four bytes following /P are set"); + prop_assert_eq!(open[8], if encrypt_metadata { b'T' } else { b'F' }, "/Perms must report /EncryptMetadata"); + prop_assert_eq!(&open[9..12], b"adb", "the adb sentinel must be present"); + + // A user session's answers come from those same bits, not from the owner shortcut. + let as_user = SecurityHandler::open(h.dict().clone(), ID, Some("user")).unwrap(); + prop_assert_eq!(as_user.permissions().print(), normalised & 0b100 != 0, "/P bit 3 decides printing"); + prop_assert_eq!(as_user.permissions().copy(), normalised & 0b1_0000 != 0, "/P bit 5 decides copying"); + prop_assert!(!as_user.permissions().owner, "the user password must not report owner rights"); + + // Bytes 13-16 of /Perms are the non-repeating filler. Nothing reads them, so pinning that + // they move with the seed is the only way to notice a producer that freezes them at zero. + let other = create( + &NewEncryption { algorithm: Algorithm::Aes256, user_password: "user", owner_password: "owner", permissions, encrypt_metadata, seed: [0x11; 32] }, + ID, + ) + .unwrap(); + let open_other = aes_cbc_decrypt(other.file_key(), &[0u8; 16], &other.dict().perms, false); + prop_assert_ne!(&open[12..16], &open_other[12..16], "the four filler bytes of /Perms must follow the seed, not sit constant"); + + // Revisions below 5 carry no /Perms; pinned so the boundary of what we emit is explicit. + for older in [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128] { + let legacy = make(older, "user", "owner"); + prop_assert!(legacy.dict().perms.is_empty(), "{:?} must emit no /Perms", older); + } + } + /// Revisions 4 mix four set bytes into every file key when `/EncryptMetadata` is false (Algorithm + /// 2, step f), so the same password over the same `/ID` must produce a *different* key than when + /// metadata is encrypted. Every other test in this crate builds `encrypt_metadata: true` only, + /// which is exactly why that step is reachable but never walked: switching it off entirely changed + /// nothing any test could see. This also records the rule that revisions below 4 always encrypt + /// metadata whatever was asked, so the boundary between the two is named rather than assumed. + #[test] + fn metadata_flag_changes_the_key_it_claims_to_change(data in payloads()) { + let build = |encrypt_metadata: bool| { + create(&NewEncryption { algorithm: Algorithm::Aes128, user_password: "user", owner_password: "owner", permissions: -1, encrypt_metadata, seed: [0x5e; 32] }, ID).unwrap() + }; + let (yes, no) = (build(true), build(false)); + + // Algorithm 2, step f: revisions 4 hash four set bytes into every key when metadata stays + // plain, so two files with identical passwords and the same /ID cannot share a key. + prop_assert_ne!(yes.file_key(), no.file_key(), "R4 must fold four set bytes into the key when /EncryptMetadata is false (Algorithm 2, step f)"); + prop_assert_eq!(no.dict().encrypt_metadata, false, "the flag must reach the dictionary"); + prop_assert!(!no.encrypts_metadata(), "/EncryptMetadata false must report itself honestly"); + prop_assert!(yes.encrypts_metadata(), "and true must report true"); + + // Both still open on the same password and round trip at every length: a derivation that + // drifted would show up as an unreadable file rather than passing quietly. + for h in [&yes, &no] { + let open = SecurityHandler::open(h.dict().clone(), ID, Some("user")).unwrap(); + prop_assert_eq!(open.file_key(), h.file_key(), "the user password must still derive this file's key"); + let enc = h.encrypt_string(7, 0, &data); + prop_assert_eq!(&open.decrypt_string(7, 0, &enc)[..], &data[..], "round trip at {} bytes", data.len()); + } + + // And the two files must not read each other's content: different keys, different plaintext. + if data.len() >= 8 { + prop_assert_ne!(&yes.decrypt_string(7, 0, &no.encrypt_string(7, 0, &data))[..], &data[..], "a key built for plain metadata must not decrypt a file whose keys encrypt it"); + } + + // Revisions below 4 always encrypt metadata, whatever the caller asked for. + let legacy = create(&NewEncryption { algorithm: Algorithm::Rc4_128, user_password: "user", owner_password: "owner", permissions: -1, encrypt_metadata: false, seed: [0x5e; 32] }, ID).unwrap(); + prop_assert!(legacy.dict().encrypt_metadata && legacy.encrypts_metadata(), "V<4 always encrypts metadata, as create() documents"); + } + +} diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs index 9f13047b01..d246df725b 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -11,10 +11,12 @@ use std::collections::{HashMap, HashSet}; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; -use pdfcraft_fonts::{GlyphError, GlyphOutline, ShapedCluster, ShapedScript, Std14, literal, shape_arabic, shape_thai, shaped_glyph, win_ansi}; +use pdfcraft_fonts::{ + GlyphError, GlyphOutline, ShapedCluster, ShapedScript, Std14, literal, shape_arabic, shape_hebrew, shape_thai, shaped_glyph, win_ansi, +}; use unicode_bidi::{Level, ParagraphBidiInfo}; -use crate::{EditError, MarkKind, check, contents, n, page_list, place_tagged}; +use crate::{EditError, MarkKind, check, contents, matrix_number, n, page_list, place_tagged}; const TAG: &str = "Added"; @@ -239,27 +241,28 @@ fn wrapped(t: &AddedText) -> Vec<(String, bool)> { let width = (t.rect[2] - t.rect[0]).max(t.size); let mut out = Vec::new(); for para in t.text.split('\n') { - let arabic = para.chars().any(is_arabic); + // Arabic or Hebrew: laid out with the bidirectional algorithm and shaped. + let rtl_script = para.chars().any(|c| is_arabic(c) || is_hebrew(c)); let thai = para.chars().any(is_thai); if thai - && !arabic + && !rtl_script && let Ok(lines) = thai_lines(t, para, width) { out.extend(lines.into_iter().map(|line| (line, false))); continue; } - let rtl = arabic && unicode_bidi::get_base_direction(para) == unicode_bidi::Direction::Rtl; + let rtl = rtl_script && unicode_bidi::get_base_direction(para) == unicode_bidi::Direction::Rtl; // Arabic words are shaped one by one (joining stops at spaces), so a line's width is the // sum of its words' and shaping stays linear in the paragraph's length. The space is the // paragraph's; a space between Latin words is drawn in the item's font, a few hundredths // of an em apart. - let measure = |s: &str| if arabic || thai { shaped_width(t, s, rtl) } else { t.family.width(s, t.size, t.bold, t.italic) }; + let measure = |s: &str| if rtl_script || thai { shaped_width(t, s, rtl) } else { t.family.width(s, t.size, t.bold, t.italic) }; let space = measure(" "); let (mut line, mut line_w) = (String::new(), 0.0); for word in para.split(' ') { let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; let candidate_w = - if arabic || thai { if line.is_empty() { measure(word) } else { line_w + space + measure(word) } } else { measure(&candidate) }; + if rtl_script || thai { if line.is_empty() { measure(word) } else { line_w + space + measure(word) } } else { measure(&candidate) }; if !line.is_empty() && candidate_w > width { out.push((std::mem::take(&mut line), rtl)); line = word.to_string(); @@ -313,9 +316,16 @@ fn is_arabic(c: char) -> bool { matches!(u32::from(c), 0x0600..=0x06FF | 0x0750..=0x077F | 0x0870..=0x08FF | 0xFB50..=0xFDFF | 0xFE70..=0xFEFC) } +/// Hebrew letters, points, cantillation marks and punctuation (and the presentation forms): an +/// item with any of them is drawn with the craft-fonts Hebrew face. +fn is_hebrew(c: char) -> bool { + matches!(u32::from(c), 0x0591..=0x05F4 | 0xFB1D..=0xFB4F) +} + /// The first character of `t` that would be drawn as `?`: outside WinAnsiEncoding and not drawn -/// with the craft-fonts Arabic face (#125), or Arabic that the face can't draw (or that has no -/// face to draw it). Page-content tools refuse such text; the Add-text editor keeps it open. +/// with the craft-fonts Arabic (#125) or Hebrew (#128) face, or Arabic or Hebrew that the face can't +/// draw (or that has no face to draw it). Page-content tools refuse such text; the Add-text editor +/// keeps it open. pub fn first_undrawable(t: &AddedText) -> Option { if t.text.chars().any(is_thai) { if let Some(c) = t.text.chars().find(|c| is_thai(*c) && !pdfcraft_fonts::thai_has(*c)) { @@ -324,11 +334,13 @@ pub fn first_undrawable(t: &AddedText) -> Option { let rest = AddedText { text: t.text.chars().filter(|c| !is_thai(*c)).collect(), ..t.clone() }; return first_undrawable(&rest); } - // Arabic that nothing can draw: there's no face, or the face lacks it. - if let Some(c) = t.text.chars().find(|c| shaped_arabic(*c) && !pdfcraft_fonts::arabic_has(*c)) { + // Arabic or Hebrew that nothing can draw: there's no face, or the face lacks it. + if let Some(c) = + t.text.chars().find(|c| (shaped_arabic(*c) && !pdfcraft_fonts::arabic_has(*c)) || (is_hebrew(*c) && !pdfcraft_fonts::hebrew_has(*c))) + { return Some(c); } - if pdfcraft_fonts::document_arabic_font().is_none() || !t.text.chars().any(is_arabic) { + if !shaped_by_a_face(t) { // Drawn line by line in the item's standard font (`\n` splits lines). Without the face // that is also how `draw` redraws an existing item, so it's checked that way. return t.text.split('\n').find_map(pdfcraft_fonts::first_non_win_ansi); @@ -343,6 +355,13 @@ pub fn first_undrawable(t: &AddedText) -> Option { }) } +/// Whether `t` has Arabic or Hebrew that its craft-fonts face draws (so `draw` lays it out with +/// [`draw_shaped`]). +fn shaped_by_a_face(t: &AddedText) -> bool { + (pdfcraft_fonts::document_arabic_font().is_some() && t.text.chars().any(is_arabic)) + || (pdfcraft_fonts::document_hebrew_font(false).is_some() && t.text.chars().any(is_hebrew)) +} + /// An Arabic character the face shapes and draws (U+061C, a direction mark, is dropped). fn shaped_arabic(c: char) -> bool { is_arabic(c) && !is_bidi_control(c) @@ -391,12 +410,16 @@ fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec, f6 let run_rtl = levels.get(run.start).is_some_and(Level::is_rtl); // Runs lie on character boundaries; a missing slice is skipped, never a panic. let Some(text) = line.get(run) else { continue }; - // Arabic letters take the Arabic face; so do spaces and punctuation between them, which the - // shaper then mirrors. Latin text (and what the face lacks) keeps the item's font. + // Arabic and Hebrew letters take their face; so do spaces and punctuation between them + // (Hebrew's when they follow Hebrew), which the shaper then mirrors. Latin text (and what + // the faces lack) keeps the item's font. let mut segments: Vec<(Option, String)> = Vec::new(); for c in text.chars().filter(|c| !is_bidi_control(*c)) { + let after_hebrew = segments.last().is_some_and(|(kind, _)| *kind == Some(ShapedScript::Hebrew)); let script = if is_thai(c) { Some(ShapedScript::Thai) + } else if is_hebrew(c) || (run_rtl && after_hebrew && !c.is_alphanumeric() && pdfcraft_fonts::hebrew_has(c)) { + Some(ShapedScript::Hebrew) } else if is_arabic(c) || (run_rtl && !c.is_alphanumeric() && pdfcraft_fonts::arabic_has(c)) { Some(ShapedScript::Arabic) } else { @@ -414,6 +437,7 @@ fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec, f6 pieces.push(if let Some(script) = script { let shaped = match script { ShapedScript::Arabic => shape_arabic(&s, run_rtl).map_err(|e| arabic_error(e, &s))?, + ShapedScript::Hebrew => shape_hebrew(&s, run_rtl).map_err(|e| hebrew_error(e, &s))?, ShapedScript::Thai => shape_thai(&s).map_err(|e| thai_error(e, &s))?, }; Piece::Shaped(shaped) @@ -450,6 +474,16 @@ fn arabic_error(e: GlyphError, text: &str) -> EditError { }) } +fn hebrew_error(e: GlyphError, text: &str) -> EditError { + EditError::Invalid(match e { + GlyphError::NoFont => "Hebrew text needs PdfCraft's Hebrew font, which this build doesn't include \ + (to build it in, set CRAFT_FONTS_DIR to a craft-fonts checkout that has a Hebr face)" + .into(), + GlyphError::Missing => format!("the Hebrew font can't show \"{text}\""), + GlyphError::TooComplex => format!("the Hebrew font's glyphs for \"{text}\" are too complex"), + }) +} + fn thai_error(e: GlyphError, text: &str) -> EditError { EditError::Invalid(match e { GlyphError::NoFont => { @@ -467,6 +501,11 @@ pub fn text_rect(t: &AddedText) -> [f64; 4] { [r[0], r[3] - h, r[2], r[3]] } +/// The widest explicit line before wrapping, measured with the PDF writing fonts. +pub fn text_width(t: &AddedText) -> f64 { + t.text.split('\n').map(|line| shaped_width(t, line, unicode_bidi::get_base_direction(line) == unicode_bidi::Direction::Rtl)).fold(0.0, f64::max) +} + fn font_name(base: &str) -> String { format!("PCF{}", base.replace('-', "")) } @@ -479,7 +518,7 @@ const GLYPHS_PER_FONT: usize = 240; /// its pieces in drawing order and its width at the item's size. type LaidLine = (String, bool, Vec, f64); -/// Draw a text item that has Arabic or Thai in it. Each line is laid out in display order; Arabic glyphs +/// Draw a text item that has Arabic, Hebrew or Thai in it. Each line is laid out in display order; their glyphs /// come from Type 3 fonts drawn from the craft-fonts face's outlines (no font file is embedded), /// with a ToUnicode map for copy and search. Other text uses the item's standard font. fn draw_shaped(doc: &mut Document, t: &AddedText, taken: &Dict, fonts: &mut Dict, out: &mut Vec) -> Result<(), EditError> { @@ -524,6 +563,7 @@ fn draw_laid_shaped( if let std::collections::hash_map::Entry::Vacant(entry) = outlines.entry((c.script, *id)) { entry.insert(outline(c.script, *id).map_err(|e| match c.script { ShapedScript::Arabic => arabic_error(e, &c.text), + ShapedScript::Hebrew => hebrew_error(e, &c.text), ShapedScript::Thai => thai_error(e, &c.text), })?); } @@ -663,6 +703,7 @@ fn type3_shaped(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &Hash Some(script) => script.font().map_or( match script { ShapedScript::Arabic => "Arabic", + ShapedScript::Hebrew => "Hebrew", ShapedScript::Thai => "Thai", }, |f| f.family, @@ -697,11 +738,21 @@ fn type3_shaped(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &Hash } /// Content and resources for an item; `view` maps display space to user space. `taken` holds the -/// page's font names. `existing`: the item is being replaced, so a build without the Arabic face -/// keeps drawing its Arabic as `?`, as before Arabic was supported, rather than refusing to move it. +/// page's font names. `existing`: the item is being replaced, so a build without the Arabic or +/// Hebrew face keeps drawing that text as `?`, as before it was supported, rather than refusing to +/// move it. fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing: bool) -> Result<(Vec, Dict), EditError> { let mut res = Dict::new(); - let mut out = format!("q {} {} {} {} {} {} cm\n", n(view[0]), n(view[1]), n(view[2]), n(view[3]), n(view[4]), n(view[5])).into_bytes(); + let mut out = format!( + "q {} {} {} {} {} {} cm\n", + matrix_number(view[0]), + matrix_number(view[1]), + matrix_number(view[2]), + matrix_number(view[3]), + matrix_number(view[4]), + matrix_number(view[5]) + ) + .into_bytes(); match c { Content::Text(t) => { let base = t.family.base_font(t.bold, t.italic); @@ -715,7 +766,11 @@ fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing: } let mut fonts = Dict::new(); fonts.set(name.clone().into_bytes(), Object::Dict(font)); - if t.text.chars().any(is_thai) || (t.text.chars().any(is_arabic) && (pdfcraft_fonts::document_arabic_font().is_some() || !existing)) { + let face_or_new = |has: fn(char) -> bool, face: bool| t.text.chars().any(has) && (face || !existing); + if t.text.chars().any(is_thai) + || face_or_new(is_arabic, pdfcraft_fonts::document_arabic_font().is_some()) + || face_or_new(is_hebrew, pdfcraft_fonts::document_hebrew_font(false).is_some()) + { draw_shaped(doc, t, taken, &mut fonts, &mut out)?; res.set(b"Font".to_vec(), Object::Dict(fonts)); out.extend_from_slice(b"Q\n"); diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs index a3f6c886a2..8202c18d83 100644 --- a/crates/edit/src/flatten.rs +++ b/crates/edit/src/flatten.rs @@ -6,7 +6,8 @@ //! the appearance's bounding box, transformed by its `/Matrix`, mapped onto `/Rect`). Hidden //! annotations are left alone; links are kept (they have no ink and stay clickable). Flattened //! comments take their pop-ups and replies with them; flattened widgets are removed from the -//! form's field tree. +//! form's field tree. An annotation whose appearance cannot be drawn is not flattened: it stays +//! as it is, so no comment or field value is lost with nothing in its place. use std::collections::HashSet; @@ -154,6 +155,8 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result = HashSet::new(); let mut gone_inline: HashSet = HashSet::new(); + // Selected, but nothing was drawn for it: it stays, pop-up and reply handling included. + let mut undrawn: HashSet = HashSet::new(); // A viewer draws many comments from their dictionary even when the file stores no // appearance (notes, FreeText, ink, stamps…). Build the appearance it would show, so // flattening keeps a comment that still has ink on screen instead of deleting it. @@ -199,9 +202,18 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result { @@ -218,9 +230,13 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result Option<(usize, Matrix)> { Some((at, Matrix(ops.get(at + 3)?.nums::<6>()?))) } +/// The `q cm … Q` whose only content after its `cm` is `start..=end` (a `Do`, or a balanced +/// `q … Q` scope), with the index and value of that `cm`. Another transform of the same +/// placement is folded into it, so repeated moves and resizes don't nest one more `q` each. +fn enclosing_transform(ops: &[Op], start: usize, end: usize) -> Option<(usize, Matrix)> { + let at = start.checked_sub(1)?; + let cm = ops.get(at)?; + if !ops.get(at.checked_sub(1)?)?.is("q") || !ops.get(end.checked_add(1)?)?.is("Q") || !cm.is("cm") || cm.operands.len() != 6 { + return None; + } + let m = Matrix(cm.nums::<6>()?); + m.0.iter().all(|v| v.is_finite()).then_some((at, m)) +} + /// The `cm` to put before the clip so that user-space transform `t` also moves the clip: /// the clip is drawn in the CTM before the image's own `cm` (`cm` · that = `draw`). fn clip_matrix(draw: Matrix, cm: Matrix, t: Matrix) -> Option { @@ -564,6 +579,16 @@ fn content_units(ops: &[Op], scan_start: usize, scan_end: usize) -> Vec Result, EditError> { +fn extract_scope_state(ops: &[Op], start: usize, target: usize, initial_ctm: Matrix) -> Result, EditError> { if target >= ops.len() || start > target { return Ok(None); } @@ -699,7 +724,7 @@ fn extract_scope_state(ops: &[Op], start: usize, target: usize) -> Result