diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8249dcf0c..388b44f03 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 3b451f246..903d13284 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 | |---|---|---|---|---|---| @@ -27,7 +27,8 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian 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 | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `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/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; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed | 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; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed | 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 07433fc45..34f17b513 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 = "0b0dafa3806a6b3508684f64e1f07b435113f8efd0e59201d12250c63c1d9547" [[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,18 +217,30 @@ 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" title = "Lithuanian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; reviewed by a native Lithuanian-speaking contributor" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed" 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 = "62151d79be5089d964876b6569d490ee3fdacef4388684b574be9627b1354e92" + + +[[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; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed" +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 = "f9002d56e44e49429e0cdeabb10525f79f32831b94233f3c0a8a0ecb202850d5" [[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 f2acd78dc..91ed3c490 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" @@ -3877,6 +3935,7 @@ dependencies = [ name = "pdfcraft" 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,6 +3951,7 @@ dependencies = [ "wayland-client", "wayland-protocols", "windows", + "winit", "winresource", "winsafe", ] @@ -3959,6 +4020,7 @@ dependencies = [ "pdfcraft-automation", "pdfcraft-cos", "pdfcraft-engine", + "pdfcraft-platform", "pdfcraft-render", "rustix 1.1.5", "serde_json", @@ -3983,6 +4045,8 @@ dependencies = [ "hayro-syntax", "pdfcraft-crypt", "pdfcraft-filters", + "pdfcraft-oracle", + "pdfcraft-testkit", "proptest", "serde_json", "thiserror 2.0.21", @@ -4065,6 +4129,7 @@ dependencies = [ "pdfcraft-xfdf", "png 0.18.1", "thiserror 2.0.21", + "unicode-segmentation", "url", ] @@ -4126,6 +4191,7 @@ dependencies = [ "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", + "pdfcraft-testkit", "serde", "thiserror 2.0.21", ] @@ -4160,6 +4226,10 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "pdfcraft-oracle" +version = "0.8.0" + [[package]] name = "pdfcraft-organize" version = "0.8.0" @@ -4167,6 +4237,7 @@ dependencies = [ "hayro-syntax", "pdfcraft-content", "pdfcraft-cos", + "pdfcraft-oracle", "thiserror 2.0.21", ] @@ -4176,6 +4247,7 @@ version = "0.8.0" dependencies = [ "async-channel", "futures-lite", + "rustix 1.1.5", "zbus", ] @@ -4183,6 +4255,7 @@ dependencies = [ name = "pdfcraft-preflight" version = "0.8.0" dependencies = [ + "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", ] @@ -4209,6 +4282,8 @@ dependencies = [ "pdfcraft-fonts", "pdfcraft-forms", "pdfcraft-model", + "pdfcraft-testkit", + "pdfcraft-xfa", "thiserror 2.0.21", ] @@ -4265,6 +4340,10 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "pdfcraft-testkit" +version = "0.8.0" + [[package]] name = "pdfcraft-ui-egui" version = "0.8.0" @@ -4272,11 +4351,13 @@ 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", @@ -5398,9 +5479,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", @@ -6868,8 +6949,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", @@ -7946,6 +8025,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 87422986d..597dd316f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,8 +14,10 @@ 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.8.0" } pdfcraft-filters = { path = "crates/filters", version = "0.8.0" } @@ -34,6 +36,8 @@ 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" } @@ -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 b20179fff..bd33ad23b 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 8f8a2056a..cb15663e8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,9 +1,15 @@ # 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-14 · **Last updated:** 2026-10-14 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **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) @@ -11,6 +17,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) @@ -48,7 +55,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 | @@ -65,7 +72,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 | @@ -80,13 +87,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 @@ -111,8 +118,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 | |---|---|---|---| @@ -127,10 +134,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 @@ -149,11 +157,61 @@ 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-14 (M1, Flate fallback):** Raw deflate whose first bytes resemble a zlib header no longer loses a decoding-limit error during fallback. Strict and tolerant filter regressions and a COS stream regression cover refusal at the caller limit and successful decoding within it. No parity status or estimate changes. +- **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. @@ -165,6 +223,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. @@ -178,6 +239,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. @@ -203,6 +265,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. @@ -431,6 +494,10 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| | 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. | +| 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. | @@ -447,5 +514,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 abe2657fb..d7b52fbd6 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 000000000..868cdf66a --- /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 190cb3da9..0e24800d3 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 000000000..eb99fa11d --- /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 000000000..162d79baf --- /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 c9a5e7e2b..b35b71ddd 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 000000000..25f1aabc2 --- /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 000000000..cf1e6b650 --- /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 4a185d1f9..a27401c70 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 a62d300f6..71ed1d2d2 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 a1d2ad998..7a0bb2043 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 000000000..950566a2e --- /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 9d15f5b26..ecfa12f5c 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 000000000..5422af59b --- /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 000000000..ec76d2b4b --- /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 aa8dd7a15..da3cfc6a7 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 1f2035405..92a5ae8c2 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 000000000..3203aca37 --- /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 7a35deb46..c7e911fde 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 81fbade91..ff81cc4c9 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 926350c3d..565f6620d 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 e406569a8..fcd681efc 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 a5394038d..0f0706428 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 a25b35c3b..f4973aa02 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 2ae46a246..faed409ab 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 c8a6a3249..5dd15de3a 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 be8afe0a8..3df666c19 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 ad26c2559..204d237b3 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 be98d7284..d7d83a7db 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 d702b7ed4..4e27702a3 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 71e96131e..1e8d71bd1 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 d98c48723..302688f72 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 0e97aa8b6..710952d7c 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 af8cf671a..ac670109f 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 000000000..55b10662f --- /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 4a34909de..88856305b 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, @@ -517,6 +552,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 { @@ -722,8 +763,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)?, @@ -754,7 +797,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)?, @@ -819,7 +864,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> { @@ -866,7 +912,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. @@ -879,11 +932,24 @@ impl Automation { // A save that adds or removes encryption, or must not keep the old revision (Sanitize, // redaction), is rewritten in full even when an incremental save was asked for. let incremental = !full && bytes.starts_with(&self.doc(a)?.saved_bytes()); + self.publish_save(id, &target, bytes, incremental, write) + } + + /// Validate the prepared bytes before publishing them or marking the document saved. + fn publish_save( + &mut self, + id: DocId, + target: &Path, + bytes: Arc>, + incremental: bool, + write: impl FnOnce(&Path, &[u8]) -> Result<()>, + ) -> Result { self.session.check_save(id, &bytes).map_err(failed)?; - write(&target, &bytes)?; + write(target, &bytes)?; let path = target.to_string_lossy().into_owned(); self.session.mark_saved(id, bytes.clone(), Some(path.clone())).map_err(failed)?; - Ok(json!({ "path": path, "bytes": bytes.len(), "incremental": incremental, "document": summary(self.doc(a)?) })) + let doc = self.session.get(id).ok_or_else(|| failed("the saved document is no longer open"))?; + Ok(json!({ "path": path, "bytes": bytes.len(), "incremental": incremental, "document": summary(doc) })) } fn doc_revisions(&mut self, a: &Args) -> Result { @@ -993,11 +1059,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; @@ -1025,6 +1094,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), @@ -1057,6 +1127,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, @@ -1197,7 +1269,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 })); } @@ -1279,6 +1351,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)?; @@ -1687,12 +1811,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 }) }; @@ -1703,6 +1838,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")?; @@ -1757,6 +1913,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; @@ -1882,6 +2060,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())); @@ -1908,8 +2109,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 } @@ -2068,7 +2278,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(); @@ -2078,7 +2288,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() { @@ -2087,6 +2300,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() { @@ -2209,6 +2452,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 @@ -2223,9 +2473,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)) } @@ -2329,32 +2581,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)] @@ -2731,6 +2971,37 @@ mod tests { } } + #[test] + fn invalid_save_bytes_never_reach_publication_or_mark_saved() { + let dir = staging_dir("invalid-save-bytes"); + let target = dir.join("out.pdf"); + let mut a = Automation::new().with_root(&dir).unwrap(); + let id = search_document(&mut a, 2); + json(&mut a, "doc_save", json!({"doc": id.0, "path": "out.pdf"})); + let old = std::fs::read(&target).unwrap(); + json(&mut a, "page_rotate", json!({"doc": id.0, "pages": [1], "degrees": 90})); + let before = summary(a.session.get(id).unwrap()); + // Exercise both parse failure and a valid PDF that loses a page. This does not + // depend on a malformed input remaining unrepaired by future reader improvements. + for bytes in [b"not a PDF".to_vec(), numbered_pages(1)] { + let mut published = false; + let result = a.publish_save(id, &target, Arc::new(bytes), false, |_, _| { + published = true; + Ok(()) + }); + assert!(result.is_err(), "invalid output must be refused"); + assert!(result.unwrap_err().to_string().contains("could not be reopened")); + assert!(!published, "validation must precede the writer"); + assert_eq!(summary(a.session.get(id).unwrap()), before, "failed validation must preserve document state"); + assert_eq!(std::fs::read(&target).unwrap(), old); + assert_eq!(listing(&dir), ["out.pdf"]); + } + json(&mut a, "doc_save", json!({"doc": id.0})); + assert!(!a.session.get(id).unwrap().dirty); + assert_ne!(std::fs::read(&target).unwrap(), old); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn a_failed_save_sync_keeps_the_document_dirty_and_can_be_retried() { let dir = staging_dir("sync-dirty"); diff --git a/crates/automation/src/links.rs b/crates/automation/src/links.rs index c8ef9fc9d..cd0fb9da0 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 e9b16fc8a..19cd48ef4 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 60d8c8b77..1b6283c0a 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 2d0281fc0..94fc4bdec 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 cfdeb1c16..5e89aef78 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"] }, + "action": { "type": "string", "enum": ["move", "crop", "rotate", "flip_horizontal", "flip_vertical", "replace", "delete"] }, "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 e788d9610..1e662d8f3 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"); @@ -524,11 +711,10 @@ fn an_in_place_save_reports_a_required_rewrite_as_not_incremental() { } } -/// Issue #1698: a save whose bytes don't reopen is refused before it touches the file. The -/// catalog's `/Pages` is not a page tree, so the reader finds the page by scanning, while a -/// full rewrite keeps only what the catalog reaches and drops it. +/// #1664 repairs the missing page tree on open. The former #1698 failure fixture now +/// saves successfully, keeping its page and pixels through a full rewrite. #[test] -fn a_save_that_does_not_reopen_leaves_the_file_as_it_was() { +fn a_recovered_page_tree_survives_a_full_save() { let dir = workdir("save-verify-before-write"); let objs: [&[u8]; 4] = [ b"<< /Type /Catalog /Pages 3 0 R >>", @@ -556,10 +742,19 @@ fn a_save_that_does_not_reopen_leaves_the_file_as_it_was() { let opened = ok(&mut a, "doc_open", json!({ "path": "damaged.pdf" })); assert_eq!(opened["pages"], 1, "{opened}"); let doc = opened["doc"].as_u64().unwrap(); - let e = err(&mut a, "doc_save", json!({ "doc": doc, "full": true })); - assert!(e.to_string().contains("could not be reopened"), "{e}"); + let (width, height, pixels) = a.render_pixels(doc, 1, 1.0).unwrap(); + assert!(pixels.as_ref().as_chunks::<4>().0.iter().any(|p| p[..3] != [255, 255, 255]), "the original page is not blank"); + let saved = ok(&mut a, "doc_save", json!({ "doc": doc, "full": true })); + assert_eq!(saved["document"]["dirty"], false); let on_disk = std::fs::read(dir.join("damaged.pdf")).unwrap(); - assert!(on_disk == original, "the file was replaced by {} bytes that don't reopen", on_disk.len()); + assert_ne!(on_disk, original, "a full save writes the repaired page tree"); + let mut reopened = auto(&dir); + let opened = ok(&mut reopened, "doc_open", json!({ "path": "damaged.pdf" })); + assert_eq!(opened["pages"], 1); + let again = opened["doc"].as_u64().unwrap(); + let (new_width, new_height, new_pixels) = reopened.render_pixels(again, 1, 1.0).unwrap(); + assert_eq!((new_width, new_height), (width, height)); + assert_eq!(new_pixels.as_ref(), pixels.as_ref(), "saving must preserve the recovered page's drawing"); } /// organize.field-name-conflicts: combining files whose forms share a field name keeps the @@ -834,7 +1029,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 +1611,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 +1845,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 +1876,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 +1889,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 +1902,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 +2294,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 +2543,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 saved = ok(&mut a, "image_save", json!({ "doc": made, "page": 1, "image": 1, "path": "out/picture" })); @@ -2862,6 +3149,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"); @@ -3079,7 +3388,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(), @@ -3116,11 +3429,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"); @@ -3207,7 +3603,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:?}"); @@ -3840,6 +4236,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"); @@ -3965,12 +4388,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 [ @@ -3990,7 +4413,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()); @@ -4026,11 +4449,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); @@ -4053,7 +4478,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"); @@ -4063,7 +4488,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"))); @@ -4085,6 +4510,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"); @@ -4125,6 +4633,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"); @@ -4523,6 +5150,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"); @@ -5145,6 +5799,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"); @@ -5169,6 +5841,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"); @@ -5466,6 +6165,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), @@ -5717,3 +6417,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 000000000..94958ea2f --- /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 d9d8af10c..6ab984dd4 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 4a8f077fe..32334e047 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 99f40d4e3..18e0b8cbc 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 f02f63c52..9ef9126ca 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 b3ac964d5..ab382219d 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 0f05661b2..a0b1f56ee 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 2605bb20f..cd0d04a14 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 34254bf37..f8d8fd3d8 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 6868a6dc2..af92cf67c 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 b7514cc05..ec18b13f3 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 d9a96181a..103448945 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 2d76055ec..993c38d9f 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 000000000..e4f1d78b5 --- /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 9f13047b0..d246df725 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 a3f6c886a..8202c18d8 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 Result, Edi pub enum ImageChange { /// Apply a user-space transform after its placement (move, resize, rotate, flip). Transform([f64; 6]), + /// Show only this part of it (a box in user space): a clipping path around the drawing, + /// inside its own scope so a later move or resize carries it along. The pixels stay where + /// they are; nothing is scaled. + Crop([f64; 4]), /// Draw this image XObject instead, in the same place. Replace(ObjRef), Delete, @@ -420,6 +424,19 @@ fn exclusive_clip(ops: &[Op], op: usize) -> 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 { @@ -444,6 +461,9 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag ImageChange::Transform(t) => { return transform_image(doc, &p, &all, &img, *t); } + ImageChange::Crop(keep) => { + return crop_image(doc, &p, &all, &img, *keep); + } ImageChange::Replace(r) => { if img.is_form { return Err(EditError::Invalid( @@ -603,6 +623,20 @@ fn transform_image( if !local.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { return Err(EditError::Invalid("the image transform is too large".into())); } + // Already alone in a `q cm … Q` (its own placement, or the wrapper an earlier edit added): + // change that `cm` in place. The CTM inside is `local · cm · outer` either way. + if let Some((at, cm)) = enclosing_transform(&ops, start, end) { + let folded = local.then(&cm); + if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { + return Err(EditError::Invalid("the image transform is too large".into())); + } + let changed = crate::text::splice(&pieces, &ops, |index| { + if index == at { (vec![Op::new("cm", folded.0.map(Object::Real).to_vec())], false) } else { (Vec::new(), true) } + }); + let contents = crate::text::rewritten_contents(doc, streams, changed); + doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), contents))?; + return Ok(()); + } let changed = crate::text::splice(&pieces, &ops, |index| { if index == start { let mut out = vec![Op::new("q", vec![]), Op::new("cm", local.0.map(Object::Real).to_vec())]; @@ -625,6 +659,86 @@ fn transform_image( Ok(()) } +/// Crop: clip the image's drawing to `keep` (user space). The clip goes just inside the scope +/// that holds the image and its private crop (or a new `q … Q` around the `Do`), written in +/// that scope's coordinates as the box's four corners, so a turned image crops to an upright +/// box too and a later transform of the scope moves the crop with the pixels. +fn crop_image( + doc: &mut Document, + page: &pdfcraft_model::Page, + streams: &[(Object, Vec)], + image: &PageImage, + keep: [f64; 4], +) -> Result<(), EditError> { + if !keep.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { + return Err(EditError::Invalid("the crop box must be finite and no larger than 1e12".into())); + } + let [x0, y0, x1, y1] = [keep[0].min(keep[2]), keep[1].min(keep[3]), keep[0].max(keep[2]), keep[1].max(keep[3])]; + if x1 - x0 < 1e-6 || y1 - y0 < 1e-6 { + return Err(EditError::Invalid("the crop box has no area".into())); + } + let mut offset = 0_usize; + for (_, data) in streams.iter().take(image.stream) { + offset = offset + .checked_add(data.len()) + .and_then(|n| n.checked_add(1)) + .ok_or_else(|| EditError::Invalid("the image content is too large".into()))?; + } + let data = streams.get(image.stream).ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?; + let op = parse(&data.1).ops.get(image.op).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; + let span = offset.checked_add(op.span.start).ok_or_else(|| EditError::Invalid("the image content is too large".into()))?; + let pieces = pdfcraft_content::Pieces::join(&streams.iter().map(|(_, data)| data.as_slice()).collect::>()); + let ops = pieces.parse().ops; + let target = ops + .iter() + .position(|op| op.span.start == span && op.is("Do")) + .ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?; + let (start, end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(image.draw_matrix))); + let inverse = placement.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?; + let corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].map(|(x, y)| inverse.apply(x, y)); + if !corners.iter().all(|(x, y)| x.is_finite() && y.is_finite() && x.abs() <= 1e12 && y.abs() <= 1e12) { + return Err(EditError::Invalid("the crop box is too large for the image's placement".into())); + } + let clip: Vec = corners + .iter() + .enumerate() + .map(|(i, (x, y))| Op::new(if i == 0 { "m" } else { "l" }, vec![Object::Real(*x), Object::Real(*y)])) + .chain([Op::new("h", vec![]), Op::new("W", vec![]), Op::new("n", vec![])]) + .collect(); + // The image's own earlier crop (a clipping path right after the scope's `q`) gives way to + // the new one, so a crop can grow again as well as shrink. + let old_clip: Option> = (start != end) + .then(|| { + let mut k = start + 1; + while ops.get(k).is_some_and(|o| matches!(o.op.as_slice(), b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re")) { + k += 1; + } + (k > start + 1 && ops.get(k).is_some_and(|o| matches!(o.op.as_slice(), b"W" | b"W*")) && ops.get(k + 1).is_some_and(|o| o.is("n"))) + .then_some(start + 1..k + 2) + }) + .flatten(); + let changed = crate::text::splice(&pieces, &ops, |index| { + if start == end && index == target { + // No scope of its own yet: one around the drawing, with the crop inside. + let mut out = vec![Op::new("q", vec![])]; + out.extend(clip.iter().cloned()); + out.extend(ops.get(index).cloned()); + out.push(Op::new("Q", vec![])); + (out, false) + } else if start != end && index == start + 1 { + // Inside the scope's `q`, before whatever it draws (in place of its old crop). + (clip.clone(), old_clip.is_none()) + } else if old_clip.as_ref().is_some_and(|r| r.contains(&index)) { + (Vec::new(), false) + } else { + (Vec::new(), true) + } + }); + let contents = crate::text::rewritten_contents(doc, streams, changed); + doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), contents))?; + Ok(()) +} + /// Plan all transforms before changing a stream; indexes refer to the original inventory. /// Splice only the chosen Do operators and preserve all other bytes and stream dictionaries. pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize], offset: [f64; 2]) -> Result<(), EditError> { @@ -672,10 +786,25 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize plans.remove(&(stream, index)); clips.insert((stream, at), c); } + // An image already alone in `q cm Do Q` moves by changing that `cm`, not by nesting another. + let mut folds = std::collections::BTreeMap::new(); + for index in 0..ops.len() { + let Some(&(movement, _, _)) = plans.get(&(stream, index)) else { continue }; + let Some((at, cm)) = enclosing_transform(&ops, index, index) else { continue }; + let folded = movement.then(&cm); + if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { + return Err(EditError::Invalid("the image move is too large".into())); + } + plans.remove(&(stream, index)); + folds.insert(at, folded); + } let changed = crate::text::splice(&pieces, &ops, |index| { if let Some(c) = clips.remove(&(stream, index)) { return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], true); } + if let Some(m) = folds.remove(&index) { + return (vec![Op::new("cm", m.0.map(Object::Real).to_vec())], false); + } let Some((matrix, _, _)) = plans.remove(&(stream, index)) else { return (Vec::new(), true) }; let original = ops.get(index).cloned().into_iter(); // Local translations can be tiny under a large CTM. Preserve their precision diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index d92f28405..515f17d8d 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -73,6 +73,17 @@ impl Default for HeaderFooter { } } +impl HeaderFooter { + /// The first character [`add_header_footer`] would refuse, because the standard fonts can't + /// draw it (#125). Tokens only add ASCII (Bates prefixes and suffixes are part of the + /// template), so checking the templates, split as `expand`'s output is, covers every page + /// (and a `\r` that would be drawn is refused too). The dialog asks this too, so its preview + /// never offers text that applying refuses (#2013). + pub fn first_undrawable(&self) -> Option { + self.text.iter().flat_map(|t| t.lines()).find_map(pdfcraft_fonts::first_non_win_ansi) + } +} + /// A picture for a background or watermark (Acrobat: Source ▸ File): an XObject already in /// the document, an image (drawn into a unit square) or a form (a page of a PDF). #[derive(Clone, Copy, Debug, PartialEq)] @@ -104,6 +115,17 @@ pub struct Watermark { pub offset: [f64; 2], } +impl Watermark { + /// The first character of a text watermark [`add_watermark`] would refuse, because the + /// standard fonts can't draw it (#125). A picture watermark draws no text. + pub fn first_undrawable(&self) -> Option { + match self.source { + Some(_) => None, + None => self.text.trim().lines().find_map(pdfcraft_fonts::first_non_win_ansi), + } + } +} + impl Default for Watermark { fn default() -> Self { Self { @@ -172,6 +194,13 @@ pub struct Context { pub date: (i64, u32, u32), } +// Model-derived transforms can contain tiny scale factors; retain their PDF decimal precision. +fn matrix_number(value: f64) -> String { + let mut bytes = Vec::new(); + pdfcraft_cos::serialize(&Object::Real(value), &mut bytes); + String::from_utf8_lossy(&bytes).into_owned() +} + fn n(v: f64) -> String { let s = format!("{:.3}", if v.abs() < 5e-4 { 0.0 } else { v }); let s = s.trim_end_matches('0').trim_end_matches('.'); @@ -365,27 +394,17 @@ fn begin(kind: MarkKind, subtype: &str, matrix: [f64; 6]) -> String { format!( "q\n/Artifact <> BDC\n{} {} {} {} {} {} cm\n", kind.tag(), - n(matrix[0]), - n(matrix[1]), - n(matrix[2]), - n(matrix[3]), - n(matrix[4]), - n(matrix[5]) + matrix_number(matrix[0]), + matrix_number(matrix[1]), + matrix_number(matrix[2]), + matrix_number(matrix[3]), + matrix_number(matrix[4]), + matrix_number(matrix[5]) ) } const END: &str = "EMC\nQ\n"; -/// Refuses page text the standard-14 fonts (WinAnsiEncoding) can't draw: written anyway, each -/// such character would become a `?` on the page (#125). `lines` must split the text the way the -/// caller draws it, so a `\r` that would be drawn is refused too. -pub(crate) fn drawable<'a>(lines: impl IntoIterator) -> Result<(), EditError> { - match lines.into_iter().find_map(pdfcraft_fonts::first_non_win_ansi) { - Some(c) => Err(undrawable(c)), - None => Ok(()), - } -} - /// The refusal for page text with `c`, which nothing here can draw. pub(crate) fn undrawable(c: char) -> EditError { EditError::Invalid(undrawable_message(c)) @@ -411,9 +430,9 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter, if hf.text.iter().all(|t| t.trim().is_empty()) { return Err(EditError::Invalid("type the header or footer text first".into())); } - // Tokens only add ASCII (Bates prefixes and suffixes are part of the template), so checking - // the templates, split as `expand`'s output is, covers every page. - hf.text.iter().try_for_each(|t| drawable(t.lines()))?; + if let Some(c) = hf.first_undrawable() { + return Err(undrawable(c)); + } if !(hf.font_size.is_finite() && hf.font_size > 0.0 && hf.font_size <= 200.0 && hf.margins.iter().all(|m| m.is_finite() && *m >= 0.0)) { return Err(EditError::Invalid("invalid font size or margins".into())); } @@ -431,18 +450,26 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter, u32::MAX ))); } + // The same goes for Bates numbers: the last selected page must still fit in a u64. + let bates0 = hf.text.iter().find_map(|t| bates_start(t)).unwrap_or(1); + if bates0.checked_add(pages.len().saturating_sub(1) as u64).is_none() { + return Err(EditError::Invalid(format!( + "the Bates start number {bates0} leaves no room for {} pages (Bates numbers stop at {})", + pages.len(), + u64::MAX + ))); + } if replace { remove_marks(doc, pages, MarkKind::HeaderFooter)?; } let all = page_list(doc); let count = all.len(); - let bates0 = hf.text.iter().find_map(|t| bates_start(t)).unwrap_or(1); for (k, &i) in pages.iter().enumerate() { let page = &all[i]; let (w, h) = page.display_size(doc); let number = u32::try_from(u64::from(hf.start_number) + k as u64).map_err(|_| EditError::Invalid("the header page numbers are too large".into()))?; - let bates = bates0 + k as u64; + let bates = bates0.checked_add(k as u64).ok_or_else(|| EditError::Invalid("the Bates numbers are too large".into()))?; let size = hf.font_size; let [top, bottom, left, right] = hf.margins; let mut body: Vec = format!("BT\n/PCHelv {} Tf\n{}\n", n(size), rgb(hf.color)).into_bytes(); @@ -491,8 +518,8 @@ pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replac if text.is_empty() && wm.source.is_none() { return Err(EditError::Invalid("type the watermark text first".into())); } - if wm.source.is_none() { - drawable(text.lines())?; + if let Some(c) = wm.first_undrawable() { + return Err(undrawable(c)); } if !(wm.opacity.is_finite() && wm.rotation.is_finite() @@ -626,6 +653,7 @@ pub mod images; pub use images::{ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre}; pub mod bidi; pub mod indic; +mod linebreak; pub mod text; pub use text::{ BlockStyle, LineEdit, RangeStyle, TextBlock, TextLine, clear_block, reading_blocks, replace_block, replace_line, rewrite_block, text_blocks, diff --git a/crates/edit/src/linebreak.rs b/crates/edit/src/linebreak.rs new file mode 100644 index 000000000..a4682679f --- /dev/null +++ b/crates/edit/src/linebreak.rs @@ -0,0 +1,124 @@ +//! Where a row of rewrapped text may end besides a space: between two characters when either is +//! written without spaces between words (Chinese and Japanese ideographs and kana), following the +//! simple cases of Unicode line breaking (UAX #14, class ID). The row must not then begin with a +//! character that closes or follows (closing brackets and quotes, commas and full stops, small +//! kana, the prolonged sound mark, iteration marks) or end with one that opens (opening brackets +//! and quotes, currency signs before an amount): the line-start and line-end prohibitions of +//! JIS X 4051 ("kinsoku"), which UAX #14 gives as classes CL, CP, EX, IS, NS and OP, PR. +//! +//! Korean is written with spaces between words and keeps breaking only there; so does Thai, whose +//! words need a dictionary to find. + +/// Writing with no spaces between words, where a row may end between two characters (UAX #14 ID). +fn ideographic(c: char) -> bool { + matches!(c, + '\u{2e80}'..='\u{2fdf}' // CJK radicals, Kangxi radicals + | '\u{3000}'..='\u{303f}' // CJK symbols and punctuation + | '\u{3040}'..='\u{30ff}' // hiragana, katakana + | '\u{3190}'..='\u{31ff}' // kanbun, CJK strokes, katakana phonetic extensions + | '\u{3400}'..='\u{4dbf}' // CJK extension A + | '\u{4e00}'..='\u{9fff}' // CJK unified ideographs + | '\u{f900}'..='\u{faff}' // CJK compatibility ideographs + | '\u{fe30}'..='\u{fe4f}' // CJK compatibility forms + | '\u{ff00}'..='\u{ffef}' // half-width and full-width forms + | '\u{20000}'..='\u{3fffd}' // supplementary ideographic planes + ) +} + +/// Characters that must not begin a row: closing brackets and quotes, and the punctuation, small +/// kana and marks that belong to the character before them (UAX #14 CL, CP, EX, IS, NS). The +/// ideographic space breaks after itself, not before (BA): it is Japanese spacing and indentation. +fn no_start(c: char) -> bool { + matches!(c, + ')' | ']' | '}' | '»' | '’' | '”' | '!' | '?' | ',' | '.' | ':' | ';' | '%' | '‰' | '°' | '℃' + | '‐' | '–' | '〜' | '‼' | '⁇' | '⁈' | '⁉' | '・' | '\u{3000}' + | '、' | '。' | '〃' | '〆' | '々' | '〻' | '〕' | '〉' | '》' | '」' | '』' | '】' | '〙' | '〗' | '〟' | '〞' + | 'ぁ' | 'ぃ' | 'ぅ' | 'ぇ' | 'ぉ' | 'っ' | 'ゃ' | 'ゅ' | 'ょ' | 'ゎ' | 'ゕ' | 'ゖ' + | 'ァ' | 'ィ' | 'ゥ' | 'ェ' | 'ォ' | 'ッ' | 'ャ' | 'ュ' | 'ョ' | 'ヮ' | 'ヵ' | 'ヶ' + | '゛' | '゜' | 'ゝ' | 'ゞ' | '゠' | 'ー' | 'ヽ' | 'ヾ' + | '\u{31f0}'..='\u{31ff}' // small katakana for Ainu + | '!' | '%' | ')' | ',' | '.' | ':' | ';' | '?' | ']' | '}' | '⦆' | '~' + | '。' | '」' | '、' | '・' | 'ー' | '゙' | '゚' + | '\u{ff67}'..='\u{ff6f}' // small half-width katakana + ) +} + +/// Characters that must not end a row: opening brackets and quotes, and currency signs written +/// before an amount (UAX #14 OP, PR). +fn no_end(c: char) -> bool { + matches!( + c, + '(' | '[' + | '{' + | '«' + | '‘' + | '“' + | '$' + | '£' + | '¥' + | '€' + | '〈' + | '《' + | '「' + | '『' + | '【' + | '〔' + | '〖' + | '〘' + | '〝' + | '(' + | '[' + | '{' + | '⦅' + | '「' + | '$' + | '£' + | '¥' + ) +} + +/// Whether a row may end between `before` and `after`, neither of them a space. +pub(crate) fn between(before: char, after: char) -> bool { + (ideographic(before) || ideographic(after)) && !no_start(after) && !no_end(before) +} + +/// `word` (text without spaces) cut where a row may end inside it; the pieces join with nothing. +pub(crate) fn pieces(word: &str) -> Vec<&str> { + let mut out = Vec::new(); + let mut start = 0; + let mut prev: Option = None; + for (at, c) in word.char_indices() { + if let Some(before) = prev + && between(before, c) + { + out.extend(word.get(start..at)); + start = at; + } + prev = Some(c); + } + out.extend(word.get(start..).filter(|rest| !rest.is_empty())); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ideographs_and_kana_break_between_characters_except_around_kinsoku_marks() { + assert_eq!(pieces("日本語の文章"), ["日", "本", "語", "の", "文", "章"]); + // Closing punctuation and small kana stay with the character before them; an opening + // bracket stays with the one after it. + assert_eq!(pieces("ちょっと、「はい」。"), ["ちょっ", "と、", "「は", "い」。"]); + assert_eq!(pieces("コーヒー"), ["コー", "ヒー"]); + assert_eq!(pieces("(例)です"), ["(例)", "で", "す"]); + // Latin text inside a Japanese sentence breaks before and after it, never inside it. + assert_eq!(pieces("PDFを編集"), ["PDF", "を", "編", "集"]); + assert_eq!(pieces("価格は¥100です"), ["価", "格", "は", "¥100", "で", "す"]); + // Words of space-separated writing are never cut. + assert_eq!(pieces("wrapping"), ["wrapping"]); + assert_eq!(pieces("한국어"), ["한국어"]); + assert_eq!(pieces(""), Vec::<&str>::new()); + assert!(!between('a', 'b') && between('字', 'a') && !between('字', '。') && !between('「', '字')); + } +} diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index fce7d5e10..b542142df 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -115,6 +115,23 @@ fn header_start_number_that_would_wrap_is_refused_without_drawing() { assert_eq!(marks_present(&doc).len(), 1); } +#[test] +fn header_bates_start_that_would_overflow_is_refused_without_drawing() { + // A Bates start of u64::MAX has no room for a second page. The run must be refused up front + // instead of overflowing (a panic in debug builds, a wrap to 0 in release builds). + let mut doc = fixture(); + let hf = HeaderFooter { + text: [String::new(), String::new(), "<>".into(), String::new(), String::new(), String::new()], + ..HeaderFooter::default() + }; + let err = add_header_footer(&mut doc, &[0, 1], &hf, false, &cx()).unwrap_err(); + assert!(err.to_string().contains("Bates"), "{err}"); + assert!(marks_present(&doc).is_empty(), "nothing is drawn when the run is refused"); + // A single page still fits the start number itself. + add_header_footer(&mut doc, &[0], &hf, false, &cx()).unwrap(); + assert_eq!(marks_present(&doc).len(), 1); +} + #[test] fn replace_and_remove_restore_the_original_content() { let mut doc = fixture(); @@ -294,6 +311,58 @@ fn flattening_fields_drops_the_ones_on_no_page() { assert_eq!(left, [pdfcraft_cos::ObjRef::new(7, 0)], "only the hidden field on the page stays"); } +/// #1794: a form field whose widget has no usable appearance (none stored, or none for its +/// selected state) has nothing to draw. Flattening used to remove it anyway, losing its value +/// with no ink in its place. It stays, in the page and in the form, like a comment that can't be +/// drawn; a field with an appearance is still baked in and removed. +#[test] +fn flattening_keeps_a_valued_widget_it_cannot_draw() { + let page = stream("", " "); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 6 0 R 7 0 R] >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R] >>", + &page, + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (Ada) /Rect [10 10 150 40] /F 4 >>", + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Yes /AS /Yes /Rect [10 50 30 70] /F 4 /AP << /N << /Off 8 0 R >> >> >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (drawn) /V (Bob) /Rect [10 90 150 120] /F 4 /AP << /N 8 0 R >> >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream", + ]); + assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1, "only the field with an appearance is drawn"); + let doc = reopen(&doc); + let refs = |o: &Object| -> Vec { doc.resolve(o).as_array().unwrap().iter().filter_map(|f| f.as_ref()).map(|r| r.num).collect() }; + let p = &pdfcraft_model::pages(&doc)[0]; + assert_eq!(refs(p.dict.get(b"Annots").unwrap()), [5, 6], "the undrawable widgets stay on the page"); + let root = doc.root().unwrap(); + let af = doc.resolve(doc.get(root).as_dict().unwrap().get(b"AcroForm").unwrap()); + assert_eq!(refs(af.as_dict().unwrap().get(b"Fields").unwrap()), [5, 6], "and in the form"); + let value = |n: u32| doc.get(pdfcraft_cos::ObjRef::new(n, 0)).as_dict().unwrap().get(b"V").cloned(); + assert_eq!(value(5), Some(Object::String(pdfcraft_cos::PdfString::literal(b"Ada".to_vec())))); + assert_eq!(value(6), Some(Object::name("Yes"))); +} + +/// #1794: a reply this builder cannot draw stays on the page even when the comment it replies to +/// is flattened; the comment's pop-up still goes with it. +#[test] +fn flattening_keeps_an_undrawn_reply_to_a_flattened_comment() { + let page = stream("", " "); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R] >>", + &page, + "<< /Type /Annot /Subtype /Square /Rect [100 600 200 650] /C [1 0 0] /Contents (Synthetic comment) /AP << /N 8 0 R >> /Popup 7 0 R >>", + "<< /Type /Annot /Subtype /Square /Rect [100 500 200 550] /C [1 0 0] /Contents (Synthetic reply) /BE << /S /C /I 2 >> /IRT 5 0 R >>", + "<< /Type /Annot /Subtype /Popup /Rect [300 600 400 700] /Parent 5 0 R >>", + "<< /Type /XObject /Subtype /Form /BBox [100 600 200 650] /Length 28 >>\nstream\n1 0 0 RG 100 600 100 50 re S\nendstream", + ]); + assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1, "the comment is drawn; the cloudy reply cannot be"); + let doc = reopen(&doc); + let p = &pdfcraft_model::pages(&doc)[0]; + let left: Vec = doc.resolve(p.dict.get(b"Annots").unwrap()).as_array().unwrap().iter().filter_map(|f| f.as_ref()).map(|r| r.num).collect(); + assert_eq!(left, [6], "the undrawn reply stays; the comment and its pop-up are gone"); +} + /// Flattening never makes a `?` permanent: it refuses, changing nothing, a text box without an /// appearance whose text Helvetica can't draw (another program's), and a comment or form field /// whose appearance PdfCraft drew with `?` (marked `PCUndrawn`). A comment another program drew @@ -641,6 +710,82 @@ fn added_arabic_text_is_shaped_in_display_order_and_stays_searchable() { } } +#[test] +fn added_hebrew_text_is_drawn_right_to_left_and_stays_searchable() { + let mut doc = fixture(); + let text = AddedText { + rect: [72.0, 600.0, 400.0, 700.0], + text: "שלום (עולם) 2026 World".into(), + size: 14.0, + align: Align::Right, + ..AddedText::default() + }; + if pdfcraft_fonts::document_hebrew_font(false).is_none() { + eprintln!("skipping the Hebrew drawing checks: built without a craft-fonts Hebr face (set CRAFT_FONTS_DIR)"); + assert_eq!(first_undrawable(&text), Some('ש')); + let err = add_content(&mut doc, 0, &Content::Text(text)).unwrap_err(); + assert!(matches!(&err, EditError::Invalid(m) if m.contains("U+05E9")), "{err:?}"); + assert!(!doc.is_modified(), "nothing is written when the font is missing"); + return; + } + assert_eq!(first_undrawable(&text), None); + add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap(); + // Points stay on their letters; a Hebrew line in an Arabic item and Arabic in a Hebrew one. + for other in ["שָׁלוֹם", "שלום مرحبا"] { + let other = AddedText { text: other.into(), rect: [72.0, 500.0, 400.0, 520.0], ..text.clone() }; + if other.text.chars().any(|c| ('\u{0600}'..='\u{06FF}').contains(&c)) && pdfcraft_fonts::document_arabic_font().is_none() { + continue; + } + add_content(&mut doc, 0, &Content::Text(other)).unwrap(); + } + let doc = reopen(&doc); + let added = list_added(&doc); + let Content::Text(t) = &added[0].content else { panic!() }; + assert_eq!(t.text, "שלום (עולם) 2026 World", "the logical text is kept for editing"); + let s = streams(&doc, 0); + let item = s.iter().find(|s| s.contains("(World) Tj")).unwrap(); + // A right-to-left paragraph: "World", typed last, is drawn left of the Hebrew. + let x_before = |s: &str, at: usize| -> f64 { + let tm = s[..at].rfind("1 0 0 1 ").unwrap() + 8; + s[tm..].split(' ').next().unwrap().parse().unwrap() + }; + let latin_x = x_before(item, item.find("(World) Tj").unwrap()); + let hebrew_at = item.find("/PCAr").unwrap(); + let hebrew_x = x_before(item, item[hebrew_at..].find(" Tj").unwrap() + hebrew_at); + assert!(latin_x < hebrew_x, "{item}"); + // Copy and search get the typing order back. + let logical: String = t.text.encode_utf16().map(|u| format!("{u:04X}")).collect(); + assert!(item.contains(&format!("/ActualText ")), "{item}"); + // The Hebrew glyphs come from a Type 3 font named after the face, whose ToUnicode map gives + // back each letter. + let p = &pdfcraft_model::pages(&doc)[0]; + let res = doc.resolve(p.dict.get(b"Resources").unwrap()); + let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap()).as_dict().unwrap().clone(); + let shaped: Vec<_> = fonts.iter().filter(|(k, _)| k.starts_with(b"PCAr")).map(|(_, v)| doc.resolve(v).as_dict().unwrap().clone()).collect(); + let mut mapped = String::new(); + for f in &shaped { + assert_eq!(f.name(b"Subtype"), Some(&b"Type3"[..])); + mapped += &String::from_utf8_lossy(&stream_bytes(&doc, f.get(b"ToUnicode").unwrap()).unwrap()); + } + for letter in ["05E9", "05DC", "05D5", "05DD", "05E2"] { + assert!(mapped.contains(&format!("<{letter}>")), "{letter} in {mapped}"); + } + let font_name = |f: &Dict| doc.resolve(f.get(b"FontDescriptor").unwrap()).as_dict().unwrap().name(b"FontName").map(<[u8]>::to_vec); + assert!(shaped.iter().any(|f| font_name(f).as_deref() == Some(&b"NotoSansHebrew"[..]))); + // A pointed letter is one glyph with its points, mapped back whole. + assert!(mapped.contains("<05E905"), "{mapped}"); +} + +#[test] +fn odd_hebrew_text_never_panics() { + let mut doc = fixture(); + let long = "שלום עולם ".repeat(2_000); + for s in ["\u{202E}ש\u{05B8}\u{05B8} (]", "\u{05B8}", "א\u{200F}\u{2067}b\u{2069}", "א\n\nב\tc", "׳״ 456", long.as_str()] { + let r = add_content(&mut doc, 0, &Content::Text(AddedText { rect: [0.0, 800.0, 300.0, 780.0], text: s.into(), ..AddedText::default() })); + assert_eq!(r.is_ok(), pdfcraft_fonts::document_hebrew_font(false).is_some(), "{:?}: {r:?}", s.chars().take(12).collect::()); + } +} + /// The `PCAr` font names in page `page`'s resources. fn arabic_font_names(doc: &Document, page: usize) -> Vec { let p = &pdfcraft_model::pages(doc)[page]; @@ -1277,6 +1422,74 @@ fn a_subset_without_a_space_glyph_keeps_its_font_when_editing_words() { } } +/// A Chrome (Skia) style subset: Type0/Identity-H over a TrueType program, CID = glyph id. +/// `/W` lists most used glyphs, writes 0 for some it dropped, and leaves `a` and `y` to `/DW`; +/// `z` has a ToUnicode entry but no glyph in the program (its CID is past the last glyph). +fn skia_subset_page() -> Document { + // Inter's glyph ids: P 375, o 790, l 736, a 507, n 773, d 597, G 141, e 614, r 852, m 766, y 998, space 1777. + let mut doc = text_page( + "BT /F3 12 Tf 72 700 Td <0177031602E001FB03050255> Tj ET \ + BT /F3 12 Tf 72 650 Td <008D0266035402FE01FB030503E606F101FB0305025506F102FE031603540266> Tj ET", + ); + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), include_bytes!("../../../assets/fonts/Inter-Regular.ttf")))); + let unicode = doc.add(Object::Stream(Stream::flate( + Dict::new(), + b"1 begincodespacerange <0000> endcodespacerange 13 beginbfchar <0177> <0050> <0316> <006F> <02E0> <006C> \ + <01FB> <0061> <0305> <006E> <0255> <0064> <008D> <0047> <0266> <0065> <0354> <0072> <02FE> <006D> <03E6> <0079> \ + <06F1> <0020> <1000> <007A> endbfchar", + ))); + let widths: Vec = [(0x8D, 746), (0x177, 639), (0x255, 612), (0x266, 583), (0x2E0, 242), (0x2FE, 876), (0x305, 591), (0x316, 600)] + .into_iter() + .chain([(0x354, 376), (0x355, 0), (0x356, 0), (0x6F1, 281)]) + .flat_map(|(cid, width)| [Object::Int(cid), Object::Array(vec![Object::Int(width)])]) + .collect(); + let descriptor = Dict::from_iter([ + (b"Type".to_vec(), Object::name("FontDescriptor")), + (b"FontName".to_vec(), Object::name("BAAAAA+SampleSans")), + (b"Flags".to_vec(), Object::Int(32)), + (b"FontFile2".to_vec(), Object::Ref(program)), + ]); + let cid_font = Dict::from_iter([ + (b"Type".to_vec(), Object::name("Font")), + (b"Subtype".to_vec(), Object::name("CIDFontType2")), + (b"BaseFont".to_vec(), Object::name("BAAAAA+SampleSans")), + (b"CIDToGIDMap".to_vec(), Object::name("Identity")), + (b"DW".to_vec(), Object::Int(562)), + (b"W".to_vec(), Object::Array(widths)), + (b"FontDescriptor".to_vec(), Object::Dict(descriptor)), + ]); + let font = Dict::from_iter([ + (b"Type".to_vec(), Object::name("Font")), + (b"Subtype".to_vec(), Object::name("Type0")), + (b"BaseFont".to_vec(), Object::name("BAAAAA+SampleSans")), + (b"Encoding".to_vec(), Object::name("Identity-H")), + (b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(cid_font)])), + (b"ToUnicode".to_vec(), Object::Ref(unicode)), + ]); + let p = pdfcraft_model::pages(&doc)[0].obj; + let fonts = Dict::from_iter([(b"F3".to_vec(), Object::Dict(font))]); + doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(Dict::from_iter([(b"Font".to_vec(), Object::Dict(fonts))])))).unwrap(); + doc +} + +#[test] +fn a_subset_keeps_its_font_for_glyphs_left_to_the_default_width() { + let doc = skia_subset_page(); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.iter().map(|b| b.text.as_str()).collect::>(), ["Poland", "Germany and more"]); + // Deleting words only, and replacing a word with one drawn elsewhere on the page (`y` and `a` + // have no /W entry): every glyph is in the subset, so the font stays. + for (block, text) in [(1, "Germany"), (0, "Germany")] { + let mut doc = doc.clone(); + assert_eq!(text::replace_block(&mut doc, 0, block, text).unwrap().substituted, None, "{text}"); + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + assert_eq!(lines.iter().find(|l| l.text == text).unwrap().font, "F3"); + } + // `z` maps to a CID the program has no glyph for: that still needs another font. + let mut doc = doc.clone(); + assert!(text::replace_block(&mut doc, 0, 0, "Graz").unwrap().substituted.is_some()); +} + #[test] fn spaces_in_another_subset_do_not_split_a_line() { let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello) Tj /F2 12 Tf ( ) Tj /F1 12 Tf (world) Tj ET"); @@ -1346,6 +1559,28 @@ fn independent_text_objects_with_different_transforms_stay_separate() { assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2); } +/// A negative font size or `Tz` runs the glyphs backwards along the text matrix: each word's +/// `ink` box still spans all its glyphs, not only the first. +#[test] +fn word_boxes_span_whole_words_when_glyphs_run_backwards() { + // "someone@example.com" in Helvetica 11 is 121.814 pt wide. + for content in [ + "BT /F1 -11 Tf -1 0 0 1 300 742 Tm (Mail the team: someone@example.com) Tj ET", + "BT /F1 11 Tf -100 Tz 300 742 Td (Mail the team: someone@example.com) Tj ET", + ] { + let lines = text::text_lines(&text_page(content), 0).unwrap(); + assert_eq!(lines.len(), 1, "{content}"); + let ink = &lines[0].ink; + assert_eq!(ink.len(), 4, "{content}: {ink:?}"); + let width = |r: &[f64; 4]| r[2] - r[0]; + assert!(ink.iter().all(|r| width(r) > 5.0), "{content}: {ink:?}"); + let address = ink.iter().map(width).fold(0.0, f64::max); + assert!((address - 121.814).abs() < 0.01, "{content}: {ink:?}"); + let (x0, x1) = ink.iter().fold((f64::MAX, f64::MIN), |(a, b), r| (a.min(r[0]), b.max(r[2]))); + assert!(x0 >= lines[0].rect[0] - 0.01 && x1 <= lines[0].rect[2] + 0.01, "{content}: {ink:?} {:?}", lines[0].rect); + } +} + #[test] fn text_lines_are_found_and_replaced_in_place() { let mut doc = @@ -1630,6 +1865,61 @@ fn close(a: [f64; 4], b: [f64; 4]) -> bool { a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6) } +/// The deepest `q` nesting on the page. +fn q_depth(doc: &Document) -> usize { + let mut depth = 0_usize; + let mut deepest = 0; + for op in pdfcraft_content::parse(&page_content_bytes(doc, 0)).ops { + if op.is("q") { + depth += 1; + deepest = deepest.max(depth); + } else if op.is("Q") { + depth = depth.saturating_sub(1); + } + } + deepest +} + +#[test] +fn repeated_image_edits_change_one_transform_instead_of_nesting() { + let variants = [ + // Its own placement, a private crop, and a bare `Do` that the first edit wraps. + "q 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET", + "q 100 100 200 100 re W* n 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET", + "200 0 0 100 100 100 cm /Im0 Do", + ]; + for content in variants { + let mut doc = image_page_with(content); + let resize = |doc: &mut Document, n: usize| -> usize { + let mut depth = 0; + for i in 0..n { + let r = images::page_images(doc, 0).unwrap()[0].visible_rect.unwrap(); + // Grow by 3% about the lower-left corner and move right by 2, staying on the page. + let target = [r[0] + 2.0, r[1], r[0] + 2.0 + (r[2] - r[0]) * 1.03, r[1] + (r[3] - r[1]) * 1.03]; + images::change_image(doc, 0, 0, &images::ImageChange::Transform(images::rect_to_rect(r, target))).unwrap(); + let after = images::page_images(doc, 0).unwrap()[0].visible_rect.unwrap(); + assert!(after.iter().zip(target).all(|(a, b)| (a - b).abs() < 1e-6), "{content}: {after:?} vs {target:?}"); + if i == 0 { + depth = q_depth(doc); + } + } + depth + }; + let first = resize(&mut doc, 20); + assert_eq!(q_depth(&doc), first, "{content}: twenty resizes nest no deeper than one"); + let mut doc = reopen(&doc); + let before = images::page_images(&doc, 0).unwrap()[0].rect; + for _ in 0..20 { + images::translate_images(&mut doc, 0, &[0], [1.0, -2.0]).unwrap(); + } + let after = images::page_images(&doc, 0).unwrap()[0].rect; + assert!(close(after, [before[0] + 20.0, before[1] - 40.0, before[2] + 20.0, before[3] - 40.0]), "{content}: {after:?}"); + assert_eq!(q_depth(&doc), first, "{content}: group moves change the same transform"); + let caption: Vec<_> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect(); + assert_eq!(caption.is_empty(), !content.contains("Caption"), "{content}: the text after the image is untouched"); + } +} + #[test] fn page_images_move_turn_replace_and_delete() { let mut doc = image_page(); @@ -2116,6 +2406,33 @@ fn hebrew_edits_are_drawn_right_to_left_and_keep_their_right_edge() { assert!((line.rect[0] - before[0]).abs() < 0.5, "left-aligned: {:?} vs {before:?}", line.rect); } +/// A generated Type 3 font numbers its glyphs from 1, so code 32 is a character, not the space: +/// justified rows are stretched after each space, never with word spacing. +#[test] +fn justified_text_in_the_type3_fallback_stretches_its_spaces() { + let src = "BT /F1 12 Tf 14 TL 100 700 Td (Some words to fill a row of text) Tj T* (and another one below it) Tj T* (end) Tj ET"; + let mut doc = text_page(src); + let rect = text::text_blocks(&doc, 0).unwrap()[0].rect; + // More than 32 different characters, so one of them gets code 32. + let typed = "日本語の 文章を 書きます 東京 大阪 京都 名古屋 札幌 福岡 神戸 横浜 仙台 広島 千葉 新潟 静岡 熊本 岡山 金沢"; + let style = text::BlockStyle { align: Some(added::Align::Justify), ..Default::default() }; + let rewritten = text::rewrite_block(&mut doc, 0, 0, Some(typed), &style); + if pdfcraft_fonts::document_japanese_font().is_none() { + assert!(rewritten.is_err()); + eprintln!("built without a craft-fonts Japanese face: the Type 3 path is checked with CRAFT_FONTS_DIR"); + return; + } + rewritten.unwrap(); + let lines = text::text_lines(&doc, 0).unwrap(); + assert!(lines.len() > 2, "{lines:?}"); + for l in &lines[..lines.len() - 1] { + assert!((l.rect[2] - rect[2]).abs() < 1.0, "{:?} spans {:?}, not {rect:?}", l.text, l.rect); + } + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + assert!(ops.iter().any(|o| o.is("TJ")), "{ops:?}"); + assert!(ops.iter().filter(|o| o.is("Tw")).all(|o| o.operands.first().and_then(|n| n.as_f64()) == Some(0.0)), "{ops:?}"); +} + /// Letters the page's font lacks are drawn with the Hebrew face from craft-fonts; a build /// without it says so and changes nothing. Hebrew points are refused, not misdrawn. #[test] @@ -2162,6 +2479,37 @@ fn hebrew_letters_the_page_font_lacks_use_the_hebrew_face() { assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap()[0].text, "עברית מלאה"); } +/// #2013: the dialog asks `first_undrawable` before offering OK, so it must refuse exactly what +/// applying refuses: a Cyrillic footer was previewed (in the interface font) and then failed. +#[test] +fn header_and_watermark_drawability_matches_what_applying_refuses() { + let footer = |t: &str| HeaderFooter { + text: [String::new(), String::new(), String::new(), String::new(), String::new(), t.into()], + ..HeaderFooter::default() + }; + for (text, refused) in [ + ("Страница <<1>> из 1 / Page <<1>> of 1", Some('С')), + ("TEST-001 <>", None), + ("Café — 5€ ™ <>", None), + ("Left\r\nRight", None), + ("A\rB", Some('\r')), + ("line one\nΕλληνικά", Some('Ε')), + ] { + let hf = footer(text); + assert_eq!(hf.first_undrawable(), refused, "{text:?}"); + let mut doc = fixture(); + let applied = add_header_footer(&mut doc, &[0], &hf, false, &cx()); + match refused { + Some(c) => assert_eq!(applied.unwrap_err().to_string(), undrawable_message(c), "{text:?}"), + None => applied.unwrap(), + } + let wm = Watermark { text: text.into(), ..Watermark::default() }; + assert_eq!(wm.first_undrawable(), refused, "{text:?}"); + let applied = add_watermark(&mut fixture(), &[0], &wm, false); + assert_eq!(applied.err().map(|e| e.to_string()), refused.map(undrawable_message), "{text:?}"); + } +} + /// #125: page-content tools refuse text the standard fonts can't draw instead of writing `?`, /// and leave the document untouched. #[test] @@ -2680,6 +3028,55 @@ fn group_moves_keep_split_stream_tokens_and_shared_form_resources() { assert_eq!(page_content_bytes(&doc, 1), source_page); } +#[test] +fn justified_text_in_a_composite_font_fills_each_row_with_adjustments_after_its_spaces() { + // Two-byte codes, so word spacing (single-byte code 32 only) can't stretch the spaces. + let hex = |s: &str| s.bytes().map(|b| format!("00{b:02x}")).collect::(); + let src = format!( + "BT /F1 10 Tf 12 TL 100 700 Td <{}> Tj T* <{}> Tj T* <{}> Tj ET", + hex("One two three four five six seven"), + hex("eight nine ten eleven twelve"), + hex("and then the end of it") + ); + let mut doc = text_page(&src); + let unicode = doc.add(Object::Stream(Stream::flate( + Dict::new(), + b"1 begincodespacerange <0000> endcodespacerange 1 beginbfrange <0020> <007e> <0020> endbfrange", + ))); + let font = Dict::from_iter([ + (b"Type".to_vec(), Object::name("Font")), + (b"Subtype".to_vec(), Object::name("Type0")), + (b"BaseFont".to_vec(), Object::name("SyntheticCID")), + (b"Encoding".to_vec(), Object::name("Identity-H")), + (b"ToUnicode".to_vec(), Object::Ref(unicode)), + ( + b"DescendantFonts".to_vec(), + Object::Array(vec![Object::Dict(Dict::from_iter([ + (b"Subtype".to_vec(), Object::name("CIDFontType2")), + (b"DW".to_vec(), Object::Int(500)), + ]))]), + ), + ]); + let p = pdfcraft_model::pages(&doc)[0].obj; + let resources = Dict::from_iter([(b"Font".to_vec(), Object::Dict(Dict::from_iter([(b"F1".to_vec(), Object::Dict(font))])))]); + doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + let before = text::text_blocks(&doc, 0).unwrap()[0].clone(); + let right = before.rect[2]; + text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { align: Some(added::Align::Justify), ..Default::default() }).unwrap(); + let lines = text::text_lines(&doc, 0).unwrap(); + assert!(lines.len() > 2, "{lines:?}"); + for l in &lines[..lines.len() - 1] { + assert!((l.rect[2] - right).abs() < 1.0, "{:?} ends at {} not {right}", l.text, l.rect[2]); + } + // The source font shows the text, with its spaces, and the text reads as before, rewrapped. + let flat = |s: &str| s.replace('\n', " "); + assert_eq!(flat(&text::text_blocks(&doc, 0).unwrap()[0].editor_text), flat(&before.editor_text)); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + assert!(ops.iter().all(|o| !o.is("Tf") || o.operands.first().and_then(|n| n.as_name()) == Some(b"F1".as_slice())), "{ops:?}"); + assert!(ops.iter().any(|o| o.is("TJ")), "{ops:?}"); + assert!(ops.iter().filter(|o| o.is("Tw")).all(|o| o.operands.first().and_then(|n| n.as_f64()) == Some(0.0)), "{ops:?}"); +} + #[test] fn group_moves_preserve_encoded_glyphs_and_refuse_vertical_cmaps() { let mut doc = text_page("BT /F1 12 Tf 40 400 Td <004100420043> Tj ET"); @@ -3034,6 +3431,30 @@ fn mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns() { assert_eq!(blocks[2].text, "Other column"); } +#[test] +fn a_single_row_with_mixed_styles_grows_before_wrapping() { + let mut doc = text_page("BT /F1 10 Tf 72 582 Td (Plain ) Tj /F2 10 Tf (bold) Tj /F1 10 Tf ( text.) Tj ET"); + doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |f| { + *f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("Font")); + f.set(b"Subtype".to_vec(), Object::name("Type1")); + f.set(b"BaseFont".to_vec(), Object::name("Helvetica-Bold")); + }) + .unwrap(); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let index = blocks.iter().position(|b| b.editor_text == "Plain bold text.").unwrap(); + let source = &blocks[index]; + assert_eq!(source.rows.len(), 1); + assert!(source.lines.len() > 1); + let draft = "Plain bold text. More words on the same line"; + text::replace_block(&mut doc, 0, index, draft).unwrap(); + let edited = text::text_blocks(&reopen(&doc), 0).unwrap(); + let block = edited.iter().find(|b| b.text == draft).unwrap(); + assert_eq!(block.rows.len(), 1, "a style boundary must not pin a single row to its original width"); + assert!((block.rect[0] - source.rect[0]).abs() < 0.1); + assert!(block.rect[2] > source.rect[2] + 20.0); +} + #[test] fn explicit_editor_rows_survive_single_run_edit_and_reopen() { let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Original heading) Tj ET"); @@ -3083,6 +3504,27 @@ fn rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen( assert_eq!(text::text_blocks(&original, 0).unwrap().len(), 3); } +/// Two edits applied together (heading and footer edited before leaving the editor, #2198): the +/// runs between them are unchanged and keep their own styles instead of taking the heading's. +#[test] +fn unchanged_runs_between_two_edits_keep_their_styles() { + let original = mixed_region_page(); + let look = + |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().map(|l| (l.text.trim().to_string(), l.base_font, l.color)).collect::>(); + let before = look(&original); + let block = text::text_blocks(&original, 0).unwrap()[1].clone(); + let mut doc = original.clone(); + let draft = block.editor_text.replace("About", "About us").replace("footer", "footnote"); + text::replace_block(&mut doc, 0, 1, &draft).unwrap(); + let after = look(&reopen(&doc)); + let style = |lines: &[(String, String, [f64; 3])], word: &str| lines.iter().find(|l| l.0 == word).map(|l| (l.1.clone(), l.2)); + for word in ["Plain", "bold", "text."] { + assert_eq!(style(&after, word), style(&before, word), "{word:?} keeps its own style; lines after the edit: {after:?}"); + } + assert_eq!(style(&after, "About us"), style(&before, "About")); + assert_eq!(style(&after, "Italic footnote"), style(&before, "Italic footer")); +} + #[test] fn disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically() { let mut doc = mixed_region_page(); @@ -3420,6 +3862,104 @@ fn subset_indic_font_without_legacy_name_keeps_logical_text() { assert_eq!(lines[0].text, "\u{092C}\u{0922}\u{093C}\u{093F}\u{092F}\u{093E}"); } +/// Lines of a paragraph drawn turned on the page (a landscape page drawn a quarter turn round) +/// are separate rows, and adding a word keeps them apart (#1443). +#[test] +fn turned_paragraph_keeps_its_line_breaks_when_a_word_is_added() { + for cm in ["0 1 -1 0 600 0", "0 -1 1 0 0 800", "-1 0 0 -1 600 800"] { + let original = + text_page(&format!("q {cm} cm BT /F1 12 Tf 72 500 Td (First line here) Tj 0 -14 Td (Second line) Tj 0 -14 Td (Third) Tj ET Q")); + let block = text::text_blocks(&original, 0).unwrap().remove(0); + assert_eq!(block.editor_text, "First line here\nSecond line\nThird", "{cm}"); + let mut doc = original.clone(); + text::rewrite_block(&mut doc, 0, 0, Some("First line here\nSecond line\nThird added"), &text::BlockStyle::default()).unwrap(); + let reopened = reopen(&doc); + assert_eq!(text::text_blocks(&reopened, 0).unwrap()[0].editor_text, "First line here\nSecond line\nThird added", "{cm}"); + let (before, after) = (text::text_lines(&original, 0).unwrap(), text::text_lines(&reopened, 0).unwrap()); + assert_eq!(after.len(), 3, "{cm}"); + for (a, b) in after.iter().zip(&before).take(2) { + assert!(close(a.rect, b.rect), "{cm}: untouched rows stay put: {:?} {:?}", a.rect, b.rect); + } + // The last row grows along its own baseline from where it started. + let (grown, old) = (after[2].rect, before[2].rect); + assert!((0..2).all(|i| grown[i] <= old[i] + 0.01) && (2..4).all(|i| grown[i] >= old[i] - 0.01), "{cm}: {grown:?} {old:?}"); + assert!((after[2].origin_baseline() - before[2].origin_baseline()).abs() < 0.01, "{cm}"); + } + // A turned single line grows rather than wrapping across the page's other axis. + let mut doc = text_page("BT /F1 12 Tf 0 1 -1 0 300 300 Tm (Only line) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, Some("Only line with several more words added"), &text::BlockStyle::default()).unwrap(); + let blocks = text::text_blocks(&reopen(&doc), 0).unwrap(); + assert_eq!(blocks[0].editor_text, "Only line with several more words added"); + // Formatting part of turned text isn't laid out yet: refused, not drawn along the page's x axis. + let mut doc = text_page("BT /F1 12 Tf 0 1 -1 0 300 300 Tm (Only line) Tj ET"); + let range = text::RangeStyle { + range: 0..4, + family: None, + resource: None, + size: Some(20.0), + color: None, + underline: None, + char_spacing: None, + scale: None, + }; + let style = text::BlockStyle { ranges: vec![range], ..Default::default() }; + assert!(text::rewrite_block(&mut doc, 0, 0, Some("Only line"), &style).is_err()); +} + +/// A paragraph in a Chrome-style subset font: `/W` leaves out the glyphs of the `/DW` width. +/// Adding a word keeps every row as it was and shows the word in the page's own font (#1443). +#[test] +fn default_width_subset_paragraph_keeps_its_rows_when_a_word_is_added() { + let hex = |s: &str| s.chars().map(|c| format!("00{:02X}", u32::from(c))).collect::(); + let rows = ["This is the first line", "the second", "and third"]; + let content = rows + .iter() + .enumerate() + .map(|(i, row)| format!("BT /F3 12 Tf 1 0 0 1 72 {} Tm <{}> Tj ET", 700 - 14 * i, hex(row))) + .collect::>() + .join(" "); + let mut doc = text_page(&content); + let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> endcodespacerange \ + 1 beginbfrange <0020> <007E> <0020> endbfrange end"; + let to_unicode = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); + let mut cid = Dict::new(); + cid.set(b"Type".to_vec(), Object::name("Font")); + cid.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + cid.set(b"BaseFont".to_vec(), Object::name("AAAAAA+LiberationSans")); + cid.set(b"CIDToGIDMap".to_vec(), Object::name("Identity")); + cid.set(b"DW".to_vec(), Object::Int(500)); + cid.set( + b"W".to_vec(), + Object::Array(vec![Object::Int(32), Object::Array(vec![Object::Int(278)]), Object::Int(84), Object::Array(vec![Object::Int(611)])]), + ); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type0")); + font.set(b"BaseFont".to_vec(), Object::name("AAAAAA+LiberationSans")); + font.set(b"Encoding".to_vec(), Object::name("Identity-H")); + font.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(cid)])); + font.set(b"ToUnicode".to_vec(), Object::Ref(to_unicode)); + let font = doc.add(Object::Dict(font)); + let page = pdfcraft_model::pages(&doc)[0].clone(); + let mut resources = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + let mut fonts = doc.resolve(resources.get(b"Font").unwrap()).as_dict().unwrap().clone(); + fonts.set(b"F3".to_vec(), Object::Ref(font)); + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + let block = text::text_blocks(&doc, 0).unwrap().remove(0); + assert_eq!(block.editor_text, rows.join("\n")); + let before = text::text_lines(&doc, 0).unwrap(); + let edit = text::rewrite_block(&mut doc, 0, 0, Some(&format!("{} added", block.editor_text)), &text::BlockStyle::default()).unwrap(); + assert_eq!(edit.substituted, None, "the subset shows every letter of the new word"); + let reopened = reopen(&doc); + assert_eq!(text::text_blocks(&reopened, 0).unwrap()[0].editor_text, "This is the first line\nthe second\nand third added"); + let after = text::text_lines(&reopened, 0).unwrap(); + for (a, b) in after.iter().zip(&before).take(2) { + assert_eq!(a.text, b.text); + assert!(close(a.rect, b.rect), "untouched rows keep their place and width: {:?} {:?}", a.rect, b.rect); + } +} + /// An added item is written out as a specific standard-14 face (`/Times-Italic`, `/Helvetica-Bold` /// …), so it has to be *measured* with that same face. Measuring the upright face while writing /// the italic one mis-sizes every box: Times-Italic `A` is 611 against Times-Roman's 722. @@ -3443,3 +3983,269 @@ fn an_items_measured_face_is_the_face_it_is_written_as() { let courier = |italic| Family::Courier.width("Anna", 12.0, false, italic); assert_eq!(courier(false), courier(true)); } + +#[test] +fn page_marks_and_added_content_preserve_fractional_user_unit_matrices() { + for rotation in [0, 90] { + let mut doc = fixture(); + let page = pdfcraft_model::pages(&doc)[2].obj; + doc.update_dict(page, |d| { + let bounds = Object::Array([0.0, 0.0, 0.1, 0.1].into_iter().map(Object::Real).collect()); + d.set(b"MediaBox".to_vec(), bounds.clone()); + d.set(b"CropBox".to_vec(), bounds); + d.set(b"UserUnit".to_vec(), Object::Int(4096)); + d.set(b"Rotate".to_vec(), Object::Int(rotation)); + }) + .unwrap(); + let mut hf = HeaderFooter::default(); + hf.text[0] = "Matrix header".into(); + add_header_footer(&mut doc, &[2], &hf, false, &cx()).unwrap(); + let text = AddedText { rect: [72.0, 100.0, 200.0, 144.0], text: "Matrix text".into(), size: 12.0, ..AddedText::default() }; + add_content(&mut doc, 2, &Content::Text(text)).unwrap(); + let saved = reopen(&doc); + let contents = streams(&saved, 2); + for marker in ["/PCMark /HeaderFooter", "(Matrix text) Tj"] { + let matching: Vec<_> = contents.iter().filter(|s| s.contains(marker)).collect(); + assert_eq!(matching.len(), 1, "{contents:?}"); + let stream = matching[0]; + let parsed = pdfcraft_content::parse(stream.as_bytes()); + assert_eq!(parsed.skipped, 0); + let cm = parsed.ops.iter().find(|op| op.is("cm")).unwrap(); + assert_eq!(cm.operands.len(), 6); + assert!(!stream[cm.span.clone()].contains('e') && !stream[cm.span.clone()].contains('E'), "PDF numbers cannot use exponents"); + let matrix = cm.nums::<6>().unwrap(); + let expected = if rotation == 0 { + [0.000244140625, 0.0, 0.0, 0.000244140625, 0.0, 0.0] + } else { + [0.0, 0.000244140625, -0.000244140625, 0.0, 0.1, 0.0] + }; + assert_eq!(matrix, expected, "rotation={rotation}, stream={stream}"); + // A fixed physical display point must remain at its intended raw page position. + let raw = [matrix[0] * 72.0 + matrix[2] * 144.0 + matrix[4], matrix[1] * 72.0 + matrix[3] * 144.0 + matrix[5]]; + let expected_raw = if rotation == 0 { [0.017578125, 0.03515625] } else { [0.06484375, 0.017578125] }; + for (actual, expected) in raw.into_iter().zip(expected_raw) { + assert!((actual - expected).abs() < 1e-12); + } + } + } +} + +#[test] +fn rows_the_edit_does_not_reach_keep_their_source_operators() { + // Word gaps in TJ, as justified text from a layout program has; the text state alone + // can't reproduce them, so a rewritten row would lose them. + let rows = [ + "[(justified) -400 ( text) -400 ( uses) -400 ( wide) -400 ( gaps)] TJ", + "[(between) -400 ( every) -400 ( single) -400 ( word) -400 ( here)] TJ", + "(last line.) Tj", + ]; + let content = format!("BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm {} T* {} T* {} ET", rows[0], rows[1], rows[2]); + let row_texts = |doc: &Document| -> Vec<(String, [f64; 2])> { + text::text_lines(doc, 0).unwrap().into_iter().map(|l| (l.text.clone(), l.baseline())).collect() + }; + let source = row_texts(&text_page(&content)); + assert_eq!(source.len(), 3); + for (row, from, to) in [(2, "last line.", "last line!"), (0, "justified", "justify"), (1, "single", "lone")] { + let mut doc = text_page(&content); + let block = text::text_blocks(&doc, 0).unwrap().into_iter().position(|b| b.text.contains("justified")).unwrap(); + let draft = text::text_blocks(&doc, 0).unwrap()[block].editor_text.replacen(from, to, 1); + text::rewrite_block(&mut doc, 0, block, Some(&draft), &text::BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + let bytes = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + let after = row_texts(&doc); + assert_eq!(after.len(), 3, "{to}: {after:?}"); + for (i, ((text, at), (old, was))) in after.iter().zip(&source).enumerate() { + assert!((at[0] - was[0]).abs() < 0.01 && (at[1] - was[1]).abs() < 0.01, "{to}: row {i} stays at {was:?}, not {at:?}"); + if i == row { + assert_eq!(*text, old.replacen(from, to, 1), "{to}: the edited row"); + assert!(!bytes.contains(rows[i]), "{to}: the edited row is rewritten"); + } else { + assert_eq!(text, old, "{to}: row {i}"); + assert_eq!(bytes.matches(rows[i]).count(), 1, "{to}: row {i} keeps its own operator, kerning included:\n{bytes}"); + } + } + } + // Colouring a selection in the last row leaves the rows above it as they were. + let mut doc = text_page(&content); + let block = text::text_blocks(&doc, 0).unwrap().into_iter().position(|b| b.text.contains("justified")).unwrap(); + let draft = text::text_blocks(&doc, 0).unwrap()[block].editor_text.clone(); + let at = draft.find("line").unwrap(); + let range = text::RangeStyle { + range: at..at + 4, + family: None, + resource: None, + size: None, + color: Some([1.0, 0.0, 0.0]), + underline: None, + char_spacing: None, + scale: None, + }; + text::rewrite_block(&mut doc, 0, block, Some(&draft), &text::BlockStyle { ranges: vec![range], ..Default::default() }).unwrap(); + let bytes = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(rows[..2].iter().all(|row| bytes.matches(row).count() == 1) && !bytes.contains(rows[2]), "{bytes}"); + assert!(bytes.contains("1 0 0 rg"), "{bytes}"); + // The coloured word is its own run now, so only the rows above compare whole. + assert_eq!(row_texts(&reopen(&doc))[..2], source[..2]); +} + +#[test] +fn kept_rows_stay_in_place_when_rows_wrap_or_move_with_quote_operators() { + let lines_of = |doc: &Document| -> Vec<(String, [f64; 2])> { + text::text_lines(doc, 0).unwrap().into_iter().map(|l| (l.text.clone(), l.baseline())).collect() + }; + let edit = |content: &str, from: &str, to: &str| { + let mut doc = text_page(content); + let block = text::text_blocks(&doc, 0).unwrap().into_iter().position(|b| b.editor_text.contains(from)).unwrap(); + let draft = text::text_blocks(&doc, 0).unwrap()[block].editor_text.replacen(from, to, 1); + text::rewrite_block(&mut doc, 0, block, Some(&draft), &text::BlockStyle::default()).unwrap(); + reopen(&doc) + }; + // `'` moves to the next line as it shows: a row after a rewritten `'` row can't stay as it is. + for (quoted, from, to) in [ + ("BT /F1 12 Tf 14 TL 72 700 Td (first row here) Tj (second row here) ' (third row here) ' ET", "second", "2nd"), + ("BT /F1 12 Tf 14 TL 72 700 Td (first row here) Tj (second row here) ' (third row here) ' ET", "third", "3rd"), + // Here the first row's own `'` makes the step down that the rows after it build on. + ("BT /F1 12 Tf 14 TL 72 714 Td (first row here) ' (second row here) ' (third row here) ' ET", "first", "1st"), + ] { + let source = lines_of(&text_page(quoted)); + let after = lines_of(&edit(quoted, from, to)); + assert_eq!(after.len(), 3, "{to}: {after:?}"); + for ((text, at), (old, was)) in after.iter().zip(&source) { + assert_eq!(*text, old.replacen(from, to, 1)); + assert!((at[0] - was[0]).abs() < 0.01 && (at[1] - was[1]).abs() < 0.01, "{to}: {text} stays at {was:?}, not {at:?}"); + } + } + // A row that grows past the paragraph's width wraps and pushes the rows below it down; + // those are rewritten in their new places, never left behind as well. + let rows = "BT /F1 12 Tf 14.4 TL 72 700 Td (one two three four) Tj T* (five six seven eight) Tj T* (nine ten eleven twelve) Tj ET"; + let doc = edit(rows, "two", "two and a great many more words"); + let text: String = lines_of(&doc).into_iter().map(|(t, _)| t + " ").collect(); + for word in ["one", "five", "nine", "twelve"] { + assert_eq!(text.matches(word).count(), 1, "{word} appears once: {text}"); + } + assert!(lines_of(&doc).len() > 3, "the edited row wrapped: {text}"); +} + +/// A page whose `/F1` is Inter embedded as a simple WinAnsi TrueType font, under `base_font`. +fn embedded_program_page(content: &str, base_font: &str) -> Document { + let mut doc = text_page(content); + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), include_bytes!("../../../assets/fonts/Inter-Regular.ttf")))); + let mut descriptor = Dict::new(); + descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); + descriptor.set(b"FontName".to_vec(), Object::name(base_font)); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("TrueType")); + font.set(b"BaseFont".to_vec(), Object::name(base_font)); + font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + font.set(b"FirstChar".to_vec(), Object::Int(32)); + font.set(b"LastChar".to_vec(), Object::Int(126)); + font.set(b"Widths".to_vec(), Object::Array((32..=126).map(|_| Object::Int(560)).collect())); + font.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(font)); + reopen(&doc) +} + +/// The font programs and subtypes of the page's font resources. +fn page_font_programs(doc: &Document) -> Vec<(Vec, Option>)> { + let page = pdfcraft_model::pages(doc).remove(0); + let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let fonts = resources.get(b"Font").and_then(|f| doc.resolve(f).as_dict().cloned()).unwrap_or_default(); + fonts + .iter() + .filter_map(|(_, f)| doc.resolve(f).as_dict().cloned()) + .map(|f| (f.name(b"Subtype").unwrap_or_default().to_vec(), pdfcraft_fonts::pdf::embedded_sfnt(doc, &f))) + .collect() +} + +#[test] +fn a_new_character_comes_from_the_source_fonts_own_embedded_program() { + let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + for (content, from, to) in [ + // One row (the single-style path) and a row of a two-row paragraph (the row editor's path). + ("BT /F1 12 Tf 72 700 Td (Stable text) Tj ET", "text", "tőxt"), + ("BT /F1 12 Tf 14 TL 72 700 Td (First row) Tj T* (Second row) Tj ET", "Second", "Sőcond"), + ] { + let mut doc = embedded_program_page(content, "Inter-Regular"); + let block = text::text_blocks(&doc, 0).unwrap()[0].clone(); + let draft = block.editor_text.replacen(from, to, 1); + let edit = text::rewrite_block(&mut doc, 0, 0, Some(&draft), &text::BlockStyle::default()).unwrap(); + assert_eq!(edit.substituted, None, "{to}: no other family stands in"); + let doc = reopen(&doc); + // The new character is its own run in the other resource, read back into the same word. + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, draft, "{to}"); + let fonts = page_font_programs(&doc); + assert!(fonts.iter().all(|(subtype, _)| subtype != b"Type3"), "{to}: no fallback glyphs"); + assert!( + fonts.iter().any(|(subtype, program)| subtype == b"Type0" && program.as_deref() == Some(inter.as_slice())), + "{to}: the source program draws it" + ); + } +} + +#[test] +fn a_subset_program_is_not_trusted_for_new_characters_and_the_error_names_the_character() { + let mut doc = embedded_program_page("BT /F1 12 Tf 72 700 Td (Stable text) Tj ET", "ABCDEF+Inter-Regular"); + let before = page_content_bytes(&doc, 0); + let result = text::rewrite_block(&mut doc, 0, 0, Some("Stable tőxt"), &text::BlockStyle::default()); + if pdfcraft_fonts::document_japanese_font().is_some() { + eprintln!("built with craft-fonts: the fallback font draws the new character"); + assert!(result.is_ok(), "{result:?}"); + return; + } + let Err(EditError::Invalid(msg)) = result else { panic!("expected a clear error, got {result:?}") }; + assert!(msg.contains("ő (U+0151)") && msg.contains("Load font") && !msg.contains("Japanese"), "{msg}"); + assert_eq!(page_content_bytes(&doc, 0), before); +} + +#[test] +fn draft_layout_matches_the_paragraph_an_applied_edit_draws() { + let content = "BT /F1 12 Tf 14 TL 72 700 Td (A first paragraph of words) Tj T* (with a second row) Tj ET \ + BT /F1 12 Tf 72 600 Td (Another paragraph below) Tj ET BT /F1 12 Tf 320 700 Td (Side note) Tj ET"; + let doc = text_page(content); + let block = text::text_blocks(&doc, 0).unwrap().into_iter().position(|b| b.text.starts_with("A first")).unwrap(); + let source = text::text_blocks(&doc, 0).unwrap()[block].clone(); + for draft in [ + source.editor_text.replacen("words", "many more words than before", 1), + source.editor_text.replacen(" of words", "", 1), + format!("{}\nand a third row", source.editor_text), + ] { + let layout = text::text_edit_layout(&doc, 0, block, &draft, &text::BlockStyle::default()).unwrap(); + // Apply the same edit and measure every run that isn't one of the page's other runs. + let mut applied = doc.clone(); + text::rewrite_block(&mut applied, 0, block, Some(&draft), &text::BlockStyle::default()).unwrap(); + let others: Vec<_> = + text::text_lines(&doc, 0).unwrap().into_iter().enumerate().filter(|(i, _)| !source.lines.contains(i)).map(|(_, l)| l.rect).collect(); + let drawn = text::text_lines(&applied, 0) + .unwrap() + .into_iter() + .map(|l| l.rect) + .filter(|r| !others.contains(r)) + .reduce(|a, b| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]); + assert_eq!(layout.draft_rect, drawn, "{draft:?}"); + assert_eq!(layout.source_rect, source.rect); + } +} + +#[test] +fn japanese_paragraphs_wrap_between_characters_without_closing_marks_at_a_row_start() { + if pdfcraft_fonts::document_japanese_font().is_none() { + eprintln!("skipping the Japanese wrapping checks: built without craft-fonts (set CRAFT_FONTS_DIR)"); + return; + } + let text = "日本語の文章は単語の間に空白がないので、どの文字の間でも行を折り返せます。ただし「句読点」や小さい「っ」は行頭に来ません。"; + // One paragraph (the single-style path), and a heading row before it (the row editor's path). + for draft in [text.to_string(), format!("見出し\n{text}")] { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Placeholder text) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, Some(&draft), &text::BlockStyle { width: Some(120.0), ..Default::default() }).unwrap(); + let block = text::text_blocks(&reopen(&doc), 0).unwrap().remove(0); + let rows: Vec<&str> = block.editor_text.split('\n').collect(); + assert!(rows.len() > 4, "{rows:?}"); + assert_eq!(rows.concat(), draft.replace('\n', ""), "no space is added between the pieces"); + assert!(block.rect[2] - block.rect[0] <= 120.5, "{:?} {rows:?}", block.rect); + for row in &rows { + assert!(!row.starts_with(['、', '。', '」', 'っ']) && !row.ends_with('「'), "{row:?} in {rows:?}"); + } + } +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 13233f71d..9c83cf9a3 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -31,6 +31,10 @@ pub struct TextLine { pub text: String, /// Its box in user space. pub rect: [f64; 4], + /// The boxes of the words it draws, in user space: from each word's first glyph to the end of + /// its last, without the spaces and `TJ` adjustments around it. `rect` also spans gaps that + /// draw nothing, such as the adjustment an applied redaction leaves where text was removed. + pub ink: Vec<[f64; 4]>, /// The font's resource name and `/BaseFont` (or Type 3 descriptor's `/FontName`), and its size in text space. pub font: String, pub base_font: String, @@ -140,7 +144,10 @@ pub struct TextEditLayout { } pub fn text_edit_layout(doc: &Document, page: usize, block: usize, text: &str, style: &BlockStyle) -> Result { - let original = text_blocks(doc, page)?; + // This runs on every keystroke: read the page once for the paragraphs, the untouched runs + // and the rewrite, which starts from the same unchanged page. + let lines = text_lines(doc, page)?; + let original = group_blocks(&lines); let source = original.get(block).ok_or(EditError::Invalid("text block does not exist".into()))?; if text == source.editor_text && *style == BlockStyle::default() { return Ok(TextEditLayout { source_rect: source.rect, draft_rect: Some(source.rect), overflow: false }); @@ -150,13 +157,13 @@ pub fn text_edit_layout(doc: &Document, page: usize, block: usize, text: &str, s let key = |line: &TextLine| (line.text.clone(), line.font.clone(), line.rect.map(f64::to_bits), line.size.to_bits(), line.color.map(f64::to_bits)); let mut untouched = std::collections::BTreeMap::new(); - for (index, line) in text_lines(doc, page)?.iter().enumerate() { + for (index, line) in lines.iter().enumerate() { if !source.lines.contains(&index) { *untouched.entry(key(line)).or_insert(0_usize) += 1; } } let mut draft = doc.clone(); - rewrite_block(&mut draft, page, block, Some(text), style)?; + rewrite_block_from(&mut draft, page, lines, block, Some(text), style, MAX_TYPE3_GLYPHS)?; let mut rect: Option<[f64; 4]> = None; for line in text_lines(&draft, page)? { if let Some(count) = untouched.get_mut(&key(&line)) @@ -263,17 +270,17 @@ pub struct TextBlock { } impl TextBlock { - /// Preserve styles outside the changed range; inserted characters inherit the surrounding - /// source run. Byte ranges are always UTF-8 boundaries. Linear time, including large edits. + /// Preserve styles outside the changed ranges; inserted characters inherit the surrounding + /// source run. The texts are aligned word by word, so words left untouched between two edits + /// keep their own runs (#2198); past [`MAX_CHANGED_WORDS`] the draft counts as one change. + /// Byte ranges are always UTF-8 boundaries. Linear time, including large edits. pub fn edited_spans(&self, draft: &str) -> Vec<(std::ops::Range, usize)> { if self.editor_text.len() > 4_000_000 || draft.len() > 4_000_000 { return vec![(0..draft.len(), self.lines.first().copied().unwrap_or(0))]; } let old: Vec<_> = self.editor_text.chars().collect(); let new: Vec<_> = draft.char_indices().collect(); - let prefix = old.iter().zip(&new).take_while(|(a, (_, b))| *a == b).count(); - let suffix = - old.iter().rev().zip(new.iter().rev()).take(old.len().min(new.len()).saturating_sub(prefix)).take_while(|(a, (_, b))| *a == b).count(); + let chars: Vec<_> = new.iter().map(|(_, c)| *c).collect(); let mut original = Vec::with_capacity(old.len()); let mut span = 0; for (byte, _) in self.editor_text.char_indices() { @@ -282,17 +289,36 @@ impl TextBlock { } original.push(self.spans.get(span).or_else(|| self.spans.last()).map_or(0, |(_, i)| *i)); } - let inherited = original.get(prefix.saturating_sub(1)).or_else(|| original.get(prefix)).copied().unwrap_or(0); + let mut sources = Vec::with_capacity(new.len()); + let slices = |text: &[char]| words(text).into_iter().map(|w| text.get(w).unwrap_or_default().to_vec()).collect::>(); + let (old_words, new_words) = (slices(&old), slices(&chars)); + match edit_script(&old_words, &new_words, MAX_CHANGED_WORDS) { + Some(script) => { + // `(o, n)`: where the current change starts in the old and new text; `(x, y)`: where it ends. + let ((mut o, mut n), (mut x, mut y)) = ((0, 0), (0, 0)); + let (mut a, mut b) = (old_words.iter(), new_words.iter()); + for step in script { + match step { + Step::Keep => { + replaced(&original, o, old.get(o..x).unwrap_or_default(), chars.get(n..y).unwrap_or_default(), &mut sources); + let len = a.next().map_or(0, Vec::len); + b.next(); + sources.extend(original.get(x..x.saturating_add(len)).unwrap_or_default()); + (x, y) = (x.saturating_add(len), y.saturating_add(len)); + (o, n) = (x, y); + } + Step::Delete => x = x.saturating_add(a.next().map_or(0, Vec::len)), + Step::Insert => y = y.saturating_add(b.next().map_or(0, Vec::len)), + } + } + replaced(&original, o, old.get(o..x).unwrap_or_default(), chars.get(n..y).unwrap_or_default(), &mut sources); + } + None => replaced(&original, 0, &old, &chars, &mut sources), + } + let fallback = original.first().copied().unwrap_or(0); let mut result: Vec<(std::ops::Range, usize)> = Vec::new(); for (i, (byte, ch)) in new.iter().enumerate() { - let source = if i < prefix { - original.get(i).copied() - } else if i >= new.len().saturating_sub(suffix) { - original.get(old.len().saturating_sub(new.len() - i)).copied() - } else { - Some(inherited) - } - .unwrap_or(inherited); + let source = sources.get(i).copied().unwrap_or(fallback); let end = byte.saturating_add(ch.len_utf8()); if let Some((range, _)) = result.last_mut().filter(|(_, prev)| *prev == source) { range.end = end; @@ -304,6 +330,108 @@ impl TextBlock { } } +/// Changed words [`TextBlock::edited_spans`] aligns before it treats the draft as one change. +/// Myers' diff costs O((N + M) · D), so bounding D keeps it linear. +const MAX_CHANGED_WORDS: usize = 64; + +/// `text` split into words, each with the whitespace after it, as char ranges. +fn words(text: &[char]) -> Vec> { + let mut out = Vec::new(); + let mut start = 0; + for (i, pair) in text.windows(2).enumerate() { + if let [a, b] = pair + && a.is_whitespace() + && !b.is_whitespace() + { + out.push(start..i + 1); + start = i + 1; + } + } + if start < text.len() { + out.push(start..text.len()); + } + out +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Step { + Keep, + Delete, + Insert, +} + +/// Myers' shortest edit script from `a` to `b`, or `None` when it takes more than `max` steps. +fn edit_script(a: &[T], b: &[T], max: usize) -> Option> { + let (n, m) = (a.len() as isize, b.len() as isize); + let max = max.min(a.len().saturating_add(b.len())) as isize; + // Furthest x reached on each diagonal k = x - y, stored at k + max + 1. + let at = |v: &[isize], k: isize| usize::try_from(k + max + 1).ok().and_then(|i| v.get(i).copied()).unwrap_or(0); + let mut v = vec![0isize; (2 * max + 3) as usize]; + let mut trace = Vec::new(); + for d in 0..=max { + trace.push(v.clone()); + for k in (-d..=d).step_by(2) { + let down = k == -d || (k != d && at(&v, k - 1) < at(&v, k + 1)); + let mut x = if down { at(&v, k + 1) } else { at(&v, k - 1) + 1 }; + let mut y = x - k; + while x < n && y < m && a.get(x as usize) == b.get(y as usize) { + x += 1; + y += 1; + } + if let Some(slot) = usize::try_from(k + max + 1).ok().and_then(|i| v.get_mut(i)) { + *slot = x; + } + if x >= n && y >= m { + return Some(backtrack(&trace, n, m, max)); + } + } + } + None +} + +/// Walk Myers' saved frontiers back from the end to the edit script. +fn backtrack(trace: &[Vec], n: isize, m: isize, max: isize) -> Vec { + let at = |v: &[isize], k: isize| usize::try_from(k + max + 1).ok().and_then(|i| v.get(i).copied()).unwrap_or(0); + let mut steps = Vec::new(); + let (mut x, mut y) = (n, m); + for (d, v) in trace.iter().enumerate().rev() { + let (d, k) = (d as isize, x - y); + let prev_k = if k == -d || (k != d && at(v, k - 1) < at(v, k + 1)) { k + 1 } else { k - 1 }; + let prev_x = at(v, prev_k); + let prev_y = prev_x - prev_k; + while x > prev_x && y > prev_y { + steps.push(Step::Keep); + x -= 1; + y -= 1; + } + if d > 0 { + steps.push(if x == prev_x { Step::Insert } else { Step::Delete }); + } + (x, y) = (prev_x, prev_y); + } + steps.reverse(); + steps +} + +/// `old`, at char `at` of the source, became `new`: characters kept at either end keep their +/// runs, and the rest inherit the run just before the change (or the first one it replaces). +fn replaced(original: &[usize], at: usize, old: &[char], new: &[char], out: &mut Vec) { + let prefix = old.iter().zip(new).take_while(|(a, b)| a == b).count(); + let suffix = old.iter().rev().zip(new.iter().rev()).take(old.len().min(new.len()).saturating_sub(prefix)).take_while(|(a, b)| a == b).count(); + let start = at.saturating_add(prefix); + let inherited = start.checked_sub(1).and_then(|i| original.get(i)).or_else(|| original.get(start)).copied().unwrap_or(0); + for i in 0..new.len() { + let source = if i < prefix { + original.get(at.saturating_add(i)).copied() + } else if i >= new.len().saturating_sub(suffix) { + original.get(at.saturating_add(old.len()).saturating_sub(new.len() - i)).copied() + } else { + Some(inherited) + }; + out.push(source.unwrap_or(inherited)); + } +} + impl TextLine { /// The baseline's height in user space. pub fn origin_baseline(&self) -> f64 { @@ -322,6 +450,47 @@ impl TextLine { let m = Matrix(self.origin.tm).then(&Matrix(self.origin.ctm)).0; m[1].atan2(m[0]).to_degrees() } + + /// The unit direction its baseline runs in, in user space. + fn direction(&self) -> (f64, f64) { + direction(&Matrix(self.origin.tm).then(&Matrix(self.origin.ctm))) + } + + /// Whether it reads left to right along the page's x axis (most text; a page drawn + /// turned a quarter, as landscape pages often are, isn't). + pub(crate) fn upright(&self) -> bool { + let (x, y) = self.direction(); + (x - 1.0).abs() < 1e-3 && y.abs() < 1e-3 + } + + /// Where it starts as `(along, across)` its baseline: user space turned to the text's direction. + fn frame_origin(&self) -> (f64, f64) { + to_frame(self.direction(), (self.origin.x, self.origin.baseline)) + } + + /// Its box in the same frame, `[along0, across0, along1, across1]` (for upright text, `rect`). + fn frame_rect(&self) -> [f64; 4] { + let dir = self.direction(); + let r = self.rect; + [(r[0], r[1]), (r[2], r[1]), (r[0], r[3]), (r[2], r[3])] + .into_iter() + .map(|p| to_frame(dir, p)) + .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]) + } +} + +/// The unit x axis of a text rendering matrix in user space (`(1, 0)` when it is degenerate). +fn direction(m: &Matrix) -> (f64, f64) { + let (x, y) = (m.0[0], m.0[1]); + let length = x.hypot(y); + if length.is_finite() && length > 1e-9 { (x / length, y / length) } else { (1.0, 0.0) } +} + +/// User-space point `p` as `(along, across)` a baseline running in unit direction `dir`. Upright +/// text (`dir` = `(1, 0)`) keeps its coordinates, so lines and rows of turned text are found the +/// same way (#1443). +fn to_frame(dir: (f64, f64), p: (f64, f64)) -> (f64, f64) { + (p.0 * dir.0 + p.1 * dir.1, p.1 * dir.0 - p.0 * dir.1) } /// What replacing a line did. @@ -470,9 +639,12 @@ struct Shown { bt_state: Ts, text: String, rect: [f64; 4], + /// The boxes of its words (see [`TextLine::ink`]). + ink: Vec<[f64; 4]>, baseline: f64, start_x: f64, end_x: f64, + end_y: f64, bt: usize, font: Vec, base_font: String, @@ -487,6 +659,43 @@ struct Shown { actual_text: bool, } +impl Shown { + /// The unit direction of its baseline, where it starts and ends along it, and the baseline's + /// offset across it (for upright text: the user-space x, x and y). + fn span(&self) -> ((f64, f64), f64, f64, f64) { + let dir = direction(&self.tm.then(&self.state.ctm)); + let (start, across) = to_frame(dir, (self.start_x, self.baseline)); + (dir, start, to_frame(dir, (self.end_x, self.end_y)).0, across) + } +} + +/// Where the previous run of a line ended: its text object, baseline direction, offset across +/// the baseline, end along it and size. +#[derive(Clone, Copy)] +struct Pen { + bt: usize, + dir: (f64, f64), + across: f64, + end: f64, + size: f64, +} + +impl Pen { + fn after(s: &Shown) -> Self { + let (dir, _, end, across) = s.span(); + Pen { bt: s.bt, dir, across, end, size: s.size.max(1.0) } + } + + /// Whether `s` could continue the same line on geometry alone: on the same baseline, starting + /// at most a word gap (`reach` sizes) after it. Also the gap along the baseline. + fn continues(&self, s: &Shown, reach: f64) -> (bool, f64) { + let (dir, start, _, across) = s.span(); + let gap = start - self.end; + let same_dir = (dir.0 - self.dir.0).abs() < 1e-3 && (dir.1 - self.dir.1).abs() < 1e-3; + (same_dir && (across - self.across).abs() < self.size * 0.3 && gap > -self.size && gap < self.size * reach, gap) + } +} + /// The graphics state carried from one of a page's content streams to the next: the streams /// are one stream in pieces (§7.8.2), so a `cm` (AutoCAD scales the whole page in the first /// stream), an unbalanced `q`, the font and the colour still apply in the streams after it. @@ -564,6 +773,7 @@ fn interpret( first.rect[2].max(shown.rect[2]), first.rect[3].max(shown.rect[3]), ]; + first.ink.extend(shown.ink); } first.extra_ops.extend([open, i]); // Only right-to-left ActualText is already in typing order and must stay @@ -574,6 +784,7 @@ fn interpret( first.text = logical; first.start_x = first.rect[0]; first.end_x = first.rect[2]; + first.end_y = first.baseline; first.advance = (first.end_x - first.start_x) / first.tm.then(&first.state.ctm).0[0].abs().max(1e-6); out.push(first); } @@ -664,16 +875,34 @@ fn interpret( let mut text = String::new(); let mut decodable = true; let mut x_text = 0.0; + // Where each word's glyphs start and end along the run (text space), and whether + // the next glyph continues the last word. + let mut words: Vec<(f64, f64)> = Vec::new(); + let mut in_word = false; let mut glyph_count = 0_usize; for p in &pieces { match p { Object::String(s) => { for (code, len) in m.codes(&s.bytes) { glyph_count = glyph_count.saturating_add(1); - match m.text_of(code) { + let shown = m.text_of(code); + let blank = + m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace)); + match shown { Some(t) => text.push_str(&t), None => decodable = false, } + if !blank { + let ink_end = x_text + m.width(code) * ts.size * ts.scale; + // A negative font size or `Tz` runs the glyphs backwards: + // the word is the union of its glyphs, whichever way they go. + let (lo, hi) = (x_text.min(ink_end), x_text.max(ink_end)); + match words.last_mut() { + Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)), + _ => words.push((lo, hi)), + } + } + in_word = !blank; let w = m.width(code) * ts.size + ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 }; x_text += w * ts.scale; } @@ -685,6 +914,11 @@ fn interpret( if dx > ts.size * 0.2 && !text.ends_with(' ') { text.push(' '); } + // Kerning keeps a word together; a jump either way of more + // than 0.2 em ends it (whatever the sign of the size or `Tz`). + if dx.abs() > (ts.size * ts.scale).abs() * 0.2 { + in_word = false; + } x_text += dx; } } @@ -702,6 +936,15 @@ fn interpret( let rect = corners .iter() .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]); + let ink = words + .iter() + .map(|&(a, b)| { + let (y0, y1) = (ts.rise + m.descent * ts.size, ts.rise + m.ascent * ts.size); + [trm0.apply(a, y0), trm0.apply(b, y0), trm0.apply(a, y1), trm0.apply(b, y1)] + .iter() + .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p.0), r[1].min(p.1), r[2].max(p.0), r[3].max(p.1)]) + }) + .collect(); let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size; // A numeric-only TJ adjusts the current text matrix but paints no glyphs. // It must not create a phantom editable line or enlarge its neighbours' boxes. @@ -720,9 +963,11 @@ fn interpret( bt_state: at_bt.1.clone(), text, rect, + ink, baseline: start.1, start_x: start.0, end_x: end.0, + end_y: end.1, bt, font: name, base_font: m.base_font.clone(), @@ -759,7 +1004,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro let pieces = Pieces::join(&content_streams(doc, &p.dict).into_iter().map(|(_, d)| d).collect::>()); let ops = pieces.parse().ops; let shown = interpret(doc, &ops, &fonts_res, &mut cache, &mut carry, None, true); - let mut last: Option<(usize, usize, f64, f64, f64)> = None; // (piece, bt, baseline, end_x, size) + let mut last: Option<(usize, Pen)> = None; // the stream piece and where its last run ended for mut s in shown { // A single RTL glyph's ActualText (e.g. lam-alef) is an inline ligature, not a // separate logical line. Put its letters into visual order before joining the @@ -772,7 +1017,9 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro // A line belongs to one stream piece. Within it, PDF producers often put every word // in its own BT/ET: geometry and text state, not object boundaries, define a line. let si = ops.get(s.op).map_or(0, |op| pieces.pieces_of(op).0); - let joins = last.is_some_and(|(piece, bt, base, end, size)| { + let joins = last.is_some_and(|(piece, pen)| { + let bt = pen.bt; + let (near, gap) = pen.continues(&s, if bt == s.bt { 3.0 } else { 1.5 }); piece == si && lines.last().is_some_and(|l| l.scope == s.scope && l.logical_order == s.logical_order) && (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s))) @@ -781,20 +1028,19 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro || lines.last().is_some_and(|l| { (s.size - l.size).abs() < 0.01 && s.bold == l.bold && s.italic == l.italic && fill_color(&s.state.fill) == l.color })) - && (s.baseline - base).abs() < size * 0.3 - && s.start_x > end - size - && s.start_x - end < size * if bt == s.bt { 3.0 } else { 1.5 } + && near // An OCR layer is one text object over the whole scan: its words continue a line // only across a word space, not across a table cell or a tab stop. - && (s.state.render_mode != 3 || s.start_x - end < size * 0.6) + && (s.state.render_mode != 3 || gap < pen.size * 0.6) }); if joins && let Some(l) = lines.last_mut() { - let gap = s.start_x - last.map_or(s.start_x, |x| x.3); + let gap = last.map_or(0.0, |(_, pen)| pen.continues(&s, 0.0).1); if gap > l.size * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') { l.text.push(' '); } l.text.push_str(&s.text); l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])]; + l.ink.extend(s.ink.iter().copied()); l.ops.push(s.op); l.ops.extend(s.extra_ops.iter().copied()); l.decodable &= s.decodable; @@ -805,6 +1051,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro lines.push(TextLine { text: s.text.clone(), rect: s.rect, + ink: s.ink.clone(), font: String::from_utf8_lossy(&s.font).into_owned(), base_font: s.base_font.clone(), size: s.size, @@ -832,7 +1079,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro }, }); } - last = Some((si, s.bt, s.baseline, s.end_x, s.size.max(1.0))); + last = Some((si, Pen::after(&s))); } // Content streams draw right-to-left lines in visual order; the editor works in typing order. for l in lines.iter_mut().filter(|l| !l.logical_order) { @@ -862,8 +1109,9 @@ fn compatible_word(line: &TextLine, shown: &Shown) -> bool { /// The line `s` continues, or a new one: `last` is the previous run of the same stream /// (`bt`, baseline, end x, size). -fn add_shown(lines: &mut Vec, last: &mut Option<(usize, f64, f64, f64)>, s: Shown, stream: usize) { - let joins = last.is_some_and(|(bt, base, end, size)| { +fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream: usize) { + let joins = last.is_some_and(|pen| { + let bt = pen.bt; lines.last().is_some_and(|l| l.scope == s.scope && l.logical_order == s.logical_order) && (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s))) && (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, s.face.as_deref()))) @@ -871,17 +1119,16 @@ fn add_shown(lines: &mut Vec, last: &mut Option<(usize, f64, f64, f64) || lines.last().is_some_and(|l| { (s.size - l.size).abs() < 0.01 && s.bold == l.bold && s.italic == l.italic && fill_color(&s.state.fill) == l.color })) - && (s.baseline - base).abs() < size * 0.3 - && s.start_x > end - size - && s.start_x - end < size * if bt == s.bt { 3.0 } else { 1.5 } + && pen.continues(&s, if bt == s.bt { 3.0 } else { 1.5 }).0 }); if joins && let Some(l) = lines.last_mut() { - let gap = s.start_x - last.map_or(s.start_x, |x| x.2); + let gap = last.map_or(0.0, |pen| pen.continues(&s, 0.0).1); if gap > l.size * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') { l.text.push(' '); } l.text.push_str(&s.text); l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])]; + l.ink.extend(s.ink.iter().copied()); l.ops.push(s.op); l.ops.extend(s.extra_ops.iter().copied()); l.decodable &= s.decodable; @@ -891,6 +1138,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option<(usize, f64, f64, f64) lines.push(TextLine { text: s.text.clone(), rect: s.rect, + ink: s.ink.clone(), font: String::from_utf8_lossy(&s.font).into_owned(), base_font: s.base_font.clone(), size: s.size, @@ -918,7 +1166,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option<(usize, f64, f64, f64) }, }); } - *last = Some((s.bt, s.baseline, s.end_x, s.size.max(1.0))); + *last = Some(Pen::after(&s)); } /// How deep form XObjects nest before reading stops (they may also refer to themselves). @@ -1166,6 +1414,22 @@ fn unicode_hex(ch: char) -> String { encoded.iter().map(|u| format!("{u:04X}")).collect() } +/// No font this build has can draw `text`. Only Japanese text is about the Japanese face: other +/// characters are missing from the paragraph's font, and choosing a font that has them works too. +fn no_fallback_font(text: &str) -> EditError { + let japanese = |c: char| matches!(u32::from(c), 0x3000..=0x30FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF); + if text.chars().any(japanese) { + return no_japanese_font(); + } + let Some(ch) = text.chars().find(|c| !c.is_whitespace() && !is_win_ansi_char(*c)) else { return no_japanese_font() }; + EditError::Invalid(format!( + "the paragraph's font has no glyph for {ch} (U+{:04X}) and this build has no fallback font for it; \ + choose a font that has it (Load font… or Installed fonts…), or use an official release, which includes \ + PdfCraft's fallback fonts (to build them in, set CRAFT_FONTS_DIR to a craft-fonts checkout)", + u32::from(ch) + )) +} + /// This build has no Japanese face to draw replacement text with. fn no_japanese_font() -> EditError { EditError::Invalid( @@ -1230,7 +1494,7 @@ fn type3_font( // character. When none has them all, the best face reports the character it lacks. Hebrew // text keeps the Hebrew face and takes each character it lacks from the next face that has it. let face = if hebrew { faces.first().copied() } else { fallback_face(&faces, &chars, |face, ch| japanese_glyph_from(face, ch).is_ok()) } - .ok_or_else(no_japanese_font)?; + .ok_or_else(|| no_fallback_font(text))?; let others: &[&CraftFont] = if hebrew { faces.get(1..).unwrap_or_default() } else { &[] }; // Every outline is read before the document is touched, so a character that fails leaves it // as it was. Each font takes the next `per_font` characters; codes restart at 1 in each. @@ -1338,6 +1602,28 @@ fn type3_encode(fallback: &Type3Fallback, text: &str) -> Option Option> { + let font = fonts.get(name.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned())?; + let base = Metrics::from_dict(doc, &font).base_font; + if base.split_once('+').is_some_and(|(tag, _)| tag.len() == 6 && tag.bytes().all(|b| b.is_ascii_uppercase())) { + return None; + } + let bytes = pdfcraft_fonts::pdf::embedded_sfnt(doc, &font)?; + pdfcraft_fonts::embedding::validate(&bytes).is_ok().then_some(bytes) +} + +/// Whether `text` has characters `line`'s font can't encode that its own program can draw. +fn source_program_completes(doc: &Document, fonts: &Dict, line: &TextLine, text: &str) -> bool { + let Some(metrics) = fonts.get(line.font.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|f| Metrics::from_dict(doc, &f)) else { + return false; + }; + let missing: String = text.chars().filter(|c| !c.is_whitespace() && !source_encodes(&metrics, &c.to_string())).collect(); + !missing.is_empty() && source_program(doc, fonts, &line.font).is_some_and(|bytes| pdfcraft_fonts::embedding::covers(&bytes, &missing)) +} + /// A subset may contain all the letters but no space glyph: producers use another subset /// or a positioning operator for spaces. Do the same instead of substituting the whole font. fn source_encodes(metrics: &Metrics, text: &str) -> bool { @@ -1611,21 +1897,32 @@ fn paragraph_separator(c: char) -> bool { c.is_whitespace() && c != '\u{3000}' } -/// Greedy word wrapping to `width` with `advance` giving a string's width. +/// Greedy word wrapping to `width` with `advance` giving a string's width. Each `\n` starts a +/// new line. A row may also end inside a word of Chinese or Japanese text ([`crate::linebreak`]); +/// those pieces join without a space. fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec { let mut out = Vec::new(); - let mut line = String::new(); - for word in text.split(paragraph_separator).filter(|word| !word.is_empty()) { - let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; - if !line.is_empty() && advance(&candidate) > width { - out.push(std::mem::take(&mut line)); - line = word.to_string(); - } else { - line = candidate; + for row in text.split('\n') { + let mut line = String::new(); + let start = out.len(); + for word in row.split(paragraph_separator).filter(|word| !word.is_empty()) { + for (i, piece) in crate::linebreak::pieces(word).into_iter().enumerate() { + let candidate = match (line.is_empty(), i) { + (true, _) => piece.to_string(), + (false, 0) => format!("{line} {piece}"), + (false, _) => format!("{line}{piece}"), + }; + if !line.is_empty() && advance(&candidate) > width { + out.push(std::mem::take(&mut line)); + line = piece.to_string(); + } else { + line = candidate; + } + } + } + if !line.is_empty() || out.len() == start { + out.push(line); } - } - if !line.is_empty() || out.is_empty() { - out.push(line); } out } @@ -1696,6 +1993,19 @@ pub(crate) fn rewrite_block_with_limit( max_glyphs: usize, ) -> Result { let lines = text_lines(doc, page)?; + rewrite_block_from(doc, page, lines, block, text, style, max_glyphs) +} + +/// [`rewrite_block_with_limit`] with the page's [`text_lines`], read from `doc` as it is now. +fn rewrite_block_from( + doc: &mut Document, + page: usize, + lines: Vec, + block: usize, + text: Option<&str>, + style: &BlockStyle, + max_glyphs: usize, +) -> Result { let blocks = group_blocks(&lines); let b = blocks.get(block).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no paragraph {}", page + 1, block + 1)))?; let members: Vec<&TextLine> = b.lines.iter().map(|i| &lines[*i]).collect(); @@ -1712,8 +2022,15 @@ pub(crate) fn rewrite_block_with_limit( "formatting part of right-to-left (Hebrew) text, or choosing a document font for it, isn't supported yet".into(), )); } + // Text turned on the page (a landscape page drawn a quarter turn round) is rewritten in its + // own text space below; the styled region path lays rows out along the page's x axis. + let upright = members.iter().all(|line| line.upright()); + if !upright && (!style.ranges.is_empty() || style.resource.is_some()) { + return Err(EditError::Invalid("formatting part of turned text, or choosing a document font for it, isn't supported yet".into())); + } let visual_indic = members.iter().any(|line| line.visual_order); - if !hebrew + if upright + && !hebrew && !visual_indic && (!style.ranges.is_empty() || style.resource.is_some() @@ -1722,6 +2039,7 @@ pub(crate) fn rewrite_block_with_limit( || pdfcraft_fonts::source_shaping::required(draft) || !pdfcraft_fonts::embedding::matching_faces(&b.base_font).is_empty() }) || registered_face + || text.is_some_and(|draft| source_program_completes(doc, &fonts_res, first, draft)) || b.rows.iter().any(|row| row.len() > 1) || members.iter().any(|line| line.font != first.font || line.size != first.size || line.color != first.color)) && style.family.is_none() @@ -1732,7 +2050,15 @@ pub(crate) fn rewrite_block_with_limit( let streams = content_streams(doc, &p.dict); let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; - let text = text.unwrap_or(&b.text).split(paragraph_separator).filter(|word| !word.is_empty()).collect::>().join(" "); + // Explicit line breaks stay (#1443); within a line, runs of white space become one space. + let text = text + .unwrap_or(&b.text) + .split('\n') + .map(|row| row.split(paragraph_separator).filter(|word| !word.is_empty()).collect::>().join(" ")) + .collect::>() + .join("\n"); + // The characters to show, for choosing fonts: the breaks are moves, not glyphs. + let flat = text.replace('\n', " "); let o = &first.origin; let (font_name, old_size) = o.state.font.clone().ok_or_else(|| EditError::Invalid("the paragraph has no font".into()))?; let k = o.k.max(1e-6); @@ -1749,7 +2075,7 @@ pub(crate) fn rewrite_block_with_limit( let o_state = ts_state.clone(); let metrics = fonts_res.get(font_name.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|d| Metrics::from_dict(doc, &d)); let visual_indic = metrics.as_ref().is_some_and(|m| m.visual_order) || first.visual_order; - let indic_text = if visual_indic { crate::indic::to_visual(&text) } else { text.clone() }; + let indic_text = if visual_indic { crate::indic::to_visual(&flat) } else { flat.clone() }; let reuse = style.family.is_none() && style.bold.is_none() && metrics.as_ref().is_some_and(|m| source_encodes(m, &indic_text)); let space = metrics.as_ref().map_or(0.33, |m| source_space(doc, &fonts_res, m)); // The chosen style also determines the real Japanese fallback outlines and advances. @@ -1764,14 +2090,22 @@ pub(crate) fn rewrite_block_with_limit( } // Right-to-left text (more Hebrew or Arabic letters than Latin) is right-aligned unless an // alignment is asked for, and a single right-to-left line keeps its right edge as it grows. - let rtl = crate::bidi::rtl_base(&text); - let anchored_right = rtl && style.width.is_none() && members.len() == 1; + let rtl = crate::bidi::rtl_base(&flat); + let anchored_right = rtl && upright && style.width.is_none() && members.len() == 1; + // The paragraph's width along its lines: in user space unless it is turned. + let frame = if upright { + b.rect + } else { + members.iter().map(|l| l.frame_rect()).reduce(|a, f| [a[0].min(f[0]), a[1].min(f[1]), a[2].max(f[2]), a[3].max(f[3])]).unwrap_or(b.rect) + }; // A paragraph keeps its width (or takes the one asked for); a single line grows to the right // (to the left when right-to-left), up to the page's margin. let width = match style.width { // Capped at the largest page PDF allows (14 400 pt). Some(w) if w.is_finite() => w.clamp(size * k, 14_400.0), Some(_) => return Err(EditError::Invalid("the paragraph's width must be a number".into())), + // Turned text: the page's margins are along another axis. A single line grows (below). + None if !upright => (frame[2] - frame[0]).max(size * k), None if anchored_right => (b.rect[2] - b.rect[0]).max(size * k).max(b.rect[2] + dx - (p.crop(doc)[0] + 36.0)), None if members.len() == 1 => (b.rect[2] - b.rect[0]).max(size * k).max(p.crop(doc)[2] - 36.0 - (b.rect[0] + dx)), None => (b.rect[2] - b.rect[0]).max(size * k), @@ -1794,7 +2128,7 @@ pub(crate) fn rewrite_block_with_limit( } else { None }; - let type3 = if !reuse && needs_type3(&text) { Some(type3_font(doc, &mut fonts_res, &text, family, bold, max_glyphs)?) } else { None }; + let type3 = if !reuse && needs_type3(&flat) { Some(type3_font(doc, &mut fonts_res, &flat, family, bold, max_glyphs)?) } else { None }; let advance = |s: &str| -> f64 { let t = match (&metrics, reuse) { (Some(m), true) => { @@ -1815,6 +2149,13 @@ pub(crate) fn rewrite_block_with_limit( }; t * o_state.scale * k }; + // A turned single line grows to fit, as far as the largest page PDF allows. + let width = if !upright && style.width.is_none() && members.len() == 1 { + text.split('\n').map(&advance).fold(width, f64::max).min(14_400.0) + } else { + width + }; + let right_edge = if anchored_right { right_edge } else { width }; let wrapped = wrap(&text, width + 0.5, advance); let mut substituted = None; let new_font = !reuse; @@ -1826,11 +2167,11 @@ pub(crate) fn rewrite_block_with_limit( let first = fallback.names.first().cloned().ok_or_else(no_japanese_font)?; (first, Box::new(move |s: &str| type3_encode(&fallback, s))) } else { - let win = pdfcraft_fonts::win_ansi(&text); + let win = pdfcraft_fonts::win_ansi(&flat); let back: String = win.iter().map(|c| char::from_u32(u32::from(*c)).unwrap_or('?')).collect(); let base = family.base_font(bold, italic); - if text.chars().zip(back.chars()).any(|(a, c)| c == '?' && a != '?') { - return Err(EditError::Invalid(format!("\"{text}\" has characters neither {} nor {base} can show", b.base_font))); + if flat.chars().zip(back.chars()).any(|(a, c)| c == '?' && a != '?') { + return Err(EditError::Invalid(format!("\"{flat}\" has characters neither {} nor {base} can show", b.base_font))); } let mut f = Dict::new(); f.set(b"Type".to_vec(), Object::name("Font")); @@ -1848,7 +2189,16 @@ pub(crate) fn rewrite_block_with_limit( // Line spacing in text space: the paragraph's own (scaled with the size), or 1.2 × the size. let lead = match style.line_spacing { Some(m) => size * m.clamp(0.5, 5.0), - None if members.len() > 1 => (members[0].origin.baseline - members[1].origin.baseline) / k * size / old_size, + // From the first two rows (a row may hold several runs). + None if let Some((upper, lower)) = b + .rows + .first() + .zip(b.rows.get(1)) + .and_then(|(upper, lower)| upper.first().zip(lower.first())) + .and_then(|(upper, lower)| lines.get(*upper).zip(lines.get(*lower))) => + { + (upper.frame_origin().1 - lower.frame_origin().1) / k * size / old_size + } None if o_state.leading > 0.0 => o_state.leading, None => size * 1.2, }; @@ -1884,9 +2234,15 @@ pub(crate) fn rewrite_block_with_limit( _ => 0.0, } }; - // Justify: word spacing (single-byte code 32 only) so each line but the last fills the width. - let single_byte = !reuse || metrics.as_ref().is_some_and(|m| !m.composite); - let justify = align == Some(crate::added::Align::Justify) && single_byte; + // Justify: each line but the last fills the width. Word spacing reaches only single-byte code 32 + // (ISO 32000-1 §9.3.3), so where a space is shown with another code (a composite font, a + // generated Type 3 font, a subset without a space) the extra space follows each space as a + // TJ adjustment instead, and code 32, which may then be a letter, gets none. + let justify = align == Some(crate::added::Align::Justify); + let word_spacing_reaches_spaces = match metrics.as_ref().filter(|_| reuse) { + Some(m) => m.encode(" ").is_some_and(|b| m.codes(&b).iter().all(|(c, len)| m.is_space(*c, *len))), + None => type3.is_none(), + }; let mut x = 0.0; let mut tw_set = 0.0; let mut underlines: Vec<(f64, f64, f64)> = Vec::new(); // (x0, x1, y) in text space @@ -1900,24 +2256,34 @@ pub(crate) fn rewrite_block_with_limit( block_ops.push(Op::new("Td", vec![n(dx - x), n(if i > 0 { -lead } else { 0.0 })])); } x = dx; + let stretch = if word_spacing_reaches_spaces { 0.0 } else { tw }; + let tw = tw - stretch; if tw != tw_set { block_ops.push(Op::new("Tw", vec![n(tw)])); tw_set = tw; } // Each wrapped line in typing order; drawn left to right (right-to-left runs reversed). let visual = crate::bidi::to_visual(line); - if let Some(metrics) = metrics.as_ref().filter(|_| reuse) { - let indic_line = if metrics.visual_order { crate::indic::to_visual(&visual) } else { visual }; - block_ops.extend(source_show(metrics, &indic_line, size, space, o_state.char_spacing, tw)?); - } else { - for (font, bytes) in encode(&visual).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? { - if font != shown_font { - block_ops.push(Op::new("Tf", vec![Object::name(&font), n(size)])); - shown_font = font; + // Stretched without word spacing: shown up to and including each space, then moved on. + let pieces = if stretch > 0.0 { visual.split_inclusive(' ').collect() } else { vec![visual.as_str()] }; + for piece in pieces { + if let Some(metrics) = metrics.as_ref().filter(|_| reuse) { + let indic_piece = if metrics.visual_order { crate::indic::to_visual(piece) } else { piece.to_owned() }; + block_ops.extend(source_show(metrics, &indic_piece, size, space, o_state.char_spacing, tw)?); + } else { + for (font, bytes) in encode(piece).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? { + if font != shown_font { + block_ops.push(Op::new("Tf", vec![Object::name(&font), n(size)])); + shown_font = font; + } + block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])); } - block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])); + } + if stretch > 0.0 && piece.ends_with(' ') { + block_ops.push(Op::new("TJ", vec![Object::Array(vec![n(-1000.0 * stretch / size)])])); } } + let tw = tw + stretch; let w = (advance(line) + tw * spaces as f64 * o_state.scale * k) / k; underlines.push((dx, dx + w, -(i as f64) * lead - size * 0.12)); } @@ -2205,6 +2571,17 @@ mod tests { assert_eq!(wrap(" \tA B\r\nC ", 3.0, width), ["A B", "C"]); } + #[test] + fn japanese_text_wraps_between_characters_but_not_before_closing_marks() { + let width = |s: &str| s.chars().count() as f64; + assert_eq!(wrap("日本語の文章です。", 3.0, width), ["日本語", "の文章", "です。"]); + // The comma can't begin a row, so the character before it moves down with it. + assert_eq!(wrap("あいう、えお", 3.0, width), ["あい", "う、え", "お"]); + // Pieces of one word join without a space; separate words keep theirs. + assert_eq!(wrap("Edit PDFを編集", 6.0, width), ["Edit", "PDFを編集"]); + assert_eq!(wrap("一行目 二行目", 4.0, width), ["一行目 ", "二行目"]); + } + #[test] fn fallback_face_moves_on_when_the_best_face_lacks_a_character() { static JPAN: &[&str] = &["Jpan"]; diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 0213d9040..2d7baf519 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -25,6 +25,12 @@ fn row_rect(row: &[usize], lines: &[TextLine]) -> Option<[f64; 4]> { row.iter().filter_map(|i| lines.get(*i)).map(|l| l.rect).reduce(union) } +/// [`row_rect`] in its text's own frame ([`TextLine::frame_rect`]), so rows of turned text +/// compare along and across their baselines. +fn frame_row_rect(row: &[usize], lines: &[TextLine]) -> Option<[f64; 4]> { + row.iter().filter_map(|i| lines.get(*i)).map(TextLine::frame_rect).reduce(union) +} + pub(super) fn group_blocks(lines: &[TextLine]) -> Vec { group_layout(lines, true) } @@ -62,14 +68,14 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { let mut rows: Vec> = Vec::new(); for (i, line) in lines.iter().enumerate() { let joins = rows.last().and_then(|r| r.last()).and_then(|i| lines.get(*i)).is_some_and(|prev| { - let gap = line.rect[0] - prev.rect[2]; + let gap = line.frame_rect()[0] - prev.frame_rect()[2]; // OCR words (invisible text over a scan) join only across a word space: a wider gap // is a table cell or a tab stop, which a rewrite would close up. let scanned = !mixed || (invisible(prev) == invisible(line) && (!invisible(line) || gap < prev.size.max(line.size) * 0.6)); scanned && (mixed || same_style(prev, line)) && same_plane(prev, line) - && (prev.origin.baseline - line.origin.baseline).abs() < prev.size.min(line.size) * 0.2 + && (prev.frame_origin().1 - line.frame_origin().1).abs() < prev.size.min(line.size) * 0.2 && gap >= -prev.size.min(line.size) * 0.2 && gap < prev.size.max(line.size) * 1.5 // Separate same-style text objects that merely touch or overlap, with no space @@ -90,14 +96,15 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { let mut blocks: Vec = Vec::new(); for row in rows { let Some(first) = row.first().and_then(|i| lines.get(*i)) else { continue }; - let Some(rect) = row_rect(&row, lines) else { continue }; + let (Some(rect), Some(frame)) = (row_rect(&row, lines), frame_row_rect(&row, lines)) else { continue }; let joins = blocks.last().is_some_and(|block| { let Some(prev_row) = block.rows.last() else { return false }; let Some(prev) = prev_row.first().and_then(|i| lines.get(*i)) else { return false }; - let Some(pr) = row_rect(prev_row, lines) else { return false }; - let gap = prev.origin.baseline - first.origin.baseline; + let Some(pr) = frame_row_rect(prev_row, lines) else { return false }; + let gap = prev.frame_origin().1 - first.frame_origin().1; let tol = prev.size.max(first.size).max(1.0); - let aligned = (pr[0] - rect[0]).abs() < tol || ((pr[0] + pr[2] - rect[0] - rect[2]) / 2.0).abs() < tol || (pr[2] - rect[2]).abs() < tol; + let aligned = + (pr[0] - frame[0]).abs() < tol || ((pr[0] + pr[2] - frame[0] - frame[2]) / 2.0).abs() < tol || (pr[2] - frame[2]).abs() < tol; // OCR lines stay apart: the scan fixes where each one is, so they never rewrap together. (!mixed || !(invisible(prev) || invisible(first))) && (mixed || same_style(prev, first)) @@ -135,7 +142,7 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { if let Some(prev) = block.lines.last().filter(|i| row.contains(i)).and_then(|i| lines.get(*i)) && !row_text.ends_with(char::is_whitespace) && !line.text.starts_with(char::is_whitespace) - && line.rect[0] - prev.rect[2] > line.size * 0.12 + && line.frame_rect()[0] - prev.frame_rect()[2] > line.size * 0.12 { row_text.push(' '); block.editor_text.push(' '); @@ -192,6 +199,46 @@ fn shape_source( Err(EditError::Invalid("complex text needs the complete source TrueType/OpenType face; load that font before editing".into())) } +/// For each output row (a range of the draft's characters), whether it is exactly the source row +/// at the same position: the same characters, none of them inserted or changed by the edit. +fn untouched_rows(block: &TextBlock, draft: &str, rows: &[std::ops::Range]) -> Vec { + let old: Vec = block.editor_text.chars().collect(); + let new: Vec = draft.chars().collect(); + let prefix = old.iter().zip(&new).take_while(|(a, b)| a == b).count(); + let suffix = old.iter().rev().zip(new.iter().rev()).take(old.len().min(new.len()).saturating_sub(prefix)).take_while(|(a, b)| a == b).count(); + // The source character a draft character is, if the edit left it alone. + let origin = |i: usize| { + if i < prefix { + Some(i) + } else if i >= new.len().saturating_sub(suffix) { + (old.len() + i).checked_sub(new.len()) + } else { + None + } + }; + let mut source_rows = Vec::new(); + let mut start = 0; + for (i, ch) in old.iter().enumerate() { + if *ch == '\n' { + source_rows.push(start..i); + start = i + 1; + } + } + source_rows.push(start..old.len()); + // A line break inside a source run would pair editor rows with the wrong visual rows. + if source_rows.len() != block.rows.len() { + return vec![false; rows.len()]; + } + rows.iter() + .enumerate() + .map(|(r, range)| { + source_rows.get(r).is_some_and(|source| { + !range.is_empty() && range.len() == source.len() && range.clone().zip(source.clone()).all(|(i, j)| origin(i) == Some(j)) + }) + }) + .collect() +} + /// Rewrite visual rows while keeping the source style on each surviving character. Resource /// names may differ between Unicode subsets of the same face; encoding searches those subsets. pub(super) fn rewrite_styled_block( @@ -227,6 +274,7 @@ pub(super) fn rewrite_styled_block( { return Err(EditError::Invalid("text style ranges must be disjoint UTF-8 boundaries with finite formatting (at most 1024 ranges)".into())); } + let source_count = lines.len(); let mut styled_lines = lines.to_vec(); let mut styled_spans = Vec::new(); let mut underlines = HashMap::new(); @@ -331,8 +379,9 @@ pub(super) fn rewrite_styled_block( if !dx.is_finite() || !dy.is_finite() || style.width.is_some_and(|w| !w.is_finite()) { return Err(EditError::Invalid("the paragraph's position and width must be finite".into())); } - // A rewritten OCR line grows to the right rather than wrapping onto the scanned line below. - let natural = if block.invisible { + // A visual row grows even when it contains several source/font runs. Multi-row + // paragraphs retain their container width; an explicit width always wins. + let natural = if block.invisible || block.rows.len() == 1 { (p.crop(doc)[2] - 36.0 - (block.rect[0] + dx)).max(block.rect[2] - block.rect[0]) } else { block.rect[2] - block.rect[0] @@ -343,6 +392,7 @@ pub(super) fn rewrite_styled_block( let mut substituted = None; let mut fallback_names: std::collections::BTreeMap<(String, char), String> = std::collections::BTreeMap::new(); let mut full_faces: HashMap = HashMap::new(); + let mut programs: HashMap>>> = HashMap::new(); for (range, index) in styled_spans { let source = lines.get(index).unwrap_or(first); for ch in draft.get(range).unwrap_or_default().chars() { @@ -378,6 +428,28 @@ pub(super) fn rewrite_styled_block( full_faces.insert(key, name.clone()); name } + } else if let Some(bytes) = programs + .entry(source.font.clone()) + .or_insert_with(|| source_program(doc, &fonts, &source.font).map(std::sync::Arc::new)) + .clone() + .filter(|bytes| pdfcraft_fonts::embedding::covers(bytes, &s)) + { + // The document's own complete program of this font has the glyph: draw it with + // that face rather than another family's fallback. + let key = format!("source {}", source.font); + if let Some(name) = full_faces.get(&key) { + name.clone() + } else { + let family = pdfcraft_fonts::embedding::names(&bytes).map_or_else(|_| source.base_font.clone(), |(family, _)| family); + let name = pdfcraft_fonts::embedding::embed(doc, &mut fonts, &bytes, &family).map_err(EditError::Invalid)?; + let font = fonts + .get(name.as_bytes()) + .and_then(|f| doc.dict(f)) + .ok_or_else(|| EditError::Invalid("the source font could not be loaded".into()))?; + metrics.insert(name.clone(), Metrics::from_dict(doc, &font)); + full_faces.insert(key, name.clone()); + name + } } else { let family = source_family(&source.base_font); if needs_type3(&s) { @@ -488,6 +560,9 @@ pub(super) fn rewrite_styled_block( Ok(width) }; let mut rows: Vec> = Vec::new(); + // Each row's characters, as indexes into the draft's characters (`chars` holds all of them). + let mut row_ranges = Vec::new(); + let mut row_start = 0; let mut row = Vec::new(); let mut row_width = 0.0; // Wrap at word boundaries, retaining explicit source row breaks and whitespace. @@ -495,14 +570,20 @@ pub(super) fn rewrite_styled_block( while at < chars.len() { let Some(item) = chars.get(at) else { break }; if item.0 == '\n' { + row_ranges.push(row_start..row_start + row.len()); rows.push(std::mem::take(&mut row)); row_width = 0.0; at += 1; + row_start = at; continue; } let mut end = at + 1; if !item.0.is_whitespace() { - while chars.get(end).is_some_and(|c| !c.0.is_whitespace()) { + // A word ends at a space, or where Chinese or Japanese text may break between characters. + while let (Some(before), Some(c)) = (chars.get(end - 1), chars.get(end)) + && !c.0.is_whitespace() + && !crate::linebreak::between(before.0, c.0) + { end += 1; } } @@ -512,17 +593,47 @@ pub(super) fn rewrite_styled_block( while row.last().is_some_and(|c: &(char, usize, String)| c.0.is_whitespace()) { row.pop(); } + row_ranges.push(row_start..row_start + row.len()); rows.push(std::mem::take(&mut row)); row_width = 0.0; + row_start = at; } row.extend_from_slice(word); row_width += w; at = end; } if !row.is_empty() { + row_ranges.push(row_start..row_start + row.len()); rows.push(row); } - for row in &rows { + let streams = content_streams(doc, &p.dict); + let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); + let ops = pieces.parse().ops; + // A row the edit doesn't reach keeps its source operators, so its kerning, TJ word gaps and + // glyph widths stay exactly as they were instead of being rebuilt from the text state. + let plain = BlockStyle { ranges: Vec::new(), ..style.clone() } == BlockStyle::default(); + let mut kept = if plain { + untouched_rows(block, draft, &row_ranges).into_iter().zip(&rows).map(|(same, row)| same && row.iter().all(|c| c.1 < source_count)).collect() + } else { + vec![false; rows.len()] + }; + let kept_lines: std::collections::HashSet = + kept.iter().enumerate().filter(|(_, k)| **k).filter_map(|(r, _)| block.rows.get(r)).flatten().copied().collect(); + let rewritten: Vec<&TextLine> = block.lines.iter().filter(|i| !kept_lines.contains(i)).filter_map(|i| lines.get(*i)).collect(); + // The rewrite goes where the first rewritten row was drawn, so it must share the paragraph's + // space. A dropped `'` or `"` also moved to the next line, which a kept row relies on. + let lead = rewritten.first().copied().filter(|lead| { + lead.origin.ctm.iter().zip(first.origin.ctm).all(|(a, b)| (a - b).abs() < 1e-9) + && !rewritten.iter().flat_map(|l| &l.ops).any(|i| ops.get(*i).is_some_and(|op| op.is("'") || op.is("\""))) + }); + let (members, lead) = match lead { + Some(lead) if !kept_lines.is_empty() => (rewritten, lead), + _ => { + kept.iter_mut().for_each(|k| *k = false); + (block.lines.iter().filter_map(|i| lines.get(*i)).collect(), first) + } + }; + for (row, _) in rows.iter().zip(&kept).filter(|(_, k)| !**k) { let text: String = row.iter().map(|v| v.0).collect(); if pdfcraft_fonts::source_shaping::required(&text) && row.first().is_some_and(|first| row.iter().any(|v| v.1 != first.1 || v.2 != first.2)) { return Err(EditError::Invalid( @@ -534,7 +645,7 @@ pub(super) fn rewrite_styled_block( let measured: Vec<_> = rows.iter().map(|r| measure(r)).collect::>()?; let mut shaped_runs = HashMap::new(); let mut segment_advances = HashMap::new(); - for (row_index, row) in rows.iter().enumerate() { + for (row_index, row) in rows.iter().enumerate().filter(|(r, _)| !kept.get(*r).copied().unwrap_or(false)) { let mut at = 0; while let Some(item) = row.get(at) { let mut end = at + 1; @@ -589,6 +700,11 @@ pub(super) fn rewrite_styled_block( baseline = anchor.origin.baseline; } let anchor = anchor.unwrap_or(first); + if kept.get(row_index).copied().unwrap_or(false) { + // Kept rows only occur without spacing or size changes, so this is the step below. + baseline -= source_leading; + continue; + } let w = measured.get(row_index).copied().unwrap_or(0.0); let align = style.align.unwrap_or(if centered { crate::added::Align::Center } else { crate::added::Align::Left }); let original_rect = block.rows.get(row_index).and_then(|row| row_rect(row, lines)).unwrap_or(block.rect); @@ -692,14 +808,10 @@ pub(super) fn rewrite_styled_block( }; } block_ops.push(Op::new("Q", vec![])); - let streams = content_streams(doc, &p.dict); - let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); - let ops = pieces.parse().ops; - let members: Vec<_> = block.lines.iter().filter_map(|i| lines.get(*i)).collect(); let mut drop = coincident_ops(lines, &members); drop.extend(members.iter().flat_map(|l| l.ops.iter().copied())); - let o = &first.origin; - let start = first.ops.first().copied().unwrap_or(o.bt_op); + let o = &lead.origin; + let start = lead.ops.first().copied().unwrap_or(o.bt_op); let split = ops .get(o.bt_op..start) .unwrap_or_default() diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index eecbb35c4..7de028763 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -41,6 +41,7 @@ png = "0.18" url = "2.5" idna = "1.1" icu_properties = "2.3" +unicode-segmentation = "1.13" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] # Already in the graph through lopdf; used only for the local UTC offset ("today"). diff --git a/crates/engine/README.md b/crates/engine/README.md index 982d2fb1a..4bf80e3f7 100644 --- a/crates/engine/README.md +++ b/crates/engine/README.md @@ -3,6 +3,9 @@ The façade every frontend talks to. It holds the open documents, their edit history and the tool catalogue; frontends never reach past it into the parsing, rendering or editing crates. +`word_count::count` provides cancellable, page-at-a-time Unicode word and character statistics +with optional semantic exclusions; see [word-count behavior and limits](../../docs/word-count.md). + - **Layer:** L6. It depends on most of L1–L4 and on nothing above itself. **No UI toolkit** — `egui`, `winit`, `eframe` and `rfd` are forbidden below L7, and `xtask layers` enforces it. - The crate denies `unwrap`/`expect`/`panic!`/`todo!`. Entry points that run untrusted-document diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 991c86a9c..b46d88acf 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -24,6 +24,11 @@ pub struct ToolItem { pub icon: &'static str, pub command: &'static str, pub availability: Availability, + /// Further commands of the same tool, shown as a drop-down beside the entry: Acrobat's + /// Watermark control offers Add, Update and Remove. The first is `command` itself. A UI + /// enables each one by its own command's needs, so Update and Remove stay greyed out until + /// the document has that kind of mark. Empty for a plain entry. + pub actions: &'static [&'static str], } #[derive(Clone, Copy, Debug)] @@ -45,7 +50,16 @@ pub struct ToolGroup { } const fn item(label: &'static str, icon: &'static str, command: &'static str, availability: Availability) -> ToolItem { - ToolItem { label, icon, command, availability } + ToolItem { label, icon, command, availability, actions: &[] } +} + +/// An entry that opens a menu of `actions` (the first one is its `command`). +const fn menu_item(label: &'static str, icon: &'static str, actions: &'static [&'static str]) -> ToolItem { + let command = match actions { + [first, ..] => *first, + [] => "", + }; + ToolItem { label, icon, command, availability: Ready, actions } } use Availability::{Planned, Provider, Ready}; @@ -115,6 +129,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ ToolSection { title: "Modify page", items: &[ + item("Edit text & images", "text-cursor-input", "edit.edit_text", Ready), item("Rotate pages", "rotate-cw", "page.rotate", Ready), item("Insert pages", "file-plus-2", "page.insert", Ready), item("Delete pages", "trash-2", "page.delete", Ready), @@ -125,14 +140,13 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ ToolSection { title: "Add content", items: &[ - item("Edit text & images", "text-cursor-input", "edit.edit_text", Ready), item("Text", "type", "edit.text", Ready), item("Image", "image-plus", "edit.image", Ready), - item("Header and footer", "heading", "edit.header_footer", Ready), - item("Watermark", "stamp", "edit.watermark", Ready), + menu_item("Header and footer", "heading", &["edit.header_footer", "edit.header_footer.update", "edit.header_footer.remove"]), + menu_item("Watermark", "stamp", &["edit.watermark", "edit.watermark.update", "edit.watermark.remove"]), item("Link", "link-2", "edit.link", Ready), item("Bates numbering", "hash", "edit.bates", Ready), - item("Background", "palette", "edit.background", Ready), + menu_item("Background", "palette", &["edit.background", "edit.background.update", "edit.background.remove"]), item("Attach file", "paperclip", "edit.attach", Planned("M12")), ], }, @@ -458,8 +472,10 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ title: "Accessibility", items: &[ item("Automatically tag PDF", "tag", "a11y.autotag", Ready), + item("Tag form fields", "tag", "a11y.autotag_fields", Ready), item("Change reading options", "book-open", "a11y.reading_options", Ready), item("Check for accessibility", "accessibility", "a11y.check", Ready), + item("Find untagged content", "search", "a11y.find_untagged", Ready), item("Open accessibility report", "file-text", "a11y.report", Ready), item("Add alternate text", "image", "a11y.alt_text", Ready), item("Tags panel", "tag", "a11y.tags", Ready), @@ -555,4 +571,38 @@ mod tests { } } } + + #[test] + fn menu_entries_list_registered_commands_starting_with_their_own() { + let mut menus = 0; + for g in TOOL_GROUPS { + for s in g.sections { + for i in s.items { + if i.actions.is_empty() { + continue; + } + menus += 1; + assert_eq!(i.actions[0], i.command, "{}: the first action is the entry's command", i.label); + for id in i.actions { + assert!(crate::commands::command(id).is_some(), "{id} is not a registered command"); + } + } + } + } + // Watermark, Header and footer, Background: Add / Update / Remove each (Acrobat's menu). + assert!(menus >= 3); + } + + #[test] + fn every_update_and_remove_command_is_reachable_from_a_tool_entry() { + let reachable: HashSet<&str> = + TOOL_GROUPS.iter().flat_map(|g| g.sections.iter().flat_map(|s| s.items.iter())).flat_map(|i| i.actions.iter().copied()).collect(); + for id in crate::commands::COMMANDS + .iter() + .map(|c| c.id) + .filter(|id| id.starts_with("edit.") && (id.ends_with(".update") || id.ends_with(".remove"))) + { + assert!(reachable.contains(id), "{id} is in no tool menu"); + } + } } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index cf4038645..b28758e24 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -190,6 +190,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("file.revert", "Revert", FILE, None, Undo, "rotate-ccw"), c("print.dialog", "Print…", FILE, Some(Shortcut::cmd("P")), Document, "printer"), c("file.properties", "Document properties…", FILE, Some(Shortcut::cmd("D")), Document, "info"), + c("file.word_count", "Word count…", FILE, None, Copying, "hash"), ct("edit.undo", "Undo", EDIT, Some(Shortcut::cmd("Z")), Undo, "undo-2"), ct("edit.redo", "Redo", EDIT, Some(Shortcut::cmd_shift("Z")), Redo, "redo-2"), c("edit.find", "Find…", EDIT, Some(Shortcut::cmd("F")), Document, "search"), @@ -212,6 +213,8 @@ pub const COMMANDS: &[CommandSpec] = &[ // Acrobat's column select: drag a rectangle and select only the text inside it. Alt/Option- // drag with the Select tool does the same; this is the way in where the desktop takes // Alt-drag for moving windows (several Linux window managers). + // Copy events stay with the frontend so focused text inputs and page clipboard routing keep ownership. + c("edit.copy", "Copy", EDIT, None, Copying, "copy"), c("edit.column_select", "Column select", EDIT, None, Document, "text-select"), c("edit.snapshot", "Take a snapshot", EDIT, None, Copying, "camera"), c("view.full_screen", "Full screen mode", VIEW, Some(Shortcut::cmd("L")), Document, "maximize"), @@ -221,6 +224,9 @@ pub const COMMANDS: &[CommandSpec] = &[ // also run from Combine files, which has no document open. c("view.next_tab", "Next tab", VIEW, Some(Shortcut { command: false, shift: false, mac_ctrl: true, key: "Tab" }), Nothing, "chevron-right"), c("view.previous_tab", "Previous tab", VIEW, Some(Shortcut { command: false, shift: true, mac_ctrl: true, key: "Tab" }), Nothing, "chevron-left"), + // View-local history also appears in the Page navigation section; don't duplicate its menu. + c("view.previous_view", "Previous view", None, Some(Shortcut::cmd("[")), Document, "chevron-left"), + c("view.next_view", "Next view", None, Some(Shortcut::cmd("]")), Document, "chevron-right"), c("view.focus_page_input", "Go to page…", VIEW, Some(Shortcut::cmd_shift("N")), Document, "text-cursor-input"), c("ui.dock.reset", "Reset panel layout", VIEW, None, Nothing, "settings"), c("view.theme", "Switch light / dark theme", None, None, Nothing, "moon"), @@ -326,10 +332,12 @@ pub const COMMANDS: &[CommandSpec] = &[ c("ocr.enhance_camera", "Enhance camera image", None, None, Modification, "camera"), c("ocr.recognize_batch", "Recognize text in multiple files…", None, None, Nothing, "files"), c("a11y.check", "Check for accessibility…", None, None, Document, "accessibility"), + c("a11y.find_untagged", "Find untagged content", None, None, Document, "search"), c("a11y.autotag", "Automatically tag PDF", None, None, Document, "tag"), c("a11y.report", "Open accessibility report", None, None, Document, "file-text"), c("a11y.reading_options", "Change reading options…", None, None, Document, "book-open"), c("a11y.alt_text", "Add alternate text…", None, None, Modification, "image"), + c("a11y.autotag_fields", "Tag form fields", None, None, Modification, "tag"), c("a11y.tags", "Tags panel", None, None, Document, "tag"), c("export.all_images", "Export all images…", FILE, None, Copying, "image"), c("edit.header_footer", "Add header & footer…", None, None, Modification, "heading"), @@ -390,7 +398,7 @@ pub fn is_enabled(spec: &CommandSpec, session: &Session, active: Option) Annotate => doc.is_some_and(|d| d.allows_annotation()), Copying => doc.is_some_and(|d| d.allows_copying()), FillForms => doc.is_some_and(|d| d.allows_form_filling() && !d.form.is_empty()), - Marks(k) => doc.is_some_and(|d| d.allows_modification() && d.marks.contains(&k)), + Marks(k) => doc.is_some_and(|d| d.allows_modification() && d.marks().contains(&k)), HasComments => doc.is_some_and(|d| d.allows_modification() && !d.info.annotations.is_empty()), HasFields => doc.is_some_and(|d| d.allows_modification() && !d.form.is_empty()), HasRedactions => doc.is_some_and(|d| d.allows_modification() && d.redaction_marks() > 0), diff --git a/crates/engine/src/dates.rs b/crates/engine/src/dates.rs index e26d9ec60..8c14a5b1b 100644 --- a/crates/engine/src/dates.rs +++ b/crates/engine/src/dates.rs @@ -56,7 +56,7 @@ const AR_DAYS: [&str; 7] = ["الأحد", "الاثنين", "الثلاثاء", /// The languages month and weekday names can be written in (Preferences ▸ Date format ▸ Language): /// the interface languages, in the same order and with the same names. -pub static DATE_LANGUAGES: [DateLanguage; 22] = [ +pub static DATE_LANGUAGES: [DateLanguage; 23] = [ DateLanguage { code: "en", name: "English", @@ -360,6 +360,44 @@ pub static DATE_LANGUAGES: [DateLanguage; 22] = [ days: ["sekmadienis", "pirmadienis", "antradienis", "trečiadienis", "ketvirtadienis", "penktadienis", "šeštadienis"], days_short: ["sk", "pr", "an", "tr", "kt", "pn", "št"], }, + // Vietnamese: numeric month forms (`tháng 1` with a day, `Tháng 1` standing alone). + // Fill & Sign still refuses any format whose final text fails the existing WinAnsi check + // (for example weekday names); `dd.mm.yyyy` remains writable. + DateLanguage { + code: "vi", + name: "Tiếng Việt", + months: [ + "Tháng 1", + "Tháng 2", + "Tháng 3", + "Tháng 4", + "Tháng 5", + "Tháng 6", + "Tháng 7", + "Tháng 8", + "Tháng 9", + "Tháng 10", + "Tháng 11", + "Tháng 12", + ], + months_with_day: Some([ + "tháng 1", + "tháng 2", + "tháng 3", + "tháng 4", + "tháng 5", + "tháng 6", + "tháng 7", + "tháng 8", + "tháng 9", + "tháng 10", + "tháng 11", + "tháng 12", + ]), + months_short: ["Thg 1", "Thg 2", "Thg 3", "Thg 4", "Thg 5", "Thg 6", "Thg 7", "Thg 8", "Thg 9", "Thg 10", "Thg 11", "Thg 12"], + days: ["Chủ Nhật", "Thứ Hai", "Thứ Ba", "Thứ Tư", "Thứ Năm", "Thứ Sáu", "Thứ Bảy"], + days_short: ["CN", "T2", "T3", "T4", "T5", "T6", "T7"], + }, ]; /// The date language for `code` (any case), or `None` when there's none. @@ -631,6 +669,26 @@ mod tests { assert_eq!(s.date_format(), "dd.mm.yyyy", "a rejected pattern keeps the previous one"); } + /// 21:30 on 14 Oct 2026 in São Paulo (UTC-3) is 00:30Z on the 15th. The date Fill & Sign + /// stamps is the wall-clock day (14th); the time the file records (`/M`, `/ModDate`) is UTC. + /// The test shortcut (`with_clock`) takes the injected time as local and says the 15th, which + /// is why it cannot serve a host that knows both the UTC time and the zone. + #[test] + fn a_wall_clock_gives_the_local_day_and_keeps_utc_for_the_file() { + let sao_paulo = Session::new().with_wall_clock(|| 1_792_024_200, || -3 * 3600); + assert_eq!(sao_paulo.today(), (2026, 10, 14)); + assert_eq!(sao_paulo.today_text(None, None).as_deref(), Ok("10/14/2026")); + assert_eq!(sao_paulo.signing_date(), "D:20261014213000-03'00'"); + assert_eq!(sao_paulo.stamp_by_line("Ada"), "By Ada at 9:30 pm, Oct 14, 2026"); + assert_eq!(sao_paulo.now_secs(), 1_792_024_200, "the file's instant stays in UTC"); + + let utc = Session::new().with_wall_clock(|| 1_792_024_200, || 0); + assert_eq!(utc.today(), (2026, 10, 15)); + + let clock_only = Session::new().with_clock(|| 1_792_024_200); + assert_eq!(clock_only.today(), (2026, 10, 15), "on its own, an injected clock counts as local time"); + } + #[test] fn names_follow_the_date_language() { assert_eq!(in_lang("dddd, d \\de mmmm \\de yyyy", "es"), "sábado, 7 de marzo de 2026"); @@ -683,6 +741,25 @@ mod tests { assert_eq!(s.date_language(), None); } + #[test] + fn vietnamese_month_and_weekday_names() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + s.set_date_language(Some("vi")).unwrap(); + assert_eq!(s.date_language(), Some("vi")); + s.set_date_format("d mmmm yyyy").unwrap(); + assert_eq!(s.today_text(None, None).as_deref(), Ok("14 tháng 11 2023")); + s.set_date_format("mmmm yyyy").unwrap(); + assert_eq!(s.today_text(None, None).as_deref(), Ok("Tháng 11 2023")); + s.set_date_format("ddd, d mmm").unwrap(); + assert_eq!(s.today_text(None, None).as_deref(), Ok("T3, 14 Thg 11")); + // Encoding follows the spelled text: `14 tháng 11 2023` is WinAnsi-representable; + // a weekday name such as `Thứ Ba` is not, so Fill & Sign refuses that format. + assert_eq!(s.today_text_for_pdf(Some("d mmmm yyyy"), Some("vi")).as_deref(), Ok("14 tháng 11 2023")); + let e = s.today_text_for_pdf(Some("dddd d mmmm yyyy"), Some("vi")).unwrap_err(); + assert!(e.contains("can't be written into the PDF yet") && e.contains("dd.mm.yyyy"), "{e}"); + assert!(s.today_text_for_pdf(Some("dd.mm.yyyy"), Some("vi")).is_ok()); + } + #[test] fn dates_the_pdf_cannot_hold_are_refused_not_damaged() { assert_eq!(unwritable("8. října 2026"), "ř"); diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index a6af0cdf8..579a483ef 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -46,8 +46,11 @@ pub fn extract_images(src: &ExportSource, pages: &[usize], min_side: u32) -> Res } /// The file name for the `index`-th (1-based) exported image: `_Page__Image_.`. -pub fn image_file_name(stem: &str, image: &pdfcraft_create::ExtractedImage, index: usize) -> String { - format!("{stem}_Page_{}_Image_{index:04}.{}", image.page + 1, image.extension) +/// The page number has leading zeros for as many digits as the document's `page_count` has, so the +/// files sort in page order (#2208): page 7 of 120 is `Page_007`. +pub fn image_file_name(stem: &str, image: &pdfcraft_create::ExtractedImage, index: usize, page_count: usize) -> String { + let width = page_count.max(1).to_string().len(); + format!("{stem}_Page_{:0width$}_Image_{index:04}.{}", image.page + 1, image.extension) } impl Exporter { @@ -212,3 +215,29 @@ pub fn encode_tiff(width: u32, height: u32, premultiplied: &[u8]) -> Result pdfcraft_create::ExtractedImage { + pdfcraft_create::ExtractedImage { + page, + object: pdfcraft_cos::ObjRef { num: 1, generation: 0 }, + width: 1, + height: 1, + extension: "png", + data: Vec::new(), + } + } + + #[test] + fn page_number_is_padded_to_the_page_count() { + assert_eq!(image_file_name("doc", &image(0), 1, 1), "doc_Page_1_Image_0001.png"); + assert_eq!(image_file_name("doc", &image(0), 1, 9), "doc_Page_1_Image_0001.png"); + assert_eq!(image_file_name("doc", &image(6), 2, 20), "doc_Page_07_Image_0002.png"); + assert_eq!(image_file_name("doc", &image(6), 3, 120), "doc_Page_007_Image_0003.png"); + assert_eq!(image_file_name("doc", &image(119), 4, 120), "doc_Page_120_Image_0004.png"); + assert_eq!(image_file_name("doc", &image(0), 1, 0), "doc_Page_1_Image_0001.png"); + } +} diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index d636c303f..8958688d2 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -250,6 +250,15 @@ fn detection_words(text: &pdfcraft_render::PageText, info: &pdfcraft_render::Pag /// A page's words in reading order, in user space ([x0, y0, x1, y1]); runs of underscores are /// words of their own. pub(crate) fn page_words(text: &pdfcraft_render::PageText, info: &pdfcraft_render::PageInfo) -> Vec<(String, [f64; 4])> { + words_with(text, |x, y| info.view_to_user(x, y)) + .into_iter() + .map(|(text, r)| (text, [f64::from(r[0]), f64::from(r[1]), f64::from(r[2]), f64::from(r[3])])) + .collect() +} + +/// A page's words in reading order, their boxes mapped from view space by `map` (and ordered +/// again, so that [x0, y0, x1, y1] holds whatever way `map` turns them). +pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32) -> [f32; 2]) -> Vec<(String, [f32; 4])> { let mut words: Vec<(String, [f32; 4])> = Vec::new(); let mut last_line = u32::MAX; // A space glyph ends the word before it. @@ -281,18 +290,23 @@ pub(crate) fn page_words(text: &pdfcraft_render::PageText, info: &pdfcraft_rende words .into_iter() .map(|(text, r)| { - let a = info.view_to_user(r[0], r[1]); - let b = info.view_to_user(r[2], r[3]); - let rect = [a[0].min(b[0]) as f64, a[1].min(b[1]) as f64, a[0].max(b[0]) as f64, a[1].max(b[1]) as f64]; - (text, rect) + let a = map(r[0], r[1]); + let b = map(r[2], r[3]); + (text, [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]) }) .collect() } impl Session { /// Prepare a form ▸ automatic field detection: the fields `pages` (0-based; empty = all) - /// seem to ask for, named from their labels, as (page, candidate). + /// seem to ask for, named from their labels, as (page, candidate). Scanned pages (a picture + /// and no drawn shapes) are read from their pixels, see [`crate::scan_fields`]. pub fn detect_fields(&self, id: DocId, pages: &[usize]) -> Vec<(usize, pdfcraft_forms::detect::Candidate)> { + self.detect_fields_turned(id, pages).into_iter().map(|(page, c, _)| (page, c)).collect() + } + + /// [`Self::detect_fields`], with how far each field turns (`/MK /R`) to read upright. + fn detect_fields_turned(&self, id: DocId, pages: &[usize]) -> Vec<(usize, pdfcraft_forms::detect::Candidate, i64)> { let Some(doc) = self.get(id) else { return Vec::new() }; let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return Vec::new() }; let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(std::sync::Arc::from), ..Default::default() }; @@ -302,13 +316,19 @@ impl Session { let list: Vec = if pages.is_empty() { (0..all).collect() } else { pages.iter().copied().filter(|p| *p < all).collect() }; let mut out = Vec::new(); for page in list { + let Some(info) = doc.info.pages.get(page) else { continue }; let res = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); - let words = res.text.map(|t| detection_words(&t, &doc.info.pages[page])).unwrap_or_default(); let shapes = pdfcraft_forms::detect::page_shapes(cos, page); let existing: Vec<[f64; 4]> = doc.form.iter().flat_map(|f| f.widgets.iter()).filter(|w| w.page == Some(page)).map(|w| w.rect).collect(); - for c in pdfcraft_forms::detect::detect(&words, &shapes, &existing, &taken) { + let (found, turn) = if shapes.boxes.is_empty() && shapes.rules.is_empty() && crate::scan_fields::is_scan(doc, page, info) { + self.detect_on_scan(id, &mut r, page, res.text.as_deref(), &existing, &taken) + } else { + let words = res.text.map(|t| detection_words(&t, info)).unwrap_or_default(); + (pdfcraft_forms::detect::detect(&words, &shapes, &existing, &taken), 0) + }; + for c in found { taken.push(c.name.clone()); - out.push((page, c)); + out.push((page, c, turn)); } } out @@ -316,23 +336,29 @@ impl Session { /// Detect fields and add them, as one undoable step. Returns the new fields' names. pub fn auto_detect_fields(&mut self, id: DocId, pages: &[usize]) -> Result, EditError> { - let found = self.detect_fields(id, pages); + let found = self.detect_fields_turned(id, pages); if found.is_empty() { return Ok(Vec::new()); } - let names = found.iter().map(|(_, c)| c.name.clone()).collect(); - let edits = found - .into_iter() - .map(|(page, c)| Edit::AddField { + let names = found.iter().map(|(_, c, _)| c.name.clone()).collect(); + let mut edits = Vec::with_capacity(found.len()); + for (page, c, turn) in found { + // A turned field keeps its place: the rectangle is already the one on the page. + let props = (turn != 0).then(|| pdfcraft_forms::FieldProps { rotation: Some((0, turn)), rect: Some((0, c.rect)), ..Default::default() }); + let tall = if turn % 180 == 0 { c.rect[3] - c.rect[1] } else { c.rect[2] - c.rect[0] }; + edits.push(Edit::AddField { page, rect: c.rect, kind: match c.kind { - pdfcraft_forms::detect::Kind::Text => pdfcraft_forms::NewField::Text { multiline: c.rect[3] - c.rect[1] > 30.0 }, + pdfcraft_forms::detect::Kind::Text => pdfcraft_forms::NewField::Text { multiline: tall > 30.0 }, pdfcraft_forms::detect::Kind::CheckBox => pdfcraft_forms::NewField::CheckBox, }, - name: Some(c.name), - }) - .collect(); + name: Some(c.name.clone()), + }); + if let Some(props) = props { + edits.push(Edit::SetFieldProps { name: c.name, props: Box::new(props) }); + } + } self.apply(id, Edit::Batch { label: "Detect form fields".into(), edits })?; Ok(names) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fbe62b2c2..01f162ae7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -28,20 +28,23 @@ pub mod js; pub mod links; pub mod ocr; pub mod optimizer; +pub mod print_raster; pub mod printcheck; mod scan_colours; +mod scan_fields; pub mod signature_image; pub mod signature_scan; pub mod text_fonts; mod text_preview; mod upright_image; +pub mod word_count; pub mod xfa; pub use signature_image::{ImageSignaturePreview, SignatureImage}; pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn}; pub use text_preview::TextEditPreview; -pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, split_ranges}; +pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, split_ranges}; /// One file produced by a split: (1-based first page, last page, PDF bytes). pub use pdfcraft_organize::LabelStyle; @@ -49,13 +52,14 @@ pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnific pub use pdfcraft_cos::Algorithm; pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind}; +pub use pdfcraft_edit::added::{text_rect as added_text_rect, text_width as added_text_width}; pub use pdfcraft_edit::{ Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark, }; pub use pdfcraft_forms::{ BorderStyle, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger, Widget as FormWidget, af as form_scripts, - flags as field_flags, + appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by, flags as field_flags, }; pub use pdfcraft_a11y as a11y; @@ -75,6 +79,8 @@ pub enum ImageEdit { Flip { horizontal: bool, }, + /// Show only this box of it (user space), the pixels staying where they are. + Crop([f64; 4]), /// Draw this image file (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) in its place. Replace { name: String, @@ -82,7 +88,7 @@ pub enum ImageEdit { }, Delete, } -pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, script_outline}; +pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, script_outline, undrawable_message}; /// Fill & Sign: `text` in the script font as a typed signature, its left edge at `at` (user /// space, vertically centred) and `height` points tall. `None` for text with no outlines. @@ -107,6 +113,19 @@ pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64, rotation: i6 let contours = o.contours.iter().map(|c| c.iter().map(|p| to_user((p[0] - left) / width, (p[1] - bottom) / span)).collect()).collect(); Some(Shape::TypedSignature { rect, contours }) } +/// `at` moved `right` and `up` points as displayed on a page turned by `rotation` (its `/Rotate`). +pub fn displayed_offset(rotation: i64, at: [f64; 2], right: f64, up: f64) -> [f64; 2] { + let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]); + [at[0] + a * right + c * up, at[1] + b * right + d * up] +} + +/// The user-space box `width` x `height` points as displayed on a page turned by `rotation`, +/// with its displayed top-left corner at `at`: where typed text and text boxes go. `None` for a +/// non-finite point. +pub fn text_box_at(rotation: i64, at: [f64; 2], width: f64, height: f64) -> Option<[f64; 4]> { + signature_image::upright_box(rotation, displayed_offset(rotation, at, 0.0, -height / 2.0), width, height) +} + /// `text` in the script font as the graphic of a visible digital signature, as Fill & Sign types /// it. `None` for text with no outlines. pub fn typed_signature_graphic(text: &str) -> Option { @@ -147,7 +166,38 @@ pub fn merge_data_files(files: &[(String, Vec)]) -> Result { pub type SplitPart = (usize, usize, Arc>); -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; + +#[cfg(test)] +thread_local! { + static SECONDARY_SCAN_COUNTS: std::cell::Cell<(usize, usize)> = const { std::cell::Cell::new((0, 0)) }; +} + +#[cfg(test)] +fn reset_secondary_scan_counts() { + SECONDARY_SCAN_COUNTS.with(|counts| counts.set((0, 0))); +} + +#[cfg(test)] +fn secondary_scan_counts() -> (usize, usize) { + SECONDARY_SCAN_COUNTS.with(std::cell::Cell::get) +} + +#[cfg(test)] +fn record_marker_scan() { + SECONDARY_SCAN_COUNTS.with(|counts| { + let (markers, links) = counts.get(); + counts.set((markers + 1, links)); + }); +} + +#[cfg(test)] +fn record_link_scan() { + SECONDARY_SCAN_COUNTS.with(|counts| { + let (markers, links) = counts.get(); + counts.set((markers, links + 1)); + }); +} use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; use pdfcraft_render::{DocInfo, Layer, LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, inspect}; @@ -179,6 +229,8 @@ enum Scope { Comments, /// Only form field values (and their widget appearances). Form, + /// Link annotations; the full inspection is refreshed, but page-content indexes stay valid. + Links, /// Only page content streams, resources and added content/marks: the page tree, /// annotations, form fields, links, outline and fonts are unchanged, so the full /// re-inspection is skipped (seconds on very large files). @@ -214,7 +266,9 @@ fn scope_of(edit: &Edit) -> Scope { | Edit::ResizeAnnotation { .. } | Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } + | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => Scope::Comments, + Edit::AddLink { .. } | Edit::SetLink { .. } | Edit::DeleteLink { .. } | Edit::RemoveLinks { .. } | Edit::AddLinks { .. } => Scope::Links, Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => Scope::Form, // Page content, resources and added content/marks only: the page tree, annotations, // fields, links and document metadata are untouched (see `refresh_content`). @@ -247,6 +301,11 @@ type Snapshot = (Label, pdfcraft_cos::Document, Keys, Scope); #[derive(Clone)] struct Editor { cos: pdfcraft_cos::Document, + /// The file as last opened or saved. Incremental saves append to it, and Revert opens it. + /// Undo may restore a snapshot whose own base is an earlier revision; this does not follow it. + saved: Arc>, + /// Passwords that open [`Self::saved`]. + saved_keys: Keys, undo: Vec, redo: Vec, keys: Keys, @@ -320,12 +379,8 @@ pub struct Document { pub read_only_reason: Option, /// Interactive form fields of the current state (empty without a form). pub form: Arc>, - /// Page marks present (headers and footers, watermarks, backgrounds), for Update/Remove. - pub marks: Vec, - /// Text and images added with Edit a PDF ▸ Add content (still editable). - pub added: Vec, - /// Link annotations, for Edit a PDF ▸ Link. - pub links: Vec, + marker_scan: OnceLock, + links: OnceLock>, /// Signature fields, validated against the session's trust store (Signatures panel). pub signatures: Arc>, trust: Arc, @@ -540,6 +595,11 @@ impl Document { self.editor.as_ref().map(|e| a11y::tags(&e.cos)).unwrap_or_default() } + /// Marked-content items directly owned by one reachable indirect structure tag. + pub fn tag_content(&self, object: pdfcraft_cos::ObjRef) -> Result, a11y::TagError> { + self.editor.as_ref().map(|e| a11y::tag_content(&e.cos, object)).unwrap_or_else(|| Ok(Vec::new())) + } + /// The edit that fixes `rule`, for the rules with an automatic fix: the document language /// (`value` is the language), the title (`value` replaces it; else the current title or the /// file name) and the tab order. @@ -636,6 +696,54 @@ impl Document { self.info.annotations.iter().filter(|a| a.subtype == "Redact").count() } + /// Page marks present (headers and footers, watermarks, backgrounds), for Update/Remove. + pub fn marks(&self) -> &[MarkKind] { + &self + .marker_scan + .get_or_init(|| { + self.editor + .as_ref() + .map(|e| { + #[cfg(test)] + record_marker_scan(); + pdfcraft_edit::scan_markers(&e.cos) + }) + .unwrap_or_default() + }) + .marks + } + + /// Text and images added with Edit a PDF ▸ Add content (still editable). + pub fn added(&self) -> &[pdfcraft_edit::Added] { + &self + .marker_scan + .get_or_init(|| { + self.editor + .as_ref() + .map(|e| { + #[cfg(test)] + record_marker_scan(); + pdfcraft_edit::scan_markers(&e.cos) + }) + .unwrap_or_default() + }) + .added + } + + /// Link annotations, for Edit a PDF ▸ Link. + pub fn links(&self) -> &[pdfcraft_annot::links::LinkItem] { + self.links.get_or_init(|| { + self.editor + .as_ref() + .map(|e| { + #[cfg(test)] + record_link_scan(); + pdfcraft_annot::links::list(&e.cos) + }) + .unwrap_or_default() + }) + } + /// Changes to content and document information are allowed. pub fn allows_modification(&self) -> bool { self.editable() && self.permissions().is_none_or(|p| p.modify()) @@ -743,9 +851,9 @@ impl Document { } /// The file as last opened or saved, without unsaved edits: what an incremental save appends - /// to. + /// to. This stays that file when undo has restored an older snapshot. pub fn saved_bytes(&self) -> Arc> { - self.editor.as_ref().map_or_else(|| self.bytes.clone(), |e| e.cos.bytes().clone()) + self.editor.as_ref().map_or_else(|| self.bytes.clone(), |e| e.saved.clone()) } /// Notes about damage repaired while opening (Document Properties ▸ Advanced, notices). @@ -767,6 +875,12 @@ fn is_placeholder_title(title: &str) -> bool { /// What is displayed: the working file, plus (in memory only, never saved) the appearances /// PdfCraft draws for comments that have none ([`pdfcraft_annot::with_missing_appearances`]). /// The working file is unchanged, and so is everything that reads it (saving, signatures, …). +/// The reader shows the same pages in `a` and `b`: count, displayed size and rotation. +fn same_pages(a: &[pdfcraft_render::PageInfo], b: &[pdfcraft_render::PageInfo]) -> bool { + a.len() == b.len() + && a.iter().zip(b).all(|(x, y)| (x.width - y.width).abs() < 0.01 && (x.height - y.height).abs() < 0.01 && x.rotation == y.rotation) +} + fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc>) -> Arc> { let Some(display) = editor.and_then(|e| pdfcraft_annot::with_missing_appearances_cached(&e.cos, &mut e.appearances)) else { return bytes.clone(); @@ -775,6 +889,23 @@ fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc>) -> Arc>, password: Option<&str>) -> Arc> { + guard(|| { + pdfcraft_cos::Document::open_with_password(bytes.clone(), password) + .ok() + .and_then(|cos| pdfcraft_annot::with_missing_appearances(&cos)) + .and_then(|display| write_incremental(&display, &SaveOptions::default()).ok()) + .map(Arc::new) + }) + .ok() + .flatten() + .unwrap_or(bytes) +} + /// Fails when the renderer still draws text under an applied redaction mark. /// /// Redaction decides what lies under a mark from the positions its own interpreter computes, and @@ -900,6 +1031,8 @@ pub struct BookmarkRow { pub open: bool, /// The 0-based page it goes to. pub page: Option, + /// Where on that page it goes ([`pdfcraft_render::DestView::Top`] when it doesn't say). + pub view: pdfcraft_render::DestView, } /// One page of the bookmark list, in outline order. @@ -927,7 +1060,7 @@ fn outline_rows(items: &[pdfcraft_render::OutlineItem]) -> Vec { top.1 += 1; } let path: Vec = levels.iter().map(|(_, listed)| listed.saturating_sub(1)).collect(); - rows.push(BookmarkRow { path, title: item.title.clone(), open: item.open, page: item.page }); + rows.push(BookmarkRow { path, title: item.title.clone(), open: item.open, page: item.page, view: item.view }); if !item.children.is_empty() { levels.push((&item.children, 0)); } @@ -1131,6 +1264,12 @@ pub enum Edit { which: PageBox, spec: BoxSpec, }, + /// Set or remove the page transition and display time of pages (ISO 32000-2 §12.4.4), + /// Organize ▸ Page Transitions. + SetTransition { + pages: Vec, + spec: TransitionSpec, + }, SetInfo { key: String, value: String, @@ -1280,6 +1419,12 @@ pub enum Edit { index: usize, fill: Option, }, + /// Change the font size (pt) of a text box, typewriter text or callout. + FontSizeAnnotation { + page: usize, + index: usize, + size: f64, + }, /// Comment properties ▸ General / note icon. SetAnnotationInfo { page: usize, @@ -1338,6 +1483,8 @@ pub enum Edit { figure: u32, alt: Option, }, + /// Add `/Form` tags and object references for all widgets in an untagged AcroForm. + AutoTagFormFields, /// Change one property on a reachable indirect structure tag. SetTagProperty { object: pdfcraft_cos::ObjRef, @@ -1354,6 +1501,10 @@ pub enum Edit { MarkDecorative { figure: u32, }, + /// Tags panel ▸ Mark as artifact: retire a leaf tag and mark its content as an artifact. + MarkTagArtifact { + object: pdfcraft_cos::ObjRef, + }, /// Document JavaScripts: add, replace (`script`) or remove (`None`) the document-level /// script `name`. SetDocumentScript { @@ -1527,6 +1678,11 @@ pub enum Edit { }, /// Sanitize Document: every category, then a full rewrite on save. Sanitize, + /// An XFA form made an ordinary (static) PDF form: the XFA packets, `/NeedsRendering` 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. + XfaToStatic, /// Flatten comments and/or form fields on every page into page content. Flatten { comments: bool, @@ -1623,6 +1779,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Crop page", "Crop pages", "Set page boxes", + "Set page transitions", "Change Title", "Change Author", "Change Subject", @@ -1758,6 +1915,7 @@ impl Edit { Edit::ReplacePages { pages, .. } => plural("Replace page", "Replace pages", pages.len()), Edit::SetPageBox { pages, which: PageBox::Crop, .. } => plural("Crop page", "Crop pages", pages.len()), Edit::SetPageBox { .. } => Label::of("Set page boxes"), + Edit::SetTransition { .. } => Label::of("Set page transitions"), // The four `/Info` keys PdfCraft itself offers get a wording of their own, so each can // be said in another language; any other key is the document's and goes in as it stands. Edit::SetInfo { key, .. } => match key.as_str() { @@ -1803,7 +1961,9 @@ impl Edit { Edit::LockAnnotation { .. } => Label::of("Unlock comment"), Edit::MoveAnnotation { .. } => Label::of("Move comment"), Edit::ResizeAnnotation { .. } => Label::of("Resize comment"), - Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::SetAnnotationInfo { .. } => Label::of("Change comment properties"), + Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => { + Label::of("Change comment properties") + } Edit::SetFieldValue { name, .. } => Label::with("Fill in {name}", name.as_str()), Edit::SetFieldImage { name, .. } => Label::with("Set the image of {name}", name.as_str()), Edit::ResetForm { .. } => Label::of("Clear form"), @@ -1815,9 +1975,11 @@ impl Edit { Edit::SetInitialView(_) => Label::of("Change initial view"), Edit::AutoTagText => Label::of("Automatically tag text"), Edit::SetAltText { .. } => Label::of("Set alternate text"), + Edit::AutoTagFormFields => Label::of("Tag form fields"), Edit::SetTagProperty { .. } => Label::of("Change tag properties"), Edit::MoveTag { .. } => Label::of("Move object"), Edit::MarkDecorative { .. } => Label::of("Mark figure as decorative"), + Edit::MarkTagArtifact { .. } => Label::of("Mark content as artifact"), Edit::AddOcrText { .. } => Label::of("Recognize text"), Edit::ApplyScriptChanges { .. } => Label::of("Run JavaScript"), Edit::XfaEvent { .. } => Label::of("Run form script"), @@ -1832,6 +1994,7 @@ impl Edit { Edit::EditPageImage { change, .. } => Label::of(match change { ImageEdit::Move(_) => "Move image", ImageEdit::Rotate(_) => "Rotate image", + ImageEdit::Crop(_) => "Crop image", ImageEdit::Flip { .. } => "Flip image", ImageEdit::Replace { .. } => "Replace image", ImageEdit::Delete => "Delete image", @@ -1860,6 +2023,7 @@ impl Edit { Edit::RemoveLinks { .. } => Label::of("Remove all links"), Edit::AddLinks { links, .. } => plural("Create link", "Create links", links.len()), Edit::Sanitize => Label::of("Sanitize document"), + Edit::XfaToStatic => Label::of("Convert to a static form"), Edit::Flatten { comments: true, fields: false } => Label::of("Flatten comments"), Edit::Flatten { comments: false, fields: true } => Label::of("Flatten form fields"), Edit::Flatten { .. } => Label::of("Flatten"), @@ -1922,6 +2086,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::DuplicatePages { .. } | Edit::ReplacePages { .. } | Edit::SetPageBox { .. } + | Edit::SetTransition { .. } | Edit::InsertPagesFrom { .. } // "Assemble the document: insert, rotate or delete pages and create bookmarks" (Table 22). | Edit::AddBookmark { .. } @@ -1952,6 +2117,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::ResizeAnnotation { .. } | Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } + | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } | Edit::SetMeasurementScale { .. } => { if p.annotate() { @@ -1996,6 +2162,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::RemoveLinks { .. } | Edit::AddLinks { .. } | Edit::Sanitize + | Edit::XfaToStatic | Edit::SetFieldProps { .. } | Edit::DeleteField { .. } | Edit::DuplicateField { .. } @@ -2004,9 +2171,11 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::SetInitialView(_) | Edit::AutoTagText | Edit::SetAltText { .. } + | Edit::AutoTagFormFields | Edit::SetTagProperty { .. } | Edit::MoveTag { .. } | Edit::MarkDecorative { .. } + | Edit::MarkTagArtifact { .. } | Edit::AddOcrText { .. } | Edit::SetDocumentScript { .. } | Edit::SetFieldScript { .. } @@ -2039,6 +2208,8 @@ struct EditCtx { xfa_datasets: Option, /// A form script ran too long and was abandoned: the document's scripts go off. xfa_ran_away: bool, + /// The edit took the XFA out of the form: what went with it, for the document's warnings. + xfa_removed: Vec, /// Pages in the document, for `xfa.layout.pageCount()`. pages: usize, date: Option, @@ -2068,6 +2239,7 @@ impl EditCtx { xfa_out: Default::default(), xfa_datasets: None, xfa_ran_away: false, + xfa_removed: Vec::new(), pages: 0, date: now.map(pdfcraft_cos::pdf_date), today: (1970, 1, 1), @@ -2130,13 +2302,16 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> pdfcraft_organize::replace_pages(doc, pages, &src, src_pages)?; } Edit::SetPageBox { pages, which, spec } => pdfcraft_organize::set_page_box(doc, pages, *which, *spec)?, + Edit::SetTransition { pages, spec } => pdfcraft_organize::set_transition(doc, pages, spec)?, Edit::InsertPagesFrom { name, bytes, pages, at, field_conflicts } => { let src = open_source(name, bytes)?; let pages = match pages { Some(p) => p.clone(), None => (0..pdfcraft_organize::page_count(&src)?).collect(), }; - let outcome = pdfcraft_organize::import_pages_with(doc, &src, &pages, *at, *field_conflicts)?; + // The file's bookmarks come along under one named after it, as in Combine Files. + let title = std::path::Path::new(name).file_stem().map_or_else(|| name.clone(), |s| s.to_string_lossy().into_owned()); + let outcome = pdfcraft_organize::insert_file_pages(doc, &src, &title, &pages, *at, *field_conflicts)?; cx.renamed_fields = outcome.renamed_fields; } Edit::AddBookmark { parent, index, title, page } => { @@ -2205,6 +2380,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, endings.as_deref(), &cx.meta())?; } Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?, + Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?, Edit::SetAnnotationInfo { page, index, author, subject, icon } => { pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?; } @@ -2284,6 +2460,9 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); a11y::set_alt(doc, r, alt.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; } + Edit::AutoTagFormFields => { + a11y::tag_form_fields(doc).map_err(EditError::Accessibility)?; + } Edit::SetTagProperty { object, property, value } => { a11y::set_tag_property(doc, *object, *property, value.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; } @@ -2331,6 +2510,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> } pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0) } + ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep), ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete, }; pdfcraft_edit::change_image(doc, *page, *index, &c)?; @@ -2346,6 +2526,9 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); a11y::mark_decorative(doc, r).map_err(|e| EditError::Accessibility(e.to_string()))?; } + Edit::MarkTagArtifact { object } => { + a11y::mark_tag_artifact(doc, *object).map_err(|e| EditError::Accessibility(e.to_string()))?; + } Edit::AddHeaderFooter { pages, settings, replace } => { let date = cx.today; pdfcraft_edit::add_header_footer(doc, pages, settings, *replace, &pdfcraft_edit::Context { date })?; @@ -2445,8 +2628,26 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> pdfcraft_redact::sanitize::sanitize(doc)?; } Edit::Flatten { comments, fields } => { + // An XFA form's fields baked into the pages: the XFA goes too, or Adobe's viewers + // would lay the form out again from its template over the baked pages. A dynamic + // form that was never laid out keeps its XFA: without it only placeholders remain. + let shape = (*fields).then(|| pdfcraft_xfa::xfa_shape(doc)).flatten(); let n = pdfcraft_model::pages(doc).len(); pdfcraft_edit::flatten(doc, &(0..n).collect::>(), *comments, *fields)?; + match shape { + Some(shape) if shape.strippable() => strip_xfa(doc, cx)?, + Some(_) => cx.xfa_removed.push("The XFA was kept: this dynamic form was not laid out, so its fields were not baked in".into()), + None => {} + } + } + Edit::XfaToStatic => { + let Some(shape) = pdfcraft_xfa::xfa_shape(doc) else { + return Err(EditError::Invalid("this document is not an XFA form".into())); + }; + if !shape.strippable() { + return Err(EditError::Invalid("this dynamic XFA form was not laid out, so there is no static form to keep".into())); + } + strip_xfa(doc, cx)?; } Edit::FlattenFillSign => { let n = pdfcraft_model::pages(doc).len(); @@ -2677,6 +2878,11 @@ pub struct Session { next_id: u64, /// Seconds since the Unix epoch, injected so saves are deterministic in tests. clock: Option i64>, + /// Seconds to add to UTC for local time, read from whoever knows the zone (a browser, say). + /// Separate from `clock` on purpose: `clock` alone is the test shortcut that takes the + /// injected time AS local time and zeroes the zone, so injecting only it leaves either the + /// local dates or the UTC ones wrong by the zone's offset. + utc_offset: Option i64>, /// Certificates trusted for signing (Acrobat: Trusted Certificates). trust: Arc, /// Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript, inverted (on by default). @@ -2832,6 +3038,62 @@ fn decode_data_file(bytes: &[u8]) -> Option> { } } +/// Take the XFA out of `doc` and note what went with it: the packets, data no field showed, +/// buttons that did nothing but run scripts, and fields whose value differs from the form's +/// data (data PdfCraft could not read into them, now lost). +fn strip_xfa(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx) -> Result<(), EditError> { + let fields = pdfcraft_forms::fields(doc); + let data = xfa_field_data(doc); + let differ = pdfcraft_xfa::read_values(doc, &data) + .into_iter() + .filter(|(name, value)| { + let Some(f) = fields.iter().find(|f| f.name == *name) else { return false }; + match value { + pdfcraft_xfa::FieldData::Text(t) => f.value.join("\n") != *t && !t.trim().is_empty(), + pdfcraft_xfa::FieldData::Check(on) => f.value.is_empty() == *on, + pdfcraft_xfa::FieldData::Radio(sel) => f.value.first() != sel.as_ref(), + pdfcraft_xfa::FieldData::None => false, + } + }) + .count(); + let r = pdfcraft_xfa::strip(doc).map_err(|e| EditError::Write(e.to_string()))?; + cx.xfa_removed.extend(r.notes()); + if differ > 0 { + cx.xfa_removed.push(format!( + "{differ} field(s) held a value other than the form's XFA data (data PdfCraft could not read into them); the data's version is gone" + )); + } + Ok(()) +} + +/// Keep the document's XFA state in step with its file after an edit: a script may have laid +/// the form out again (rows added, subforms shown), an edit may have taken the XFA out +/// (flattened, or made static), or an undo put it back. +fn sync_xfa_state(doc: &mut Document) { + let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return }; + match pdfcraft_xfa::existing_layout(cos) { + Some(report) => { + if doc.xfa.is_none() { + // Back after an undo: the XFA's own warnings apply again, the strip's don't. + doc.xfa_warnings.retain(|w| !w.starts_with(XFA_REMOVED_PREFIX)); + if doc.xfa_template.is_none() && !doc.xfa_scripts_off { + doc.xfa_template = xfa::template(cos); + } + } + doc.xfa = Some(report); + } + None => { + doc.xfa = None; + doc.xfa_template = None; + // Only what the strip said stays (the XFA's own warnings no longer apply). + doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); + } + } +} + +/// How the warnings of a strip start, so they can be told from the XFA's own. +const XFA_REMOVED_PREFIX: &str = "XFA removed: "; + /// Is this an XFA data file (an XDP, a datasets document, a data element or a data document), /// rather than XFDF, Acrobat's `` XML or a table? By its root element. fn is_xfa_data_file(text: &str) -> bool { @@ -2959,6 +3221,28 @@ impl Session { self } + /// A real clock for a host that has neither a clock nor a time zone of its own (a + /// `wasm32-unknown-unknown` build: `now()` would be empty and `local_utc_offset()` is 0, so + /// Fill & Sign stamps 1 Jan 1970 and `/ModDate` is never written). `clock` is the UTC time in + /// seconds since the epoch; `utc_offset` is the local zone's seconds east of UTC, asked on every + /// use because the zone changes with the date (daylight saving). Unlike + /// [`Session::with_clock`], which is a test shortcut, nothing here is taken as local time. + pub fn with_wall_clock(mut self, clock: fn() -> i64, utc_offset: fn() -> i64) -> Self { + self.clock = Some(clock); + self.utc_offset = Some(utc_offset); + self + } + + /// Seconds to add to UTC for the local time of this session: the injected zone, else 0 under + /// the test clock (which is already local), else the system's. + fn local_offset(&self) -> i64 { + match (self.utc_offset, self.clock) { + (Some(zone), _) => zone(), + (None, Some(_)) => 0, + (None, None) => local_utc_offset(), + } + } + /// Seconds since the Unix epoch from the session clock (0 when unknown). pub fn now_secs(&self) -> i64 { self.now().unwrap_or(0) @@ -2978,7 +3262,7 @@ impl Session { /// is taken as local time. /// A dynamic stamp's second line: "By Ada at 2:14 pm, Oct 02, 2026" (local time). pub fn stamp_by_line(&self, author: &str) -> String { - let offset = if self.clock.is_some() { 0 } else { local_utc_offset() }; + let offset = self.local_offset(); let d = self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).unwrap_or_default(); let num = |a: usize, b: usize| d.get(a..b).and_then(|s| s.parse::().ok()).unwrap_or(0); let (y, mo, day, hh, mm) = (num(2, 6), num(6, 8), num(8, 10), num(10, 12), num(12, 14)); @@ -2989,7 +3273,7 @@ impl Session { } pub fn today(&self) -> (i64, u32, u32) { - let offset = if self.clock.is_some() { 0 } else { local_utc_offset() }; + let offset = self.local_offset(); self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).as_deref().and_then(parse_ymd).unwrap_or((1970, 1, 1)) } @@ -3174,6 +3458,31 @@ impl Session { }, (_, cos) => (bytes, info, cos), }; + // The catalog leads to no page, but the reader found pages by scanning: gather the page + // objects into a page tree as one more revision, or page edits address none of them + // (#1656). Kept only when the reader then shows the same pages, and never on a signed + // document, where writing the repair could rewrite the file under its signatures. + let (bytes, info, cos) = match cos { + Ok(Ok(cos)) if !info.pages.is_empty() && pdfcraft_organize::page_count(&cos).unwrap_or(0) == 0 => { + let mut work = cos.clone(); + let signed = || pdfcraft_sign::pdf::list(&cos, &bytes, &context.trust).iter().any(|s| s.signed); + let rebuilt = match pdfcraft_organize::recover_page_tree(&mut work) { + Ok(n) if n > 0 && !signed() => rebase(&work).ok().filter(|(_, i, _)| same_pages(&i.pages, &info.pages)), + _ => None, + }; + match rebuilt { + Some((b, i, mut c)) => { + for line in cos.repair_log() { + c.note_repair(line.clone()); + } + c.note_repair(format!("the page tree is missing; its {} pages were found by scanning the file", i.pages.len())); + (b, i, Ok(Ok(c))) + } + None => (bytes, info, Ok(Ok(cos))), + } + } + cos => (bytes, info, cos), + }; // XFA notes survive the document being re-read after edits. let xfa_warnings: Vec = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() }; let mut document = Self::build_document(&context.trust, name, path, bytes, info, cos, render_password, password, xfa); @@ -3205,9 +3514,12 @@ impl Session { let (mut editor, read_only_reason) = match cos { Ok(Ok(cos)) => { let keys = Keys { render: render_password.clone(), reopen: password.map(str::to_owned) }; + let saved = cos.bytes().clone(); ( Some(Editor { cos, + saved, + saved_keys: keys.clone(), undo: Vec::new(), redo: Vec::new(), keys, @@ -3241,14 +3553,6 @@ impl Session { }; e.cos.note_repair(line); } - let (marks, added) = editor - .as_ref() - .map(|e| { - let scan = pdfcraft_edit::scan_markers(&e.cos); - (scan.marks, scan.added) - }) - .unwrap_or_default(); - let links = editor.as_ref().map(|e| pdfcraft_annot::links::list(&e.cos)).unwrap_or_default(); let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default()); let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, trust, &sig_cache)).unwrap_or_default(); Document { @@ -3265,9 +3569,8 @@ impl Session { snapshot_generation: 0, read_only_reason, form: Arc::new(form), - marks, - added, - links, + marker_scan: OnceLock::new(), + links: OnceLock::new(), signatures, trust: trust.clone(), sig_cache, @@ -3418,8 +3721,9 @@ impl Session { } // Signed documents are only ever saved incrementally: an edit that needs a full rewrite // (applying redactions, changing security, sanitizing) would invalidate the signatures. + // So does any edit to a file rebuilt from object headers: it has no chain to append to. let rewrites = |c: &pdfcraft_cos::Document| c.full_save_required() || c.encryption_changed(); - if signed && rewrites(&next) && !rewrites(&editor.cos) { + if signed && ((rewrites(&next) && !rewrites(&editor.cos)) || (next.is_modified() && next.revisions().is_empty())) { return Err(EditError::SignedRewrite(edit.label().to_string())); } // What this edit drew with `?` (a value or comment text its font can't draw, stored as @@ -3458,6 +3762,12 @@ impl Session { doc.dirty = true; doc.generation += 1; doc.edit_notices = notices; + if !cx.xfa_removed.is_empty() { + let removed: Vec = cx.xfa_removed.iter().map(|n| format!("{XFA_REMOVED_PREFIX}{n}")).collect(); + doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); + note_warnings(&mut doc.xfa_warnings, &removed); + note_warnings(&mut doc.info.warnings, &removed); + } note_warnings(&mut doc.xfa_warnings, &xfa_notes); let notes = doc.xfa_warnings.clone(); note_warnings(&mut doc.info.warnings, ¬es); @@ -3483,7 +3793,7 @@ impl Session { editor.redo.push((label.clone(), current, current_keys, scope)); Self::adopt_keys(doc); Self::refresh_scoped(doc, scope).inspect_err(|_| Self::show_released(doc))?; - doc.dirty = true; + Self::sync_dirty(doc); doc.generation += 1; Ok(label) } @@ -3498,11 +3808,18 @@ impl Session { editor.undo.push((label.clone(), current, current_keys, scope)); Self::adopt_keys(doc); Self::refresh_scoped(doc, scope).inspect_err(|_| Self::show_released(doc))?; - doc.dirty = true; + Self::sync_dirty(doc); doc.generation += 1; Ok(label) } + /// Unsaved when the working graph is not the last opened or saved file. Undo back to that + /// file is clean; undo past a later save is not, because the restored snapshot's own base + /// is an earlier revision. + fn sync_dirty(doc: &mut Document) { + doc.dirty = doc.editor.as_ref().is_some_and(|e| e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved)); + } + /// Use the current state's password for viewing (protection edits change it). fn adopt_keys(doc: &mut Document) { if let Some(e) = doc.editor.as_ref() { @@ -3522,6 +3839,9 @@ impl Session { if scope == Scope::Content { return Self::refresh_content(doc, None); } + if scope == Scope::Links { + return Self::refresh_links(doc); + } // Metadata edits keep the renderer, which holds the working file anyway. if scope != Scope::Metadata { Self::release_working(doc); @@ -3559,7 +3879,11 @@ impl Session { let form = Arc::new(form); match scope { Scope::Metadata => return Ok(()), - Scope::Comments => doc.info.annotations = comment_list(&editor.cos), + Scope::Comments => { + doc.info.annotations = comment_list(&editor.cos); + // Link indexes are positions in /Annots, so comment insertion/deletion can move them. + doc.links = OnceLock::new(); + } Scope::Form => { // PERF: keystroke path, index once. A linear find per field is O(F^2); // forms reach 10k fields. @@ -3580,6 +3904,7 @@ impl Session { } } } + Scope::Links => return Self::refresh_links(doc), // Handled above; kept as a full refresh so the match stays total. Scope::Full => return Self::refresh(doc), // Handled above (content-only refresh); the arm keeps the match total. @@ -3619,13 +3944,8 @@ impl Session { /// appearances are kept: content edits leave every annotation dictionary alone. fn refresh_content(doc: &mut Document, edit: Option<&Edit>) -> Result<(), EditError> { Self::release_working(doc); + sync_xfa_state(doc); let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; - // A script may have laid an XFA form out again (rows added, subforms shown). - if doc.xfa.is_some() - && let Some(report) = pdfcraft_xfa::existing_layout(&editor.cos) - { - doc.xfa = Some(report); - } let bytes = if editor.cos.is_modified() { Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) } else { @@ -3634,11 +3954,8 @@ impl Session { // The edit must still leave a file the viewer can open; anything else rolls back above. pdfcraft_render::check_pages(bytes.clone(), doc.password.as_deref(), doc.info.pages.len()).map_err(|e| EditError::Reopen(e.to_string()))?; let (touches_added, touches_marks) = Self::content_refresh_lists(edit); - if touches_added { - doc.added = pdfcraft_edit::list_added(&editor.cos); - } - if touches_marks { - doc.marks = pdfcraft_edit::marks_present(&editor.cos); + if touches_added || touches_marks { + doc.marker_scan = OnceLock::new(); } doc.info.file_size = bytes.len(); if !doc.signatures.is_empty() { @@ -3675,19 +3992,23 @@ impl Session { /// Rebuild working bytes, inspection and renderer from the current edit state. fn refresh(doc: &mut Document) -> Result<(), EditError> { + Self::refresh_with_indexes(doc, true, true) + } + + /// Reinspect a link edit while preserving the page-content marker scan cache. + fn refresh_links(doc: &mut Document) -> Result<(), EditError> { + Self::refresh_with_indexes(doc, false, true) + } + + fn refresh_with_indexes(doc: &mut Document, invalidate_markers: bool, invalidate_links: bool) -> Result<(), EditError> { // A full refresh may follow structural or annotation changes: cached appearances // may be stale, so they are rebuilt below. if let Some(editor) = doc.editor.as_mut() { editor.appearances.clear(); } Self::release_working(doc); + sync_xfa_state(doc); let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; - // A script may have laid an XFA form out again (rows added, subforms shown). - if doc.xfa.is_some() - && let Some(report) = pdfcraft_xfa::existing_layout(&editor.cos) - { - doc.xfa = Some(report); - } let bytes = if editor.cos.is_modified() { Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) } else { @@ -3707,10 +4028,12 @@ impl Session { let mut form = pdfcraft_forms::fields(&editor.cos); xfa::mark_script_buttons(&editor.cos, &mut form); doc.form = Arc::new(form); - let marker_scan = pdfcraft_edit::scan_markers(&editor.cos); - doc.marks = marker_scan.marks; - doc.added = marker_scan.added; - doc.links = pdfcraft_annot::links::list(&editor.cos); + if invalidate_markers { + doc.marker_scan = OnceLock::new(); + } + if invalidate_links { + doc.links = OnceLock::new(); + } doc.signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); doc.display = display_bytes(Some(editor), &bytes); doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); @@ -3723,11 +4046,27 @@ impl Session { pub fn save_bytes(&self, id: DocId) -> Result>, EditError> { let doc = self.get(id).ok_or(EditError::NoDocument)?; let Some(editor) = doc.editor.as_ref() else { return Ok(doc.bytes.clone()) }; - if !editor.cos.is_modified() { - return Ok(editor.cos.bytes().clone()); + let on_saved = Arc::ptr_eq(editor.cos.bytes(), &editor.saved); + if !editor.cos.is_modified() && on_saved { + return Ok(editor.saved.clone()); } let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - guard(|| write_incremental(&editor.cos, &opts)).map_err(EditError::Write)?.map(Arc::new).map_err(|e| EditError::Write(e.to_string())) + // A restored snapshot can be based on an earlier revision. Append to the last saved + // file so that revision stays in the chain. A full rewrite (redaction, new encryption, + // a reconstructed file) still writes the current graph on its own. + let rewrite = editor.cos.full_save_required() || editor.cos.encryption_changed() || editor.cos.revisions().is_empty(); + guard(|| { + if on_saved || rewrite { + write_incremental(&editor.cos, &opts) + } else { + let mut base = pdfcraft_cos::Document::open_with_password(editor.saved.clone(), editor.saved_keys.reopen.as_deref())?; + base.overlay_changes_from(&editor.cos)?; + write_incremental(&base, &opts) + } + }) + .map_err(EditError::Write)? + .map(Arc::new) + .map_err(|e| EditError::Write(e.to_string())) } /// A compact, garbage-collected rewrite (Save As ▸ "Optimized" / Reduce File Size groundwork). @@ -3758,8 +4097,11 @@ impl Session { if let Some(editor) = doc.editor.as_mut() { // An encrypted file needs the password it is protected with now (the owner // password when known, so saving never downgrades this session's rights). - editor.cos = pdfcraft_cos::Document::open_with_password(bytes.clone(), editor.keys.reopen.as_deref()) - .map_err(|e| EditError::Reopen(e.to_string()))?; + let keys = editor.keys.clone(); + editor.cos = + pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; + editor.saved = bytes; + editor.saved_keys = keys; } if let Some(p) = path { doc.name = std::path::Path::new(&p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone()); @@ -3808,10 +4150,10 @@ impl Session { pub fn revert(&mut self, id: DocId) -> Result<(), EditError> { let doc = self.doc_mut(id)?; let editor = doc.editor.as_mut().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; - let base = editor.cos.bytes().clone(); - // The saved file opens with the passwords of the state that was saved: try the current - // ones, then each earlier state's. - let mut candidates = vec![editor.keys.clone()]; + let base = editor.saved.clone(); + // The saved file opens with the passwords it was saved with. The current snapshot may + // be an older revision (undo past that save), so its own base is not the saved file. + let mut candidates = vec![editor.saved_keys.clone(), editor.keys.clone()]; candidates.extend(editor.undo.iter().rev().map(|s| s.2.clone())); candidates.push(Keys::default()); let (cos, keys) = candidates @@ -3964,6 +4306,26 @@ impl Session { if !doc.allows_printing() { return Err(EditError::NotPermitted("printing")); } + // Print as image: the pages used become pictures first, then the picture document is + // imposed in their place (its page k is the k-th distinct source page used). + if let Some(raster) = &settings.raster { + let mut distinct: Vec = Vec::new(); + for &p in &settings.pages { + if !distinct.contains(&p) { + distinct.push(p); + } + } + let hide_comments = matches!(settings.content, print::Content::Document | print::Content::FormFieldsOnly); + let bytes = print_raster::rasterize(doc, &distinct, raster, hide_comments).map_err(EditError::Print)?; + let pictures = pdfcraft_cos::Document::open(Arc::new(bytes)).map_err(|e| EditError::Print(e.to_string()))?; + let remapped = print::Settings { + pages: settings.pages.iter().map(|p| distinct.iter().position(|d| d == p).unwrap_or(0)).collect(), + content: print::Content::Document, + raster: None, + ..settings.clone() + }; + return pdfcraft_print::impose(&pictures, &remapped).map_err(|e| EditError::Print(e.to_string())); + } let cos = match doc.editor.as_ref() { Some(e) => e.cos.clone(), None => { @@ -3973,6 +4335,20 @@ impl Session { pdfcraft_print::impose(&cos, settings).map_err(|e| EditError::Print(e.to_string())) } + /// For each page, whether its text mostly runs bottom-to-top ([`pdfcraft_print::text_reads_up`]); + /// only `pages` are read, the others are `false`. + pub fn print_text_up(&self, id: DocId, pages: &[usize]) -> Vec { + let Some(doc) = self.get(id) else { return Vec::new() }; + let cos = match doc.editor.as_ref() { + Some(e) => e.cos.clone(), + None => match pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()) { + Ok(c) => c, + Err(_) => return Vec::new(), + }, + }; + (0..doc.info.pages.len()).map(|i| pages.contains(&i) && pdfcraft_print::text_reads_up(&cos, i)).collect() + } + /// Combine whole files, in order, into new PDF bytes (one bookmark per file). pub fn combine(&self, sources: &[(String, Arc>)]) -> Result>, EditError> { let docs = sources.iter().map(|(n, b)| open_source(n, b)).collect::, _>>()?; @@ -4166,7 +4542,7 @@ impl Session { let mut cuts = Vec::new(); let mut used = 0usize; for p in 0..n { - let size = self.write_new(&pdfcraft_organize::extract_pages(src, &[p])?)?.len(); + let size = self.write_unchecked(&pdfcraft_organize::extract_pages(src, &[p])?)?.len(); if used > 0 && used + size > max_bytes { cuts.push(p); used = 0; @@ -4209,7 +4585,7 @@ impl Session { ] }) .filter(|r| { - !doc.links.iter().any(|l| l.page == page && l.rect[0] < r[2] && r[0] < l.rect[2] && l.rect[1] < r[3] && r[1] < l.rect[3]) + !doc.links().iter().any(|l| l.page == page && l.rect[0] < r[2] && r[0] < l.rect[2] && l.rect[1] < r[3] && r[1] < l.rect[3]) }) .collect(); if !rects.is_empty() { @@ -4226,7 +4602,17 @@ impl Session { let doc = self.get(id)?; if let Some(editor) = doc.editor.as_ref() { let page = pdfcraft_organize::bookmark_page(&editor.cos, offset, limit); - let rows = page.bookmarks.into_iter().map(|b| BookmarkRow { path: b.path, title: b.title, open: b.open, page: b.page }).collect(); + let rows = page + .bookmarks + .into_iter() + .map(|b| { + let view = b.view.map_or_else(Default::default, |(kind, numbers)| { + let numbers: Vec> = numbers.iter().map(|n| n.map(|n| n as f32)).collect(); + pdfcraft_render::DestView::from_operands(Some(&kind), &numbers) + }); + BookmarkRow { path: b.path, title: b.title, open: b.open, page: b.page, view } + }) + .collect(); return Some(BookmarkPage { rows, next: page.next, truncated: false }); } let all = outline_rows(&doc.info.outline); @@ -4264,7 +4650,18 @@ impl Session { doc.editor.as_ref().map(|e| &e.cos).ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default())) } + /// A new file (Combine, Extract, Split, Create) as bytes, checked like a save before anyone + /// writes it (#1698, #2166): it reopens, and shows the pages it was built from. fn write_new(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { + let bytes = self.write_unchecked(doc)?; + let expected = pdfcraft_organize::page_count(doc)?; + pdfcraft_cos::Document::open(bytes.clone()).map_err(|e| EditError::Reopen(e.to_string()))?; + pdfcraft_render::check_pages(bytes.clone(), None, expected).map_err(|e| EditError::Reopen(e.to_string()))?; + Ok(bytes) + } + + /// [`Self::write_new`] without the check, for bytes nobody writes: measuring a part's size. + fn write_unchecked(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; write_full(doc, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string())) } @@ -4383,7 +4780,7 @@ impl Session { /// The signing time as a PDF date in local time with its offset (`D:…+02'00'`). pub fn signing_date(&self) -> String { - let offset = if self.clock.is_some() { 0 } else { local_utc_offset() }; + let offset = self.local_offset(); let local = pdfcraft_cos::pdf_date(self.now().unwrap_or(0) + offset); let stamp = local.trim_end_matches('Z'); if offset == 0 { @@ -4420,7 +4817,10 @@ impl Session { if opts.date.is_empty() { opts.date = self.signing_date(); } - Ok(Arc::new(pdfcraft_sign::sign_with_timestamp(&editor.cos, id, &opts, tsa)?)) + let signed = Arc::new(pdfcraft_sign::sign_with_timestamp(&editor.cos, id, &opts, tsa)?); + // Checked like a save before anyone writes it (#2166). + self.check_save(doc, &signed)?; + Ok(signed) } /// Append a standalone document timestamp (RFC 3161, `/ETSI.RFC3161`) covering the file's @@ -4429,7 +4829,9 @@ impl Session { let d = self.get(doc).ok_or(EditError::NoDocument)?; let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; let date = if date.is_empty() { self.signing_date() } else { date }; - Ok(Arc::new(pdfcraft_sign::timestamp_document(&editor.cos, tsa, &date)?)) + let stamped = Arc::new(pdfcraft_sign::timestamp_document(&editor.cos, tsa, &date)?); + self.check_save(doc, &stamped)?; + Ok(stamped) } /// Embed revocation evidence into the catalog's `/DSS` with `/VRI` entries per signature @@ -4437,7 +4839,9 @@ impl Session { pub fn embed_ltv(&self, doc: DocId, evidence: &pdfcraft_sign::dss::Evidence) -> Result>, EditError> { let d = self.get(doc).ok_or(EditError::NoDocument)?; let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; - Ok(Arc::new(pdfcraft_sign::dss::embed(&editor.cos, evidence)?)) + let embedded = Arc::new(pdfcraft_sign::dss::embed(&editor.cos, evidence)?); + self.check_save(doc, &embedded)?; + Ok(embedded) } /// Record that the signed file `bytes` was saved (to `path`): like [`Session::mark_saved`], diff --git a/crates/engine/src/ocr.rs b/crates/engine/src/ocr.rs index 6bf6dc9c7..dcd4142d7 100644 --- a/crates/engine/src/ocr.rs +++ b/crates/engine/src/ocr.rs @@ -30,6 +30,10 @@ pub struct OcrSettings { /// text read again, not a word in a picture. const ALREADY_ON_PAGE: f32 = 0.5; +/// The long side of an A4 sheet in points (a letter page's, 792, is shorter): "Downsample to" +/// is a resolution on paper this size or smaller. +const SHEET: f32 = 842.0; + /// How much of `word` the `text` boxes cover, from 0 to 1. Boxes are `[x0, y0, x1, y1]` in page /// view space. Overlaps between the text boxes are counted twice (glyphs rarely overlap), so the /// sum is capped at the whole word. @@ -121,11 +125,7 @@ impl OcrJob { out.push(skip("the page already contains text")); continue; } - // A scan is read at its own resolution (enlarging it only blurs the letters), at most - // the chosen one. - let wanted = self.settings.dpi.clamp(72.0, 600.0) / 72.0; - let scale = self.native.get(page).copied().flatten().map_or(wanted, |n| n.clamp(1.0, wanted)); - let shot = r.render(RenderRequest { page, scale, ..Default::default() }); + let shot = r.render(RenderRequest { page, scale: self.scale(page, info), ..Default::default() }); if let Some(e) = shot.error { out.push(skip(&e)); continue; @@ -151,6 +151,18 @@ impl OcrJob { progress(total, total); out } + + /// The render scale (pixels per point) `page` is read at. A scan is read at its own + /// resolution (enlarging it only blurs the letters), at most the chosen one. + fn scale(&self, page: usize, info: &PageInfo) -> f32 { + let wanted = self.settings.dpi.clamp(72.0, 600.0) / 72.0; + let Some(native) = self.native.get(page).copied().flatten() else { return wanted }; + // A scan whose page is larger than a sheet (often a box given in pixels, one point per + // pixel, #1848) is measured as if shrunk to one: otherwise 1 px/pt is already its full + // image, and no "Downsample to" choice could ask for less. + let fit = (SHEET / info.width.max(info.height).max(1e-3)).min(1.0); + native.clamp(fit, wanted * fit) + } } impl Session { @@ -236,12 +248,16 @@ pub fn recognize_file( if s.apply_ocr(id, &pages).map_err(|e| e.to_string())? == 0 { return Ok(FileResult { bytes, pages }); } - Ok(FileResult { bytes: s.save_bytes(id).map_err(|e| e.to_string())?, pages }) + // Checked like a save before the caller writes it (#2166). + let bytes = s.save_bytes(id).map_err(|e| e.to_string())?; + s.check_save(id, &bytes).map_err(|e| e.to_string())?; + Ok(FileResult { bytes, pages }) } #[cfg(test)] mod tests { use super::*; + use crate::export; /// #1028: a recognised word over the page's own text is that text again; one in an empty /// part of the page (a picture) is new. @@ -260,6 +276,28 @@ mod tests { assert_eq!(covered([100.5, 52.0, 134.0, 61.0], &[]), 0.0); } + /// #1848: an A4 scan at 300 dpi whose page box is its pixel size (2480 x 3508 pt, one pixel + /// per point) gets a smaller bitmap for each lower "Downsample to" choice, and a low-resolution + /// letter page is still never shrunk below one pixel per point. + #[test] + fn downsample_shrinks_a_page_box_given_in_pixels() { + let mut s = Session::new(); + let page = |w: u32, h: u32| export::encode_png(w, h, &vec![255; (w * h * 4) as usize]).unwrap(); + let bytes = s.create_from_images(&[("a4.png".into(), page(2480, 3508)), ("letter.png".into(), page(612, 792))]).unwrap(); + let id = s.open("scan.pdf", None, bytes, None).unwrap(); + let mut r = PageRenderer::new(s.get(id).unwrap().bytes.clone(), RenderConfig::default()); + for (dpi, a4) in [(72.0, [595, 842]), (150.0, [1240, 1754]), (300.0, [2480, 3508]), (600.0, [2480, 3508])] { + let job = s.ocr_job(id, &[], OcrSettings { dpi, ..Default::default() }).unwrap(); + for (page, want) in [(0, a4), (1, [612, 792])] { + let scale = job.scale(page, &job.infos[page]); + let shot = r.render(RenderRequest { page, scale, ..Default::default() }); + assert!(shot.error.is_none(), "{:?}", shot.error); + let near = |got: u32, want: u32| got.abs_diff(want) <= 1; + assert!(near(shot.width, want[0]) && near(shot.height, want[1]), "{dpi} dpi, page {page}: {}x{}", shot.width, shot.height); + } + } + } + #[test] fn coverage_of_overlapping_or_broken_boxes_stays_between_zero_and_one() { // A glyph drawn twice (fake bold) does not cover more than the whole word. diff --git a/crates/engine/src/optimizer.rs b/crates/engine/src/optimizer.rs index 569dd81b0..ee8c957f9 100644 --- a/crates/engine/src/optimizer.rs +++ b/crates/engine/src/optimizer.rs @@ -17,6 +17,8 @@ pub enum OptimizeStage { /// Removing the user data chosen under Discard User Data. #[default] Discarding, + /// Merging the fragments of a page picture into one image. + Fragments, /// About to process image `done` (0-based) of `total`. Images { done: usize, total: usize }, /// Discarding objects and compressing unencoded streams. @@ -33,6 +35,7 @@ impl OptimizeStage { pub fn fraction(self) -> f32 { match self { OptimizeStage::Discarding => 0.0, + OptimizeStage::Fragments => 0.03, OptimizeStage::Images { done, total } => 0.05 + 0.75 * if total == 0 { 1.0 } else { done.min(total) as f32 / total as f32 }, OptimizeStage::CleaningUp => 0.8, OptimizeStage::Merging => 0.85, @@ -85,6 +88,7 @@ impl OptimizeJob { let discarded = if discard.is_empty() { Vec::new() } else { pdfcraft_redact::sanitize::remove_hidden(&mut cos, &discard)? }; let report = optimize::optimize_with_progress(&mut cos, &settings, &mut |s| { step(match s { + optimize::Stage::Fragments => OptimizeStage::Fragments, optimize::Stage::Images { done, total } => OptimizeStage::Images { done, total }, optimize::Stage::CleanUp => OptimizeStage::CleaningUp, }) diff --git a/crates/engine/src/print_raster.rs b/crates/engine/src/print_raster.rs new file mode 100644 index 000000000..117c8e14c --- /dev/null +++ b/crates/engine/src/print_raster.rs @@ -0,0 +1,61 @@ +//! Print as image (Acrobat's Advanced Print Setup ▸ Print As Image): every page +//! sent to the printer as a picture at a chosen resolution, for printers that skip text, +//! shading or transparency. The pages are rendered the way the viewer shows them (reading +//! colours off, the print's own colours), made into an image-only document, and that document +//! is imposed on the paper like any other. The print is no longer searchable text. + +use pdfcraft_create::ImageResolution; +use pdfcraft_print::Raster; +use pdfcraft_render::{PRINT_TAG, PageRenderer, RenderRequest}; + +use crate::Document; +use crate::export::encode_png; + +/// `doc`'s pages `pages` (0-based, each once, in the order given) as an image-only PDF, one +/// page per entry, the size of the source page as displayed. Pages too large for the renderer +/// at `raster.dpi` render at the largest resolution it allows. +pub fn rasterize(doc: &Document, pages: &[usize], raster: &Raster, hide_comments: bool) -> Result, String> { + let dpi = raster.dpi.clamp(72.0, 600.0); + let src = doc.export_source(); + let mut config = src.config.clone(); + config.hide_comments = hide_comments; + let mut renderer = PageRenderer::new(src.bytes, config); + let mut images: Vec<(String, Vec)> = Vec::with_capacity(pages.len()); + let mut used: Option = None; + for &page in pages { + let (w, h) = src.sizes.get(page).copied().ok_or_else(|| format!("page {} does not exist", page + 1))?; + let scale = pdfcraft_render::effective_scale(w, h, (dpi / 72.0) as f32); + let page_dpi = f64::from(scale) * 72.0; + // Every page of the image document shares one resolution, so its pages keep the + // source's sizes; the lowest a page needed is used for all. + used = Some(used.map_or(page_dpi, |u: f64| u.min(page_dpi))); + let r = renderer.render(RenderRequest { page, scale, tag: PRINT_TAG, ..Default::default() }); + if let Some(e) = r.error { + return Err(format!("page {}: {e}", page + 1)); + } + let mut rgba = r.rgba.to_vec(); + over_white(&mut rgba, raster.grayscale); + images.push((format!("page-{}.png", page + 1), encode_png(r.width, r.height, &rgba)?)); + } + let dpi = used.unwrap_or(dpi); + let doc = pdfcraft_create::from_images_with_resolution(&images, ImageResolution::Dpi(dpi)).map_err(|e| e.to_string())?; + pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string()) +} + +/// Premultiplied RGBA composed over white paper (opaque), in grey when asked (Rec. 601 luma). +fn over_white(rgba: &mut [u8], grayscale: bool) { + for px in rgba.as_chunks_mut::<4>().0 { + let gap = 255 - u32::from(px[3]); + let (r, g, b) = ((u32::from(px[0]) + gap).min(255), (u32::from(px[1]) + gap).min(255), (u32::from(px[2]) + gap).min(255)); + let (r, g, b) = if grayscale { + let y = ((299 * r + 587 * g + 114 * b) / 1000).min(255); + (y, y, y) + } else { + (r, g, b) + }; + px[0] = r as u8; + px[1] = g as u8; + px[2] = b as u8; + px[3] = 255; + } +} diff --git a/crates/engine/src/scan_fields.rs b/crates/engine/src/scan_fields.rs new file mode 100644 index 000000000..8287743fc --- /dev/null +++ b/crates/engine/src/scan_fields.rs @@ -0,0 +1,144 @@ +//! Prepare a form ▸ field detection on scanned pages. A page that is a picture has no text and +//! no drawn shapes to read, so it is rendered and its lines and boxes are read from the pixels +//! ([`pdfcraft_forms::detect::raster`]). The labels come from the page's text layer (an earlier +//! Recognize text), else from text recognition when its models are installed. Without either, +//! the fields are still found and get the default names (Text1, Check Box1, …). +//! +//! Detection works in the page as displayed (after `/Rotate`), in points with y up, so a scan +//! stored sideways and turned upright by `/Rotate` reads like any other page. The fields are +//! mapped back to user space and turned with the page (`/MK /R`), so typed text reads upright. + +use pdfcraft_forms::detect::{Candidate, Word, detect, raster}; +use pdfcraft_render::{PageInfo, PageRenderer, PageText, RenderRequest}; + +use crate::{DocId, Document, Session, ocr}; + +/// Pixels per point the page is rendered at for its lines (144 dpi). +const SCALE: f32 = 2.0; +/// The longest side of that rendering, in pixels. +const MAX_SIDE: f32 = 3000.0; +/// A page is a scan when its images cover at least this share of it. +const COVERED: f64 = 0.5; +/// Recognised "words" less tall than this (points) are specks read as letters, and would make +/// the box they sit on look filled in. +const MIN_WORD_HEIGHT: f64 = 4.0; + +/// Whether `page` is a picture of a page: images cover most of it. +pub(crate) fn is_scan(doc: &Document, page: usize, info: &PageInfo) -> bool { + let [cx0, cy0, cx1, cy1] = info.crop.map(f64::from); + let page_area = (cx1 - cx0) * (cy1 - cy0); + if !page_area.is_finite() || page_area <= 0.0 { + return false; + } + let covered: f64 = doc + .page_images(page) + .iter() + .filter(|i| !i.is_form && i.width > 1 && i.height > 1) + .map(|i| { + let r = i.visible_rect.unwrap_or(i.rect); + let w = (r[2].min(cx1) - r[0].max(cx0)).max(0.0); + let h = (r[3].min(cy1) - r[1].max(cy0)).max(0.0); + w * h + }) + .filter(|a| a.is_finite()) + .sum(); + covered >= COVERED * page_area +} + +/// The page as displayed, in points with y up: where detection on a scan works. +struct Frame<'a>(&'a PageInfo); + +impl Frame<'_> { + /// The box around view-space points (y down). + fn view_box(&self, points: impl IntoIterator) -> Option<[f64; 4]> { + let h = f64::from(self.0.height); + let mut out = [f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY]; + for [x, y] in points { + let (x, y) = (f64::from(x), h - f64::from(y)); + out = [out[0].min(x), out[1].min(y), out[2].max(x), out[3].max(y)]; + } + out.iter().all(|v| v.is_finite()).then_some(out) + } + + /// A user-space rectangle (or the box around user-space points) in the frame. + fn user_box(&self, points: impl IntoIterator) -> Option<[f64; 4]> { + self.view_box(points.into_iter().map(|[x, y]| self.0.user_to_view(x as f32, y as f32))) + } + + /// A frame rectangle in user space. + fn user_rect(&self, r: [f64; 4]) -> [f64; 4] { + let h = f64::from(self.0.height); + let a = self.0.view_to_user(r[0] as f32, (h - r[1]) as f32); + let b = self.0.view_to_user(r[2] as f32, (h - r[3]) as f32); + [f64::from(a[0].min(b[0])), f64::from(a[1].min(b[1])), f64::from(a[0].max(b[0])), f64::from(a[1].max(b[1]))] + } +} + +fn corners(r: [f64; 4]) -> [[f64; 2]; 4] { + [[r[0], r[1]], [r[2], r[1]], [r[2], r[3]], [r[0], r[3]]] +} + +impl Session { + /// The fields on scanned `page` of `id`, in user space, and how far the page turns them + /// (its `/Rotate`). `text` is the page's text layer, `existing` its widget rectangles. + pub(crate) fn detect_on_scan( + &self, + id: DocId, + r: &mut PageRenderer, + page: usize, + text: Option<&PageText>, + existing: &[[f64; 4]], + taken: &[String], + ) -> (Vec, i64) { + let Some(info) = self.get(id).and_then(|d| d.info.pages.get(page)) else { return (Vec::new(), 0) }; + let frame = Frame(info); + let scale = SCALE.min(MAX_SIDE / info.width.max(info.height).max(1.0)); + let shot = r.render(RenderRequest { page, scale, ..Default::default() }); + if shot.error.is_some() || shot.width == 0 { + return (Vec::new(), 0); + } + let px_per_pt = f64::from(shot.width) / f64::from(info.width.max(1e-3)); + let shapes = raster::shapes(&shot.rgba, shot.width, shot.height, px_per_pt); + if shapes.boxes.is_empty() && shapes.rules.is_empty() { + return (Vec::new(), 0); + } + let mut words: Vec = text + .map(|t| crate::js::words_with(t, |x, y| [x, y])) + .unwrap_or_default() + .into_iter() + .filter_map(|(text, v)| Some(Word { text, rect: frame.view_box([[v[0], v[1]], [v[2], v[3]]])? })) + .collect(); + if words.is_empty() { + words = self.recognized_words(id, page, &frame); + } + let existing: Vec<[f64; 4]> = existing.iter().filter_map(|r| frame.user_box(corners(*r))).collect(); + let found = detect(&words, &shapes, &existing, taken); + let turn = i64::from(info.rotation % 360); + (found.into_iter().map(|c| Candidate { rect: frame.user_rect(c.rect), ..c }).collect(), turn) + } + + /// The words text recognition reads on `page`, in the frame; none without the models. + fn recognized_words(&self, id: DocId, page: usize, frame: &Frame) -> Vec { + if !ocr::available() { + return Vec::new(); + } + let (Ok(engine), Some(job)) = (ocr::engine(), self.ocr_job(id, &[page], ocr::OcrSettings::default())) else { + return Vec::new(); + }; + job.run(&engine, |_, _| true) + .into_iter() + .flat_map(|p| p.words) + .filter_map(|w| { + let o = w.origin; + let points = [ + o, + [o[0] + w.across[0], o[1] + w.across[1]], + [o[0] + w.up[0], o[1] + w.up[1]], + [o[0] + w.across[0] + w.up[0], o[1] + w.across[1] + w.up[1]], + ]; + Some(Word { rect: frame.user_box(points)?, text: w.text }) + }) + .filter(|w| w.rect[3] - w.rect[1] >= MIN_WORD_HEIGHT) + .collect() + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 37cafd440..ed90eea41 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -196,6 +196,59 @@ fn rubber_stamps_point_left_as_displayed_on_rotated_pages() { } } +/// Fill & Sign text and text boxes read across as displayed on turned pages (scanned forms are +/// often stored sideways with `/Rotate`), including after save. +#[test] +fn typed_text_reads_across_as_displayed_on_rotated_pages() { + for degrees in [0, 90, 180, 270] { + let (mut session, id) = rotated_blank(degrees); + let info = session.get(id).unwrap().info.pages[0].clone(); + // Displayed top-left of the text at (40, 60): a wide first line, then one letter below + // its start. + let at = info.view_to_user(40.0, 60.0); + let at = [f64::from(at[0]), f64::from(at[1])]; + let rect = text_box_at(degrees, at, 120.0, 2.0 * 12.0 * 1.2 + 6.0).unwrap(); + let shape = Shape::Typewriter { rect, font_size: 12.0 }; + let style = Style::default_for(&shape); + session + .apply( + id, + Edit::AddAnnotation(NewAnnotation { + page: 0, + style, + shape, + contents: "MMMMMMMM +I" + .into(), + author: "Test".into(), + }), + ) + .unwrap(); + let check = |session: &Session, id: DocId| { + let rendered = render_page(session, id); + let dark = |x: u32, y: u32| stamp_pixel(&rendered, x, y)[..3].iter().all(|c| *c < 128); + let ink: Vec<(u32, u32)> = + (0..rendered.height).flat_map(|y| (0..rendered.width).map(move |x| (x, y))).filter(|&(x, y)| dark(x, y)).collect(); + assert!(!ink.is_empty(), "rotation {degrees}: no text drawn"); + let (x0, x1) = (ink.iter().map(|p| p.0).min().unwrap(), ink.iter().map(|p| p.0).max().unwrap()); + let (y0, y1) = (ink.iter().map(|p| p.1).min().unwrap(), ink.iter().map(|p| p.1).max().unwrap()); + // Rendered at scale 2: the text starts at the clicked point and runs to the right. + assert!((x1 - x0) > 2 * (y1 - y0), "rotation {degrees}: ink {x0}..{x1} x {y0}..{y1} should be wide, not tall"); + assert!(x0.abs_diff(80) < 16 && y0.abs_diff(120) < 16, "rotation {degrees}: text starts at ({x0}, {y0}), not near (80, 120)"); + // The lone "I" is on the second line under the start of the first, not upside down + // (above the end). + let mid = (y0 + y1) / 2; + let lower: Vec = ink.iter().filter(|p| p.1 > mid + 2).map(|p| p.0).collect(); + assert!(!lower.is_empty() && lower.iter().all(|x| *x < x0 + (x1 - x0) / 4), "rotation {degrees}: second line is not under the start"); + }; + check(&session, id); + let saved = session.save_bytes(id).unwrap(); + let mut reopened = Session::new().with_clock(|| 1_700_000_000); + let again = reopened.open("drawing.pdf", None, saved, None).unwrap(); + check(&reopened, again); + } +} + /// A one-page PDF whose left half is red and whose right half is white, as displayed. fn half_red_page() -> Vec { let content = "1 0 0 rg 0 0 40 40 re f"; @@ -472,12 +525,85 @@ fn undo_still_works_after_save() { s.apply(id, Edit::DeletePages { pages: vec![0] }).unwrap(); let saved = s.save_bytes(id).unwrap(); s.mark_saved(id, saved, None).unwrap(); + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + s.undo(id).unwrap(); + let doc = s.get(id).unwrap(); + assert!(!doc.dirty, "undo back to the saved file is clean"); + assert_eq!(doc.info.pages.len(), 1); + assert_eq!(doc.info.pages[0].rotation, 0); s.undo(id).unwrap(); let doc = s.get(id).unwrap(); assert!(doc.dirty, "undoing past a save makes the document dirty again"); assert_eq!(doc.info.pages.len(), 2); } +#[test] +fn undo_to_the_opened_file_is_not_dirty() { + let mut session = Session::new().with_clock(|| 1_700_000_000); + let opened = Arc::new(fixture(2)); + let id = session.open("existing.pdf", None, opened.clone(), None).unwrap(); + assert!(!session.get(id).unwrap().dirty); + assert!(session.autosave_snapshots().is_empty()); + + session.apply(id, Edit::DeletePages { pages: vec![0] }).unwrap(); + assert_eq!(session.get(id).unwrap().info.pages.len(), 1); + session.undo(id).unwrap(); + let document = session.get(id).unwrap(); + assert!(!document.dirty, "the opened bytes are the saved version"); + assert_eq!(document.bytes, opened); + assert_eq!(document.info.pages.len(), 2); + assert!(session.autosave_snapshots().is_empty(), "nothing unsaved to recover"); + + session.redo(id).unwrap(); + assert!(session.get(id).unwrap().dirty); + session.undo(id).unwrap(); + assert!(!session.get(id).unwrap().dirty); +} + +#[test] +fn save_after_undo_retains_the_latest_saved_revision() { + let (mut session, document) = session_with(3); + session.apply(document, Edit::DeletePages { pages: vec![1] }).unwrap(); + let deleted = session.save_bytes(document).unwrap(); + session.mark_saved(document, deleted, None).unwrap(); + session.apply(document, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + let rotated = session.save_bytes(document).unwrap(); + session.mark_saved(document, rotated.clone(), None).unwrap(); + let rotated_revisions = pdfcraft_cos::Document::open(rotated.clone()).unwrap().revisions().len(); + + session.undo(document).unwrap(); + session.apply(document, Edit::SetInfo { key: "Title".into(), value: "Report".into() }).unwrap(); + let saved = session.save_bytes(document).unwrap(); + assert!(saved.starts_with(rotated.as_slice()), "the saved rotation stays in the file"); + let reopened = pdfcraft_cos::Document::open(saved.clone()).unwrap(); + assert!(reopened.revisions().len() > rotated_revisions, "the title is a newer revision, not a rewrite of the earlier one"); + assert_eq!(pdfcraft_organize::info(&reopened, "Title").as_deref(), Some("Report")); + + session.mark_saved(document, saved, None).unwrap(); + let doc = session.get(document).unwrap(); + assert_eq!(page_texts(&session, document), ["Page 1", "Page 3"]); + assert_eq!(doc.info.pages[0].rotation, 0, "the undone rotation is the current page"); + assert_eq!(doc.info_value("Title").as_deref(), Some("Report")); +} + +#[test] +fn revert_preserves_the_saved_pages_after_undo() { + let (mut s, id) = session_with(2); + s.apply(id, Edit::DeletePages { pages: vec![0] }).unwrap(); + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + let saved_bytes = s.save_bytes(id).unwrap(); + s.mark_saved(id, saved_bytes, None).unwrap(); + s.undo(id).unwrap(); + assert!(s.get(id).unwrap().dirty); + assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 0); + + s.revert(id).unwrap(); + let d = s.get(id).unwrap(); + assert!(!d.dirty && d.can_undo().is_none() && d.can_redo().is_none()); + assert_eq!(page_texts(&s, id), ["Page 2"], "the last saved pages, not the file as opened"); + assert_eq!(d.info.pages[0].rotation, 90); +} + #[test] fn full_save_is_readable_and_smaller_after_deletes() { let (mut s, id) = session_with(5); @@ -1113,8 +1239,14 @@ fn autosave_snapshots_only_changed_documents() { assert_eq!(snaps.len(), 1); assert_eq!(snaps[0].bytes, s.get(id).unwrap().bytes, "the working file"); assert!(s.autosave_snapshots().is_empty(), "nothing new since the last snapshot"); + s.apply(id, Edit::DeletePages { pages: vec![1] }).unwrap(); + assert_eq!(s.autosave_snapshots().len(), 1); s.undo(id).unwrap(); - assert_eq!(s.autosave_snapshots().len(), 1, "undo is a change too"); + assert!(s.get(id).unwrap().dirty, "the rotation is still unsaved"); + assert_eq!(s.autosave_snapshots().len(), 1, "undo that leaves unsaved work is a change"); + s.undo(id).unwrap(); + assert!(!s.get(id).unwrap().dirty, "undo back to the opened file is saved"); + assert!(s.autosave_snapshots().is_empty(), "a clean document needs no recovery"); let saved = s.save_bytes(id).unwrap(); s.mark_saved(id, saved, None).unwrap(); assert!(s.autosave_snapshots().is_empty(), "saved documents need no recovery"); @@ -1679,6 +1811,34 @@ fn form_refresh_keeps_first_value_when_field_names_repeat() { assert_eq!(dup.as_deref(), Some("first")); } +#[test] +fn multiselect_choice_indices_stay_sorted_through_engine_save_and_reopen() { + let (mut s, id) = session_with(1); + s.apply( + id, + Edit::AddField { + page: 0, + rect: [20.0, 180.0, 180.0, 240.0], + kind: NewField::List { options: vec!["Cheese".into(), "Ham".into(), "Olives".into()], multi: true }, + name: Some("toppings".into()), + }, + ) + .unwrap(); + let chosen = vec!["Olives".to_string(), "Ham".to_string()]; + s.apply(id, Edit::SetFieldValue { name: "toppings".into(), value: FieldValue::Choice(chosen.clone()) }).unwrap(); + assert_eq!(s.get(id).unwrap().form[0].value, chosen, "the live engine keeps selection order"); + let saved = s.save_bytes(id).unwrap(); + let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap(); + let all = pdfcraft_forms::fields(&cos); + let list = all.iter().find(|field| field.name == "toppings").unwrap(); + let object = cos.get(list.obj); + let indices = object.as_dict().unwrap().get(b"I").unwrap().as_array().unwrap(); + assert_eq!(indices.iter().map(|index| index.as_int().unwrap()).collect::>(), [1, 2]); + assert_eq!(list.value, chosen, "sorted /I does not reorder /V"); + let reopened = s.open("reopened.pdf", None, saved, None).unwrap(); + assert_eq!(s.get(reopened).unwrap().form[0].value, chosen, "reopening keeps selection order"); +} + #[test] fn preparing_a_form_adds_renames_and_deletes_fields() { let (mut s, id) = session_with(1); @@ -1802,6 +1962,31 @@ fn redaction_marks_apply_for_good_and_undo() { assert!(matches!(s.apply(id, Edit::ApplyRedactions { pages: None }), Err(EditError::Redact(_)))); } +#[test] +fn oversized_redaction_overlay_preserves_the_document_and_undo() { + let (mut s, id) = session_with(1); + let shape = Shape::Redact { + quads: vec![pdfcraft_annot::rect_quad([0.0, 0.0, 16000.0, 1600.0])], + overlay: "X".into(), + look: pdfcraft_annot::OverlayLook { size: 2.0, repeat: true, ..Default::default() }, + }; + s.apply( + id, + Edit::AddAnnotation(NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: String::new(), author: "Tester".into() }), + ) + .unwrap(); + let before = s.get(id).unwrap().bytes.clone(); + assert!(matches!(s.apply(id, Edit::ApplyRedactions { pages: None }), Err(EditError::Redact(pdfcraft_redact::RedactError::OverlayLimit)))); + let doc = s.get(id).unwrap(); + assert_eq!(doc.bytes, before); + assert_eq!(doc.redaction_marks(), 1); + assert_eq!(doc.can_undo(), Some("Add redaction mark")); + assert_eq!(page_texts(&s, id), ["Page 1"]); + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().redaction_marks(), 0); + assert_eq!(page_texts(&s, id), ["Page 1"]); +} + #[test] fn printing_lays_out_sheets_that_render() { let (s, id) = session_with(5); @@ -1827,6 +2012,42 @@ fn printing_lays_out_sheets_that_render() { assert_eq!(s4.print_pdf(id4, &print::Settings { pages: vec![0], ..Default::default() }), Err(EditError::NotPermitted("printing"))); } +/// A one-page document whose red square is in a layer; `off` switches the layer off by default. +fn red_layer_pdf(off: bool) -> Vec { + let content = "/OC /L1 BDC 1 0 0 rg 0 0 100 100 re f EMC"; + let off = if off { "/OFF [5 0 R] " } else { "" }; + format!( + "%PDF-1.7\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R] /D << {off}/Order [5 0 R] >> >> >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /Properties << /L1 5 0 R >> >> >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /OCG /Name (Red) >> endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len() + ) + .into_bytes() +} + +/// Does the first page of `pdf` show any red when drawn? +fn first_page_has_red(pdf: Vec) -> bool { + let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(pdf), pdfcraft_render::RenderConfig::default()); + let page = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 0.5, ..Default::default() }); + page.rgba.as_chunks::<4>().0.iter().any(|p| p[0] > 200 && p[1] < 60 && p[2] < 60) +} + +#[test] +fn a_layer_that_is_off_does_not_print_and_one_that_is_on_does() { + for (off, prints) in [(true, false), (false, true)] { + let source = red_layer_pdf(off); + assert_eq!(first_page_has_red(source.clone()), prints, "on screen (off: {off})"); + let mut s = Session::new(); + let id = s.open("layers.pdf", None, Arc::new(source), None).unwrap(); + let sheet = s.print_pdf(id, &print::Settings { pages: vec![0], ..Default::default() }).unwrap(); + assert_eq!(first_page_has_red(sheet), prints, "printed (layer off by default: {off})"); + } +} + #[test] fn crop_and_duplicate_pages_show_in_the_viewer() { let (mut s, id) = session_with(2); @@ -1849,7 +2070,7 @@ fn headers_footers_watermarks_and_backgrounds_show_update_and_remove() { s.apply(id, Edit::AddHeaderFooter { pages: vec![0, 1], settings: hf.clone(), replace: false }).unwrap(); // The session clock is 2023-11-14. assert_eq!(page_texts(&s, id)[1], "Page 2\nPage 2 of 2 · 2023-11-14"); - assert_eq!(s.get(id).unwrap().marks, [MarkKind::HeaderFooter]); + assert_eq!(s.get(id).unwrap().marks(), [MarkKind::HeaderFooter]); hf.text[4] = "Draft".into(); s.apply(id, Edit::AddHeaderFooter { pages: vec![0, 1], settings: hf, replace: true }).unwrap(); assert_eq!(page_texts(&s, id)[0], "Page 1\nDraft"); @@ -1868,19 +2089,19 @@ fn headers_footers_watermarks_and_backgrounds_show_update_and_remove() { }, ) .unwrap(); - assert_eq!(s.get(id).unwrap().marks.len(), 3); + assert_eq!(s.get(id).unwrap().marks().len(), 3); assert!(page_texts(&s, id)[0].contains("SECRET")); s.apply(id, Edit::RemoveMarks { kind: MarkKind::HeaderFooter }).unwrap(); s.apply(id, Edit::RemoveMarks { kind: MarkKind::Watermark }).unwrap(); s.apply(id, Edit::RemoveMarks { kind: MarkKind::Background }).unwrap(); assert_eq!(page_texts(&s, id), ["Page 1", "Page 2"]); - assert!(s.get(id).unwrap().marks.is_empty()); + assert!(s.get(id).unwrap().marks().is_empty()); assert!(matches!(s.apply(id, Edit::RemoveMarks { kind: MarkKind::Watermark }), Err(EditError::Edit(_)))); assert_eq!(s.get(id).unwrap().can_undo(), Some("Remove background")); } #[test] -fn open_populates_marks_and_added_from_the_combined_marker_scan() { +fn marker_indexes_match_after_open_and_save_when_requested() { let (mut source, source_id) = session_with(1); let mut header = HeaderFooter::default(); header.text[0] = "Header".into(); @@ -1915,18 +2136,95 @@ fn open_populates_marks_and_added_from_the_combined_marker_scan() { let expected_added = pdfcraft_edit::list_added(&reference); source.mark_saved(source_id, bytes.clone(), None).unwrap(); let refreshed = source.get(source_id).unwrap(); - assert_eq!(refreshed.marks, expected_marks); - assert_eq!(refreshed.added, expected_added); + assert_eq!(refreshed.marks(), expected_marks); + assert_eq!(refreshed.added(), expected_added); let mut reopened = Session::new(); let reopened_id = reopened.open("marked.pdf", None, bytes, None).unwrap(); let opened = reopened.get(reopened_id).unwrap(); - assert_eq!(opened.marks, expected_marks); - assert_eq!(opened.added, expected_added); - assert_eq!(opened.marks.len(), 3); - assert!(opened.marks.contains(&MarkKind::HeaderFooter)); - assert!(opened.marks.contains(&MarkKind::Watermark)); - assert!(opened.marks.contains(&MarkKind::Background)); - assert_eq!(opened.added.len(), 1); + assert_eq!(opened.marks(), expected_marks); + assert_eq!(opened.added(), expected_added); + assert_eq!(opened.marks().len(), 3); + assert!(opened.marks().contains(&MarkKind::HeaderFooter)); + assert!(opened.marks().contains(&MarkKind::Watermark)); + assert!(opened.marks().contains(&MarkKind::Background)); + assert_eq!(opened.added().len(), 1); +} + +#[test] +fn secondary_indexes_load_once_and_follow_edit_scopes() { + reset_secondary_scan_counts(); + let (mut session, id) = session_with(1); + assert_eq!(secondary_scan_counts(), (0, 0), "open does not scan markers or links"); + + { + let doc = session.get(id).unwrap(); + assert!(doc.marks().is_empty()); + assert!(doc.added().is_empty()); + assert!(doc.added().is_empty()); + assert_eq!(secondary_scan_counts(), (1, 0), "marks and added content share one scan"); + assert!(doc.links().is_empty()); + assert!(doc.links().is_empty()); + assert_eq!(secondary_scan_counts(), (1, 1), "repeated reads reuse the link list"); + } + + session.apply(id, Edit::SetInfo { key: "Title".into(), value: "Changed".into() }).unwrap(); + assert!(session.get(id).unwrap().links().is_empty()); + assert!(session.get(id).unwrap().marks().is_empty()); + assert_eq!(secondary_scan_counts(), (1, 1), "metadata does not invalidate either index"); + + session.apply(id, rect_comment(0, [20.0, 20.0, 60.0, 60.0])).unwrap(); + assert!(session.get(id).unwrap().links().is_empty()); + assert_eq!(secondary_scan_counts(), (1, 2), "comment edits invalidate the annotation-position link index"); + + session + .apply( + id, + Edit::AddLink { + page: 0, + rect: [10.0, 10.0, 80.0, 30.0], + action: LinkAction::Uri("https://example.org".into()), + style: LinkStyle::default(), + }, + ) + .unwrap(); + assert_eq!(secondary_scan_counts(), (1, 2), "a link edit invalidates only the link list"); + assert_eq!(session.get(id).unwrap().links()[0].index, 1, "link indexes include earlier annotations"); + assert_eq!(secondary_scan_counts(), (1, 3)); + session.undo(id).unwrap(); + assert_eq!(session.get(id).unwrap().links().len(), 0); + assert_eq!(secondary_scan_counts(), (1, 4), "undo rebuilds the link list once"); + session.redo(id).unwrap(); + assert_eq!(session.get(id).unwrap().links()[0].index, 1); + assert_eq!(secondary_scan_counts(), (1, 5), "redo rebuilds the link list once"); + session.apply(id, Edit::DeleteAnnotation { page: 0, index: 0 }).unwrap(); + assert_eq!(session.get(id).unwrap().links()[0].index, 0, "deleting an earlier annotation shifts link indexes"); + assert_eq!(secondary_scan_counts(), (1, 6), "comment deletion rebuilds annotation-position indexes"); + + session + .apply(id, Edit::AddText { page: 0, text: AddedText { rect: [20.0, 250.0, 100.0, 280.0], text: "Added".into(), ..Default::default() } }) + .unwrap(); + assert_eq!(secondary_scan_counts(), (1, 6), "content edits defer rebuilding until a marker getter is used"); + assert_eq!(session.get(id).unwrap().added().len(), 1); + assert_eq!(session.get(id).unwrap().links().len(), 1); + assert_eq!(secondary_scan_counts(), (2, 6), "content edits preserve the link cache"); + + let mut header = HeaderFooter::default(); + header.text[0] = "Header".into(); + session.apply(id, Edit::AddHeaderFooter { pages: vec![0], settings: header, replace: false }).unwrap(); + assert_eq!(session.get(id).unwrap().marks(), [MarkKind::HeaderFooter]); + assert_eq!(secondary_scan_counts(), (3, 6)); + session.undo(id).unwrap(); + assert!(session.get(id).unwrap().marks().is_empty()); + assert_eq!(secondary_scan_counts(), (4, 6), "undo invalidates marker state"); + session.undo(id).unwrap(); + assert!(session.get(id).unwrap().added().is_empty()); + assert_eq!(secondary_scan_counts(), (5, 6), "undo of added content invalidates marker state"); + session.redo(id).unwrap(); + assert_eq!(session.get(id).unwrap().added().len(), 1); + assert_eq!(secondary_scan_counts(), (6, 6), "redo of added content rebuilds once"); + session.redo(id).unwrap(); + assert_eq!(session.get(id).unwrap().marks(), [MarkKind::HeaderFooter]); + assert_eq!(secondary_scan_counts(), (7, 6), "redo of marks rebuilds once"); } #[test] @@ -2169,15 +2467,15 @@ fn added_text_and_images_render_and_stay_editable() { } s.apply(id, Edit::AddImage { page: 0, rect: None, name: "dot.png".into(), bytes: Arc::new(png) }).unwrap(); let d = s.get(id).unwrap(); - assert_eq!(d.added.len(), 2); - assert_eq!(d.added[1].content.rect(), [90.0, 145.0, 110.0, 155.0], "20×10 px at 72 dpi, centred on 200×300"); - let moved = d.added[0].content.with_rect([30.0, 100.0, 190.0, 130.0]); + assert_eq!(d.added().len(), 2); + assert_eq!(d.added()[1].content.rect(), [90.0, 145.0, 110.0, 155.0], "20×10 px at 72 dpi, centred on 200×300"); + let moved = d.added()[0].content.with_rect([30.0, 100.0, 190.0, 130.0]); s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: moved }).unwrap(); - assert_eq!(s.get(id).unwrap().added[0].content.rect()[3], 130.0); + assert_eq!(s.get(id).unwrap().added()[0].content.rect()[3], 130.0); s.apply(id, Edit::DeleteContent { page: 0, index: 1 }).unwrap(); - assert_eq!(s.get(id).unwrap().added.len(), 1); + assert_eq!(s.get(id).unwrap().added().len(), 1); s.undo(id).unwrap(); - assert_eq!(s.get(id).unwrap().added.len(), 2); + assert_eq!(s.get(id).unwrap().added().len(), 2); } #[test] @@ -2209,7 +2507,7 @@ fn added_images_rotate_flip_and_crop_as_drawn() { let blue = |c: [u8; 3]| c[2] > 200 && c[0] < 60; // Display y 100–200 is rows 100–200 from the top on a 300 pt page. assert!(red(colour_at(&s, 70, 150)) && blue(colour_at(&s, 130, 150)), "as placed: red left, blue right"); - let item = |s: &Session| s.get(id).unwrap().added[0].content.clone(); + let item = |s: &Session| s.get(id).unwrap().added()[0].content.clone(); let AddedContent::Image(mut img) = item(&s) else { panic!() }; img.rotation = 1; s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: AddedContent::Image(img.clone()) }).unwrap(); @@ -2310,21 +2608,21 @@ fn links_from_urls_and_link_edits() { assert_eq!(found[0].2, "http://www.example.org"); s.apply(id, Edit::AddLinks { links: found, style: LinkStyle::default() }).unwrap(); let d = s.get(id).unwrap(); - assert_eq!(d.links.len(), 1); + assert_eq!(d.links().len(), 1); assert_eq!(d.info.links.len(), 1, "the viewer follows it"); assert!(s.find_urls(id).is_empty(), "already linked"); // Remove all links and undo. s.apply(id, Edit::RemoveLinks { pages: None }).unwrap(); - assert!(s.get(id).unwrap().links.is_empty()); + assert!(s.get(id).unwrap().links().is_empty()); assert_eq!(s.get(id).unwrap().can_undo(), Some("Remove all links")); s.undo(id).unwrap(); - assert_eq!(s.get(id).unwrap().links.len(), 1); + assert_eq!(s.get(id).unwrap().links().len(), 1); // Link Properties and delete. - let l = s.get(id).unwrap().links[0].clone(); + let l = s.get(id).unwrap().links()[0].clone(); s.apply(id, Edit::SetLink { page: 0, index: l.index, rect: None, action: Some(LinkAction::Page(0)), style: None }).unwrap(); - assert_eq!(s.get(id).unwrap().links[0].action, LinkAction::Page(0)); + assert_eq!(s.get(id).unwrap().links()[0].action, LinkAction::Page(0)); s.apply(id, Edit::DeleteLink { page: 0, index: l.index }).unwrap(); - assert!(s.get(id).unwrap().links.is_empty()); + assert!(s.get(id).unwrap().links().is_empty()); assert!(s.apply(id, Edit::RemoveLinks { pages: None }).is_err(), "nothing left to remove"); } @@ -2354,14 +2652,14 @@ fn appearance_only_edit_preserves_gotor_action_semantics() { let mut session = Session::new(); let id = session.open("source.pdf", Some("/docs/source.pdf".into()), Arc::new(bytes), None).unwrap(); let before = session.get(id).unwrap().info.links[0].target.clone(); - let mut style = session.get(id).unwrap().links[0].style; + let mut style = session.get(id).unwrap().links()[0].style; style.visible = true; style.width = 2.0; session.apply(id, Edit::SetLink { page: 0, index: 0, rect: None, action: None, style: Some(style) }).unwrap(); let after = &session.get(id).unwrap().info.links[0].target; assert_eq!(*after, before, "the GoToR file, destination and /NewWindow hint must remain unchanged"); - assert_eq!(session.get(id).unwrap().links[0].style, style, "the appearance edit is applied"); + assert_eq!(session.get(id).unwrap().links()[0].style, style, "the appearance edit is applied"); } #[test] @@ -2603,7 +2901,8 @@ fn recognize_text_makes_a_scanned_page_searchable() { let mut s = Session::new().with_clock(|| 1_700_000_000); let text = s.create_from_text("t", "The quick brown fox jumps over the lazy dog.").unwrap(); let id = s.open("text.pdf", None, text, None).unwrap(); - let png = export::Exporter::new(s.get(id).unwrap()).png(0, 150.0).unwrap(); + // A typical 300 DPI scan keeps the small leading glyph in "quick" legible to OCR. + let png = export::Exporter::new(s.get(id).unwrap()).png(0, 300.0).unwrap(); let scan = s.create_from_images(&[("scan.png".into(), png)]).unwrap(); let id = s.open("scan.pdf", None, scan, None).unwrap(); assert_eq!(page_texts(&s, id), [""], "a picture has no text"); @@ -2620,7 +2919,9 @@ fn recognize_text_makes_a_scanned_page_searchable() { let q = words.iter().find(|w| w.text.to_lowercase().contains("quick")).unwrap(); let l = words.iter().find(|w| w.text.to_lowercase().contains("lazy")).unwrap(); let top = |w: &ocr::PlacedWord| w.origin[1] + w.up[1]; - assert!(q.origin[0] < l.origin[0] && (top(q) - top(l)).abs() < 3.0, "{q:?} {l:?}"); + // OCR boxes can differ by a few pixels at 300 DPI and still sit on the same line. + let line_tolerance = q.up[1].abs().max(l.up[1].abs()) / 4.0; + assert!(q.origin[0] < l.origin[0] && (top(q) - top(l)).abs() < line_tolerance, "{q:?} {l:?}"); // A second pass skips the page: it has text now. let again = s.recognize_text(id, &[], ocr::OcrSettings::default()).unwrap(); @@ -2794,6 +3095,155 @@ fn detecting_fields_on_a_printed_form() { assert!(s.detect_fields(id, &[]).is_empty(), "nothing left to detect"); } +/// A printed form, 420 × 300 pt: a title, a label with a rule to write on, a box with a small +/// label in its corner, an empty box with its label above, and two check boxes. +fn paper_form() -> Vec { + let content = "BT /F1 14 Tf 30 270 Td (Gewerbe-Ummeldung) Tj ET \ + BT /F1 10 Tf 30 230 Td (Name:) Tj ET 0.75 w 80 228 m 380 228 l S \ + 1 w 30 150 350 50 re S BT /F1 7 Tf 34 191 Td (Anschrift) Tj ET \ + BT /F1 8 Tf 30 124 Td (Telefon) Tj ET 30 90 350 30 re S \ + 30 50 10 10 re S BT /F1 9 Tf 46 51 Td (Haupt) Tj ET 150 50 10 10 re S BT /F1 9 Tf 166 51 Td (Zweig) Tj ET"; + let objs = [ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 420 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes(), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, obj) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(obj); + 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 offset in offsets { + out.extend_from_slice(format!("{offset: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 +} + +/// How the paper form was scanned. +#[derive(Clone, Copy, Debug)] +enum Scan { + Straight, + /// Stored sideways and turned upright by `/Rotate 90`. + Sideways, + /// Speckled paper, uneven light and lines that lose some pixels. + Noisy, + /// Laid on the scanner a degree askew. + Skewed, +} + +/// The paper form as an image-only PDF at 150 dpi (the page keeps its 420 × 300 pt). +fn scanned_form(scan: Scan) -> Arc> { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("paper.pdf", None, Arc::new(paper_form()), None).unwrap(); + let png = export::Exporter::new(s.get(id).unwrap()).png(0, 150.0).unwrap(); + let mut img = image::load_from_memory(&png).unwrap().to_rgba8(); + match scan { + Scan::Straight => {} + Scan::Sideways => img = image::imageops::rotate270(&img), + Scan::Skewed => { + // Turned a degree about the centre (nearest pixel; the corners stay paper). + let (w, h) = (img.width() as f64, img.height() as f64); + let (sin, cos) = 1f64.to_radians().sin_cos(); + let src = img.clone(); + for (x, y, p) in img.enumerate_pixels_mut() { + let (dx, dy) = (x as f64 - w / 2.0, y as f64 - h / 2.0); + let (sx, sy) = (cos * dx + sin * dy + w / 2.0, -sin * dx + cos * dy + h / 2.0); + *p = if sx >= 0.0 && sy >= 0.0 && sx < w && sy < h { *src.get_pixel(sx as u32, sy as u32) } else { image::Rgba([255; 4]) }; + } + } + Scan::Noisy => { + let (w, mut seed) = (img.width(), 7u32); + for (x, _, p) in img.enumerate_pixels_mut() { + seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12345); + let noise = ((seed >> 16) % 48) as u8; + let shade = (x * 50 / w) as u8; + for c in &mut p.0[..3] { + *c = if *c < 128 && noise < 4 { 230 } else { c.saturating_sub(shade).saturating_sub(noise) }; + } + } + } + } + let mut bytes = std::io::Cursor::new(Vec::new()); + img.write_to(&mut bytes, image::ImageFormat::Png).unwrap(); + let pdf = s.create_from_images_with_resolution(&[("scan.png".into(), bytes.into_inner())], ImageResolution::Dpi(150.0)).unwrap(); + if !matches!(scan, Scan::Sideways) { + return pdf; + } + let id = s.open("scan.pdf", None, pdf, None).unwrap(); + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + s.save_bytes(id).unwrap() +} + +/// Fields on a scanned paper form are found from its lines and boxes, wherever the page turns +/// them, and named from the labels when text recognition is installed. +#[test] +fn detecting_fields_on_a_scanned_form() { + use pdfcraft_forms::detect::Kind::{CheckBox, Text}; + for scan in [Scan::Straight, Scan::Sideways, Scan::Noisy, Scan::Skewed] { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("scan.pdf", None, scanned_form(scan), None).unwrap(); + assert_eq!(page_texts(&s, id), [""], "{scan:?}: a picture has no text"); + let info = s.get(id).unwrap().info.pages[0].clone(); + assert_eq!((info.width.round(), info.height.round()), (420.0, 300.0), "{scan:?}"); + // Where a field shows on the page, in points from the bottom-left as displayed. + let shown = |r: [f64; 4]| { + let a = info.user_to_view(r[0] as f32, r[1] as f32); + let b = info.user_to_view(r[2] as f32, r[3] as f32); + [a[0].min(b[0]), 300.0 - a[1].max(b[1]), a[0].max(b[0]), 300.0 - a[1].min(b[1])].map(f64::from) + }; + let found = s.detect_fields(id, &[]); + let got: Vec<(pdfcraft_forms::detect::Kind, [f64; 4])> = found.iter().map(|(_, c)| (c.kind.clone(), shown(c.rect))).collect(); + // In reading order: the rule after "Name:", the box below "Anschrift", the "Telefon" + // box, two check boxes. + let want = [ + (Text, [80.0, 229.0, 380.0, 245.0]), + (Text, [31.0, 151.0, 379.0, 189.0]), + (Text, [31.0, 91.0, 379.0, 119.0]), + (CheckBox, [30.0, 50.0, 40.0, 60.0]), + (CheckBox, [150.0, 50.0, 160.0, 60.0]), + ]; + assert_eq!(got.len(), want.len(), "{scan:?}: {got:?}"); + for ((kind, r), (want_kind, w)) in got.iter().zip(&want) { + assert_eq!(kind, want_kind, "{scan:?}: {got:?}"); + // The labelled box's top is wherever the label ends; everything else is close. A + // degree askew, a 350 pt line climbs 6 pt, and the field stays inside the box. + let tol = if matches!(scan, Scan::Skewed) { [6.5; 4] } else { [2.5, 2.5, 2.5, if w[3] == 189.0 { 6.0 } else { 2.5 }] }; + assert!(r.iter().zip(w).zip(tol).all(|((a, b), t)| (a - b).abs() <= t), "{scan:?}: {r:?}, want {w:?}"); + } + let names: Vec<&str> = found.iter().map(|(_, c)| c.name.as_str()).collect(); + if ocr::available() { + // (Askew, recognition may read "Haupt" and "Zweig" as one phrase.) + assert!(names.contains(&"Name") && names.contains(&"Telefon") && names.iter().any(|n| n.starts_with("Haupt")), "{scan:?}: {names:?}"); + } else { + // Without text recognition the fields get the default names. + assert!(names.iter().all(|n| n.starts_with("Text") || n.starts_with("Check Box")), "{scan:?}: {names:?}"); + } + + // Added as one step, where they were found; on the turned page each field turns with + // it, so typing reads upright. + let added = s.auto_detect_fields(id, &[]).unwrap(); + assert_eq!(added.len(), want.len()); + let d = s.get(id).unwrap(); + assert_eq!(d.can_undo(), Some("Detect form fields")); + let turn = if matches!(scan, Scan::Sideways) { 90 } else { 0 }; + for (_, c) in &found { + let f = d.form.iter().find(|f| f.name == c.name).unwrap(); + assert_eq!(f.widgets[0].rotation, turn, "{scan:?}: {}", f.name); + let r = f.widgets[0].rect; + assert!(r.iter().zip(c.rect).all(|(a, b)| (a - b).abs() < 0.01), "{scan:?}: {r:?} moved from {:?}", c.rect); + } + assert!(s.detect_fields(id, &[]).is_empty(), "{scan:?}: nothing left to detect"); + } +} + #[test] fn comparing_two_versions_of_a_document() { let mut s = Session::new().with_clock(|| 1_700_000_000); @@ -3573,6 +4023,113 @@ fn a_value_a_script_sets_reaches_every_field_bound_to_the_same_data_node() { ); } +#[test] +fn flattening_an_xfa_form_bakes_its_pages_and_takes_the_xfa_out() { + let data = "
JPEnglish\nSpanish
"; + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(data))); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("fields.pdf", None, bytes, None).expect("opens"); + assert_eq!(s.get(id).unwrap().info.xfa, Some(pdfcraft_render::Xfa::Dynamic)); + s.apply(id, Edit::SetFieldValue { name: "other".into(), value: FieldValue::Text("typed here".into()) }).unwrap(); + s.apply(id, Edit::Flatten { comments: false, fields: true }).unwrap(); + let d = s.get(id).unwrap(); + assert!(d.form.is_empty(), "no fields remain"); + assert_eq!(d.info.xfa, None, "no longer an XFA form: Adobe's viewers show the baked pages"); + assert!(d.xfa.is_none()); + let texts = page_texts(&s, id).join("\n"); + for want in ["Japan", "typed here", "Country: JP", "12345"] { + assert!(texts.contains(want), "{want} is page content now: {texts}"); + } + // Saved and reopened: a plain PDF, the same page content, nothing of the XFA left. + let saved = s.save_bytes(id).unwrap(); + let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap(); + assert!(pdfcraft_xfa::read_packets(&cos).unwrap().is_none()); + let id2 = s.open("flat.pdf", None, saved, None).unwrap(); + let d2 = s.get(id2).unwrap(); + assert_eq!((d2.info.xfa, d2.form.len()), (None, 0)); + assert!(page_texts(&s, id2).join("\n").contains("typed here")); + // Undone, the form is a laid-out XFA form again. + s.undo(id).unwrap(); + let d = s.get(id).unwrap(); + assert_eq!(d.info.xfa, Some(pdfcraft_render::Xfa::Dynamic)); + assert_eq!(d.form.iter().find(|f| f.name == "other").unwrap().value, vec!["typed here".to_string()]); + // Flattening comments alone leaves the XFA form as it is. + s.apply(id, Edit::Flatten { comments: true, fields: false }).unwrap(); + assert_eq!(s.get(id).unwrap().info.xfa, Some(pdfcraft_render::Xfa::Dynamic)); +} + +#[test] +fn a_dynamic_xfa_form_that_was_never_laid_out_keeps_its_xfa_and_the_user_is_told_what_went() { + // A template that can't be read: the form keeps its placeholder page and its XFA. + let broken = "", "
3kept nowherex
")))).unwrap(); + let before = crate::xfa_shape(&doc).unwrap(); + assert_eq!(before, crate::XfaShape { needs_rendering: true, has_fields: false, laid_out: false }); + assert!(!before.strippable(), "nothing laid out yet: stripping would leave the placeholder page"); + render_into(&mut doc).unwrap(); + assert!(crate::xfa_shape(&doc).unwrap().strippable()); + let r = strip(&mut doc).unwrap(); + assert_eq!(r.unbound_data, Some(2), "{r:?}"); + assert_eq!(r.script_buttons, 4, "addRow, removeRow, hello, legacy: {r:?}"); + assert!(crate::xfa_shape(&doc).is_none()); + // A field tree whose dictionaries share one Kids array (every kid listing every other) is + // walked once per dictionary, not once per listing. + let mut doc = Document::open(Arc::new(shell(&fields_template("")))).unwrap(); + render_into(&mut doc).unwrap(); + let root = doc.root().unwrap(); + let acro_ref = doc.get(root).as_dict().unwrap().get(b"AcroForm").and_then(Object::as_ref); + let fields: Vec = (0..2000).map(|_| Object::Ref(doc.add(Object::Dict(pdfcraft_cos::Dict::new())))).collect(); + let kids = doc.add(Object::Array(fields.clone())); + for f in &fields { + if let Some(r) = f.as_ref() { + doc.update_dict(r, |d| { + d.set(b"Kids".to_vec(), Object::Ref(kids)); + d.set(b"PCSom".to_vec(), Object::String(pdfcraft_cos::PdfString::text("x"))); + }) + .unwrap(); + } + } + let update = |d: &mut pdfcraft_cos::Dict| d.set(b"Fields".to_vec(), Object::Array(fields.clone())); + match acro_ref { + Some(r) => doc.update_dict(r, update).unwrap(), + None => doc + .update_dict(root, |c| { + let mut a = c.get(b"AcroForm").and_then(|a| a.as_dict().cloned()).unwrap_or_default(); + update(&mut a); + c.set(b"AcroForm".to_vec(), Object::Dict(a)); + }) + .unwrap(), + } + let started = std::time::Instant::now(); + let r = strip(&mut doc).unwrap(); + assert_eq!(r.field_keys, 2000); + assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed()); +} + +#[test] +fn som_indices_count_earlier_same_named_siblings() { + let draw = |name: Option<&str>| { + Node::Draw(Box::new(Draw { common: Common { name: name.map(str::to_string), ..Default::default() }, ..Default::default() })) + }; + let children = [draw(Some("a")), draw(Some("b")), draw(Some("a")), draw(None), draw(Some("a")), draw(Some("b"))]; + let sibs = SomIndices::of(&children); + assert_eq!((0..=children.len()).map(|i| sibs.at(i)).collect::>(), [0, 0, 1, 0, 2, 1, 0]); +} diff --git a/crates/xfdf/README.md b/crates/xfdf/README.md index fd2852584..2b179e200 100644 --- a/crates/xfdf/README.md +++ b/crates/xfdf/README.md @@ -13,7 +13,7 @@ let report = import(&mut doc, &bytes)?; // XFDF, FDF, XML, CSV o circle, polygon, polyline, markup, stamp, caret, ink, attachments, sound, redact) with rect, name, author, subject, dates, flags, colours, opacity, width, icon, quads, line ends, ink, default appearance, callouts (`callout`, `fringe`, `intent`, `head`), pop-ups and reply threads - (`inreplyto`); field values nested by name. + (`inreplyto`); field values nested by name, with text preserved across XML comments and processing instructions. - **FDF**: fields (with `/Kids` hierarchies) and comments as direct objects. - **Form data**: Acrobat's XML (`xfdf:original` keeps names that aren't XML names), CSV and tab-delimited text (a row of names, a row of values). diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index d9287101b..50326c4e7 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -558,10 +558,30 @@ pub fn export_fdf(doc: &Document, comments: bool, fields: bool, file: &str) -> V let _ = write!(out, "trailer\n<< /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n"); out } -/// The XML element name for a field name (letters, digits, `_`, `-`, `.`). +/// XML 1.0 fifth-edition NameStartChar, excluding `:` for an unprefixed NCName. +/// https://www.w3.org/TR/2008/REC-xml-20081126/#NT-NameStartChar +fn xml_name_start(c: char) -> bool { + matches!( + c, + '_' | 'A'..='Z' | 'a'..='z' + | '\u{C0}'..='\u{D6}' | '\u{D8}'..='\u{F6}' | '\u{F8}'..='\u{2FF}' + | '\u{370}'..='\u{37D}' | '\u{37F}'..='\u{1FFF}' + | '\u{200C}'..='\u{200D}' | '\u{2070}'..='\u{218F}' + | '\u{2C00}'..='\u{2FEF}' | '\u{3001}'..='\u{D7FF}' + | '\u{F900}'..='\u{FDCF}' | '\u{FDF0}'..='\u{FFFD}' + | '\u{10000}'..='\u{EFFFF}' + ) +} + +fn xml_name_char(c: char) -> bool { + xml_name_start(c) || matches!(c, '-' | '.' | '0'..='9' | '\u{B7}' | '\u{300}'..='\u{36F}' | '\u{203F}'..='\u{2040}') +} + +/// Keep the existing letters/digits/`_`/`-`/`.` normalization, constrained to valid NCNames. fn xml_name(name: &str) -> String { - let mut s: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '_' | '-' | '.') { c } else { '_' }).collect(); - if !s.chars().next().is_some_and(|c| c.is_alphabetic() || c == '_') { + let mut s: String = + name.chars().map(|c| if (c.is_alphanumeric() && xml_name_char(c)) || matches!(c, '_' | '-' | '.') { c } else { '_' }).collect(); + if !s.chars().next().is_some_and(|c| (c.is_alphabetic() && xml_name_start(c)) || c == '_') { s.insert(0, '_'); } s @@ -592,7 +612,7 @@ pub fn export_data(doc: &Document, format: Format) -> String { out } Format::Csv => { - let q = |s: &str| if s.contains([',', '"', '\n']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }; + let q = |s: &str| if s.contains([',', '"', '\n', '\r']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }; format!( "{}\n{}\n", fields.iter().map(|f| q(&f.0)).collect::>().join(","), @@ -925,7 +945,12 @@ fn walk_xfdf(node: roxmltree::Node, prefix: &str, out: &mut Vec<(String, Vec = f.children().filter(|c| c.has_tag_name("value")).map(|v| v.text().unwrap_or("").to_string()).collect(); + let vals: Vec = f + .children() + .filter(|c| c.has_tag_name("value")) + // Comments and processing instructions can split a value into several text nodes. + .map(|v| v.children().filter(|n| n.is_text()).filter_map(|n| n.text()).collect()) + .collect(); if !vals.is_empty() { out.push((full.clone(), vals)); } diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index be747d77e..d244df1ad 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -292,6 +292,60 @@ trailer\n<< /Root 1 0 R >>\n%%EOF\n"; assert_eq!((parent("cycle-a").as_deref(), parent("cycle-b").as_deref()), (Some("cycle-b"), Some("cycle-a"))); } +#[test] +fn xml_field_names_remain_valid_and_round_trip_without_collisions() { + assert_eq!(xml_name(".Value"), "_.Value"); + let names = [ + ("Area²", "Area_"), + ("Area_", "Area_"), + ("Aµ", "A_"), + ("µRate", "_Rate"), + ("²Rate", "_Rate"), + ("½Amount", "_Amount"), + ("1Rate", "_1Rate"), + ("Café", "Café"), + ("Δείγμα", "Δείγμα"), + ("姓名", "姓名"), + ("٣Value", "_٣Value"), + ("\u{345}Rate", "_\u{345}Rate"), + ("A\u{345}", "A\u{345}"), + ("Alpha_1-2", "Alpha_1-2"), + ("ns:value", "ns_value"), + ("-Value", "_-Value"), + ]; + let mut src = one_field(); + pdfcraft_forms::delete_field(&mut src, "Name").unwrap(); + for (index, (name, _)) in names.iter().enumerate() { + add_field(&mut src, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some(name)).unwrap(); + set_value(&mut src, name, &FieldValue::Text(format!("value-{index}"))).unwrap(); + } + let source = Arc::new(write_full(&src, &SaveOptions::default()).unwrap()); + let src = Document::open(source.clone()).unwrap(); + let want = values(&src); + for format in [Format::Xml, Format::Xdp] { + let text = export_data(&src, format); + let xml = roxmltree::Document::parse(&text).unwrap(); + let children: Vec<_> = xml.root_element().children().filter(|n| n.is_element()).collect(); + assert_eq!(children.len(), names.len()); + for (child, (original, tag)) in children.iter().zip(names) { + assert_eq!(child.tag_name().name(), tag); + assert_eq!(child.attribute(("http://ns.adobe.com/xfdf-transition/", "original")), (original != tag).then_some(original)); + } + assert!(!src.is_modified(), "export must not change the source PDF"); + assert_eq!(src.bytes().as_slice(), source.as_slice()); + + let mut dst = Document::open(source.clone()).unwrap(); + for (name, _) in names { + set_value(&mut dst, name, &FieldValue::Text("replaced".into())).unwrap(); + } + let report = import(&mut dst, text.as_bytes()).unwrap(); + assert_eq!(report.fields, names.len()); + assert!(report.rejected.is_empty(), "{report:?}"); + let reopened = Document::open(Arc::new(write_full(&dst, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(values(&reopened), want, "{format:?}: sanitized names must preserve distinct original fields"); + } +} + #[test] fn form_data_as_xml_csv_and_text() { let src = filled(); @@ -346,6 +400,41 @@ fn one_field() -> Document { Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap() } +#[test] +fn csv_exports_preserve_carriage_returns_in_names_and_values() { + for (name, value, expected) in [ + ("Name", "plain", "Name\nplain\n"), + ("Name", "Tail\r", "Name\n\"Tail\r\"\n"), + ("Name", "Alpha\rBeta", "Name\n\"Alpha\rBeta\"\n"), + ("Name", "\rLead", "Name\n\"\rLead\"\n"), + ("Name", "\r", "Name\n\"\r\"\n"), + ("Name", "Alpha\r\nBeta", "Name\n\"Alpha\r\nBeta\"\n"), + ("Name", "Alpha\nBeta", "Name\n\"Alpha\nBeta\"\n"), + ("Name", "comma,\r", "Name\n\"comma,\r\"\n"), + ("Name", "\"quoted\"\r", "Name\n\"\"\"quoted\"\"\r\"\n"), + ("Name", "before\r,\"after\"", "Name\n\"before\r,\"\"after\"\"\"\n"), + ("Na\rme", "both\r", "\"Na\rme\"\n\"both\r\"\n"), + ] { + let mut src = one_field(); + let field = fields(&src)[0].obj; + src.update_dict(field, |d| d.set(b"T".to_vec(), PdfString::text(name))).unwrap(); + set_value(&mut src, name, &FieldValue::Text(value.into())).unwrap(); + let source = Arc::new(write_full(&src, &SaveOptions::default()).unwrap()); + let src = Document::open(source.clone()).unwrap(); + assert_eq!(values(&src), [(name.to_string(), vec![value.to_string()])]); + let csv = export_data(&src, Format::Csv); + + let mut dst = Document::open(source).unwrap(); + set_value(&mut dst, name, &FieldValue::Text("replaced".into())).unwrap(); + let report = import(&mut dst, csv.as_bytes()).unwrap(); + assert_eq!(report.fields, 1); + assert!(report.rejected.is_empty(), "{report:?}"); + let reopened = Document::open(Arc::new(write_full(&dst, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(values(&reopened), [(name.to_string(), vec![value.to_string()])]); + assert_eq!(csv, expected, "CSV must quote CR as well as LF, commas and quotes"); + } +} + /// #1321: the text export of a one-field form has no tab to tell it from CSV, so a comma in the /// value used to split it and only the text before the comma was imported. #[test] @@ -887,3 +976,38 @@ fn duplicate_xfdf_annotation_flags_do_not_turn_on_unrelated_bits() { assert_eq!(square.get(b"F").and_then(Object::as_int), Some(expected), "flags={flags}"); } } + +// XML comments and processing instructions are not field text (XML 1.0 §§2.5–2.6). +fn split_text_values() -> [(&'static str, &'static str); 5] { + [ + ("Ada Lovelace", "Ada Lovelace"), + ("Ada", "Ada"), + ("Ada Lovelace", "Ada Lovelace"), + (" Å]]>Ω ", " Å<&>Ω "), + ("", ""), + ] +} + +#[test] +fn xfdf_data_values_join_text_around_xml_comments_and_instructions() { + for (xml_text, expected) in split_text_values() { + let xml = format!(r#"{xml_text}"#); + assert_eq!(data_values(xml.as_bytes()).unwrap(), vec![("Name".into(), vec![expected.to_string()])], "{xml_text}"); + } +} + +#[test] +fn xfdf_import_keeps_text_around_xml_comments_and_instructions() { + for (xml_text, expected) in split_text_values() { + let xml = format!(r#"{xml_text}"#); + let mut doc = one_field(); + set_value(&mut doc, "Name", &FieldValue::Text("old value".into())).unwrap(); + let report = import(&mut doc, xml.as_bytes()).unwrap(); + assert_eq!(report.fields, 1); + assert!(report.rejected.is_empty(), "{report:?}"); + let saved = write_full(&doc, &SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(saved)).unwrap(); + let want: Vec<&str> = if expected.is_empty() { vec![] } else { vec![expected] }; + assert_eq!(values(&reopened)[0].1, want, "{xml_text}"); + } +} diff --git a/docs/development.md b/docs/development.md index 59483b533..e599effc7 100644 --- a/docs/development.md +++ b/docs/development.md @@ -50,7 +50,7 @@ do not contact a desktop session bus. Page and document colours retain their exi ## Logs The desktop app writes its `log` records to standard error and to `logs/pdfcraft.log` in its -settings folder, next to `app.ron`: Linux and FreeBSD `~/.local/share/pdfcraft/logs/` (or +data folder: Linux and FreeBSD `~/.local/share/pdfcraft/logs/` (or `$XDG_DATA_HOME/pdfcraft/logs/`), macOS `~/Library/Application Support/PdfCraft/logs/`, Windows `%APPDATA%\PdfCraft\data\logs\`. In portable mode (a `portable.txt` file next to the executable, as the Windows portable zip ships) the settings folder is `PdfCraftData\` beside the executable, and @@ -105,13 +105,26 @@ reach this retry instead of the uncaptured-error panic handler. This does not fi driver's shader compilation, and explicit `PDFCRAFT_RENDERER=wgpu` still disables the retry. `PDFCRAFT_RENDERER` skips it or chooses OpenGL from the start. +## Preferences + +Linux and FreeBSD store `app.ron` in `$XDG_CONFIG_HOME/pdfcraft/`, or +`~/.config/pdfcraft/` when that variable is unset, empty or relative. This follows the +[XDG Base Directory Specification](https://specifications.freedesktop.org/basedir/latest/). +On first launch after upgrading, existing `app.ron` preferences are copied from the old +data folder without replacing an existing configuration file. The old copy is retained; +logs, crash recovery and digital IDs stay in their data locations. If migration fails, +the app logs the failure and continues using the original preferences folder. +The existing PrintCraft-to-PdfCraft migration runs first. macOS, Windows and portable +copies retain their current preferences locations. + ## Environment variables | Variable | Effect | |---|---| | `RUST_LOG` | Log levels for standard error and the log file (see [Logs](#logs)) | | `RUST_BACKTRACE` | `1` adds a backtrace to the report of an internal error | -| `XDG_DATA_HOME` | Linux/FreeBSD: base of the settings folder (`pdfcraft/`), the log folder and crash recovery | +| `XDG_CONFIG_HOME` | Linux/FreeBSD: absolute configuration base for `pdfcraft/app.ron`; defaults to `~/.config` | +| `XDG_DATA_HOME` | Linux/FreeBSD: base of the log folder and crash recovery; previous preferences are migrated from here | | `WGPU_POWER_PREF` | GPU choice; by default PdfCraft draws on the GPU the Wayland compositor draws with (it says so through linux-dmabuf feedback), else the GPU that drives the primary display (Windows) or the built-in panel (Linux), else the low-power (integrated) GPU. The log says which one was chosen | | `WGPU_BACKEND` | Graphics backend; by default Windows uses Direct3D 12, falling back to OpenGL | | `WGPU_VALIDATION_INDIRECT_CALL` | `1` turns wgpu's check of indirect draw arguments back on. PdfCraft draws nothing indirectly and turns it off, since the compute shader the check needs fails to compile on some drivers (Metal on an Intel Iris Pro), which lost the device at start-up | @@ -120,3 +133,17 @@ driver's shader compilation, and explicit `PDFCRAFT_RENDERER=wgpu` still disable | `PDFCRAFT_BUILD_LABEL` | Build time: a label for a local or patched build (letters, digits, inner dots), shown after the version in `--version`, About and the log (`0.7.1 (titlebar)`). `packaging/linux/package.sh` also puts it in the file names and the .rpm/.deb release (`1.titlebar`), which upgrades over the release of the same version | | `CRAFT_FONTS_DIR` | Build time: a [craft-fonts](https://github.com/storytold/craft-fonts) checkout to embed (Japanese fonts) | | `PDFCRAFT_SYSTEM_FONTS` | `0` stops the desktop app from using an installed font for characters its embedded fonts lack (`cargo xtask screenshots` sets it) | +| `FONTCONFIG_FILE` | On Linux/Unix, the Fontconfig configuration file used to discover installed UI fallback fonts (default: `/etc/fonts/fonts.conf`); relative filenames are resolved under `FONTCONFIG_PATH` | +| `FONTCONFIG_PATH` | Directory for a relative `FONTCONFIG_FILE` (default: `/etc/fonts`; the first entry of a search path is used) | + +On Linux/Unix, the UI's existing fallback filenames are also searched in Fontconfig's configured +directories and their subdirectories. This finds fonts in Nix store paths and user font directories +without invoking `fc-match` or linking libfontconfig. The scan is cached and bounded; missing or +malformed configuration leaves the ordinary `/usr/share/fonts` and `/usr/local/share/fonts` +candidates available. Font size and glyph-coverage checks still apply. This only reads installed +fonts for the interface; it does not embed them into PDFs or packages. + +Relative includes resolve beside the config that contains them. Included directories load only +digit-prefixed `*.conf` files, in lexical order; backups and disabled files are ignored. The scan +limits each config to 1 MiB, visits at most 256 config files/directories, and bounds include depth +to 16 and directory entries to 32,768. Cyclic includes are skipped. diff --git a/docs/editing-existing-text.md b/docs/editing-existing-text.md index 3e71c8c72..e8bb03b9a 100644 --- a/docs/editing-existing-text.md +++ b/docs/editing-existing-text.md @@ -4,23 +4,46 @@ Click a text region in **Edit a PDF → Edit text and images**. One editor conta source rows and mixed styles. Embedded TrueType and OpenType/CFF faces are used in memory; source font programs, colour, size and baseline anchors stay intact. A textless preview prevents a second copy underneath. The frame shows the live extent as text grows or shrinks. +Rows a text edit doesn't reach keep their original operators, so their kerning, `TJ` word +gaps and glyph widths stay exactly as they were. Only rows whose characters changed, or that +moved because an earlier row wrapped, are rewritten, and formatting a selection rewrites +only the rows it covers. Changing the whole paragraph's formatting, width or position rewrites +every row. + +Add Text also opens existing text when it is clicked; a blank-space click starts a new item. +Both text tools retype an added text item with one click. Draft boxes grow to the page edge +before wrapping, and the saved item uses that width. A single visual source row grows across font/style boundaries; +multi-row paragraphs retain their existing wrap width. Retyping uses a preview with only +that added item's old drawing removed, so deleted draft characters do not show underneath. A subset font contains only some of the original face's glyphs. A new character cannot be reconstructed from a glyph the PDF does not contain. **Load font…** imports an openly licensed, embeddable standalone `.ttf` or `.otf` face. **Installed fonts…** discovers eligible user-installed faces on request, off the UI thread; the document-font menu selects resources already on the page. Select **Keep source fonts** to preserve each run's original face. A matching full face -completes its subsets automatically, including new Chinese characters. Choosing another face +completes its subsets automatically, including new Chinese characters. When the PDF itself embeds +the complete program of a paragraph's font (not a subset) and its licence allows editable +embedding, a character the font's encoding can't show is drawn with that same program. A +character no available face has is refused with an error that names it. Choosing another face is explicit. Fonts with restricted embedding, unsupported collections, excluded families or no open-licence declaration are refused. Interface fallback fonts are never exported. The full selected program, glyph mappings, widths and Unicode text are embedded in the PDF. -Saving and reopening therefore needs no locally installed font. Original subset bytes are +Saving and reopening therefore needs no locally installed font. Editing the same text again +reuses the font resource the page already has, and every resource made from one program +shares a single copy of it. Original subset bytes are preserved. Optional matching faces from `CRAFT_FONTS_DIR` can also complete source subsets; this works only when that build input actually contains the full face. This change does not add a font binary or change the release font pin. Fonts shared by releases belong in `storytold/craft-fonts` under the repository's asset rules. +Rewrapped Chinese and Japanese text, which has no spaces between words, may break between any +two characters. A row never begins with closing punctuation, a closing bracket or quote, a small +kana or the prolonged sound mark, and never ends with an opening bracket or quote or a currency +sign before an amount: the line-start and line-end rules of JIS X 4051, as in Unicode line +breaking (UAX #14). Latin words inside such text are not split. Korean and Thai still break only +at spaces. + Select text within the editor before changing size, colour, font, underline or spacing to format that selection. Automation supports up to 1024 disjoint UTF-8 byte ranges with the same properties; invalid or overlapping ranges fail atomically. Untouched source formatting @@ -33,6 +56,12 @@ a second bidi reversal; adjacent visual glyph runs still recover their own readi Only right-to-left ActualText is kept apart this way: left-to-right spans, such as the Latin ligatures (`fi`, `fl`) Word and Chrome wrap in ActualText mid-word, join their line as before. +A justified paragraph stretches each row but its last to the paragraph's width by widening its +spaces. Word spacing (`Tw`) widens only single-byte code 32, so where the font shows a space with +another code (a composite font, the generated Type 3 fallback) each space is followed by a `TJ` +adjustment instead. Justifying a paragraph with several formats in it is not supported yet: it is +left-aligned. + For headless editing, use `text_font_files` → `text_font_import` → `text_fonts` and pass a resource to `text_edit.font_resource`. `text_edit.ranges` holds selected range formatting. `text_edit_layout` accepts resource/ranges too and measures a draft without applying it. diff --git a/docs/editing-images.md b/docs/editing-images.md index 3417b4c6d..eba918ef3 100644 --- a/docs/editing-images.md +++ b/docs/editing-images.md @@ -6,6 +6,9 @@ that placement. The context menu also rotates or flips it. Changes support undo survive saving/reopening. For single-figure transforms, a clipping path exclusive to one image moves and resizes together with its pixels, including nested graphics states, intervening state operators and paths split across content streams. A clip shared with other content stays where it is. +When the figure already sits alone in a `q … cm … Q` (its own placement, or the one an +earlier edit added), moving or resizing it again changes that `cm`, so repeated edits don't +nest one more graphics state each time. PDF figures are not always bitmap images. Imported diagrams often use a **Form XObject**, which packages text, vector paths, and nested images into one object. PdfCraft selects diff --git a/docs/gaps.md b/docs/gaps.md index 457a868fc..003d93deb 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,8 @@ # Where PdfCraft falls short of Acrobat Pro > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (gap 14: Vietnamese catalog shipped; Hindi/Indonesian/Korean and Arabic RTL remain) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (gap 6: #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-12 · **Last updated:** 2026-10-12 · **Change:** minor (gap 6: #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: #1317, #1327, #1302, #1315 and #1340; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (#1316: replacing a line keeps a neighbouring line whose glyph box overlaps; gap 3 remains) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -39,7 +41,7 @@ update the numbers. | 11 | Accessibility: checker only, no tagging tools | feature | 40–70 | [target-app-parity.md](target-app-parity.md) | | 12 | No measured fidelity harness against Acrobat | quality | (in 2) | [target-app-parity.md](target-app-parity.md) | | 13 | Performance budgets and lazy loading of very large files | performance | 25–45 | this file | -| 14 | Arabic not mirrored; no Hindi, Indonesian, Korean, Vietnamese | localization | 30–50 | [localization-parity.md](localization-parity.md) | +| 14 | Arabic not mirrored; no Hindi, Indonesian, Korean | localization | 25–45 | [localization-parity.md](localization-parity.md) | | 15 | Forms: JavaScript object model, actions, submit, XFA depth | feature | 40–75 | [pdf-spec-parity.md](pdf-spec-parity.md) | | 16 | UI precision gaps (multi-select move, rulers, accelerators, windows) | UI/UX | 30–60 | [ui-parity.md](ui-parity.md) | | 17 | Certificate security and other protect gaps | feature, spec | 15–30 | [pdf-spec-parity.md](pdf-spec-parity.md) | @@ -129,14 +131,15 @@ update the numbers. - **Missing:** fixes for the ≈ 13 of ≈ 25 filed reports of features marked `shipped` that give wrong results still open, plus triage of ≈ 220 other open issues. - **Evidence:** 245 open issues on 2026-10-10. The first batch of `fix(...)` reports was fixed by - 2026-10-10 (#799, #800, #807, #809, #810, #811, #815, #816, #817, #818, #819, #821). Stamps placed - on a rotated page read upright: rubber stamps, natural-size and explicit-rectangle images, and - PDF-page stamps (#1398). The rest of the backlog is untriaged. 2026-10-10 (#799, #800, #807, #809, #810, #811, #815, #816, #817, #818, #819, #821). A later batch keeps a cloudy comment flatten cannot draw (#1317), leaves a refused text-box restyle or resize unchanged (#1327), keeps arrow defaults off plain lines (#1302), stops a custom text match on the separator instead of the next glyph (#1315), and states the vertical-text - redaction refusal without claiming an overlap (#1340). The rest of the backlog is untriaged. + redaction refusal without claiming an overlap (#1340). Organize Pages opens on the page being + read, selected and scrolled into view (#1910). Stamps placed on a rotated page read upright + (#1398). Another batch keeps a spaced XFA radio value in the datasets (#1319), replaces only the + selected pages when they are not adjacent (#1305), and leaves unrelated page streams in place + when a figure is marked decorative (#1324). The rest of the backlog is untriaged. - **Fix verified (2026-10-14):** raw-deflate fallback returns a decoding-limit error instead of accepting an empty partial stream when a misleading zlib header triggers a retry. Synthetic filter and COS stream regressions cover refusal at the limit and decoding within it. @@ -199,8 +202,10 @@ update the numbers. ### 11. Accessibility: only text-only automatic structure creation -- **Missing:** auto-tag mixed text, images, tables, lists, links and fields; connect preview heading - proposals to Apply; visual Order and Content panels, Reading Order tool, table editor, Make Accessible action, +- **Missing:** apply auto-tag proposals to marked content and a valid parent tree, tag fields + into structure trees with unusual layouts (only a direct Document root with a direct + ParentTree Nums array is extended), + visual Tags, Order and Content panels, Reading Order tool, table editor, Make Accessible action, Read Out Loud; full keyboard operation of PdfCraft itself and a screen-reader audit. - **Evidence:** all 32 checker rules, figure alt text, tag property and sibling order edits, and a bounded auto-tag preview exist. Apply writes valid structure and parent-tree links for @@ -216,10 +221,46 @@ update the numbers. signature validation against Acrobat on synthetic fixtures; an interop suite that opens every kind of output we write (encrypted ones above all, after #774) in pdf.js, MuPDF and Poppler as oracle processes. -- **Evidence:** none exists; only text extraction has an oracle (word-F1 0.98 against `pdftotext`). - Acrobat captures must stay local in `plan/acrobat/` (clean-room, `AGENTS.md` §1.1). -- **Impact:** until it exists, every quality number here is a judgement. -- **Estimate:** counted in gap 2 (≈ 20–30 h of it). **Doc:** [target-app-parity.md](target-app-parity.md). +- **Progress (2026-10-14):** rendering now has an oracle too. `cargo xtask render-oracle` compares + page 1 of every corpus file against Poppler's `pdftoppm` and reports a disagreement rate: the + share of pixels with no near match in the other renderer's 3×3 neighbourhood, which forgives + antialiasing and subpixel placement but not missing or wrong content (`crates/oracle`, module + `pixels`). **First reading, 250 files at 72 dpi, tolerance 8: 229 compared, median 0.89%, mean + 2.16%, p90 5.35%, 25 within 0.1%.** It reports rather than gates — the corpus is not fetched on + CI, and a first reading of a borrowed renderer is a baseline to improve on, not a bar to clear. + The worst results cluster on JBIG2 `bitmap-*` files (`hayro-jbig2` carries 3 local patches) and + on ZapfDingbats, which points the renderer work at something specific for the first time. +- **Read the score as a disagreement, not a verdict (2026-10-14).** The worst file of the 250, + `bug1552113.pdf` at 84%, turned out to be Poppler's defect: it paints the link's `/Border [0 0 + 112]` as a 112-unit band over the two lines of text, and the fixture is named "this text should + be visible". Ours is the readable rendering. Anything at the top of the list needs a person to + look at both images before it is treated as a bug of ours. +- **The JBIG2 cluster was Poppler too, and the suite checks itself (2026-10-14).** The next four + worst files were JBIG2, which looked like a defect in `hayro-jbig2` and its local patches. It is + the reverse. The 96 `bitmap-*.pdf` fixtures are a conformance suite: one picture encoded through + every corner of JBIG2, so a correct decoder renders all 96 identically — a real test needing no + reference image and no oracle. **We render 1 distinct image across all 96; Poppler renders 8**, + drawing a blank page for the seven halftone-skip and symbol fixtures and mis-decoding TPGRON + refinement (its output changes when TPGRON is switched on, which a correct decoder's cannot). + Pinned as `crates/render/tests/jbig2_invariants.rs`. **Poppler is not a usable oracle for JBIG2**, + and roughly a fifth of its fixtures should be read as its bugs rather than ours. +- **Annotations are shown, and that was measured rather than argued.** Poppler synthesises an + appearance for an annotation that has none, where we draw only what the document carries (link + borders without `/AP` are recorded as not covered under `view.render-annotation-appearances`), so + showing them scores us for a known gap. Hiding them is worse, because it then scores Poppler for + every appearance we *do* draw: over the same 250 files, shown gives mean 2.16% / p90 5.35% / 25 + files within 0.1%, hidden gives 2.18% / 5.55% / 22, with `annotation-tx3.pdf` jumping to 45.64%. + The biases nearly cancel; `--hide-annotations` tells the two apart for one file at a time. +- **Evidence:** text extraction (word-F1 0.98 against `pdftotext`) and now rendering (above); qpdf + checks written output on every CI run since `PDFCRAFT_ORACLES_REQUIRED`. Acrobat captures must + stay local in `plan/acrobat/` (clean-room, `AGENTS.md` §1.1). +- **Still missing:** Acrobat itself as the reference rather than other open renderers; form + calculation and signature validation; the interop suite that opens every kind of output we write + (encrypted ones above all, after #774) in pdf.js and MuPDF; and 15 of those 250 files could not + be compared at all because the two renderers round the page box to different pixel sizes. +- **Impact:** until it is complete, quality numbers outside rendering and text are judgements. +- **Estimate:** counted in gap 2 (≈ 20–30 h of it; roughly a third done). + **Doc:** [target-app-parity.md](target-app-parity.md). ### 13. Performance budgets and lazy loading of very large files @@ -240,14 +281,15 @@ update the numbers. to edit. - **Estimate:** 25–45 h. **Doc:** this file. -### 14. Arabic not mirrored; no Hindi, Indonesian, Korean, Vietnamese +### 14. Arabic not mirrored; no Hindi, Indonesian, Korean - **Missing:** a right-to-left layout (panels, menus, alignment), bidirectional text in the text - layout itself (wrapping, typed text); catalogs for Hindi, Indonesian, Korean and Vietnamese - (Acrobat ships Korean; it ships Arabic in its Middle East edition; it ships none of the other - three). + layout itself (wrapping, typed text); catalogs for Hindi, Indonesian and Korean (Acrobat ships + Korean; it ships Arabic in its Middle East edition; it ships none of Hindi or Indonesian). + Vietnamese ships as a catalog (`vi`, 100% of the current localizable union) but stays `partial` + like other non-English languages (no translated help; engine/OS details stay English). - **Evidence:** [localization.md](localization.md) §Arabic; `misc.rtl-ui` planned. -- **Estimate:** 30–50 h (Arabic RTL 20–35, four catalogs 10–15 plus review). **Doc:** +- **Estimate:** 25–45 h (Arabic RTL 20–35, three catalogs 8–12 plus review). **Doc:** [localization-parity.md](localization-parity.md). ### 15. Forms: JavaScript object model, actions, submit, XFA depth @@ -320,8 +362,8 @@ update the numbers. - **Missing:** a menu and tool listing captured from Acrobat (names only, `plan/acrobat/`, never committed) diffed against the checklist, so that features the checklist never listed show up. - Also correct `misc.localization` and `misc.installers`, which are still `planned` though both - ship. + `misc.localization` and `misc.installers` are now `partial` (Chinese, Telugu and Arabic are not + fully usable yet; Linux packages are unsigned). - **Estimate:** 4–8 h. **Doc:** [target-app-parity.md](target-app-parity.md). ## Revision history @@ -329,6 +371,8 @@ update the numbers. | Date | Change | Summary | |---|---|---| | 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. | +| 2026-10-15 | patch | Gap 24: record localization and installers as partial (unusable Chinese, Telugu and Arabic; unsigned Linux packages) | +| 2026-10-13 | minor | Gap 6: Organize Pages selects and reveals the current page on open, including after an earlier grid selection elsewhere (#1910). Estimates unchanged. | | 2026-10-12 | minor | Gap 6: rubber stamps, explicit image rectangles and PDF-page stamps read upright on rotated pages (#1398). Estimates unchanged. | | 2026-10-12 | minor | #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps. Gap 3 remains. Estimates unchanged. | | 2026-10-12 | minor | 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. | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index ccd5f88b9..995293af3 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,7 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (list all registered interface languages; preserve the original coverage snapshot) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Vietnamese 100% of current key union; native-speaker community review; twelve-language mean ≈68%) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Italian count after completing its catalog) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) Per-language status of PdfCraft's interface against Acrobat Pro's. How translations work and how to add a language: [localization.md](localization.md). Summary in [ROADMAP.md](../ROADMAP.md). @@ -8,10 +9,11 @@ add a language: [localization.md](localization.md). Summary in [ROADMAP.md](../R ## How this was measured - **Strings translated** is measured: the catalogs in `crates/ui-egui/src/i18n/.tsv`, counted - as distinct `(context, English)` keys with a non-empty translation. The denominator is the union - of keys across all catalogs, **2,143**. Cross-check: of the 1,151 distinct `tl!("…")` literals in - `crates/ui-egui/src`, every full catalog covers 96.5–99.4% (7 literals are in no catalog yet). - English is built in. + as distinct `(context, English)` keys with a non-empty translation. The **current** denominator is + the union of keys across all catalogs (**2,590** plain keys as of 2026-10-13, plus language-specific + context rows such as Vietnamese `theme`/`Appearance`). The twelve-language percentage table below + still quotes the **historical 2,143-key snapshot** from 2026-10-10 for every language except + Vietnamese (re-measured against the current union). English is built in. - **Not counted:** error details from the engine and the operating system, which are shown in English inside a translated frame; there is no translated user documentation or help yet (Acrobat ships localized help). @@ -38,15 +40,17 @@ add a language: [localization.md](localization.md). Summary in [ROADMAP.md](../R | Japanese | `ja` | 2,123 (99.1%) | dialogs and tooltips; no help | BIZ UDPGothic from craft-fonts; IME patched on macOS, Windows, X11, Wayland; vertical text renders but can't be edited | no | partial | yes | 4–8 | | German | `de` | 2,128 (99.3%) | dialogs and tooltips; no help | Latin | community, in progress (#828: terminology, clipping) | partial | yes | 2–4 | | Korean | `ko` | 0 | none | Hangul IME patched (Microsoft and Apple Korean, `vendor/README.md` winit 3, 6, 9, 10); no Hangul UI face in craft-fonts | no | none | yes | 6–10 | -| Vietnamese | `vi` | 0 | none | Latin with stacked diacritics; bundled Inter covers them (not tested) | no | none | no | 3–5 | +| Vietnamese | `vi` | 2,591 (100% of current union + theme Appearance) | dialogs and tooltips; no help | Latin with stacked diacritics; bundled Inter covers them (fonts test) | community | partial | no | 2–4 | -**Localization dimension ≈ 59%**: the mean over the twelve, counting a catalog's percentage but only -60% for Chinese and Arabic until their script problems are fixed, and 0 for the four missing -languages. **≈ 60–115 h** to bring all twelve to `full`, of which ≈ 35–60 h is engineering (Hans -face, RTL layout, bidi text) and the rest catalogs and fixes; native-speaker review needs humans. +**Localization dimension ≈ 68%**: the mean over the twelve, counting a catalog's percentage but only +60% for Chinese and Arabic until their script problems are fixed, and 0 for Hindi, Indonesian and +Korean (still missing). Vietnamese moved from 0% to 100% in that mean (59% → 68%). **≈ 55–110 h** +to bring all twelve to `full`, of which ≈ 35–60 h is engineering (Hans face, RTL layout, bidi text) +and the rest catalogs and fixes; native-speaker review needs humans. No language is `full` except English: every catalog leaves engine error details in English, and -none has translated help. +none has translated help. Vietnamese's **To `full`** hours are therefore residual help/docs polish +(catalog and script work are already done), not missing UI strings. ## Other shipped languages @@ -59,21 +63,26 @@ none has translated help. | Telugu | `te` | 2,058 (96.0%) | partial (the release-pinned craft-fonts has no Telugu face yet) | no | | Hungarian | `hu` | 2,126 (99.2%) | partial | yes | | Ukrainian | `uk` | 2,142 (100%) | partial | yes | -| Italian | `it` | 2,130 (99.4%) | partial | yes | +| Italian | `it` | 2,528 entries (every key of the other catalogs that the code looks up) | partial | yes | | Kazakh | `kk` | 2,327 entries | partial | no | | Polish | `pl` | 2,287 entries | partial | yes | | Slovenian | `sl` | 2,324 entries | partial | no | | Greek | `el` | 2,341 entries | partial | no | | Turkish | `tr` | 2,313 entries | partial | yes | -| Lithuanian | `lt` | 2,578 entries | partial; community native-speaker review ([#1401](https://github.com/storytold/pdfcraft/pull/1401)) | no | +| Lithuanian | `lt` | 2,585 entries | partial; community native-speaker review ([#1401](https://github.com/storytold/pdfcraft/pull/1401)) | no | +**14 other languages** ship (22 catalogs plus English = **23** interface languages in Preferences). +The entry counts without percentages were measured on 2026-10-12–13; they are not percentages of the older 2,143-key snapshot above. Their presence does not imply a detailed linguistic or runtime review. +Acrobat languages PdfCraft lacks: Danish, Dutch, Finnish, Norwegian, Swedish, Hebrew. Vietnamese is shipped here but not by Acrobat. **14 other languages** ship (21 catalogs plus English, 22 interface languages in Preferences). -The entry counts without percentages were measured on 2026-10-12; they are not percentages of the older 2,143-key snapshot above. Their presence does not imply a detailed linguistic or runtime review. +The entry counts without percentages were measured on 2026-10-12 (Italian on 2026-10-14); they are not percentages of the older 2,143-key snapshot above. Their presence does not imply a detailed linguistic or runtime review. Acrobat languages PdfCraft lacks: Danish, Dutch, Finnish, Norwegian, Swedish, Hebrew. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Vietnamese (`vi`) catalog: 2,591 entries (100% of current key union + `theme`/`Appearance`); twelve-language mean ≈ 68%; native-speaker community review; registered with `plural_none` | +| 2026-10-14 | minor | Italian: 2,528 entries after adding the 201 keys the other catalogs had and the code looks up | | 2026-10-12 | minor | Added current counts for Kazakh, Polish, Slovenian, Greek, Turkish and Lithuanian; retained existing language assessments and the original percentage snapshot | | 2026-10-10 | major | Created. Catalog counts measured from `crates/ui-egui/src/i18n/*.tsv` against the 2,143-key union; Acrobat's localizations from its bundle's `.lproj` and Sequences folder names | diff --git a/docs/localization.md b/docs/localization.md index 05240cafb..657e2eb24 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -1,10 +1,10 @@ # Interface language -Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma elsewhere) **> Interface language** and select **Auto**, **English**, **日本語**, **简体中文**, **繁體中文**, **Čeština**, **Português (Brasil)**, **Deutsch**, **Español**, **Français**, **Русский**, **Қазақша**, **Български**, **తెలుగు**, **Magyar**, **Українська**, **Italiano**, **العربية**, **Polski**, **Slovenščina**, **Ελληνικά**, **Türkçe** or **Lietuvių**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged. +Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma elsewhere) **> Interface language** and select **Auto**, **English**, **日本語**, **简体中文**, **繁體中文**, **Čeština**, **Português (Brasil)**, **Deutsch**, **Español**, **Français**, **Русский**, **Қазақша**, **Български**, **తెలుగు**, **Magyar**, **Українська**, **Italiano**, **العربية**, **Polski**, **Slovenščina**, **Ελληνικά**, **Türkçe**, **Lietuvių** or **Tiếng Việt**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged. Displayed keyboard shortcuts use the same active interface language. For example, German shows `Strg+K` and `Strg+Umschalt+S` in the toolbar, menus, command palette, tooltips and Keyboard shortcuts dialog; macOS keeps its modifier symbols (`⌘`, `⇧`, `⌃`). Key names have their own `key` catalog context, so the Delete key can read `Entf` while the command remains `Löschen`. A missing key-name entry falls back to the English key name. Actual bindings and machine-facing command ids remain stable. -**Auto** (the default) follows the system language: `LC_ALL`, `LC_MESSAGES` or `LANG`, then the preferred-languages list on macOS. Any Portuguese locale (`pt_BR`, `pt_PT`) uses the Brazilian catalog, any French locale (`fr`, `fr_FR`, `fr_CA`, `fr_BE`) the French one, any German locale (`de`, `de_DE`, `de_AT`, `de_CH`) the German one, and Chinese locales for Taiwan, Hong Kong and Macau (`zh_TW`, `zh_HK`, `zh_MO`, `zh-Hant`) the Traditional Chinese one; other Chinese locales (`zh`, `zh_CN`, `zh_SG`, `zh-Hans`) the Simplified Chinese one. Telugu locales (`te`, `te_IN`) use the Telugu catalog, Bulgarian locales (`bg`, `bg_BG`) the Bulgarian one, and Hungarian locales (`hu`, `hu_HU`) the Hungarian one, and Ukrainian locales (`uk`, `uk_UA`) the Ukrainian one, and Italian locales (`it`, `it_IT`, `it_CH`) the Italian one, and Arabic locales (`ar`, `ar_MA`, `ar_EG`, `ar_SA`, …) the Arabic one, and Turkish locales (`tr`, `tr_TR`) the Turkish one, and Slovenian locales (`sl`, `sl_SI`) the Slovenian one. A system language without a catalog shows English. On Windows, **Auto** uses the Windows display languages, in preference order, when no supported locale is selected by those environment variables. Neutral `C`/`POSIX` locale values (including `C.UTF-8`) inherited from a launcher do not override the Windows display language; explicitly named supported locales such as `fr_FR.UTF-8` still do. The query asks Windows directly (`GetUserPreferredUILanguages`, through the `sys-locale` crate) without starting a process, and is cached for the process. If detection fails or the display language has no catalog, PdfCraft shows English. +**Auto** (the default) follows the system language: `LC_ALL`, `LC_MESSAGES` or `LANG`, then the preferred-languages list on macOS. Any Portuguese locale (`pt_BR`, `pt_PT`) uses the Brazilian catalog, any French locale (`fr`, `fr_FR`, `fr_CA`, `fr_BE`) the French one, any German locale (`de`, `de_DE`, `de_AT`, `de_CH`) the German one, and Chinese locales for Taiwan, Hong Kong and Macau (`zh_TW`, `zh_HK`, `zh_MO`, `zh-Hant`) the Traditional Chinese one; other Chinese locales (`zh`, `zh_CN`, `zh_SG`, `zh-Hans`) the Simplified Chinese one. Telugu locales (`te`, `te_IN`) use the Telugu catalog, Bulgarian locales (`bg`, `bg_BG`) the Bulgarian one, and Hungarian locales (`hu`, `hu_HU`) the Hungarian one, and Ukrainian locales (`uk`, `uk_UA`) the Ukrainian one, and Italian locales (`it`, `it_IT`, `it_CH`) the Italian one, and Arabic locales (`ar`, `ar_MA`, `ar_EG`, `ar_SA`, …) the Arabic one, and Turkish locales (`tr`, `tr_TR`) the Turkish one, and Slovenian locales (`sl`, `sl_SI`) the Slovenian one, and Vietnamese locales (`vi`, `vi_VN`) the Vietnamese one. A system language without a catalog shows English. On Windows, **Auto** uses the Windows display languages, in preference order, when no supported locale is selected by those environment variables. Neutral `C`/`POSIX` locale values (including `C.UTF-8`) inherited from a launcher do not override the Windows display language; explicitly named supported locales such as `fr_FR.UTF-8` still do. The query asks Windows directly (`GetUserPreferredUILanguages`, through the `sys-locale` crate) without starting a process, and is cached for the process. If detection fails or the display language has no catalog, PdfCraft shows English. The control channel exposes the setting through `ui.set`: @@ -12,18 +12,21 @@ The control channel exposes the setting through `ui.set`: {"method":"ui.set","params":{"key":"language","value":"ja"}} ``` -The value is `auto` or a language code (`en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `pl`, `sl`, `el`, `tr`, `lt`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `pl`, `sl`, `el`, `tr` or `lt`). Unknown values return an error without changing the current setting. Preferences saved before this setting existed follow the system language. +The value is `auto` or a language code (`en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `pl`, `sl`, `el`, `tr`, `lt` or `vi`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `pl`, `sl`, `el`, `tr`, `lt` or `vi`). Unknown values return an error without changing the current setting. Preferences saved before this setting existed follow the system language. -Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Japanese, Traditional Chinese, Simplified Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic translate them (about 2,000–2,140 entries each); Czech and Turkish cover the menus so far. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md). -Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Japanese, Traditional Chinese, Simplified Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese, Arabic and Slovenian translate them (about 2,000–2,190 entries each); Czech covers the menus so far. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md). +Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Most shipped catalogs (Japanese, Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese, Arabic, Slovenian, Kazakh, Polish, Greek, Turkish, Lithuanian, Vietnamese and others) cover the current localizable surface (about 2,000–2,600 entries each); Czech still covers mainly the menus. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Document contents, filenames, CLI/MCP/control protocol strings and external help stay outside the catalog. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md). +Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Japanese, Traditional Chinese, Simplified Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic translate them (about 2,000–2,140 entries each), and Italian has 2,528 entries; Czech and Turkish cover the menus so far. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md). +Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Japanese, Traditional Chinese, Simplified Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese, Arabic and Slovenian translate them (about 2,000–2,190 entries each), and Italian has 2,528 entries; Czech covers the menus so far. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md). Whole sentences that the layers below word — what the accessibility checker found, what the PDF/A verifier reports, what a signature's details say, what is wrong with the pages to print, what an edit is called in the history — are translated too, because those layers hand the sentence over apart from the document's own words. See [Sentences the engine words](#sentences-the-engine-words). The Ukrainian catalog has 2,047 entries, written from the meaning of the English labels using Ukrainian computing vocabulary. Commands use infinitives; fixed-count messages use count-neutral wording. Plural-aware messages use the three [CLDR Ukrainian integer forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#uk): `1 сторінка`, `2 сторінки`, `5 сторінок`; 11–14 take the third form, while 21 and 22 return to the first and second. Calendar dates use genitive month names (`9 жовтня 2026`), while headings use standalone names (`Жовтень 2026`). Home recommendation cards grow to fit translated text; the toolbar search hint truncates before the shortcut, with the full label available on hover. -The Lithuanian catalog has 2,578 entries and three integer plural forms. Calendar headings use standalone month names; dates use genitive forms. Coverage tests check commands/tools, scanned literal and direct lookups, and source/context keys in the other bundled catalogs. Calendar, inspector and password-dialog UI regressions check dynamic labels and preserve document values. A native Lithuanian-speaking contributor has reviewed the translation. +The Lithuanian catalog has 2,585 entries and three integer plural forms. Calendar headings use standalone month names; dates use genitive forms. Coverage tests check commands/tools, scanned literal and direct lookups, and source/context keys in the other bundled catalogs. Calendar, inspector and password-dialog UI regressions check dynamic labels and preserve document values. A native Lithuanian-speaking contributor has reviewed the translation. -Edit text reads Hebrew lines in typing order and writes them back in drawing order (`crates/edit/src/bidi.rs`); letters the page's own font lacks are drawn with the craft-fonts `Hebr` face (Noto Sans Hebrew) as Type 3 glyphs, and a build without one says so. Hebrew points (niqqud) can't be written yet. The craft-fonts revision pinned for CI and releases has no `Hebr` face yet, so until one is added releases report that error instead of drawing the missing letters. +The Vietnamese catalog has 2,591 entries and one plural form (`plural_none`). Preferences theme `Appearance` uses context `theme` → `Giao diện`; object/comment/signature appearance stays plain `Diện mạo`. Form fields use `Trường`; redaction apply wording states permanent removal. Calendar months are numeric (`Tháng 1`…`12` / `tháng 1`…`12`). Coverage tests require every current command, UI literal, bundled key and credit label. A native Vietnamese-speaking contributor has reviewed the translation. Status stays `partial`: no translated help, and engine/OS details stay English inside a translated frame. + +Edit text reads Hebrew lines in typing order and writes them back in drawing order (`crates/edit/src/bidi.rs`); letters the page's own font lacks are drawn with the craft-fonts `Hebr` face (Noto Sans Hebrew) as Type 3 glyphs, and a build without one says so. Hebrew points (niqqud) can't be written yet in Edit text. Add text (#128) draws Hebrew with the same face through the shaped path Arabic and Thai use: right to left with numbers and Latin in place, points kept on their letter, and typing order for copy and search. The craft-fonts revision pinned for CI and releases has no `Hebr` face yet, so until one is added releases report that error instead of drawing the missing letters, and refuse Hebrew in Add text. Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian, Italian, Turkish and Lithuanian need only the bundled Latin faces (tested in `tests/fonts.rs`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet. Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian, Italian and Slovenian need only the bundled Latin faces (tested in `tests/fonts.rs`). Slovenian has four plural forms: one (1, 101, 201…), two (2, 102…), few (3, 4, 103…) and other (everything else, including 0, 5–20 and 21+), so `@plural` entries list four forms (`1 stran`, `2 strani`, `3 strani`, `5 strani`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet. @@ -62,7 +65,7 @@ PdfCraft uses the same system as PhotoCraft (`crates/ui-egui/src/i18n/`): - Each language has a catalog, `crates/ui-egui/src/i18n/.tsv`, with one entry per line: `contextEnglishtranslation`. An empty context marks a plain string. `@id` keys an entry by command id (for example `file.saveAs`), so one menu item can read differently from another with the same English label. `@plural` holds plural forms. Any other context disambiguates an English word with several meanings (`tl_ctx!` in UI code, for example the signature pad's "Type" button against the "Type" column). The header of `ja.tsv` documents the format and escapes. - Placeholders (`{name}`) must appear in both columns. A trailing `…` must be kept. `i18n::fmt` fills placeholders in one pass, so a value such as a file name containing `{n}` is inserted as written. - Catalogs are validated strictly: unknown escapes, unknown `@` contexts, placeholder or ellipsis mismatches, wrong plural form counts and duplicates are errors. The tests require the bundled catalogs to have none; at run time a bad line is skipped (and logged) and that string shows in English. -- Catalogs are assets: each `.tsv` has an `ATTRIBUTION.toml` entry with `kind = "translation"` and its SHA-256, checked by `cargo xtask assets`. +- Catalogs are assets: each `.tsv` has an `ATTRIBUTION.toml` entry with `kind = "translation"` and its SHA-256, checked by `cargo xtask assets`. After editing a catalog, put the new hash into its entry by hand (`sha256sum crates/ui-egui/src/i18n/.tsv`), because `cargo xtask assets --write` does not touch `ATTRIBUTION.toml`; the hash check is deliberate, so a changed asset gets its provenance re-checked. Without it `cargo xtask assets` and the test `assets::tests::the_real_manifest_passes` fail. - Translations are clean-room: write them from the meaning of the English text, never from another product's string tables. ## Sentences the engine words @@ -81,6 +84,6 @@ Error details that come from the operating system, and faults rather than mistak 1. Copy the header of `ja.tsv` into `xx.tsv` and translate entries. 2. Add one row to `LANGUAGES` in `crates/ui-egui/src/i18n/mod.rs`: code, native name, catalog and plural rule. -3. Add an `ATTRIBUTION.toml` entry for the catalog (`kind = "translation"`), then run `cargo xtask assets --write` and `cargo xtask assets`. +3. Add an `ATTRIBUTION.toml` entry for the catalog (`kind = "translation"`), with the catalog's `sha256sum` as `sha256`, then run `cargo xtask assets --write` (it regenerates `ATTRIBUTION.md` only) and `cargo xtask assets`. The Preferences dropdown, the system-language match and the catalog tests (format, duplicates, placeholders, plural forms, command ids) then pick it up. diff --git a/docs/mcp.md b/docs/mcp.md index 830a6c312..96493487c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,6 +1,6 @@ # MCP conventions -Start explicitly with `pdfcraft-cli mcp [--root DIR] [--compact]`. The server uses JSON lines on +Start explicitly with `pdfcraft-cli mcp [--root DIR] [--compact] [--automation-read-root DIR] [--automation-write-root DIR]`. The server uses JSON lines on stdin/stdout, opens no port, and stops on EOF. `--root` confines all reads and writes, including commands invoked through the common tools. Logs stay on stderr. @@ -8,6 +8,68 @@ Supply `--root` at most once, followed by a nonempty directory path. Missing ope repeated roots and another long option in place of the directory are refused before any script is read or server is started. Prefix a directory name beginning with `--` with `./`. +## Confining file access + +```sh +pdfcraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project/out +``` + +```json +{"mcpServers": {"pdfcraft": {"command": "/abs/path/target/release/pdfcraft-cli", + "args": ["mcp", "--automation-read-root", "/work/project", "--automation-write-root", "/work/project/out"]}}} +``` + +The two flags (VectorCraft, PhotoCraft and the other Craft apps take the same ones, so one client +config works across the suite) keep the agent to the folders you give it: + +- Every file a tool reads must lie inside the **read root**: `doc_open`, `page_insert_file`, + `page_replace`, `doc_combine` and `doc_create_multiple` inputs, `doc_create` images, stamp, + picture, attachment, signature and form-image files, `doc_watermark` and `doc_background` files, + `doc_import_data`, `form_merge_data`, the files `action_run` and `ocr_recognize_files` process, + digital ID, graphic and certificate files for signing and `sign_trust`, `text_font_import`, and + the folders `text_font_files` searches. Without `directories`, `text_font_files` searches the read + root instead of the installed fonts. +- Every file a tool writes must lie inside the **write root**: `doc_save` (also saving in place), + `page_extract`, `doc_combine`, `doc_create_multiple`, `doc_split`, `doc_reduce`, `doc_optimize`, + `doc_print` to a file, the `doc_export_*` tools, `image_save`, `measure_export`, + `comments_summarize`, the accessibility report and review record, `doc_compare_report`, + `form_merge_data`, the output folders of `action_run` and `ocr_recognize_files`, `sign_id_create` + and `sign_document`. +- Read and write access are separate. Give the same folder twice for both. A root left out grants + none of its access: with only `--automation-read-root`, nothing can be written to disk, though + tools that give their result back instead of a path still do. Without either flag nothing changes. +- A path is made absolute against the server's working directory, then its links are followed: a file + (or folder) link that leads out of a root is outside it. For a file that doesn't exist yet, its + nearest existing folder's links are followed and the rest must be plain names, so `..` below a + folder that doesn't exist is refused, as is a link that leads nowhere (writing through it would + create its target). The result is compared with the root by whole folder names: `/work2/x` is not + inside `/work`. On Windows case is ignored, UNC paths are resolved like drive-letter ones, and + device names (`CON`, `NUL`, `COM1`...) are refused. +- A refused path is an ordinary tool error the agent can read and correct, for example + `automation path rejected: outside the write root /work/project/out: /etc/x.pdf` or + `automation filesystem access is not granted: write authority is absent: /work/project/out/x.pdf`. + Tools that handle several files report a refused one in its own entry. +- The roots must be existing folders; a missing one stops the server with an error. Each flag is + given once (`--flag ` or `--flag=`). +- `--root DIR` still works and can be combined with these flags. Both then apply: a path must be + inside `--root` and inside the matching automation root. + +`pdfcraft-cli mcp` always runs the session in its own process (there is no flag to connect it to a +running app), so the roots cover every server it starts. The desktop app is not confined by them. + +What the roots do not cover: tools that take no path still read and change the open documents, and +`text_extract` and the other inspection tools return their content. Keychain and Windows +certificate-store identities for `sign_document` are not files and are not checked. Sending a job +to a printer is not a file write. Every path a tool reads or writes goes through +`Automation::resolve`, and a unit test keeps the tool modules from touching the file system +without it. + +A path is checked, then opened: another program that swaps a link in between can still win that +race, so the roots keep an agent to the files it was given rather than guarding against other +software on the computer. + +## Tools + | Tool | Arguments | |---|---| | `command_list` | `doc?`, `filter?`, `enabled_only?`; returns `{commands: [...]}` with mapped tool schemas | diff --git a/docs/pdf-spec-parity.md b/docs/pdf-spec-parity.md index 23f2b66a3..4e21ef2ec 100644 --- a/docs/pdf-spec-parity.md +++ b/docs/pdf-spec-parity.md @@ -1,6 +1,6 @@ # PDF specification and standards parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (§7.6.4: written encrypted files open in Acrobat, #774) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (§12.4.4: page transitions and display times read) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) How much of ISO 32000-2 (PDF 2.0) and the PDF subset standards PdfCraft implements, set against what Acrobat Pro implements. This is PdfCraft's equivalent of a "geometry" or "codec" checklist: @@ -35,7 +35,7 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2 | §10 | Rendering: transfer functions, halftones, overprint, smoothness | full | borrowed; no overprint preview | `print.overprint-preview` planned | | §11 | Transparency: blend modes, soft masks, groups, knockout | full | borrowed (knockout shipped) | `view.render-transparency` partial | | §12.1–12.3 | Viewer preferences, page layout, page labels, outlines, destinations | full | **own**: initial view, page labels, bookmarks edit and from structure, named destinations rewired on copy | named-destination editing panel planned | -| §12.4 | Page transitions, thumbnails, articles, presentations | full | none (thumbnails discarded or kept) | `organize.page-transitions`, `view.articles-panel` planned | +| §12.4 | Page transitions, thumbnails, articles, presentations | full | `/Trans` and `/Dur` read and listed by `doc_info`, not played; thumbnails discarded or kept | `organize.page-transitions` partial, `view.articles-panel` planned | | §12.5 | Annotations | 28 types | see table below | | | §12.6 | Actions | 19 types | see table below | | | §12.7 | Interactive forms (AcroForm): fields, appearances, calculation order, JavaScript actions | full | **own**: all field types, regenerated appearances, Acrobat's AF functions and event order, sandboxed JavaScript (boa) | `form.js-host` partial (object-model subset); submit planned | @@ -130,5 +130,6 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2 | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | §12.4.4: `/Trans` and `/Dur` read into `DocInfo::transitions` and reported by `doc_info` | | 2026-10-11 | minor | §7.6.4: encrypted files we write open in Acrobat (#774, fixed by #836) | | 2026-10-10 | major | Created from the code, `vendor/README.md` and `parity/acrobat-features.toml`, against ISO 32000-2 and Acrobat Pro 26.002.21931's documented capabilities | diff --git a/docs/releasing.md b/docs/releasing.md index 5d6a850ab..ec2e5853b 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -121,10 +121,14 @@ neither the MSI nor the portable zip needs the Visual C++ redistributable. dialogs. Full UI confirms success with Finish; failures and cancellations have distinct messages. The welcome dialog has a "Create a desktop shortcut" checkbox and an "Edit with PdfCraft" right-click menu checkbox, both ticked by default. `/qn` and `/qb` stay unattended and create - both unless `INSTALLDESKTOPSHORTCUT=0` or `INSTALLPDFCONTEXTMENU=0` is passed. An update (the - launcher runs `msiexec /passive` with no properties) keeps the desktop shortcut only if - `PdfCraft.lnk` is still on the all-users desktop, so deleting it once keeps it away (#1058); a - value given on the command line wins. + both unless `INSTALLDESKTOPSHORTCUT=0` or `INSTALLPDFCONTEXTMENU=0` is passed; + `INSTALLIMAGECONTEXTMENU=0` (no checkbox) leaves out the image menu. An update (the launcher runs + `msiexec /passive` with no properties) keeps the desktop shortcut only if `PdfCraft.lnk` is still + on the all-users desktop, so deleting it once keeps it away (#1058), and keeps each menu only if + its key path is still in HKLM (#305). Both are AppSearch results read before the old version is + removed. An update from a release that didn't have a menu yet (before 0.7.1 for the PDF menu, + before 0.4.0 for the image menu, found by `OnlyDetect` Upgrade rows) adds it like a first install. + A value given on the command line wins. - The PDF right-click entry (#682) is a classic verb under `HKLM\Software\Classes\SystemFileAssociations\.pdf\shell\PdfCraft.Edit`, so it shows whichever app is the default for `.pdf` and never changes that default. Several selected PDFs (up to 15, the @@ -132,8 +136,12 @@ neither the MSI nor the portable zip needs the Visual C++ redistributable. hand-off opens them as tabs. On Windows 11 it is under "Show more options"; a first-level entry needs an `IExplorerCommand` handler and package identity (a sparse MSIX package), which the MSI doesn't have. +- The image right-click entry (#190) is the same kind of verb, `PdfCraft.CreatePdf`, for ten image + types, in its own component (`PdfcraftImageContextMenu`) since it became optional. Its rows moved + out of `PdfcraftApp`; that's safe because `MajorUpgrade` removes the old version before installing + the new one (`afterInstallValidate`), which `test-msi.ps1` checks. - `test-msi.ps1 ` checks the compiled shortcut, context-menu and dialog tables, and - evaluates the desktop shortcut's first-install and update conditions. Packaging + evaluates the first-install and update conditions of the desktop shortcut and both menus. Packaging runs it in a child process before signing, so the MSI isn't held open when signtool runs. The ARM64 install smoke test checks that both all-users shortcuts point at the installed `pdfcraft.exe` and are removed on uninstall, and that `INSTALLDESKTOPSHORTCUT=0` skips the desktop one. diff --git a/docs/roadmap.md b/docs/roadmap.md index 8da76e0d2..d9470232b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,7 +1,13 @@ # PdfCraft roadmap: milestones and what's next > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **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, 2,528 entries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; 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 on slow frames and coalesced text selection; 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 (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) @@ -9,6 +15,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: #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: unchanged incremental saves of a classic cross-reference table write 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 (M3: Windows' Animation effects setting is followed, and Preferences has Animation effects; 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) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M7: inline Arabic ActualText ligatures join their word; earlier: preserve Arabic ActualText typing order; M3: Windows' Animation effects setting is followed, and Preferences has Animation effects; 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) @@ -25,6 +32,7 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (M6: keep accepted form values when Tab reopens the same field before Save; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (M13: reject malformed CLI --root options before dispatch; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Traditional Chinese accessibility semantics and Taiwan form/table terminology; 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:** patch (M1 #1337: exhausted xref hints are repaired; allocation avoids overflow; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) Forward-looking: the milestones, the current focus and what comes next. The parity numbers behind it are in [target-app-parity.md](target-app-parity.md), the ranked work list in [gaps.md](gaps.md), @@ -89,37 +97,46 @@ total matches the parity estimate. | M | Milestone | Plan size (h) | Done | Remaining (h, 2026-10-10) | Notes | |---|---|---|---|---|---| | M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 98% | 1–2 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates | -| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). Fuzzing runs nightly (`xtask fuzz`). Raw-deflate fallback preserves decoding-limit errors, including through tolerant COS stream decoding. Missing: ≥ 250 tests, own image codecs | -| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. | +| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). Fuzzing runs nightly (`xtask fuzz`). Raw-deflate fallback preserves decoding-limit errors, including through tolerant COS stream decoding. Missing: ≥ 250 tests, own image codecs | +| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. | +| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | -| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation.
Candidate: coordinate control clicks pair press/release across slow frames; coalesced primary presses retain text multi-click selection. Regression cases cover slow-frame button activation, disabled controls and text multi-click selection; estimates unchanged. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #2080 candidate: search-result jumps enter per-tab history; rail and control-channel Back/Forward; no exact-view or restart persistence. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. A custom text match that ends on a reconstructed separator stops before the next glyph (#1315). Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | -| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); Lab Bradford adaptation uses LMS white-point ratios, covered by independent neutral/chromatic and direct/Indexed render oracles; inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | +| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); Lab Bradford adaptation uses LMS white-point ratios, covered by independent neutral/chromatic and direct/Indexed render oracles; inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Literal page-label prefix whitespace is preserved; nine public-inspection cases cover prefix spaces, encodings and empty-prefix controls | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). | -| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Missing: page transitions, recovery on the web | +| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Missing: page transitions, recovery on the web | +| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. With Windows' Animation effects turned off, the wheel scrolls and zooms without easing and egui's animations take no time (`motion.rs`; macOS, Linux and the browser aren't asked yet); Preferences ▸ Animation effects (Use system setting, On, Off) decides it on every platform. | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | +| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles; Ctrl+Enter submission for highlight comment text and replies, with Shift+Enter newlines (reconciled with upstream main for PR #1457 on 2026-10-14). Missing: custom stamps, replace-text proposals, summary layouts with the page | +| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page | -| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA | +| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | -| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | -| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. | +| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | +| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Vertical-writing text is still refused, and the error no longer claims the text was under the mark (#1340). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | -| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | +| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | | M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | -| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | +| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. | -| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation | +| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. | +| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers | -| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation | +| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation | | | **Total** | **2,085–3,840** | **≈ 49% ready** | **≈ 770–1,400** | Area view of the same work: [target-app-parity.md](target-app-parity.md#by-feature-area) | ## Upcoming milestones, ranked @@ -164,6 +181,19 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| | 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. | +| 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-12 | minor | M7 local candidate: text drafts grow and save their width; single visual rows grow across style boundaries; both text tools retype added items and Add Text edits source text. Added-item textless previews are read-only and covered by UI/tool regressions. Estimates unchanged. | +| 2026-10-12 | minor | M14/#1395: XDG configuration preferences, safe migration and fallback; logs/recovery retain data locations, portable/non-XDG unchanged. Estimates unchanged. | +| 2026-10-12 | minor | M6: XFDF field import and data extraction preserve all text around XML comments and processing instructions; synthetic save/reopen regressions. Estimates unchanged. | +| 2026-10-12 | minor | M2: preserve whitespace-bounded EI bytes in known unfiltered inline images; row padding, masks and aliases tested, with independent saved-PDF pixel checks. Estimates unchanged. | +| 2026-10-12 | minor | M1 #1338: a full rewrite keeps unknown trailer entries and objects reachable only from them. Estimates unchanged. | +| 2026-10-12 | minor | M7 #1443: adding a word to a multi-line paragraph keeps its line breaks. Composite subset fonts whose `/W` leaves out default-width glyphs (Chrome, Skia) no longer measure those letters as missing, and paragraphs drawn turned on the page group into rows along their own baselines. Estimates unchanged. | +| 2026-10-14 | minor | M14: Italian catalog completed (2,528 entries) and dropped from the ≈ 2,000–2,140-entry group; estimates unchanged | +| 2026-10-12 | minor | M4 #1310 #1311 #1312: undo back to the opened file is clean, Revert returns to the last saved file, and the next save keeps that revision. Estimates unchanged. | +| 2026-10-14 | minor | M5: reconciled PR #1457 with upstream main, preserving comment submission shortcuts and upstream roadmap progress. Estimates unchanged. | +| 2026-10-13 | minor | M2/M6: images with at most 16 bits of samples a pixel (8-bit Separation/DeviceN, 1-4-bit gray or indexed) convert once per pixel value (hayro-interpret patch 28), so spot-colour form backgrounds draw in milliseconds instead of seconds; widgets without `/P` are placed in one pass; the Fields panel no longer searches the form per row and draws only the rows in view. Estimates unchanged. | +| 2026-10-13 | minor | M3: native-open admission preserves modal/picker ownership; regressions cover queued replies and one-shot picker wakes. Estimates unchanged | | 2026-10-12 | minor | M13: CLI check directory identity, with overlap and symlink regressions; estimates unchanged | | 2026-10-12 | minor | M4: shared Type 3 glyph resources survive extraction, with save/reopen and independent raster regressions; estimates unchanged | | 2026-10-12 | minor | M2: packed TIFF-predictor RGB row boundaries, with decoding and page-render regressions; estimates unchanged | @@ -174,14 +204,18 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | 2026-10-12 | minor | M7 #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps; same-baseline copies are still replaced. Estimates unchanged. | | 2026-10-12 | minor | M1 #1339: unchanged incremental saves of a classic cross-reference table write a zero-length subsection. Estimates unchanged. | | 2026-10-12 | minor | M12: bounded text-only auto-tag preview through the engine and automation; actual PDF structure writing and visual review remain open. Estimates unchanged. | +| 2026-10-12 | minor | Gap 6: XFA radio values with spaces stay in the datasets (#1319); Replace Pages keeps a gap in a split selection (#1305); decorative figures rewrite only their own content stream (#1324). Estimates unchanged. | | 2026-10-12 | minor | M7: keep added Arabic copy/search in typing order with per-line ActualText; preserve that order in the editor and keep visual runs separate. Font-independent regressions and tool save/reopen coverage; font fallback and bidi typing remain partial. Estimates unchanged. | | 2026-10-12 | minor | M3: case-insensitive Find matches normal, final and capital Greek sigma; exact search, word boundaries, glyph ranges and match limits have regression coverage. The tab-menu test uses Finder on macOS. Estimates unchanged. | | 2026-10-12 | minor | M14: Windows low-DPI JetBrains Mono hinting candidate; raster and layout evidence recorded without a Windows runtime claim. 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. | +| 2026-10-12 | patch | M1 #1337: exhausted xref entries are repaired; allocation safely handles exhausted /Size and high edits; classic-table/stream regressions and opening/allocation controls. Estimates unchanged. | | 2026-10-12 | minor | 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-11 | minor | M4: editable Organize page-range selection (#1092), synchronized with thumbnails and available through UI control. Estimates unchanged. | +| 2026-10-11 | minor | M6 #1208: expose dropdown and list-box alignment; preserve existing values and cover cancel, undo/redo, save/reopen. Estimates unchanged. | | 2026-10-11 | minor | M10: Word invoice export regressions (#773); estimates unchanged. | | 2026-10-12 | minor | M2: short same-line redraws split before placement; estimates unchanged. | +| 2026-10-12 | minor | M5: Ctrl+Enter posts highlight comment text and replies; Shift+Enter adds newlines. Estimates unchanged. | | 2026-10-11 | minor | M3: Windows' Animation effects setting is followed: wheel scrolling and zooming without easing, no other animations. Preferences ▸ Animation effects (Use system setting, On, Off) overrides it. macOS, Linux and the browser aren't asked yet. Estimates unchanged. | | 2026-10-11 | minor | M10: printing on the web through the browser's print dialog (gap 4 closed); rank 1 drops web printing, 65–120 → 50–90 h | | 2026-10-11 | minor | M13/#971: cap materialized text-search matches to the requested limit plus one continuation match. | @@ -199,4 +233,5 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | 2026-10-10 | minor | M3 records the System appearance completion after #881; estimates unchanged | | 2026-10-10 | minor | Re-judged convert and share with the cross-app gate rule (only a workflow that can't be completed at all blocks): partial, not blocking → stage alpha | | 2026-10-10 | minor | Added the alpha gate (six core workflows; convert and share fails → pre-alpha, 40–70 h to alpha) and rank 0 in the upcoming milestones | +| 2026-10-12 | minor | M8: image redaction removes alternate images and unsupported masks only on affected images. Estimates unchanged. | | 2026-10-10 | major | Created from ROADMAP.md §Milestones, §Critical path, §Risks and §Where we're lacking. Merged the duplicated M1, M2, M3 and M14 rows, re-measured Done and Remaining, added Current focus and the ranked upcoming milestones | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 85d6f5a8b..e7698baee 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PdfCraft parity with Adobe Acrobat Pro -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** web printing shipped, gap 4 closed, scores not re-judged; exclusive image-crop resize follow-up; minor (document-font subset completion, source styles and curved/shared image clips in the contributor fork; closed issues synced, scores not re-judged: #774, #756, #740, #759, #799–#821, #825, #844; earlier: readiness table with hours per audience; full number restated as the standard's additive weighted sum, 47% → 49%; earlier: second look: cross-cutting double count removed, mainstream-practitioner number added, core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (localization ≈59% → ≈68%; weighted readiness 49.25 → 49.7 ≈ 50%) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) The authoritative assessment of how close PdfCraft is to Acrobat Pro. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every shortfall one by one; the deep-dive checklists are @@ -21,7 +21,7 @@ Approved Trust List) are out of scope: 23 such features are tracked as `na` in | Feature breadth, unweighted | 56.9% (423 shipped + 74 partial of 808) | measured (`cargo xtask parity` arithmetic) | | Must-have (P0) features | 94.2% (226 + 23 partial of 252) | measured | | Feature breadth, mainstream practitioner | ≈ 70% tier-weighted (63.2% unweighted: 292 + 44 partial of 497; P0 95.3%) | measured ([split below](#mainstream-practitioner-vs-full-acrobat-pro)) | -| **Ready for real work**, full Acrobat Pro | **≈ 49%** (range 45–53%) | estimated (weighted sum over dimensions, [below](#by-dimension)) | +| **Ready for real work**, full Acrobat Pro | **≈ 50%** (range 45–53%) | estimated (weighted sum over dimensions, [below](#by-dimension)) | | **Ready for real work**, mainstream practitioner | **≈ 55%** (range 50–60%) | estimated ([split below](#mainstream-practitioner-vs-full-acrobat-pro)) | | **Ready for real work**, essentials user | **≈ 66%** (range 60–70%) | estimated ([below](#essentials-user)) | | **Stage** | **alpha** (passes the core-workflow gate) | see [Stage](#stage) | @@ -32,7 +32,7 @@ Approved Trust List) are out of scope: 23 such features are tracked as `na` in | 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 | @@ -86,8 +86,8 @@ The **full ready-for-real-work number is an additive weighted sum over the dimen method; weights by what decides whether a professional can switch): features 40%, file formats and conversion 12%, UI/UX 10%, PDF specification 8%, stability 8%, performance 6%, localization 5%, platforms 5%, hardware 4%, ecosystem 1%, AI 1%: -0.40 × 49 + 0.12 × 40 + 0.10 × 55 + 0.08 × 60 + 0.08 × 50 + 0.06 × 40 + 0.05 × 59 + 0.05 × 70 + -0.04 × 35 + 0.01 × 30 + 0.01 × 0 = 49.25 → **≈ 49%**. +0.40 × 49 + 0.12 × 40 + 0.10 × 55 + 0.08 × 60 + 0.08 × 50 + 0.06 × 40 + 0.05 × 68 + 0.05 × 70 + +0.04 × 35 + 0.01 × 30 + 0.01 × 0 = 49.7 → **≈ 50%**. History: the first version of this file subtracted 9 points from the area figure for cross-cutting shortfalls (40%); the second look found that a double count (Windows printing, encrypted-output @@ -181,13 +181,13 @@ features total and are shown for orientation; the others add to it. | File formats and conversion | ≈ 40% | 12% | 120–220 | within features | PDF read/write strong, encrypted output included (#774 fixed); export Word is layout-poor (#773); no Excel/PowerPoint export, no Office/HTML/PostScript import | [file-format-parity.md](file-format-parity.md) | | PDF specification and standards | ≈ 60% | 8% | 150–260 | within features | ISO 32000 syntax, encryption, annotations and AcroForm strong; rendering and fonts borrowed from `hayro`; PDF 2.0 extras, linearization, public-key security absent; PDF/A partial, PDF/X/UA none | [pdf-spec-parity.md](pdf-spec-parity.md) | | Hardware | ≈ 35% | 4% | 25–50 | additional | Page raster is CPU only (Acrobat has GPU rendering); Windows CNG and macOS Keychain keys sign, no PKCS #11; no scanners; web printing goes through the browser's print dialog as pictures, not vectors | [hardware-parity.md](hardware-parity.md) | -| Localization | ≈ 59% over the 12 key languages | 5% | 35–60 | additional | 15 catalogs, 13 at 94–100%; Chinese UI shows tofu in releases (#826, #688); Arabic not mirrored; no Hindi, Indonesian, Korean, Vietnamese | [localization-parity.md](localization-parity.md) | +| Localization | ≈ 68% over the 12 key languages | 5% | 35–60 | additional | 22 catalogs + English (23 interface languages); Chinese UI shows tofu in releases (#826, #688); Arabic not mirrored; no Hindi, Indonesian, Korean | [localization-parity.md](localization-parity.md) | | Performance | ≈ 40% | 6% | 25–45 | additional | 500-page smooth scrolling and large-file memory work (#307) done; no performance budgets (`misc.performance-budgets`), no lazy loading of GB files; blurry text at 1080p/1440p (#730) | [gaps.md](gaps.md) | | Stability | ≈ 50% | 8% | 45–80 | additional | Never-crash rules and nightly fuzzing; 245 open issues on 2026-10-10, many of them `fix(...)` reports of wrong results in shipped features (the #799–#821 batch is fixed) | [gaps.md](gaps.md) | | Platforms | ≈ 70% | 5% | 15–30 | additional | macOS (signed, notarized), Windows x64/x86/ARM64 (signed), Linux (AppImage, deb, rpm, Flatpak), FreeBSD, web (WASM). Acrobat has no Linux, FreeBSD or full web editor; we lack web OCR and web recovery | [hardware-parity.md](hardware-parity.md) | | Ecosystem and plug-ins | ≈ 30% | 1% | 10–20 | within features | No plug-in SDK or folder-level JavaScript; Action Wizard partial. Agent automation (CLI, MCP, UI control channel) has no Acrobat equivalent | [gaps.md](gaps.md) | | AI features | ≈ 0% (in scope) | 1% | 20–40 | within features | Acrobat's AI Assistant is cloud-only (out of scope). Local, opt-in provider interface (`misc.ai-provider`) not started | [gaps.md](gaps.md) | -| **Total (weighted sum)** | **≈ 49%** | 100% | **≈ 780–1,400** | | | | +| **Total (weighted sum)** | **≈ 50%** | 100% | **≈ 780–1,400** | | | | ## By feature area @@ -222,7 +222,7 @@ with evidence when next touched. ## Stage -**alpha.** Ready for real work, ≈ 49%, is inside the 40–75% band, and every one of Acrobat Pro's +**alpha.** Ready for real work, ≈ 50%, is inside the 40–75% band, and every one of Acrobat Pro's six everyday workflows can be completed end to end on macOS. Under the gate rule shared by all the Crafting Apps, a workflow blocks only when a typical user can't complete it at all; lossy exchange or a missing sub-case is a beta item. **Converting and sharing** is therefore partial, not @@ -241,7 +241,7 @@ stage set to pre-alpha; see the revision history.) | Edit text and images | partial, not blocking (Latin text with embedded or standard fonts) | | Convert and share (Office ↔ PDF, password protection) | partial, not blocking (Office→PDF and Word layout #773 are beta items; encryption interop #774 fixed) | -**To reach beta** (≈ 75% ready, no blocking gap in PDF interchange), ≈ 26 points and +**To reach beta** (≈ 75% ready, no blocking gap in PDF interchange), ≈ 25 points and **≈ 400–700 agent-hours**: | Beta requirement | Hours | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b5b93452e..a78409fad 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -47,7 +47,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Hand tool, middle-button pan, autoscroll | yes | hand tool; Linux autoscroll | partial | | Text selection, column select | yes, Alt-drag columns | reading-order selection; Alt/Option-drag or Edit ▸ Column select (#740) | shipped | | Select and move several objects at once | yes | on one page: Shift/Ctrl/⌘-click and marquee, one undo step (#844); not across pages, no group resize | partial | -| Object handles: corners keep proportion, edges free | yes | yes for added images and signatures | shipped | +| Object handles: corners keep proportion, edges free | yes | yes for added images and signatures; Shift on a corner keeps the proportions of comments, stamps and form fields (#2199); no Alt resize from the centre | shipped | | Arrange, align and distribute page objects | yes | fields only | partial | | Rulers, grids, snap to grid, guides | yes | no | planned | | Cursor coordinates, line weights | yes | no | planned | diff --git a/docs/word-count.md b/docs/word-count.md new file mode 100644 index 000000000..4b3d02cba --- /dev/null +++ b/docs/word-count.md @@ -0,0 +1,52 @@ +# Word count + +File ▸ Word count counts the current document, including unsaved edits. Select **Current page +only** to count just the displayed page. On desktop, counting runs in the background, one page +at a time. Closing the dialog or changing its options cancels the old count between pages. + +Words use Unicode word boundaries (UAX #29); punctuation alone is not a word. Characters are +Unicode scalar values, not bytes or displayed glyphs. “Characters without spaces” removes all +Unicode whitespace. Characters include the line breaks and spaces reconstructed by text +extraction. The total is the sum of page counts, with no extra separator between pages. + +The three independent exclusions are off by default: + +- **Tagged tables:** Table, TR, TH, TD, THead, TBody and TFoot content. +- **Tagged code blocks:** Code content. +- **Image/OCR text:** Figure content and invisible text, including typical OCR layers. Invisible + text can have other uses too. Visible OCR text without Figure tags is not identifiable as OCR. + +Tags can be explicit marked-content tags or page-local MCIDs classified through the PDF's +structure tree, including parent roles and RoleMap aliases. These are semantic exclusions, not +visual table/code recognition. Untagged or unsupported structure stays in the count; the dialog +reports when structure classification is incomplete. On pages with Form XObjects, structure-tree +MCID exclusions are conservatively disabled because forms have separate MCID namespaces. +Explicit Table/Code/Figure marked content still works there. Cyclic and oversized structure +graphs are bounded and reported as incomplete. + +PDF `/ActualText` replacements are respected. If one replacement spans both included and +excluded glyphs, there is no reliable mapping of its words to the selected content: counting +fails with an explanation instead of silently returning the wrong total. Disable exclusions to +count that page. Unreadable pages likewise fail the operation, without a misleading partial total. + +Images alone contain no extractable words. Run Scan & OCR first when counting scanned pages. +Copy restrictions are respected. Counting never edits, rasterizes or saves the document, and +keeps only one page's extracted text at a time, plus the resulting page statistics. + +## CLI and MCP + +`text_word_count` is the shared read-only automation tool: + +```json +{"tool":"text_word_count","args":{"doc":1,"pages":[1,3],"exclude_tables":true,"exclude_code":true,"exclude_images":false}} +``` + +Omit `pages` to count all pages. Page numbers are 1-based; duplicates count once. The result +contains `total`, per-page `pages` entries with `page` and `counts`, `pages_without_text`, and +`unclassified_structure`. Each count has `words`, `characters`, and `characters_without_spaces`. +`pages_without_text` is measured after exclusions, so it can also include pages whose entire +text was deliberately excluded. + +The engine entry point is `pdfcraft_engine::word_count::count`. Its page indexes are 0-based; +its progress callback can cancel between pages. Frontends must enforce document copy +permissions before supplying an `ExportSource`. diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml index b1b8a1404..646eb61a3 100644 --- a/packaging/linux/nfpm.yaml +++ b/packaging/linux/nfpm.yaml @@ -46,9 +46,16 @@ overrides: - mesa-vulkan-drivers - xdg-desktop-portal rpm: + # Library sonames rather than package names, so the same requirements resolve on Fedora/RHEL + # and openSUSE (which has no libxkbcommon, vulkan-loader or libglvnd-egl package). The glibc + # floor names libm because the binaries' GLIBC_2.35 symbols (hypot, hypotf) are libm's: RHEL 9's + # glibc 2.34 provides libc.so.6(GLIBC_2.35) through backports, but not this, so it refuses. depends: - - libxkbcommon - - (vulkan-loader or libglvnd-egl) + - libc.so.6()(64bit) + - libm.so.6(GLIBC_2.35)(64bit) + - libgcc_s.so.1()(64bit) + - libxkbcommon.so.0()(64bit) + - (libvulkan.so.1()(64bit) or libEGL.so.1()(64bit)) recommends: - cups-client - libwayland-client diff --git a/packaging/macos/Credits.rtf b/packaging/macos/Credits.rtf new file mode 100644 index 000000000..18c053f5d --- /dev/null +++ b/packaging/macos/Credits.rtf @@ -0,0 +1,10 @@ +{\rtf1\ansi\ansicpg1252\cocoartf2822 +{\fonttbl\f0\fswiss\fcharset0 Helvetica;} +{\colortbl;\red255\green255\blue255;} +\pard\qc\f0\fs24\cf0 +A clean-room, open-source PDF application written in Rust.\ +MIT OR Apache-2.0.\ +\ +{\field{\*\fldinst{HYPERLINK "https://getartcraft.com"}}{\fldrslt getartcraft.com}}\ +Help and feedback: {\field{\*\fldinst{HYPERLINK "https://discord.gg/artcraft"}}{\fldrslt discord.gg/artcraft}}\ +} diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index e04be13c8..267a87280 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -103,6 +103,9 @@ mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" # Executable and icon carry the display name (CFBundleExecutable / CFBundleIconFile). cp "$WORK/bin/pdfcraft" "$APP/Contents/MacOS/PdfCraft" cp "$ROOT/assets/app-icon/pdfcraft.icns" "$APP/Contents/Resources/PdfCraft.icns" +# The standard About panel (PdfCraft menu > About PdfCraft) shows Credits.rtf under the version: +# the website and Discord links the in-app About dialog has (#1287). +cp "$HERE/Credits.rtf" "$APP/Contents/Resources/Credits.rtf" copy_font_licences "$APP/Contents/Resources" # OCR models: the app finds them at Contents/MacOS/../Resources/models. Before signing, so the # bundle's seal covers them. diff --git a/packaging/windows/pdfcraft.wxs b/packaging/windows/pdfcraft.wxs index 3f291721a..7fae1329f 100644 --- a/packaging/windows/pdfcraft.wxs +++ b/packaging/windows/pdfcraft.wxs @@ -6,9 +6,10 @@ Per-machine install into Program Files, a Start Menu shortcut, a desktop shortcut (on by default, optional, and an update keeps it only if it is still there), an App Paths entry, PdfCraft registered as a PDF handler (offered under "Open with" - and in Default apps, never forced as the default), and "Edit with PdfCraft" on PDF files' - right-click menu (on by default, optional). Files opened with PdfCraft arrive on its command - line. + and in Default apps, never forced as the default), "Edit with PdfCraft" on PDF files' + right-click menu and "Create PDF with PdfCraft…" on image files' right-click menu (both on by + default, optional, and an update keeps the installed version's choice). Files opened with + PdfCraft arrive on its command line. --> - - + + + + + + + + + + + + + + + @@ -98,38 +121,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/test-msi.ps1 b/packaging/windows/test-msi.ps1 index 3af5bd037..45e1147fa 100644 --- a/packaging/windows/test-msi.ps1 +++ b/packaging/windows/test-msi.ps1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS Check the compiled MSI's install scope, publisher, shortcuts, native UI and OCR models, without installing it. .EXAMPLE @@ -88,33 +88,50 @@ foreach ($entry in @(@('StartMenuShortcut', 'ProgramMenuFolder', 'PdfcraftStartM Assert-Equal $component[0] $entry[1] "$($entry[2]) directory" Assert-Equal $component[1] $entry[3] "$($entry[2]) condition" } -# No static default (#1058): a SetProperty after AppSearch turns it on for a first install, and for an -# update only while the old shortcut is still on the desktop; a command-line value is left alone. -Assert-NoRow 'SELECT `Value` FROM `Property` WHERE `Property` = ''INSTALLDESKTOPSHORTCUT''' 'desktop shortcut static default' -$setDesktop = Read-Row 'SELECT `Action`, `Target` FROM `CustomAction` WHERE `Source` = ''INSTALLDESKTOPSHORTCUT''' 2 -Assert-Equal $setDesktop[1] '1' 'Desktop shortcut default value' -foreach ($sequence in @('InstallUISequence', 'InstallExecuteSequence')) { - $search = Read-Row ('SELECT `Sequence` FROM `' + $sequence + '` WHERE `Action` = ''AppSearch''') 1 - $set = Read-Row ('SELECT `Sequence`, `Condition` FROM `' + $sequence + '` WHERE `Action` = ''' + $setDesktop[0] + '''') 2 - if ([int] $set[0] -le [int] $search[0]) { throw "$sequence must set the desktop shortcut default after AppSearch" } -} -$session = $null -try { - $session = $Installer.OpenPackage((Resolve-Path -LiteralPath $Path).Path, 1) - foreach ($case in @( - @('first install', '', '', '', 1), - @('first install, INSTALLDESKTOPSHORTCUT=0', '0', '', '', 0), - @('update, shortcut still there', '', '{00000000-0000-0000-0000-000000000000}', 'C:\Users\Public\Desktop\PdfCraft.lnk', 1), - @('update, shortcut deleted', '', '{00000000-0000-0000-0000-000000000000}', '', 0), - @('update, INSTALLDESKTOPSHORTCUT=0', '0', '{00000000-0000-0000-0000-000000000000}', 'C:\Users\Public\Desktop\PdfCraft.lnk', 0))) { - $session.Property('INSTALLDESKTOPSHORTCUT') = $case[1] - $session.Property('WIX_UPGRADE_DETECTED') = $case[2] - $session.Property('EXISTINGDESKTOPSHORTCUT') = $case[3] - Assert-Equal ($session.EvaluateCondition($set[1])) $case[4] "Desktop shortcut default: $($case[0])" +# Defaults an update keeps (#1058, #305). No static default: a SetProperty after AppSearch turns it on +# for a first install and, for an update from a release that offered it ($Release set), only while what +# it installed is still there ($Found, from AppSearch). A command-line value is left alone. Windows +# Installer's evaluator runs the SetProperty condition and then $Component's own condition. +function Assert-KeptOnUpdate([string] $Property, [string] $Release, [string] $Found, [string] $FoundValue, [string] $Component, [string] $What) { + Assert-NoRow ('SELECT `Value` FROM `Property` WHERE `Property` = ''' + $Property + '''') "$What static default" + $action = Read-Row ('SELECT `Action`, `Target` FROM `CustomAction` WHERE `Source` = ''' + $Property + '''') 2 + Assert-Equal $action[1] '1' "$What default value" + $condition = $null + foreach ($sequence in @('InstallUISequence', 'InstallExecuteSequence')) { + $search = Read-Row ('SELECT `Sequence` FROM `' + $sequence + '` WHERE `Action` = ''AppSearch''') 1 + $set = Read-Row ('SELECT `Sequence`, `Condition` FROM `' + $sequence + '` WHERE `Action` = ''' + $action[0] + '''') 2 + if ([int] $set[0] -le [int] $search[0]) { throw "$sequence must set the $What default after AppSearch" } + $condition = $set[1] + } + $installed = Read-Row ('SELECT `Condition` FROM `Component` WHERE `Component` = ''' + $Component + '''') 1 + Assert-Equal $installed[0] "$Property = 1" "$Component condition" + $old = '{00000000-0000-0000-0000-000000000000}' + # Name, command-line value, WIX_UPGRADE_DETECTED, $Release, $Found, installed (1) or not (0). + $cases = @( + @('first install', '', '', '', '', 1), + @("first install, $Property=0", '0', '', '', '', 0), + @('update, still there', '', $old, $old, $FoundValue, 1), + @('update, removed', '', $old, $old, '', 0), + @("update, $Property=0", '0', $old, $old, $FoundValue, 0), + @("update, removed, $Property=1", '1', $old, $old, '', 1)) + # An update from a release from before it existed (WIX_UPGRADE_DETECTED alone) is a first install. + if ($Release -ne 'WIX_UPGRADE_DETECTED') { $cases += , @('update from a release without it', '', $old, '', '', 1) } + $session = $null + try { + $session = $Installer.OpenPackage((Resolve-Path -LiteralPath $Path).Path, 1) + foreach ($case in $cases) { + $session.Property($Property) = $case[1] + $session.Property('WIX_UPGRADE_DETECTED') = $case[2] + $session.Property($Release) = $case[3] + $session.Property($Found) = $case[4] + if ($session.EvaluateCondition($condition) -eq 1) { $session.Property($Property) = $action[1] } + Assert-Equal ($session.EvaluateCondition($installed[0])) $case[5] "$What`: $($case[0])" + } + } finally { + if ($session) { [void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($session) } } -} finally { - if ($session) { [void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($session) } } +Assert-KeptOnUpdate 'INSTALLDESKTOPSHORTCUT' 'WIX_UPGRADE_DETECTED' 'EXISTINGDESKTOPSHORTCUT' 'C:\Users\Public\Desktop\PdfCraft.lnk' 'PdfcraftDesktopShortcut' 'Desktop shortcut' $secure = Read-Row 'SELECT `Value` FROM `Property` WHERE `Property` = ''SecureCustomProperties''' 1 if (($secure[0] -split ';') -notcontains 'INSTALLDESKTOPSHORTCUT') { throw "INSTALLDESKTOPSHORTCUT is not secure: '$($secure[0])'" } $checkbox = Read-Row 'SELECT `Type`, `Property`, `Text` FROM `Control` WHERE `Dialog_` = ''InstallWelcome'' AND `Control` = ''DesktopShortcut''' 3 @@ -128,23 +145,71 @@ Assert-Equal $app[0] 'PdfcraftExe' 'Shortcut executable key path' $scope = Read-Row 'SELECT `Value` FROM `Property` WHERE `Property` = ''ALLUSERS''' 1 Assert-Equal $scope[0] '1' 'Per-machine shortcut scope' -# Image context menu opens the DPI chooser; the app component owns every registry row so -# uninstall removes it. No image default association is changed. +# Each File Explorer menu remembers whether it was installed by finding its key path in HKLM, in the +# package's own registry view (64-bit for x64 and arm64). Only a release that offered the menu can +# leave it out, so the OnlyDetect Upgrade rows start at the first release with that menu. +$upgradeCode = Read-Row 'SELECT `Value` FROM `Property` WHERE `Property` = ''UpgradeCode''' 1 +$view64 = $Database.SummaryInformation(0).Property(7) -notmatch '^Intel;' +function Assert-MenuSearch([string] $Found, [string] $Key, [string] $Release, [string] $Since, [string] $What) { + $search = Read-Row ('SELECT `Root`, `Key`, `Name`, `Type` FROM `RegLocator`, `AppSearch` WHERE `AppSearch`.`Signature_` = `RegLocator`.`Signature_` AND `AppSearch`.`Property` = ''' + $Found + '''') 4 + Assert-Equal $search[0] '2' "$What search HKLM root" + Assert-Equal $search[1] $Key "$What search key" + Assert-Equal $search[2] '' "$What search reads the default value" + # msidbLocatorTypeRawValue (2), plus msidbLocatorType64bit (16) in a 64-bit package. + Assert-Equal ([int] $search[3]) $(if ($view64) { 18 } else { 2 }) "$What search type" + $upgrade = Read-Row ('SELECT `UpgradeCode`, `VersionMin`, `VersionMax`, `Attributes` FROM `Upgrade` WHERE `ActionProperty` = ''' + $Release + '''') 4 + Assert-Equal $upgrade[0] $upgradeCode[0] "$What release detection upgrade code" + Assert-Equal $upgrade[1] $Since "$What release detection minimum" + Assert-Equal $upgrade[2] '' "$What release detection maximum" + # msidbUpgradeAttributesOnlyDetect (2) + VersionMinInclusive (256): never removes anything itself. + Assert-Equal ([int] $upgrade[3]) 258 "$What release detection attributes" + if (($secure[0] -split ';') -notcontains $Release) { throw "$Release is not secure: '$($secure[0])'" } +} +# The old version is removed before the new one is installed, so rows that moved between components +# (the image menu left PdfcraftApp after 0.7.1) are removed with it rather than orphaned. +$validate = Read-Row 'SELECT `Sequence` FROM `InstallExecuteSequence` WHERE `Action` = ''InstallValidate''' 1 +$remove = Read-Row 'SELECT `Sequence` FROM `InstallExecuteSequence` WHERE `Action` = ''RemoveExistingProducts''' 1 +$initialize = Read-Row 'SELECT `Sequence` FROM `InstallExecuteSequence` WHERE `Action` = ''InstallInitialize''' 1 +if ([int] $remove[0] -le [int] $validate[0] -or [int] $remove[0] -ge [int] $initialize[0]) { + throw "RemoveExistingProducts ($($remove[0])) must run between InstallValidate and InstallInitialize" +} + +# Image context menu (#190) opens the DPI chooser. Its own component, gated by INSTALLIMAGECONTEXTMENU +# (on for a first install, kept on update only while present, secure), with the .png label as key +# path so uninstall removes every row. No image default association is changed. +$imageKey = 'Software\Classes\SystemFileAssociations\.png\shell\PdfCraft.CreatePdf' foreach ($ext in @('png', 'jpg', 'jpeg', 'tif', 'tiff', 'gif', 'bmp', 'jp2', 'j2k', 'jpx')) { $key = 'Software\Classes\SystemFileAssociations\.' + $ext + '\shell\PdfCraft.CreatePdf' $menu = Read-Row ('SELECT `Value`, `Component_`, `Root` FROM `Registry` WHERE `Key` = ''' + $key + ''' AND `Name` IS NULL') 3 Assert-Equal $menu[0] 'Create PDF with PdfCraft…' "$ext context menu label" - Assert-Equal $menu[1] 'PdfcraftApp' "$ext context menu component" + Assert-Equal $menu[1] 'PdfcraftImageContextMenu' "$ext context menu component" Assert-Equal $menu[2] '2' "$ext context menu HKLM root" - $command = Read-Row ('SELECT `Value` FROM `Registry` WHERE `Key` = ''' + $key + '\command''') 1 + $command = Read-Row ('SELECT `Value`, `Component_` FROM `Registry` WHERE `Key` = ''' + $key + '\command''') 2 Assert-Equal $command[0] '"[#PdfcraftExe]" --create-images "%1"' "$ext context menu command" - $selection = Read-Row ('SELECT `Value` FROM `Registry` WHERE `Key` = ''' + $key + ''' AND `Name` = ''MultiSelectModel''') 1 + Assert-Equal $command[1] 'PdfcraftImageContextMenu' "$ext context menu command component" + $selection = Read-Row ('SELECT `Value`, `Component_` FROM `Registry` WHERE `Key` = ''' + $key + ''' AND `Name` = ''MultiSelectModel''') 2 Assert-Equal $selection[0] 'Single' "$ext context menu selection" + Assert-Equal $selection[1] 'PdfcraftImageContextMenu' "$ext context menu selection component" } +$view = $Database.OpenView('SELECT `Key` FROM `Registry` WHERE `Component_` = ''PdfcraftApp''') +try { + [void] $view.Execute() + while ($record = $view.Fetch()) { + if ($record.StringData(1) -match 'PdfCraft\.CreatePdf') { throw "image menu row left in PdfcraftApp: $($record.StringData(1))" } + } +} finally { [void] $view.Close() } +$imageKeyPath = Read-Row ('SELECT `Registry` FROM `Registry` WHERE `Key` = ''' + $imageKey + ''' AND `Name` IS NULL') 1 +$imageComponent = Read-Row 'SELECT `KeyPath`, `Attributes` FROM `Component` WHERE `Component` = ''PdfcraftImageContextMenu''' 2 +Assert-Equal $imageComponent[0] $imageKeyPath[0] 'Image context menu key path' +# msidbComponentAttributesRegistryKeyPath (4). +if (([int] $imageComponent[1] -band 4) -eq 0) { throw "Image context menu key path is not a registry value: attributes $($imageComponent[1])" } +Assert-KeptOnUpdate 'INSTALLIMAGECONTEXTMENU' 'IMAGECONTEXTMENURELEASE' 'EXISTINGIMAGECONTEXTMENU' 'Create PDF with PdfCraft…' 'PdfcraftImageContextMenu' 'Image context menu' +Assert-MenuSearch 'EXISTINGIMAGECONTEXTMENU' $imageKey 'IMAGECONTEXTMENURELEASE' '0.4.0' 'Image context menu' +if (($secure[0] -split ';') -notcontains 'INSTALLIMAGECONTEXTMENU') { throw "INSTALLIMAGECONTEXTMENU is not secure: '$($secure[0])'" } # PDF context menu (#682): "Edit with PdfCraft" opens the file in PdfCraft. Its own component, gated -# by INSTALLPDFCONTEXTMENU (default 1, secure, welcome checkbox), with its menu value as key path so -# uninstall removes it. Several selected PDFs each start pdfcraft.exe "%1" (Document), which the +# by INSTALLPDFCONTEXTMENU (on for a first install, kept on update only while present, secure, welcome +# checkbox), with its menu value as key path so uninstall removes it. Several selected PDFs each start pdfcraft.exe "%1" (Document), which the # single-instance hand-off opens as tabs. The .pdf default is never set, so the user's default app # stays. $pdfKey = 'Software\Classes\SystemFileAssociations\.pdf\shell\PdfCraft.Edit' @@ -163,8 +228,8 @@ Assert-Equal $pdfCommand[1] 'PdfcraftPdfContextMenu' 'PDF context menu command c $pdfComponent = Read-Row 'SELECT `Condition`, `KeyPath` FROM `Component` WHERE `Component` = ''PdfcraftPdfContextMenu''' 2 Assert-Equal $pdfComponent[0] 'INSTALLPDFCONTEXTMENU = 1' 'PDF context menu condition' Assert-Equal $pdfComponent[1] $pdfMenu[0] 'PDF context menu key path' -$pdfDefault = Read-Row 'SELECT `Value` FROM `Property` WHERE `Property` = ''INSTALLPDFCONTEXTMENU''' 1 -Assert-Equal $pdfDefault[0] '1' 'PDF context menu default' +Assert-KeptOnUpdate 'INSTALLPDFCONTEXTMENU' 'PDFCONTEXTMENURELEASE' 'EXISTINGPDFCONTEXTMENU' 'Edit with PdfCraft' 'PdfcraftPdfContextMenu' 'PDF context menu' +Assert-MenuSearch 'EXISTINGPDFCONTEXTMENU' $pdfKey 'PDFCONTEXTMENURELEASE' '0.7.1' 'PDF context menu' if (($secure[0] -split ';') -notcontains 'INSTALLPDFCONTEXTMENU') { throw "INSTALLPDFCONTEXTMENU is not secure: '$($secure[0])'" } $pdfCheckbox = Read-Row 'SELECT `Type`, `Property`, `Text` FROM `Control` WHERE `Dialog_` = ''InstallWelcome'' AND `Control` = ''PdfContextMenu''' 3 Assert-Equal $pdfCheckbox[0] 'CheckBox' 'Welcome PDF context-menu control' @@ -204,4 +269,4 @@ $rm = Read-Row 'SELECT `Dialog` FROM `Dialog` WHERE `Dialog` = ''MsiRMFilesInUse Assert-Equal $rm[0] 'MsiRMFilesInUse' 'Files-in-use dialog' [void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($Database) [void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($Installer) -Write-Output 'ok MSI: per-machine scope guard, publisher, persistent progress text, Start Menu shortcut, optional desktop shortcut (on for a first install, kept on update only while present, checkbox), optional PDF context menu (default on, checkbox), icon/key path, full-UI success/cancel/error and Finish controls, files-in-use dialog, OCR models' +Write-Output 'ok MSI: per-machine scope guard, publisher, persistent progress text, Start Menu shortcut, optional desktop shortcut and PDF and image context menus (on for a first install, kept on update only while present; checkboxes for the first two), icon/key path, full-UI success/cancel/error and Finish controls, files-in-use dialog, OCR models' diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 37b98326d..7aa5e629d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -13,6 +13,18 @@ areas = { A = "core", B = "view", C = "edit", D = "organize", E = "comment", F = # A. Core document model and fidelity # ================================================================================================== +[[feature]] +id = "core.word-count" +title = "Word and character count" +area = "A" +tier = "P2" +milestone = "M2" +status = "shipped" +commands = ["file.word_count"] +tools = ["text_word_count"] +evidence = ["crates/engine/src/word_count.rs::word_count_filters_are_independent_and_do_not_change_normal_extraction", "crates/automation/tests/automation.rs::word_count_reads_selected_pages_and_unsaved_edits_without_mutating", "crates/ui-egui/src/word_count_ui.rs::word_count_dialog_counts_changes_pages_and_closes"] +notes = "Unicode words and characters for the current snapshot; all or current page in GUI, selected pages headlessly. Desktop background work with cancellation. Optional semantic table/code/Figure and invisible-text exclusions; no OCR or visual layout classification. Unsupported structure remains counted and is reported. Mixed ActualText exclusions fail explicitly. See docs/word-count.md." + [[feature]] id = "core.open-pdf" title = "Open PDF files" @@ -334,8 +346,8 @@ milestone = "M1" status = "shipped" commands = [] tools = [] -evidence = ["crates/cos/tests/encryption.rs::qpdf_agrees", "crates/cos/tests/writer.rs::qpdf_accepts_object_stream_output"] -notes = "skipped when qpdf is absent" +evidence = ["crates/cos/tests/encryption.rs::qpdf_agrees", "crates/cos/tests/writer.rs::qpdf_accepts_object_stream_output", "crates/platform/src/staging.rs::an_atomic_write_replaces_the_target_only_when_complete", "apps/pdfcraft-cli/tests/edit.rs::editing_in_place_leaves_a_valid_file_and_no_staging_file", "crates/automation/tests/automation.rs::a_root_less_write_output_is_atomic_too"] +notes = "qpdf is installed on the Linux CI runner and PDFCRAFT_ORACLES_REQUIRED makes its absence a failure, so these run on every CI run rather than skipping; on a machine without qpdf they skip with a notice (crates/oracle). Every PDF leaves the engine checked (it reopens with its pages): saves, new files from Combine/Extract/Split/Create, signed, timestamped and LTV-embedded files, OCR batch output; and every writer goes through one staging-file writer (the desktop app, the tools with or without --root, the CLI), so a failed write never leaves a truncated file (#1698, #2166)" [[feature]] id = "core.deterministic-save" @@ -538,8 +550,8 @@ milestone = "M4" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/recovery.rs::a_crashed_session_is_offered_and_recovered_unsaved", "crates/ui-egui/tests/recovery.rs::discarding_removes_the_autosaves", "crates/engine/src/tests.rs::recovered_documents_reopen_unsaved_at_their_original_path"] -notes = "" +evidence = ["crates/ui-egui/tests/recovery.rs::a_crashed_session_is_offered_and_recovered_unsaved", "crates/ui-egui/tests/recovery.rs::discarding_removes_the_autosaves", "crates/ui-egui/tests/recovery.rs::a_running_instances_autosaves_are_not_offered_to_a_second_instance", "crates/engine/src/tests.rs::recovered_documents_reopen_unsaved_at_their_original_path"] +notes = "A running instance holds an exclusively locked instance file in the recovery folder and names it in its snapshots, so a second instance neither offers nor discards what the first is autosaving (#2166); the OS releases the lock when the process dies. Where the folder can't be written or the platform has no file locks, snapshots are offered as before." [[feature]] id = "core.encrypted-autosave" @@ -634,8 +646,8 @@ milestone = "M4" status = "shipped" commands = [] tools = [] -evidence = ["crates/engine/src/tests.rs::reload_reads_the_new_file_into_the_same_document", "crates/ui-egui/tests/reload.rs::a_rebuilt_file_reloads_in_place_keeping_the_page_and_zoom", "crates/ui-egui/tests/reload.rs::a_half_written_file_waits_until_the_pdf_is_whole", "crates/ui-egui/tests/reload.rs::saving_does_not_reload_or_lose_undo", "crates/ui-egui/tests/reload.rs::unsaved_changes_are_kept_until_reload_is_chosen", "crates/ui-egui/tests/reload.rs::a_deleted_file_leaves_the_document_open", "crates/ui-egui/tests/reload.rs::nothing_reloads_with_the_preference_off", "crates/ui-egui/tests/reload.rs::a_shorter_new_version_drops_what_pointed_into_the_old_one"] -notes = "Desktop, on by default (Preferences ▸ Documents and view, #431), for LaTeX/Typst builds: a worker thread checks each open file's metadata every 250 ms and reads a change once it has settled and ends in %%EOF; a clean document reloads in place (same tab, page, zoom, scroll; old page images stay until new ones are drawn), one with unsaved changes gets a Reload notice; PdfCraft's own saves never reload. Measured on Windows: one check 11 µs per file, reload of a 417-page 0.9 MB pdflatex file 16 ms. Not yet: OS change notifications (polling adds up to 250 ms), the web build; a reload clears undo history, like Revert." +evidence = ["crates/engine/src/tests.rs::reload_reads_the_new_file_into_the_same_document", "crates/ui-egui/tests/reload.rs::a_rebuilt_file_reloads_in_place_keeping_the_page_and_zoom", "crates/ui-egui/tests/reload.rs::a_half_written_file_waits_until_the_pdf_is_whole", "crates/ui-egui/tests/reload.rs::saving_does_not_reload_or_lose_undo", "crates/ui-egui/tests/reload.rs::unsaved_changes_are_kept_until_reload_is_chosen", "crates/ui-egui/tests/reload.rs::a_deleted_file_leaves_the_document_open", "crates/ui-egui/tests/reload.rs::nothing_reloads_with_the_preference_off", "crates/ui-egui/tests/reload.rs::a_shorter_new_version_drops_what_pointed_into_the_old_one", "crates/ui-egui/tests/reload.rs::saving_over_a_file_changed_by_something_else_asks_first", "crates/ui-egui/tests/reload.rs::saving_checks_the_file_with_the_watcher_off_too", "crates/ui-egui/tests/reload.rs::opening_a_file_that_is_open_focuses_its_tab"] +notes = "Desktop, on by default (Preferences ▸ Documents and view, #431), for LaTeX/Typst builds: a worker thread checks each open file's metadata every 250 ms and reads a change once it has settled and ends in %%EOF; a clean document reloads in place (same tab, page, zoom, scroll; old page images stay until new ones are drawn), one with unsaved changes gets a Reload notice; PdfCraft's own saves never reload. Save asks before writing over a version of the file the document did not come from, watcher on or off, with Save as… to keep it (#2166); a file that is open already comes to the front instead of opening twice. Measured on Windows: one check 11 µs per file, reload of a 417-page 0.9 MB pdflatex file 16 ms. Not yet: OS change notifications (polling adds up to 250 ms), the web build; a reload clears undo history, like Revert." [[feature]] id = "core.signed-save-policy" @@ -734,8 +746,8 @@ milestone = "M2" status = "partial" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::soft_mask_without_group_colour_space_is_applied"] -notes = "via hayro bootstrap" +evidence = ["crates/render/src/raster.rs::soft_mask_without_group_colour_space_is_applied", "crates/render/src/raster.rs::a_multiply_highlight_inside_a_transparency_group_keeps_the_text"] +notes = "via hayro bootstrap. A transparency group that is neither isolated nor knockout and is entered at full opacity, with no soft mask and Normal blending, is drawn straight onto the page, so a blend mode inside it (the Multiply of a highlight appearance written by other editors) sees the page content (#1968). Missing: a non-isolated group that needs a layer (partial opacity, a soft mask or a blend mode on the group itself) still composites its elements against a blank backdrop, and knockout groups are drawn as ordinary ones" [[feature]] id = "view.render-knockout" @@ -794,8 +806,8 @@ milestone = "M2" status = "shipped" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved"] -notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, never saved (a read-only document still gets the renderer's Highlight fallback only); Link borders without /AP and printing are not covered yet" +evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source"] +notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved (a read-only document still gets the renderer's Highlight fallback only); Link borders without /AP and printing are not covered yet" [[feature]] id = "view.render-error-isolation" @@ -830,8 +842,8 @@ milestone = "M3" status = "partial" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::tiles_match_full_render", "crates/render/src/raster.rs::effective_scale_never_exceeds_the_caps"] -notes = "capped at 8192 px per side (M3.3)" +evidence = ["crates/render/src/raster.rs::tiles_match_full_render", "crates/render/src/raster.rs::effective_scale_never_exceeds_the_caps", "crates/ui-egui/tests/view.rs::returning_to_a_visited_zoom_reuses_its_cached_tiles", "crates/ui-egui/tests/view.rs::zooming_a_tiled_page_shows_its_earlier_tiles_until_new_ones_arrive"] +notes = "capped at 8192 px per side (M3.3); tiles of a zoom already rendered stay cached while their page is on screen, so zooming away and back shows the area sharp instead of re-rendering it tile by tile (#2196)" [[feature]] id = "view.layout-continuous" @@ -842,8 +854,8 @@ milestone = "M3" status = "shipped" commands = ["view.continuous"] tools = [] -evidence = ["crates/ui-egui/tests/view.rs::continuous_layout_stacks_pages_vertically", "crates/ui-egui/tests/view.rs::page_display_commands_switch_layouts", "crates/ui-egui/tests/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/src/canvas.rs::fit_page_and_height_hold_still_while_mixed_page_sizes_scroll_past"] -notes = "the default for PDFs that don't ask for a layout (Preferences ▸ Default page display); pages stack with no snapping. Fit page and Fit height fit the largest page, so the zoom holds still across mixed page sizes" +evidence = ["crates/ui-egui/tests/view.rs::continuous_layout_stacks_pages_vertically", "crates/ui-egui/tests/view.rs::page_display_commands_switch_layouts", "crates/ui-egui/tests/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/src/canvas.rs::fit_page_and_height_hold_still_while_mixed_page_sizes_scroll_past", "crates/ui-egui/tests/view.rs::touchpad_swipe_glides_on_after_the_fingers_lift_and_stops", "crates/ui-egui/tests/view.rs::touching_the_touchpad_again_or_turning_animations_off_stops_the_glide"] +notes = "the default for PDFs that don't ask for a layout (Preferences ▸ Default page display); pages stack with no snapping. Fit page and Fit height fit the largest page, so the zoom holds still across mixed page sizes. A touchpad swipe glides on after the fingers lift on Wayland (#531; macOS glides by itself; Windows and X11 report no lift through winit), unless Animation effects is off; a click, key, zoom or new scroll catches it. Not yet: fingers resting on the touchpad don't catch it (needs Wayland hold gestures, which the vendored winit doesn't bind)" [[feature]] id = "view.layout-single-page" @@ -854,8 +866,8 @@ milestone = "M3" status = "shipped" commands = ["view.layout.single"] tools = [] -evidence = ["crates/ui-egui/tests/view.rs::single_page_layout_shows_one_page_at_a_time", "crates/ui-egui/tests/view.rs::single_page_wheel_pans_instead_when_zoomed_in", "crates/ui-egui/tests/view.rs::wheel_turns_one_page_per_notch_and_per_trackpad_swipe", "crates/ui-egui/tests/view.rs::rail_page_display_button_switches_layouts", "crates/ui-egui/tests/view.rs::view_menu_page_display_uses_the_same_commands"] -notes = "when the whole page fits, each wheel notch turns a page and each trackpad swipe turns one, momentum included; zoomed in, the wheel pans and never turns the page. Switched from the View menu, the rail's Page display button or the palette" +evidence = ["crates/ui-egui/tests/view.rs::single_page_layout_shows_one_page_at_a_time", "crates/ui-egui/tests/view.rs::single_page_wheel_pans_instead_when_zoomed_in", "crates/ui-egui/tests/view.rs::wheel_turns_one_page_per_notch_and_per_trackpad_swipe", "crates/ui-egui/tests/view.rs::single_page_wheel_at_the_bottom_edge_turns_to_the_next_page_top_and_back", "crates/ui-egui/tests/view.rs::single_page_swipe_that_reaches_the_edge_does_not_turn_but_a_fresh_one_does", "crates/ui-egui/tests/view.rs::rail_page_display_button_switches_layouts", "crates/ui-egui/tests/view.rs::view_menu_page_display_uses_the_same_commands"] +notes = "when the whole page fits, each wheel notch turns a page and each trackpad swipe turns one, momentum included; zoomed in so the page is taller than the window, the wheel pans, and at the bottom (top) edge a wheel notch, or a fresh trackpad swipe, 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 until the fingers lift and swipe again (#1924). Not verified against Acrobat's own thresholds. Switched from the View menu, the rail's Page display button or the palette" [[feature]] id = "view.layout-two-up" @@ -1152,10 +1164,10 @@ area = "B" tier = "P1" milestone = "M3" status = "shipped" -commands = [] +commands = ["view.previous_view", "view.next_view"] tools = [] -evidence = ["crates/ui-egui/tests/viewer.rs::view_history_select_all_and_find_options"] -notes = "⌘[ / ⌘] and View ▸ Page navigation" +evidence = ["crates/ui-egui/tests/viewer.rs::view_history_select_all_and_find_options", "crates/ui-egui/tests/control.rs::navigation_history_commands_and_rail_are_per_tab_and_non_editing", "crates/ui-egui/tests/control.rs::routine_continuous_scrolling_does_not_add_navigation_history"] +notes = "Per-tab page history: explicit search-result jumps, page navigation, bookmarks and links; no continuous-scroll history or restart persistence; existing page-step history is retained. Rail buttons, View ▸ Page navigation and Ctrl/⌘[ / Ctrl/⌘]. UI control ui.command exposes view.previous_view / view.next_view; standalone engine sessions have no viewport history. Restores page tops, not exact scroll/zoom positions." [[feature]] id = "view.follow-links" @@ -1181,6 +1193,18 @@ tools = [] evidence = ["crates/ui-egui/tests/comments.rs::hovering_a_comment_shows_its_author_and_text"] notes = "a popup with the author and the comment text, as Acrobat shows" +[[feature]] +id = "view.page-size-hover" +title = "Page size on hover (bottom-left of the page view)" +area = "B" +tier = "P2" +milestone = "M3" +status = "shipped" +commands = [] +tools = [] +evidence = ["crates/ui-egui/tests/control.rs::page_size_shows_while_the_pointer_is_in_the_bottom_left_quadrant", "crates/ui-egui/src/canvas.rs::page_size_hint_zone_is_the_bottom_left_quadrant"] +notes = "While the pointer is in the bottom-left quadrant of the page view, a small label shows the current page's size (after /Rotate and /UserUnit) in the Preferences unit, formatted as in Document Properties (#1855). It never takes clicks and hides when the pointer leaves. Acrobat's trigger is the corner itself and may differ in exact area and wording (not compared with real Acrobat); the size of the current page only, not of the page under the pointer; no headless tool (page sizes are in document info)." + [[feature]] id = "view.document-tabs" title = "Tabbed documents" @@ -1512,10 +1536,10 @@ area = "B" tier = "P0" milestone = "M3" status = "shipped" -commands = [] -tools = [] -evidence = ["crates/ui-egui/tests/ui.rs::drag_selects_text_and_copy_returns_it"] -notes = "" +commands = ["edit.copy"] +tools = ["text_extract"] +evidence = ["crates/ui-egui/tests/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/ui_group/text_select.rs::selected_text_copy_edit_and_context_menus", "crates/ui-egui/tests/ui_group/text_select.rs::selected_text_copy_floating_button_honours_permissions", "crates/ui-egui/tests/ui_group/text_select.rs::selected_text_copy_keeps_focused_input_clipboard_ownership"] +notes = "Keyboard, Edit-menu, page-context-menu and floating-selection Copy honour document copy permissions. Menus require selected page text; focused text inputs and Organize page copying retain their own clipboard handling. Headless text extraction uses text_extract." [[feature]] id = "view.select-all" @@ -1694,8 +1718,8 @@ milestone = "M4" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/commands.rs::shortcuts_run_registered_commands", "crates/engine/src/commands.rs::shortcut_labels_follow_platform_conventions"] -notes = "" +evidence = ["crates/ui-egui/tests/commands.rs::shortcuts_run_registered_commands", "crates/engine/src/commands.rs::shortcut_labels_follow_platform_conventions", "crates/ui-egui/tests/commands.rs::quit_shortcut_requires_exact_modifiers_and_preserves_unsaved_work", "crates/ui-egui/tests/signing.rs::ctrl_alt_q_types_an_email_without_quitting"] +notes = "Quit requires exact Ctrl+Q/Cmd+Q modifiers and preserves unsaved-document confirmation. Ctrl+Alt+Q email input is covered with synthetic key/text events; native German keyboard-layout translation is not covered by that test (#1980)." [[feature]] id = "view.shortcuts-dialog" @@ -1842,8 +1866,8 @@ milestone = "M7" status = "partial" commands = ["edit.edit_text"] tools = ["text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"] -evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools"] -notes = "click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; see docs/editing-existing-text.md" +evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/ui.rs::the_edit_tab_turns_on_edit_text_and_images"] +notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; see docs/editing-existing-text.md" [[feature]] id = "edit.text-reflow" @@ -1986,8 +2010,8 @@ milestone = "M7" status = "shipped" commands = ["edit.text"] tools = ["page_add_text", "content_update"] -evidence = ["crates/ui-egui/tests/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/content.rs::clicking_elsewhere_keeps_the_text_and_starts_a_new_box", "crates/automation/tests/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable", "crates/edit/src/tests.rs::added_arabic_text_is_shaped_in_display_order_and_stays_searchable", "crates/automation/tests/automation.rs::thai_text_through_tools_saves_reopens_extracts_and_renders", "crates/ui-egui/tests/content.rs::typing_thai_text_keeps_marks_and_commits_to_page", "crates/automation/tests/automation.rs::added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen"] -notes = "page content, editable later (retype, move, restyle); standard fonts, Windows-1252 text. Arabic text is shaped with the craft-fonts Arab face (Type 3 outlines with ToUnicode) and laid out in bidirectional display order; per-line ActualText keeps copy, search and editor text in typing order through retyping and save/reopen; it is drawn regular whatever the bold/italic setting, Hebrew and other right-to-left scripts are not covered, and typing in the box is still left to right (misc.rtl-ui). Thai Add Text uses the craft-fonts Thai face, with shaped clusters and ToUnicode; Thai is regular style, wrapping preserves clusters but is not dictionary-based, and forms/Fill and Sign are not covered by this change. Each click on the page keeps the box being typed and starts a new one; Done and Discard beside the box" +evidence = ["crates/ui-egui/tests/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/content.rs::clicking_elsewhere_keeps_the_text_and_starts_a_new_box", "crates/automation/tests/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable", "crates/edit/src/tests.rs::added_arabic_text_is_shaped_in_display_order_and_stays_searchable", "crates/automation/tests/automation.rs::thai_text_through_tools_saves_reopens_extracts_and_renders", "crates/ui-egui/tests/content.rs::typing_thai_text_keeps_marks_and_commits_to_page", "crates/automation/tests/automation.rs::added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen", "crates/edit/src/tests.rs::added_hebrew_text_is_drawn_right_to_left_and_stays_searchable", "crates/automation/tests/automation.rs::hebrew_text_through_tools_saves_reopens_extracts_and_renders"] +notes = "page content, editable later (retype, move, restyle); standard fonts, Windows-1252 text. Arabic text is shaped with the craft-fonts Arab face (Type 3 outlines with ToUnicode) and laid out in bidirectional display order; per-line ActualText keeps copy, search and editor text in typing order through retyping and save/reopen; it is drawn regular whatever the bold/italic setting, and typing in the box is still left to right (misc.rtl-ui). Hebrew (#128) takes the same path with the craft-fonts Hebr face (Noto Sans Hebrew): right-to-left display order, points and cantillation kept on their letter as one glyph, ToUnicode and per-line ActualText; regular style only, and right-to-left scripts other than Arabic and Hebrew are not covered. Thai Add Text uses the craft-fonts Thai face, with shaped clusters and ToUnicode; Thai is regular style, wrapping preserves clusters but is not dictionary-based, and forms/Fill and Sign are not covered by this change. Each click on the page keeps the box being typed and starts a new one; Done and Discard beside the box" [[feature]] id = "edit.find-replace" @@ -2067,11 +2091,11 @@ title = "Crop an image" area = "C" tier = "P0" milestone = "M7" -status = "partial" +status = "shipped" commands = ["edit.image"] -tools = ["content_update"] -evidence = ["crates/engine/src/tests.rs::added_images_rotate_flip_and_crop_as_drawn", "crates/automation/tests/automation.rs::adding_content_through_tools"] -notes = "for added images: trim each side, clipped" +tools = ["content_update", "image_edit"] +evidence = ["crates/ui-egui/tests/editing.rs::cropping_a_picture_with_its_handles", "crates/engine/src/tests.rs::cropping_an_existing_image_clips_it_and_moves_with_it", "crates/automation/tests/automation.rs::editing_page_images_through_tools", "crates/engine/src/tests.rs::added_images_rotate_flip_and_crop_as_drawn", "crates/automation/tests/automation.rs::adding_content_through_tools"] +notes = "Existing page images: right-click ▸ Crop Image puts the picture in crop mode: its eight handles trim (the inside pans), the part that will go is dimmed, Enter or Done applies, Esc or Cancel leaves it; Remove Crop shows the whole picture again. The pixels stay where they are (a clipping path inside the picture's own scope, so a later move or resize carries it along; cropping again replaces the earlier crop, so it can grow back); image_edit crop. Added images: trim each side by percent in Edit image, clipped. Not yet: cropping grouped Form artwork by handles is allowed the same way (it is a clip), but a shared clip of the original file is not changed." [[feature]] id = "edit.image-rotate-flip" @@ -2596,8 +2620,8 @@ milestone = "M4" status = "shipped" commands = ["page.organize"] tools = [] -evidence = ["crates/ui-egui/tests/editing.rs::shift_click_selects_a_range_and_moves_it", "crates/ui-egui/tests/editing.rs::organize_tool_opens_the_overview_directly_and_can_be_reentered", "crates/ui-egui/tests/control.rs::organize_overview_is_reachable_through_the_control_channel"] -notes = "Opening the Organize pages tool enters the overview directly. Hovered, focused and selected pages expose individual rotate/delete actions; the toolbar acts on the selection." +evidence = ["crates/ui-egui/tests/editing.rs::shift_click_selects_a_range_and_moves_it", "crates/ui-egui/tests/editing.rs::organize_tool_opens_the_overview_directly_and_can_be_reentered", "crates/ui-egui/tests/editing.rs::organize_opens_on_the_current_page", "crates/ui-egui/tests/control.rs::organize_overview_is_reachable_through_the_control_channel", "crates/ui-egui/tests/editing.rs::organize_panel_comes_with_the_grid_and_closes_it", "crates/ui-egui/tests/editing.rs::leaving_organize_goes_back_to_the_workspace_panel"] +notes = "Opening the Organize pages tool enters the overview directly, on the current page: selected and scrolled into view (#1910). Hovered, focused and selected pages expose individual rotate/delete actions; the toolbar acts on the selection. The left panel is laid out as Acrobat's Organize pages panel: Pages selected (typed ranges, or All, Even, Odd, Landscape or Portrait pages; empty, it shows the current page), rotate, delete, extract, Insert (a blank page, pages from a file, duplicates), Replace, Split, Bates numbering, Set page boxes, Crop pages, Print pages and Page labels. Closing it, by its Close, the grid's Close or opening a page, returns to the workspace's own panel (Edit, Convert, E-Sign or All tools). Page transitions, Page templates and Page properties are listed but planned." [[feature]] id = "organize.multi-select-pages" @@ -2692,8 +2716,8 @@ milestone = "M4" status = "shipped" commands = ["page.insert"] tools = ["page_insert_file"] -evidence = ["crates/organize/src/tests.rs::inserting_pages_from_another_file_is_an_incremental_edit", "crates/ui-egui/tests/editing.rs::inserting_a_file_goes_after_the_selection_and_undoes", "crates/ui-egui/tests/editing.rs::plus_between_pages_inserts_picked_files_there_in_order"] -notes = "After the selection (toolbar, File menu) or at any gap of the page grid (a + between pages and at both ends); several files at once, in order; images and .txt files are converted first; the inserted pages end up selected. The page_insert_file tool takes PDFs only. Missing: choosing which pages of the file to insert in the app." +evidence = ["crates/organize/src/tests.rs::inserting_pages_from_another_file_is_an_incremental_edit", "crates/organize/src/tests.rs::inserting_a_file_brings_its_bookmarks_under_one_named_after_it", "crates/automation/tests/automation.rs::inserting_a_file_brings_its_bookmarks", "crates/ui-egui/tests/editing.rs::inserting_a_file_goes_after_the_selection_and_undoes", "crates/ui-egui/tests/editing.rs::plus_between_pages_inserts_picked_files_there_in_order"] +notes = "After the selection (toolbar, File menu) or at any gap of the page grid (a + between pages and at both ends); several files at once, in order; images and .txt files are converted first; the inserted pages end up selected. The file's bookmarks come along, collapsed under one named after the file, placed among the document's bookmarks where the pages went (as Combine does per file). The page_insert_file tool takes PDFs only. Missing: choosing which pages of the file to insert in the app; the file's tags (structure tree) are not merged." [[feature]] id = "organize.insert-from-clipboard" @@ -3169,11 +3193,11 @@ title = "Page transitions" area = "D" tier = "P2" milestone = "M4" -status = "planned" +status = "partial" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["doc_info", "page_transition_set"] +evidence = ["crates/render/src/inspect.rs::page_transitions_and_display_times_are_read", "crates/automation/tests/automation.rs::doc_info_lists_page_transitions_and_display_times", "crates/organize/src/tests.rs::page_transitions_are_set_removed_and_checked", "crates/automation/tests/automation.rs::page_transitions_are_set_and_removed_through_the_tools"] +notes = "Read and set, not played: each page's /Trans (style, duration, dimension, motion, direction, Fly scale and backdrop) and /Dur display time are read with the spec's defaults and listed by doc_info (DocInfo::transitions); page_transition_set (Edit::SetTransition, one undo step) writes or removes them on chosen pages with the values checked first. Not yet: playing them in full screen, auto-advancing on /Dur, and the Page Transitions dialog" [[feature]] id = "organize.page-tab-order" @@ -3316,8 +3340,8 @@ milestone = "M4" status = "shipped" commands = ["bookmark.from_structure"] tools = ["bookmark_from_structure"] -evidence = ["crates/a11y/src/tests.rs::headings_are_found_with_their_levels_titles_and_pages", "crates/organize/src/tests.rs::a_bookmark_tree_nests_entries_by_level_under_a_new_first_bookmark", "crates/engine/src/tests.rs::bookmarks_from_structure_nest_the_headings_and_undo", "crates/automation/tests/automation.rs::bookmarks_from_structure_through_tools", "crates/ui-egui/tests/bookmarks.rs::new_bookmarks_from_structure_nest_the_tagged_headings"] -notes = "Bookmark options ▸ New bookmarks from structure: tagged headings (H1–H6 through the role map; a plain H takes its Sect/Part/Art nesting) become bookmarks nested by level under a new first \"Untitled\" bookmark, each linked to its element (/SE). Titles come from /ActualText, the marked text drawn directly on the page (not inside Form XObjects), /Alt or /T. Not yet: Acrobat's choice of element types (only headings), and destinations go to the heading's page rather than its position on it" +evidence = ["crates/a11y/src/tests.rs::headings_are_found_with_their_levels_titles_and_pages", "crates/a11y/src/tests.rs::headings_drawn_glyph_by_glyph_keep_their_words", "crates/a11y/src/tests.rs::horizontally_scaled_wrapped_heading_keeps_a_space_between_lines", "crates/organize/src/tests.rs::a_bookmark_tree_nests_entries_by_level_under_a_new_first_bookmark", "crates/engine/src/tests.rs::bookmarks_from_structure_nest_the_headings_and_undo", "crates/automation/tests/automation.rs::bookmarks_from_structure_through_tools", "crates/ui-egui/tests/bookmarks.rs::new_bookmarks_from_structure_nest_the_tagged_headings"] +notes = "Bookmark options ▸ New bookmarks from structure: tagged headings (H1–H6 through the role map; a plain H takes its Sect/Part/Art nesting) become bookmarks nested by level under a new first \"Untitled\" bookmark, each linked to its element (/SE). Titles come from /ActualText, the marked text drawn directly on the page (not inside Form XObjects; words split where the glyphs are apart, so text placed glyph by glyph, as Chromium writes it, keeps its words; vertical line breaks use the absolute font size independently of Tz), /Alt or /T. Not yet: Acrobat's choice of element types (only headings), and destinations go to the heading's page rather than its position on it" [[feature]] id = "organize.attachments-add" @@ -3448,8 +3472,8 @@ milestone = "M4" status = "shipped" commands = ["file.properties"] tools = ["doc_initial_view"] -evidence = ["crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/ui-egui/tests/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/automation/tests/automation.rs::initial_view_through_tools"] -notes = "navigation panel, layout, magnification, page, window and interface options; honoured on open; script open actions kept" +evidence = ["crates/ui-egui/tests/ui.rs::a_document_asking_for_full_screen_is_asked_about_first", "crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/ui-egui/tests/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/automation/tests/automation.rs::initial_view_through_tools"] +notes = "navigation panel, layout, magnification, page, window and interface options; honoured on open; script open actions kept. Open in Full Screen mode (/PageMode /FullScreen) asks first (Full Screen / Not now), as Acrobat does, instead of taking the screen. Missing: Acrobat's Full Screen preference to turn that alert off and its \"remember my choice for this document\" (misc.prefs-full-screen)" [[feature]] id = "organize.custom-properties" @@ -3524,8 +3548,8 @@ milestone = "M3" status = "shipped" commands = ["comment.list"] tools = ["comment_list"] -evidence = ["crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/comments.rs::the_panel_posts_comments_and_replies"] -notes = "cards grouped by page with replies, status badges, selection, reply box and an Add a comment box" +evidence = ["crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/comments.rs::the_panel_posts_comments_and_replies", "crates/ui-egui/tests/comments.rs::page_groups_collapse_and_expand"] +notes = "cards grouped by page with replies, status badges, selection, reply box and an Add a comment box; page groups fold and unfold from their chevron or with Collapse/Expand all pages in the panel's \"…\" menu (a comment selected on a folded page opens its group). Groups by author, type or colour don't fold yet" [[feature]] id = "comment.appearance-generation" @@ -3548,8 +3572,8 @@ milestone = "M5" status = "shipped" commands = ["comment.note"] tools = ["comment_add"] -evidence = ["crates/ui-egui/tests/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"] -notes = "our own Comment/Note icon drawings, with a pop-up" +evidence = ["crates/ui-egui/tests/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/ui-egui/tests/comments.rs::the_comment_composer_shortcut_only_posts_the_focused_editor", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"] +notes = "our own Comment/Note icon drawings, with a pop-up; Ctrl+Enter (also Command+Enter) posts from the focused editor, Shift+Enter adds a newline" [[feature]] id = "comment.highlight" @@ -3716,8 +3740,8 @@ milestone = "M5" status = "shipped" commands = ["comment.square"] tools = ["comment_add", "comment_edit"] -evidence = ["crates/ui-egui/tests/comments.rs::shapes_and_ink_are_drawn_by_dragging", "crates/annot/src/tests.rs::restyle_and_resize_redraw_the_appearance", "crates/annot/src/tests.rs::shapes_can_be_filled_and_unfilled", "crates/ui-egui/tests/comments.rs::shapes_take_a_fill_colour_from_the_quick_bar_and_properties"] -notes = "resize with the 8 handles; fill colour or no fill (#686) from the quick bar, Properties and comment_edit `fill`; fill swatches are the comment palette only (no custom colour picker yet)" +evidence = ["crates/ui-egui/tests/comments.rs::shapes_and_ink_are_drawn_by_dragging", "crates/annot/src/tests.rs::restyle_and_resize_redraw_the_appearance", "crates/annot/src/tests.rs::shapes_can_be_filled_and_unfilled", "crates/ui-egui/tests/comments.rs::shapes_take_a_fill_colour_from_the_quick_bar_and_properties", "crates/ui-egui/tests/comments.rs::a_shape_just_drawn_resizes_from_its_handles_without_the_select_tool"] +notes = "resize with the 8 handles, straight after drawing with the tool still on (#2338); fill colour or no fill (#686) from the quick bar, Properties and comment_edit `fill`; fill swatches are the comment palette only (no custom colour picker yet)" [[feature]] id = "comment.oval" @@ -3836,8 +3860,8 @@ milestone = "M5" status = "shipped" commands = ["comment.list"] tools = ["comment_edit"] -evidence = ["crates/ui-egui/tests/comments.rs::comment_properties_change_appearance_and_author", "crates/annot/src/tests.rs::author_subject_and_note_icon_change", "crates/ui-egui/tests/comments.rs::shapes_take_a_fill_colour_from_the_quick_bar_and_properties"] -notes = "Appearance (colour, fill colour for rectangles/ovals/polygons, opacity, thickness, note icon), General (author, subject), Review History" +evidence = ["crates/ui-egui/tests/comments.rs::comment_properties_change_appearance_and_author", "crates/annot/src/tests.rs::author_subject_and_note_icon_change", "crates/ui-egui/tests/comments.rs::shapes_take_a_fill_colour_from_the_quick_bar_and_properties", "crates/ui-egui/tests/comments.rs::text_box_properties_change_the_font_size", "crates/annot/src/tests.rs::text_box_font_size_changes_the_da_and_grows_the_box", "crates/automation/tests/automation.rs::text_box_font_size_through_the_tool"] +notes = "Appearance (colour, fill colour for rectangles/ovals/polygons, opacity, thickness, note icon, font size of text boxes, typewriter text and callouts: 1-400 pt, written to /DA and the rich-text strings, the box grows to fit and never shrinks; comment_edit font_size), General (author, subject), Review History. Still missing: font family, bold/italic, alignment and per-run formatting of text boxes; the quick bar has no font size for new text boxes (they start at 12 pt, callouts 10 pt)" [[feature]] id = "comment.properties-bar" @@ -3872,8 +3896,8 @@ milestone = "M5" status = "shipped" commands = ["comment.list"] tools = [] -evidence = ["crates/ui-egui/tests/comments.rs::make_current_properties_default"] -notes = "colour, opacity and line width become the tool's defaults (canvas and Comments panel menus) and survive a restart: the per-tool styles are persisted with the settings and restored through their clamping setters (#340)" +evidence = ["crates/ui-egui/tests/comments.rs::make_current_properties_default", "crates/ui-egui/tests/comments.rs::properties_dialog_makes_the_strikethrough_colour_default"] +notes = "colour, opacity and line width become the tool's defaults (canvas and Comments panel menus, and Acrobat's Make Properties Default box in Comment Properties, applied on OK with the edited look) and survive a restart: the per-tool styles are persisted with the settings and restored through their clamping setters (#340)" [[feature]] id = "comment.author-identity" @@ -3896,8 +3920,8 @@ milestone = "M5" status = "shipped" commands = ["comment.list"] tools = ["comment_reply"] -evidence = ["crates/ui-egui/tests/comments.rs::the_panel_posts_comments_and_replies", "crates/annot/src/tests.rs::replies_and_status_thread_onto_the_parent"] -notes = "" +evidence = ["crates/ui-egui/tests/comments.rs::the_panel_posts_comments_and_replies", "crates/ui-egui/tests/comments.rs::ctrl_enter_posts_a_reply_and_shift_enter_adds_a_line", "crates/annot/src/tests.rs::replies_and_status_thread_onto_the_parent"] +notes = "multiline reply field: Enter or Ctrl+Enter (also Command+Enter) posts, Shift+Enter adds a newline; empty replies are not posted" [[feature]] id = "comment.set-status" @@ -3944,8 +3968,8 @@ milestone = "M5" status = "shipped" commands = ["comment.list"] tools = ["comment_edit"] -evidence = ["crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn", "crates/annot/src/tests.rs::editing_a_text_box_refits_its_rectangle", "crates/automation/tests/automation.rs::comments_through_tools"] -notes = "double-click a comment or card, or Edit in the menus; a text box's rectangle follows the new text (wrap width and top edge stay, the height fits the wrapped lines)" +evidence = ["crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn", "crates/annot/src/tests.rs::editing_a_text_box_refits_its_rectangle", "crates/automation/tests/automation.rs::comments_through_tools", "crates/ui-egui/tests/comments.rs::ctrl_enter_posts_a_highlight_comment_and_shift_enter_adds_a_line"] +notes = "double-click a comment or card, or Edit in the menus; Ctrl+Enter (also Command+Enter) saves text in the focused comment editor, Shift+Enter adds a newline; a text box's rectangle follows the new text (wrap width and top edge stay, the height fits the wrapped lines)" [[feature]] id = "comment.copy-comment-text" @@ -4184,8 +4208,8 @@ milestone = "M5" status = "shipped" commands = ["comment.flatten"] tools = ["doc_flatten"] -evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/automation.rs::flattening_through_tools"] -notes = "appearances become page content; pop-ups and replies removed" +evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/edit/src/tests.rs::flattening_keeps_an_undrawn_reply_to_a_flattened_comment", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/automation.rs::flattening_through_tools"] +notes = "appearances become page content; pop-ups and replies removed; a comment or reply whose appearance cannot be drawn (a cloudy border, an unsupported subtype) stays as it is instead of being deleted" [[feature]] id = "comment.migrate" @@ -4392,8 +4416,8 @@ milestone = "M6" status = "shipped" commands = ["form.fields"] tools = ["form_fill", "form_fields"] -evidence = ["crates/ui-egui/tests/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/automation.rs::orphan_form_fields_through_tools"] -notes = "in-place editor; Enter or clicking away commits, Escape cancels; no format/keystroke JavaScript yet (M6.4)" +evidence = ["crates/ui-egui/tests/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/automation.rs::orphan_form_fields_through_tools", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value"] +notes = "in-place editor; Enter or clicking away commits, Escape cancels; auto-size (0 Tf) fields shrink, and multiline ones wrap, the text being typed to fit the box, on screen as on the page (the screen measures in the interface font, so its size can be a little smaller than the page's Helvetica fit); fixed sizes are kept and clip, as in Acrobat; no format/keystroke JavaScript yet (M6.4)" [[feature]] id = "form.fill-checkbox-radio" @@ -4452,8 +4476,8 @@ milestone = "M6" status = "partial" commands = ["form.fields"] tools = ["form_fill", "form_set_props"] -evidence = ["crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text", "crates/forms/src/tests.rs::rotated_fields_draw_in_their_quadrant", "crates/engine/tests/form_rotation.rs::a_rotated_field_draws_along_the_vertical_edge", "crates/automation/tests/automation.rs::rotating_a_field_through_tools", "crates/ui-egui/tests/prepare.rs::position_tab_rotates_a_field"] -notes = "auto-size, comb, multiline, password, quadding, /MK colours and /MK /R rotation (0, 90, 180, 270); approximate Helvetica metrics" +evidence = ["crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/forms/src/tests.rs::auto_text_size_fits_single_and_multiline_text", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text", "crates/forms/src/tests.rs::rotated_fields_draw_in_their_quadrant", "crates/engine/tests/form_rotation.rs::a_rotated_field_draws_along_the_vertical_edge", "crates/automation/tests/automation.rs::rotating_a_field_through_tools", "crates/ui-egui/tests/prepare.rs::position_tab_rotates_a_field", "crates/ui-egui/tests/prepare.rs::dropdown_alignment_is_editable_and_preserved", "crates/ui-egui/tests/prepare.rs::list_box_alignment_is_editable_and_preserved"] +notes = "auto-size, comb, multiline, password, quadding (text, dropdown and list-box Options controls), /MK colours and /MK /R rotation (0, 90, 180, 270); approximate Helvetica metrics" [[feature]] id = "form.rich-text-fields" @@ -4656,8 +4680,8 @@ milestone = "M6" status = "shipped" commands = ["form.import_data"] tools = ["doc_import_data"] -evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"] -notes = "XFDF, FDF; values go through formats and validation; read-only fields take data; XFA forms also take XDP/XML data files (form.xfa-data)" +evidence = ["crates/xfdf/src/tests.rs::xfdf_import_keeps_text_around_xml_comments_and_instructions", "crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"] +notes = "XFDF, FDF; XFDF text survives XML comments and processing instructions; values go through formats and validation; read-only fields take data; XFA forms also take XDP/XML data files (form.xfa-data)" [[feature]] id = "form.export-data" @@ -4704,8 +4728,8 @@ milestone = "M6" status = "shipped" commands = ["form.flatten"] tools = ["doc_flatten"] -evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/automation.rs::flattening_through_tools"] -notes = "widgets drawn into content and pruned from the field tree" +evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/edit/src/tests.rs::flattening_keeps_a_valued_widget_it_cannot_draw", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/automation.rs::flattening_through_tools"] +notes = "widgets drawn into content and pruned from the field tree; a widget with no appearance to draw (none stored, or none for its selected state) stays a live field rather than losing its value, so a form with such fields is not fully flattened — Acrobat regenerates appearances first, which PdfCraft does not yet do here" [[feature]] id = "form.prepare-form-mode" @@ -4716,8 +4740,8 @@ milestone = "M6" status = "shipped" commands = ["form.prepare"] tools = ["form_add_field", "form_set_props", "form_delete_field"] -evidence = ["crates/ui-egui/tests/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/ui-egui/tests/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel", "crates/engine/src/tests.rs::preparing_a_form_adds_renames_and_deletes_fields", "crates/ui-egui/tests/ui_group/bulk_fields.rs::shared_properties_preserve_mixed_values_and_undo_the_whole_change", "crates/ui-egui/tests/ui_group/bulk_fields.rs::select_all_in_prepare_a_form_selects_only_the_current_pages_fields", "crates/ui-egui/tests/ui_group/bulk_fields.rs::dragging_an_empty_page_area_selects_fields_without_moving_them"] -notes = "fields shown with blue fill and name tags; select, move, resize by corners, Delete (every selected field, one undo step); field tools return to Select after one field. Shared General, Appearance and applicable Options edits are atomic and opt-in; Ctrl/Command+A selects the current page, rectangle and panel selection are supported. Names, positions, choices/defaults and script tabs remain individual GUI edits" +evidence = ["crates/ui-egui/tests/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/ui-egui/src/prepare.rs::corner_drags_stretch_freely_and_keep_proportions_with_shift", "crates/ui-egui/tests/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel", "crates/engine/src/tests.rs::preparing_a_form_adds_renames_and_deletes_fields", "crates/ui-egui/tests/ui_group/bulk_fields.rs::shared_properties_preserve_mixed_values_and_undo_the_whole_change", "crates/ui-egui/tests/ui_group/bulk_fields.rs::select_all_in_prepare_a_form_selects_only_the_current_pages_fields", "crates/ui-egui/tests/ui_group/bulk_fields.rs::dragging_an_empty_page_area_selects_fields_without_moving_them"] +notes = "fields shown with blue fill and name tags; select, move, resize by corners (Shift keeps the field's proportions, #2199), Delete (every selected field, one undo step); field tools return to Select after one field. Shared General, Appearance and applicable Options edits are atomic and opt-in; Ctrl/Command+A selects the current page, rectangle and panel selection are supported. Names, positions, choices/defaults and script tabs remain individual GUI edits" [[feature]] id = "form.add-text-field" @@ -5064,8 +5088,8 @@ milestone = "M6" status = "partial" commands = ["form.detect", "form.prepare"] tools = ["form_detect_fields"] -evidence = ["crates/forms/src/tests.rs::detection_finds_blanks_rules_boxes_and_names_them", "crates/engine/src/tests.rs::detecting_fields_on_a_printed_form", "crates/automation/tests/automation.rs::detecting_form_fields_through_tools", "crates/ui-egui/tests/javascript.rs::prepare_a_form_detects_fields_on_a_paper_form", "crates/ui-egui/tests/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel"] -notes = "Underscore runs, rules, empty boxes and small squares (check boxes); runs when Prepare a form starts on a document without fields, and from Prepare a form ▸ Detect form fields at any time (existing fields are skipped). Not yet: comb boxes, tables of cells, scanned forms (needs OCR first), radio groups." +evidence = ["crates/forms/src/tests.rs::detection_finds_blanks_rules_boxes_and_names_them", "crates/engine/src/tests.rs::detecting_fields_on_a_printed_form", "crates/engine/src/tests.rs::detecting_fields_on_a_scanned_form", "crates/forms/src/detect/raster.rs::a_scanned_form_gives_its_rules_boxes_and_check_boxes", "crates/forms/src/detect/raster.rs::noise_and_uneven_paper_do_not_hide_the_lines", "crates/automation/tests/automation.rs::detecting_form_fields_through_tools", "crates/ui-egui/tests/javascript.rs::prepare_a_form_detects_fields_on_a_paper_form", "crates/ui-egui/tests/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel"] +notes = "Underscore runs, rules, empty boxes and small squares (check boxes); runs when Prepare a form starts on a document without fields, and from Prepare a form ▸ Detect form fields at any time (existing fields are skipped). Scanned pages (an image covers at least half the page and the content draws no lines) are rendered and their rules, boxes and check boxes read from the pixels; a box with a small printed label gets a field in its empty part. Tested on synthetic scans only: straight, stored sideways with /Rotate 90 (fields get /MK /R so typing reads upright), noisy, and 1 degree askew (fields stay inside their boxes but lose up to the skew in height). Labels come from the text layer or, when the OCR models are installed, from text recognition (Latin without accents, so German labels with umlauts make poorer names); without either, fields get default names (Text1, Check Box1). Recognition runs synchronously, about a second per scanned page. Not yet: comb boxes, dotted leader lines, tables of cells as such, deskewing, radio groups." [[feature]] id = "form.auto-name-fields" @@ -5229,11 +5253,11 @@ title = "Flatten XFA forms" area = "F" tier = "P2" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.flatten"] +tools = ["doc_flatten", "xfa_to_static"] +evidence = ["crates/xfa/src/tests.rs::stripping_takes_the_xfa_out_and_leaves_an_ordinary_document", "crates/engine/src/tests.rs::flattening_an_xfa_form_bakes_its_pages_and_takes_the_xfa_out", "crates/engine/src/tests.rs::an_xfa_form_converts_to_a_static_form_that_keeps_its_fields", "crates/automation/tests/automation.rs::xfa_forms_flatten_and_convert_to_static_through_tools"] +notes = "Flatten form fields on a laid-out XFA form bakes the fields into the pages and takes the XFA out (packets, /NeedsRendering, PdfCraft's private keys), so every viewer, Adobe's included, shows the baked pages rather than laying the form out again from the template. xfa_to_static (tool and engine edit) keeps the laid-out pages and the fields with their values as an ordinary AcroForm whose scripts no longer run. Both undoable until saved. Missing: a menu entry for the conversion (the tool and the MCP server have it); flattening keeps the layout as PdfCraft laid it out, not as Acrobat would (form.xfa-dynamic)" [[feature]] id = "form.fill-sign-text" @@ -5244,8 +5268,8 @@ milestone = "M5" status = "shipped" commands = ["sign.fill.text"] tools = ["fill_sign_add"] -evidence = ["crates/ui-egui/tests/fill_sign.rs::text_marks_and_date", "crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/automation/tests/automation.rs::fill_and_sign_through_tools"] -notes = "typewriter FreeText, 10 pt Helvetica" +evidence = ["crates/ui-egui/tests/fill_sign.rs::text_marks_and_date", "crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/engine/src/tests.rs::typed_text_reads_across_as_displayed_on_rotated_pages"] +notes = "typewriter FreeText, 10 pt Helvetica. Reads across as displayed on pages with /Rotate (sideways-stored scans): the box follows the displayed axes and the appearance is turned back (PdfCraft key /PCTextRotate); other apps that regenerate the appearance from /DA draw it unturned. A later page rotation does not re-turn it. Field detection does not work on scanned (image-only) forms yet" [[feature]] id = "form.fill-sign-marks" @@ -5280,8 +5304,8 @@ milestone = "M5" status = "shipped" commands = ["sign.fill.signature", "sign.fill.initials", "sign.fill.signature.change", "sign.fill.initials.change", "sign.fill.signature.remove", "sign.fill.initials.remove"] tools = ["fill_sign_add", "comment_edit", "comment_image_preview"] -evidence = ["crates/ui-egui/tests/fill_sign.rs::drawn_signatures_resize_from_their_corners", "crates/ui-egui/tests/fill_sign.rs::drawn_and_typed_signatures_stay_upright_on_rotated_pages", "crates/automation/tests/automation.rs::typed_signatures_stay_upright_on_rotated_pages", "crates/ui-egui/tests/fill_sign.rs::image_signature_corners_restore_original_aspect_after_edge_resize_and_reopen", "crates/ui-egui/src/comments.rs::proportional_corners_keep_the_opposite_anchor_and_edges_stretch_one_axis", "crates/ui-egui/tests/fill_sign.rs::image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles", "crates/ui-egui/tests/fill_sign.rs::embedded_image_signature_live_gestures_follow_document_and_view_rotation", "crates/automation/tests/automation.rs::image_signature_preview_layers_are_read_only_and_survive_encrypted_save", "crates/engine/src/signature_image.rs::embedded_signature_preview_preserves_alpha_without_editing_the_document", "crates/engine/src/signature_image.rs::oversized_embedded_signature_preview_is_rejected_before_decoding", "crates/ui-egui/tests/fill_sign.rs::image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation", "crates/ui-egui/tests/fill_sign.rs::image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat", "crates/ui-egui/tests/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/fill_sign.rs::long_typed_names_fit_the_placed_signature_and_keep_every_outline", "crates/ui-egui/src/fill_sign.rs::long_signature_previews_have_clear_margins", "crates/ui-egui/tests/control.rs::saved_signature_can_be_changed_through_the_control_channel", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/fonts/src/script.rs::long_names_keep_every_glyph_with_bounded_outline_work", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/fill_sign.rs::image_signatures_and_initials_can_be_imported_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::image_import_cancel_clear_and_errors_preserve_saved_signatures", "crates/engine/src/signature_image.rs::image_signatures_preserve_alpha_and_fit_the_signature_box", "crates/engine/src/signature_image.rs::invalid_and_oversized_signature_images_are_rejected", "crates/engine/src/signature_image.rs::large_signature_images_are_scaled_down", "crates/engine/src/signature_image.rs::jpeg_signatures_are_normalized_to_png", "crates/engine/src/signature_image.rs::jpeg_signature_orientation_is_applied_before_preview_and_embedding", "crates/engine/src/signature_image.rs::very_wide_image_signatures_keep_the_requested_rectangle", "crates/automation/tests/automation.rs::image_signatures_through_tools_preserve_transparency_and_survive_save", "crates/ui-egui/tests/fill_sign.rs::fill_and_sign_tools_pick_up_placed_marks_to_move_and_resize_them", "crates/ui-egui/tests/fill_sign.rs::select_tool_picks_up_a_signature_over_a_form_field", "crates/ui-egui/tests/fill_sign.rs::scanned_signatures_are_framed_cleaned_adjusted_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::scan_cancel_errors_and_stale_answers_preserve_saved_signatures", "crates/automation/tests/automation.rs::scanned_signatures_through_tools_drop_the_paper_and_report_bad_arguments", "crates/engine/src/signature_scan.rs::a_photo_is_scanned_into_a_trimmed_transparent_signature_in_its_ink_colour"] -notes = "signature and initials, typed (Dancing Script outlines as filled paths, no font embedded), drawn or imported from local PNG/JPEG images; all three are placed upright and anchored as displayed on pages with /Rotate (#299); saved in settings with preview cards and add/change/remove controls; changes leave placed annotations untouched; images preserve alpha, aspect ratio and EXIF orientation, fit within 150 pt by 32 pt (24 pt for initials), and are normalized without source paths or metadata; image previews follow the pointer at placement size with zoom and rotation; signatures/initials place once then select the new annotation; while a Fill & Sign tool is active, marks already placed are picked up as with Select (hover shows the move cursor, a click selects, a drag moves, the selected mark's handles resize) and the tool places only away from them; embedded image signatures resize and move live, hiding the frame during movement and restoring it on release; corner handles preserve the original aspect ratio of the embedded image with the opposite corner anchored, while edge midpoint handles stretch width or height independently; cached background layers preserve page content and alpha, including reopened/encrypted documents; Escape cancels a gesture without committing; stamps resize through selection handles or comment_edit with their original appearance; native and web asynchronous pickers; fill_sign_add accepts path instead of text; image imports capped at 4 MiB, 32 megapixels and 8192 pixels per side, larger than 4 megapixels or 4096 pixels per side scaled down to fit (#1075); Scan tab (fill_sign_add scan/sensitivity headless): a phone photo of a signature on paper (PNG/JPEG within the same image limits, EXIF-oriented, kept at most 4 megapixels) is framed automatically (all words; table, sheet edge and dust ignored; a sideways signature turned upright), the frame can be moved, resized or redrawn and the photo turned in quarter turns, and an ink-sensitivity slider (0-100) controls how faint ink is kept; the paper is estimated by a morphological closing, so shadows and uneven light are removed, and the result is trimmed ink in its own colour on transparency, saved like an imported image. Still missing: perspective correction of a skewed sheet, live camera capture, keyboard control of the frame, and processing off the UI thread (bounded by the 4-megapixel working copy; quick 900 px previews while dragging)" +evidence = ["crates/ui-egui/tests/fill_sign.rs::drawn_signatures_resize_from_their_corners", "crates/ui-egui/tests/fill_sign.rs::drawn_and_typed_signatures_stay_upright_on_rotated_pages", "crates/automation/tests/automation.rs::typed_signatures_stay_upright_on_rotated_pages", "crates/ui-egui/tests/fill_sign.rs::image_signature_corners_restore_original_aspect_after_edge_resize_and_reopen", "crates/ui-egui/src/comments.rs::proportional_corners_keep_the_opposite_anchor_and_edges_stretch_one_axis", "crates/ui-egui/tests/fill_sign.rs::image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles", "crates/ui-egui/tests/fill_sign.rs::embedded_image_signature_live_gestures_follow_document_and_view_rotation", "crates/automation/tests/automation.rs::image_signature_preview_layers_are_read_only_and_survive_encrypted_save", "crates/engine/src/signature_image.rs::embedded_signature_preview_preserves_alpha_without_editing_the_document", "crates/engine/src/signature_image.rs::oversized_embedded_signature_preview_is_rejected_before_decoding", "crates/ui-egui/tests/fill_sign.rs::image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation", "crates/ui-egui/tests/fill_sign.rs::image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat", "crates/ui-egui/tests/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/fill_sign.rs::long_typed_names_fit_the_placed_signature_and_keep_every_outline", "crates/ui-egui/src/fill_sign.rs::long_signature_previews_have_clear_margins", "crates/ui-egui/tests/control.rs::saved_signature_can_be_changed_through_the_control_channel", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/fonts/src/script.rs::long_names_keep_every_glyph_with_bounded_outline_work", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/fill_sign.rs::image_signatures_and_initials_can_be_imported_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::image_import_cancel_clear_and_errors_preserve_saved_signatures", "crates/engine/src/signature_image.rs::image_signatures_preserve_alpha_and_fit_the_signature_box", "crates/engine/src/signature_image.rs::invalid_and_oversized_signature_images_are_rejected", "crates/engine/src/signature_image.rs::large_signature_images_are_scaled_down", "crates/engine/src/signature_image.rs::jpeg_signatures_are_normalized_to_png", "crates/engine/src/signature_image.rs::jpeg_signature_orientation_is_applied_before_preview_and_embedding", "crates/engine/src/signature_image.rs::very_wide_image_signatures_keep_the_requested_rectangle", "crates/automation/tests/automation.rs::image_signatures_through_tools_preserve_transparency_and_survive_save", "crates/ui-egui/tests/fill_sign.rs::fill_and_sign_tools_pick_up_placed_marks_to_move_and_resize_them", "crates/ui-egui/tests/fill_sign.rs::select_tool_picks_up_a_signature_over_a_form_field", "crates/ui-egui/tests/fill_sign.rs::scanned_signatures_are_framed_cleaned_adjusted_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::scan_cancel_errors_and_stale_answers_preserve_saved_signatures", "crates/automation/tests/automation.rs::scanned_signatures_through_tools_drop_the_paper_and_report_bad_arguments", "crates/engine/src/signature_scan.rs::a_photo_is_scanned_into_a_trimmed_transparent_signature_in_its_ink_colour", "crates/ui-egui/tests/fill_sign.rs::undo_button_and_ctrl_z_take_back_one_drawn_stroke_at_a_time", "crates/ui-egui/src/fill_sign.rs::undo_removes_the_last_stroke_and_skips_empty_ones", "crates/ui-egui/src/comments.rs::shift_keeps_the_current_proportions_unless_the_box_has_its_own"] +notes = "signature and initials, typed (Dancing Script outlines as filled paths, no font embedded), drawn (an Undo button and Ctrl/Cmd+Z in the pad take back the last stroke; Clear empties it) or imported from local PNG/JPEG images; all three are placed upright and anchored as displayed on pages with /Rotate (#299); saved in settings with preview cards and add/change/remove controls; changes leave placed annotations untouched; images preserve alpha, aspect ratio and EXIF orientation, fit within 150 pt by 32 pt (24 pt for initials), and are normalized without source paths or metadata; image previews follow the pointer at placement size with zoom and rotation; signatures/initials place once then select the new annotation; while a Fill & Sign tool is active, marks already placed are picked up as with Select (hover shows the move cursor, a click selects, a drag moves, the selected mark's handles resize) and the tool places only away from them; embedded image signatures resize and move live, hiding the frame during movement and restoring it on release; corner handles preserve the original aspect ratio of the embedded image with the opposite corner anchored, while edge midpoint handles stretch width or height independently; cached background layers preserve page content and alpha, including reopened/encrypted documents; Escape cancels a gesture without committing; stamps resize through selection handles or comment_edit with their original appearance; holding Shift on a corner handle keeps any resizable comment's current proportions (#2199); native and web asynchronous pickers; fill_sign_add accepts path instead of text; image imports capped at 4 MiB, 32 megapixels and 8192 pixels per side, larger than 4 megapixels or 4096 pixels per side scaled down to fit (#1075); Scan tab (fill_sign_add scan/sensitivity headless): a phone photo of a signature on paper (PNG/JPEG within the same image limits, EXIF-oriented, kept at most 4 megapixels) is framed automatically (all words; table, sheet edge and dust ignored; a sideways signature turned upright), the frame can be moved, resized or redrawn and the photo turned in quarter turns, and an ink-sensitivity slider (0-100) controls how faint ink is kept; the paper is estimated by a morphological closing, so shadows and uneven light are removed, and the result is trimmed ink in its own colour on transparency, saved like an imported image. Still missing: perspective correction of a skewed sheet, live camera capture, keyboard control of the frame, and processing off the UI thread (bounded by the 4-megapixel working copy; quick 900 px previews while dragging)" [[feature]] id = "form.fill-sign-flatten" @@ -5629,11 +5653,11 @@ title = "Redaction fill colour and overlay text" area = "G" tier = "P0" milestone = "M8" -status = "partial" +status = "shipped" commands = ["redact.properties"] tools = ["redact_mark"] -evidence = ["crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing", "crates/redact/src/tests.rs::overlay_text_takes_its_font_size_colour_alignment_and_repeats"] -notes = "fill colour or none; overlay text with font, size or auto-size, colour, alignment and repeat, or redaction codes (protect.redaction-codes)" +evidence = ["crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing", "crates/ui-egui/tests/ui_group/redact.rs::redaction_properties_survive_a_restart", "crates/ui-egui/tests/ui_group/redact.rs::redaction_properties_for_this_run_only_and_cancel", "crates/redact/src/tests.rs::overlay_text_takes_its_font_size_colour_alignment_and_repeats"] +notes = "Acrobat's three groups: redacted area fill colour or none; overlay text with font, size or auto-size, colour, alignment and repeat, or redaction codes (protect.redaction-codes); Redaction mark appearance: outline colour (/C), fill colour shown while the pointer is over a mark (a preference, not written), opacity (/CA). Make properties default: ticked, OK keeps the values for the next run (settings \"redact_prefs\"); unticked, this run only and the saved defaults stay; Cancel puts back what the dialog opened with." [[feature]] id = "protect.redaction-codes" @@ -5788,8 +5812,8 @@ milestone = "M8" status = "partial" commands = [] tools = ["doc_audit_redactions"] -evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover", "crates/automation/tests/automation.rs::auditing_faux_redactions_through_tools"] -notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the text it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content is an error, never a clean bill. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist)." +evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/audit/src/tests.rs::applied_redaction_at_the_end_of_a_line_is_not_flagged", "crates/audit/src/tests.rs::faux_redaction_at_the_end_of_a_line_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_short_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_mirrored_text_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found", "crates/audit/src/tests.rs::faux_redactions_with_multiple_covers_lines_and_spacing_are_found", "crates/automation/tests/automation.rs::auditing_faux_redactions_through_tools", "crates/automation/tests/automation.rs::an_applied_redaction_at_the_end_of_a_line_audits_clean", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover", "apps/pdfcraft-cli/tests/audit.rs::audit_rectangles_match_the_tool_in_top_left_points"] +notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the text it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Coverage is measured on the words a line draws, not its box, so the gap an applied redaction leaves is not counted as hidden text; a cover over 40% of the line's box still counts when it has a positive-area intersection with one word, so a short value after a long label is found. A cover over less than 40% of both a line's box and its words (a short value in the middle of a long line) is still not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content is an error, never a clean bill. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist). Tool and CLI report rectangles in points from the top-left of the displayed page, after /Rotate and rounded to 0.01 pt, like the other tools." [[feature]] id = "protect.remove-hidden-on-close" @@ -5984,8 +6008,8 @@ milestone = "M9" status = "partial" commands = ["sign.digital"] tools = ["sign_document"] -evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/ui-egui/tests/signing.rs::the_fill_and_sign_signature_or_an_image_shows_in_a_digital_signature", "crates/sign/tests/pdf.rs::visible_signatures_show_a_drawn_typed_or_image_graphic", "crates/sign/tests/pdf.rs::malformed_signature_graphics_are_refused_not_drawn", "crates/sign/tests/pdf.rs::an_encrypted_documents_signature_graphic_is_encrypted_with_it", "crates/automation/tests/automation.rs::signing_with_an_image_typed_or_drawn_graphic_through_tools"] -notes = "text items (name, date, reason, location, DN, labels), and a graphic where the large name would be: the saved Fill & Sign signature (drawn, typed or image) or an imported PNG/JPEG (white paper made transparent, as in Fill & Sign), remembered for the next signing; alone it fills the box. Missing: named saved appearances (one remembered choice only), a PDF page as the graphic, the logo/watermark background, drawing a new signature inside the dialog" +evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/ui-egui/tests/signing.rs::the_fill_and_sign_signature_or_an_image_shows_in_a_digital_signature", "crates/sign/tests/pdf.rs::visible_signatures_show_a_drawn_typed_or_image_graphic", "crates/sign/tests/pdf.rs::malformed_signature_graphics_are_refused_not_drawn", "crates/sign/tests/pdf.rs::an_encrypted_documents_signature_graphic_is_encrypted_with_it", "crates/automation/tests/automation.rs::signing_with_an_image_typed_or_drawn_graphic_through_tools", "crates/sign/tests/pdf.rs::appearance_labels_helvetica_cannot_draw_are_refused", "crates/ui-egui/tests/signing.rs::the_signature_labels_follow_the_interface_language", "crates/automation/tests/automation.rs::signing_with_translated_labels_through_tools"] +notes = "text items (name, date, reason, location, DN, labels), and a graphic where the large name would be: the saved Fill & Sign signature (drawn, typed or image) or an imported PNG/JPEG (white paper made transparent, as in Fill & Sign), remembered for the next signing; alone it fills the box. The labels are stamped in the interface language when Helvetica (WinAnsi) can draw all of them (German, Spanish, French, Italian, Portuguese, Polish, Turkish, Slovene, Hungarian), otherwise all in English with a note under the preview, which shows the same labels and the signing time; sign_document takes labels and the shown items (appearance), and a drawn label the font can't draw is refused, never written as ?. Missing: labels in non-Latin scripts (needs an embedded font), the DN line in the preview, named saved appearances (one remembered choice only), a PDF page as the graphic, the logo/watermark background, drawing a new signature inside the dialog" [[feature]] id = "sign.reason-location" @@ -6296,8 +6320,8 @@ milestone = "M9" status = "shipped" commands = ["sign.panel"] tools = ["sign_list"] -evidence = ["crates/sign/tests/pdf.rs::later_changes_are_classified_under_the_signature_permissions", "crates/sign/tests/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/engine/src/tests.rs::signing_saving_trusting_and_commenting_afterwards", "crates/sign/tests/pdf.rs::validation_data_and_xmp_added_after_signing_are_not_tampering", "crates/sign/tests/pdf.rs::a_dss_entry_does_not_make_the_object_it_names_part_of_the_store", "crates/sign/tests/pdf.rs::an_existing_dictionary_is_not_a_store_part_because_the_store_names_it", "crates/sign/tests/pdf.rs::an_existing_array_stays_what_it_is_when_the_store_points_at_it", "crates/sign/tests/pdf.rs::a_signed_store_may_grow_but_not_lose_or_swap_entries", "crates/sign/tests/pdf.rs::a_dictionary_labelled_metadata_is_not_a_metadata_change", "crates/sign/tests/pdf.rs::a_stream_already_labelled_metadata_when_signed_is_not_metadata_unless_the_catalog_names_it", "crates/sign/tests/pdf.rs::the_catalogs_own_xmp_stream_may_still_be_rewritten_after_signing"] -notes = "revision diff classified as signing, form fill, comments, metadata, page content or structure. Later revisions that add DSS data (new /Certs, /CRLs, /OCSPs, /VRI objects, or ones of the signed store that only grew) or rewrite the catalog's XMP are permitted changes; a /DSS entry that names other objects, or a /Type /DSS or /Type /Metadata label on an existing dictionary or stream, does not excuse changing them (negative tests at every DocMDP level)" +evidence = ["crates/sign/tests/pdf.rs::later_changes_are_classified_under_the_signature_permissions", "crates/sign/tests/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/engine/src/tests.rs::signing_saving_trusting_and_commenting_afterwards", "crates/sign/tests/pdf.rs::validation_data_and_xmp_added_after_signing_are_not_tampering", "crates/sign/tests/pdf.rs::a_dss_entry_does_not_make_the_object_it_names_part_of_the_store", "crates/sign/tests/pdf.rs::an_existing_dictionary_is_not_a_store_part_because_the_store_names_it", "crates/sign/tests/pdf.rs::an_existing_array_stays_what_it_is_when_the_store_points_at_it", "crates/sign/tests/pdf.rs::a_signed_store_may_grow_but_not_lose_or_swap_entries", "crates/sign/tests/pdf.rs::a_dictionary_labelled_metadata_is_not_a_metadata_change", "crates/sign/tests/pdf.rs::a_stream_already_labelled_metadata_when_signed_is_not_metadata_unless_the_catalog_names_it", "crates/sign/tests/pdf.rs::the_catalogs_own_xmp_stream_may_still_be_rewritten_after_signing", "crates/sign/tests/pdf.rs::rewritten_page_resources_are_page_content_whatever_keys_they_carry"] +notes = "revision diff classified as signing, form fill, comments, metadata, page content or structure. Later revisions that add DSS data (new /Certs, /CRLs, /OCSPs, /VRI objects, or ones of the signed store that only grew) or rewrite the catalog's XMP are permitted changes; a /DSS entry that names other objects, or a /Type /DSS or /Type /Metadata label on an existing dictionary or stream, does not excuse changing them (negative tests at every DocMDP level). A signed page's resources (fonts, XObjects, graphics states and what they reach) are page content by the role they held when signed, whatever keys a later revision writes into them (#1787)" [[feature]] id = "sign.view-signed-version" @@ -6953,7 +6977,7 @@ status = "partial" commands = ["export.docx"] tools = ["doc_export_office"] evidence = ["crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/lib.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/lib.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/lib.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"] -notes = "Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells, per-page sections/sizes, and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. Not yet: exact font matching, vector rules, arbitrary paint-order/transparency, colour per run within a paragraph, general exact layout fidelity." +notes = "Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells, per-page sections/sizes, and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. Not yet: exact font matching, vector rules, arbitrary paint-order/transparency, colour per run within a paragraph, general exact layout fidelity." [[feature]] id = "create.export-rtf" @@ -7213,7 +7237,7 @@ status = "shipped" commands = ["optimize.reduce"] tools = ["doc_reduce"] evidence = ["crates/ui-egui/tests/create.rs::reduce_file_size_writes_a_compact_copy", "crates/ui-egui/tests/create.rs::reduce_file_size_saves_nothing_when_the_file_is_already_small", "crates/engine/src/tests.rs::create_and_reduce", "crates/automation/tests/automation.rs::creating_and_reducing_through_tools", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::an_identity_decode_array_does_not_stop_reduce"] -notes = "images above 225 ppi to 150 ppi JPEG medium, thumbnails dropped, resources merged, object streams; scanner images with the default decode array are included; a copy that isn't smaller is not saved (\"already as small as it can be made\"); runs in the background with a progress bar and Cancel" +notes = "images above 225 ppi to 150 ppi JPEG medium (after merging page pictures printed as one-pixel strips into one image), thumbnails dropped, resources merged, object streams; scanner images with the default decode array are included; a copy that isn't smaller is not saved (\"already as small as it can be made\"); runs in the background with a progress bar and Cancel" [[feature]] id = "optimize.compatibility-level" @@ -7236,8 +7260,8 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/ui-egui/src/optimize_ui.rs::the_progress_card_shows_the_stage_and_cancels", "crates/engine/src/tests.rs::an_optimize_job_reports_its_stages_and_can_be_cancelled", "crates/optimize/tests/optimize.rs::images_and_forms_nothing_draws_are_dropped", "crates/automation/tests/automation.rs::a_deleted_image_is_not_kept_by_reduce_or_optimize"] -notes = "Images, Discard Objects, Discard User Data, Clean Up (also drops images and forms the pages list but never draw, such as deleted images); runs in the background with a progress bar (per image) and Cancel; no Fonts or Transparency panels, no space audit" +evidence = ["crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/ui-egui/src/optimize_ui.rs::the_progress_card_shows_the_stage_and_cancels", "crates/engine/src/tests.rs::an_optimize_job_reports_its_stages_and_can_be_cancelled", "crates/optimize/tests/optimize.rs::images_and_forms_nothing_draws_are_dropped", "crates/automation/tests/automation.rs::a_deleted_image_is_not_kept_by_reduce_or_optimize", "crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_picks_a_preset", "crates/automation/tests/automation.rs::a_page_printed_in_strips_gets_small_through_tools", "crates/optimize/tests/optimize.rs::the_mobile_preset_makes_a_printed_page_small"] +notes = "Settings menu with the Standard and Mobile presets (Custom once an option changes; doc_optimize preset), Images (with Merge image fragments: a page picture drawn as one-pixel strips, as some PDF printers write it, is put back together so it can be resampled), Discard Objects, Discard User Data, Clean Up (also drops images and forms the pages list but never draw, such as deleted images); runs in the background with a progress bar (per image) and Cancel; no Fonts or Transparency panels, no saved custom presets, no space audit" [[feature]] id = "optimize.downsample-images" @@ -7248,8 +7272,8 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/automation.rs::optimizing_through_tools"] -notes = "colour and grayscale, bicubic, effective resolution measured where drawn; monochrome not yet" +evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone", "crates/optimize/tests/optimize.rs::a_deeply_nested_form_is_measured_at_its_largest_use", "crates/optimize/tests/optimize.rs::images_are_not_resampled_when_some_uses_could_not_be_measured"] +notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome not yet" [[feature]] id = "optimize.recompress-images" @@ -7572,8 +7596,8 @@ milestone = "M11" status = "shipped" commands = ["optimize.advanced"] tools = ["doc_audit_space"] -evidence = ["crates/optimize/tests/optimize.rs::space_audit_shares_the_file_out_by_kind", "crates/automation/tests/automation.rs::auditing_space_through_tools", "crates/ui-egui/tests/viewer.rs::optimizer_audits_space_usage"] -notes = "PDF Optimizer ▸ Audit space usage: 16 categories (images, content streams, fonts, X Object forms, structure, bookmarks, thumbnails, comments, forms, named destinations, colour spaces, shadings, patterns, graphics states, embedded files, overhead) with bytes and percentages" +evidence = ["crates/optimize/tests/optimize.rs::space_audit_shares_the_file_out_by_kind", "crates/optimize/tests/optimize.rs::space_audit_counts_what_fields_and_comments_draw_with_by_kind", "crates/optimize/tests/optimize.rs::space_audit_survives_deeply_nested_forms", "crates/automation/tests/automation.rs::auditing_space_through_tools", "crates/ui-egui/tests/viewer.rs::optimizer_audits_space_usage", "crates/ui-egui/tests/viewer.rs::space_audit_numbers_line_up_under_their_headers"] +notes = "PDF Optimizer ▸ Audit space usage: what the file's bytes are spent on now (not what optimizing would save), in 16 categories (images, content streams, fonts, X Object forms, structure, bookmarks, thumbnails, comments, forms, named destinations, colour spaces, shadings, patterns, graphics states, embedded files, overhead) with bytes and percentages. Resources are counted by kind wherever they are used, so the fonts and images form fields and comments draw with count as fonts and images. Still missing: sizes are each object as it would be written uncompressed, so objects packed in compressed object streams count larger than they take on disk (overhead, the rest of the file, shrinks to match)" [[feature]] id = "optimize.optimizer-presets" @@ -7973,7 +7997,7 @@ status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] evidence = ["crates/print/src/tests.rs::size_modes", "crates/print/src/tests.rs::architectural_sheets_preserve_full_scale", "crates/automation/tests/automation.rs::architectural_and_iso_papers_through_tools", "crates/ui-egui/tests/print.rs::architectural_page_can_be_matched_at_100_percent"] -notes = "fit, actual size, shrink oversized, custom %; ARCH A/B/C/D/E1/E, ANSI C/D/E, ISO A0/A1/A2 presets; Match page at 100% matches the current page to a standard sheet. PDF scale shows the previewed source-to-sheet transform, not calibrated physical output; driver substitution/scaling remains possible" +notes = "fit, actual size, shrink oversized, custom %; ARCH A/B/C/D/E1/E, ANSI C/D/E, ISO A0/A1/A2 and A6 presets; Match page at 100% matches the current page to a standard sheet. PDF scale shows the previewed source-to-sheet transform, not calibrated physical output; driver substitution/scaling remains possible" [[feature]] id = "print.paper-source-by-size" @@ -8089,11 +8113,11 @@ title = "Print in grayscale" area = "L" tier = "P1" milestone = "M10" -status = "partial" +status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list"] -notes = "asks the printer for monochrome (lp -o, and the Windows page settings as the job's); the PDF itself stays in colour" +evidence = ["crates/engine/src/tests.rs::printing_as_image_sends_pictures_instead_of_pages", "crates/ui-egui/tests/print.rs::print_as_image_is_an_advanced_choice_that_saves_pictures", "crates/automation/tests/automation.rs::doc_print_as_image"] +notes = "asks the printer for monochrome (lp -o, and the Windows page settings as the job's); with Print as image the pictures themselves are rendered grey (Rec. 601 luma), so the print-ready PDF is grayscale too. Still missing against Acrobat: a grey conversion of the vector pages without rendering them." [[feature]] id = "print.print-as-image" @@ -8101,11 +8125,11 @@ title = "Print as image" area = "L" tier = "P1" milestone = "M10" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["print.dialog"] +tools = ["doc_print"] +evidence = ["crates/engine/src/tests.rs::printing_as_image_sends_pictures_instead_of_pages", "crates/ui-egui/tests/print.rs::print_as_image_is_an_advanced_choice_that_saves_pictures", "crates/automation/tests/automation.rs::doc_print_as_image"] +notes = "The Print dialog's Advanced section (folded unless on): Print as image at 150, 300 or 600 dpi; doc_print as_image and dpi (72–600). The pages used are rendered once each the way the viewer shows them (comments left out when the print leaves them out), composed over white, made into an image-only document and imposed on the paper like the pages themselves. The print is no longer searchable text. Still missing against Acrobat: per-printer defaults for it." [[feature]] id = "print.page-setup" @@ -8837,11 +8861,11 @@ title = "Autotag form fields" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["a11y.autotag_fields"] +tools = ["accessibility_autotag_form_fields"] +evidence = ["crates/a11y/src/tests.rs::autotag_form_fields_writes_widget_structure_and_parent_tree", "crates/automation/tests/automation.rs::autotag_form_fields_writes_reviewable_widget_tags"] +notes = "For an untagged AcroForm, writes Form structure elements, OBJR references, widget StructParent keys and ParentTree entries. An existing tree is extended when its root points directly to a Document element and its ParentTree uses a direct Nums array; other layouts are refused before any edit. A widget listed on several pages is tagged once. Text, figures, links and other content remain untagged." [[feature]] id = "a11y.reading-order-tool" @@ -8864,8 +8888,8 @@ milestone = "M12" status = "partial" commands = ["a11y.tags"] tools = ["accessibility_tags"] -evidence = ["crates/ui-egui/tests/accessibility.rs::tags_panel_edits_a_tag_property_and_undoes_it", "crates/ui-egui/tests/accessibility.rs::checker_finding_opens_its_structure_tag"] -notes = "The right-side Tags panel lists reachable indirect tags as a collapsible hierarchy, filters by type or title with ancestors, navigates to their page and edits properties. Checker findings linked to indirect tags open the matching tag without losing unsaved edits. Inline tags, adding/deleting tags, artifact marking and finding content remain planned in #982." +evidence = ["crates/ui-egui/tests/accessibility.rs::tags_panel_edits_a_tag_property_and_undoes_it", "crates/ui-egui/tests/accessibility.rs::checker_finding_opens_its_structure_tag", "crates/automation/tests/automation.rs::structure_tags_are_listed_a_page_at_a_time"] +notes = "The right-side Tags panel lists reachable indirect tags as a collapsible hierarchy, filters by type or title with ancestors, navigates to their page and edits properties. accessibility_tags lists them a page at a time (offset, limit, next) and can keep only some types, pages or depths. Checker findings linked to indirect tags open the matching tag without losing unsaved edits. Inline tags, adding/deleting tags, artifact marking and finding content remain planned in #982." [[feature]] id = "a11y.tag-properties" @@ -8921,11 +8945,11 @@ title = "Content panel" area = "M" tier = "P2" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["a11y.tags"] +tools = ["accessibility_tag_content"] +evidence = ["crates/a11y/src/tests.rs::tag_content_lists_direct_marked_content", "crates/ui-egui/tests/accessibility.rs::tags_panel_edits_a_tag_property_and_undoes_it"] +notes = "Selecting a tag in the Tags panel lists its direct marked-content references with MCID, page and extracted text, and selecting an item navigates to the page. The content tree is presented within the Tags panel; editing content objects and arbitrary inline structure nodes remain planned." [[feature]] id = "a11y.create-tag-from-selection" @@ -8945,11 +8969,11 @@ title = "Mark content as artifact" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["a11y.tags"] +tools = ["accessibility_mark_tag_artifact"] +evidence = ["crates/a11y/src/tests.rs::marking_a_leaf_tag_as_an_artifact_preserves_appearance_and_structure_integrity", "crates/automation/tests/automation.rs::marking_a_leaf_tag_as_an_artifact_through_automation_is_undoable_and_saved", "crates/ui-egui/tests/accessibility.rs::tags_panel_marks_a_leaf_tag_as_an_artifact"] +notes = "Tags panel and automation can retire a reachable indirect leaf tag that owns direct MCIDs: its visible page content remains but becomes /Artifact and the parent-tree references are cleared. Tags with structural children, non-MCID content, missing pages or ambiguous parents are refused. Arbitrary painted-content selection, direct structure dictionaries and removing whole tagged subtrees remain unsupported." [[feature]] id = "a11y.find-untagged" @@ -8957,11 +8981,11 @@ title = "Find unmarked content, comments and links" area = "M" tier = "P2" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["a11y.find_untagged"] +tools = ["accessibility_find_untagged"] +evidence = ["crates/automation/tests/automation.rs::finding_untagged_content_limits_results_to_content_annotations_and_widgets", "crates/ui-egui/tests/accessibility.rs::finding_untagged_content_shows_the_focused_accessibility_results"] +notes = "Lists findings for untagged painted page content, non-widget annotations and form widgets, optionally on selected pages. It reuses the checker and does not identify untagged table structure, links already represented by an annotation, or structure-tree elements that lack content." [[feature]] id = "a11y.table-editor" @@ -9080,8 +9104,8 @@ milestone = "M12" status = "shipped" commands = [] tools = ["view_reading_colors"] -evidence = ["crates/automation/tests/automation.rs::reading_colors_through_tools", "crates/render/src/raster.rs::reading_colors_map_solid_paints_and_leave_images", "crates/render/src/raster.rs::reading_colors_tiles_match_full_render", "crates/render/src/raster.rs::print_preview_requests_render_with_reading_colors_off", "crates/ui-egui/tests/reading_colors.rs::reading_colours_preference_round_trips", "crates/ui-egui/tests/reading_colors.rs::reading_colours_seed_on_open"] -notes = "View only, off by default: Preferences ▸ Reading colours holds the app-wide preference, seeded into every document as it opens (including created ones), and view_reading_colors reads and sets it per document (Acrobat's Replace Document Colors). Solid text paints and, by default, line-art paints (including stencil-mask image paints) are remapped by luminance — each lerps between the preset's text and background colour by how dark or light the original paint is — while images, shadings and tiling patterns pass through untouched. Presets: black on white, white on black, yellow on black, green on black, or a custom #RRGGBB pair; only_black limits the mapping to near-black paints (a 10% luminance threshold — our choice; Acrobat's was not observed). Display only: the document is not changed and print and export are unaffected; print previews and view snapshots render with the document's true colours (a preview matches the uncoloured printout, and a snapshot is an export surface), so reading colours apply to the reading view only. Closes #897 (duplicate #488)." +evidence = ["crates/automation/tests/automation.rs::reading_colors_through_tools", "crates/render/src/raster.rs::reading_colors_map_solid_paints_and_leave_images", "crates/render/src/raster.rs::reading_colors_tiles_match_full_render", "crates/render/src/raster.rs::print_preview_requests_render_with_reading_colors_off", "crates/render/src/raster.rs::reading_colors_keep_highlight_annotations_readable", "crates/ui-egui/tests/reading_colors.rs::reading_colours_preference_round_trips", "crates/ui-egui/tests/reading_colors.rs::reading_colours_seed_on_open"] +notes = "View only, off by default: Preferences ▸ Reading colours holds the app-wide preference, seeded into every document as it opens (including created ones), and view_reading_colors reads and sets it per document (Acrobat's Replace Document Colors). Solid text paints and, by default, line-art paints (including stencil-mask image paints) are remapped by luminance — each lerps between the preset's text and background colour by how dark or light the original paint is — while images, shadings and tiling patterns pass through untouched. Annotations keep the colours they were given (both /AP appearances and the no-/AP Highlight fallback), so a Multiply highlight over a dark background shows the marked-up words in the highlight's colour instead of a flat bar that hides them (#1548). Presets: black on white, white on black, yellow on black, green on black, or a custom #RRGGBB pair; only_black limits the mapping to near-black paints (a 10% luminance threshold — our choice; Acrobat's was not observed). Display only: the document is not changed and print and export are unaffected; print previews and view snapshots render with the document's true colours (a preview matches the uncoloured printout, and a snapshot is an export surface), so reading colours apply to the reading view only. Closes #897 (duplicate #488)." [[feature]] id = "a11y.reflow-tagged" @@ -9141,11 +9165,11 @@ title = "Side-by-side compare view with synchronised scrolling" area = "N" tier = "P1" milestone = "M12" -status = "planned" -commands = [] +status = "shipped" +commands = ["doc.compare"] tools = [] -evidence = [] -notes = "" +evidence = ["crates/ui-egui/tests/compare.rs::compare_two_versions"] +notes = "After Compare files the document area splits (when the old version is open in a tab and the window is at least 1000 px wide; the Compare panel's Side by side / New file only toggles it): the old file on the left, the new on the right, each a page view of its own with its shading (deleted and replaced on the old, inserted and replaced on the new, visual differences on both). The half the pointer is over leads: the other takes its zoom, fit, layout, rotation and scroll offset; a change clicked in the panel jumps both halves to their side of it. Clear ends it. Still missing against Acrobat: scrolling by matching content rather than by offset (pages of different sizes drift), and the comparison kept when the old file's tab closes." [[feature]] id = "misc.compare-report" @@ -9396,8 +9420,8 @@ milestone = "M13" status = "shipped" commands = [] tools = [] -evidence = ["crates/automation/tests/automation.rs::tool_table_is_well_formed", "crates/automation/tests/automation.rs::edit_undo_redo_and_save_round_trip", "apps/pdfcraft-cli/tests/stdout.rs::output_commands_exit_cleanly_when_the_reader_has_closed"] -notes = "CLI stdout exits cleanly if a pipe reader closes early; other output errors and command failures remain errors." +evidence = ["crates/automation/tests/automation.rs::tool_table_is_well_formed", "crates/automation/tests/automation.rs::edit_undo_redo_and_save_round_trip", "apps/pdfcraft-cli/tests/stdout.rs::output_commands_exit_cleanly_when_the_reader_has_closed", "apps/pdfcraft-cli/tests/run_report.rs::a_failing_step_is_named_and_the_rest_are_skipped"] +notes = "CLI stdout exits cleanly if a pipe reader closes early; other output errors and command failures remain errors. `run --script --report` prints one JSON document with a status per step (#1891); without it the output is unchanged." [[feature]] id = "misc.cli-completeness" @@ -9768,8 +9792,8 @@ milestone = "M14" status = "partial" commands = ["app.preferences"] tools = ["js_enabled"] -evidence = ["crates/ui-egui/tests/javascript.rs::document_scripts_and_preferences", "crates/ui-egui/tests/ui.rs::preferences_selects_default_workspace_for_next_open", "crates/ui-egui/tests/control.rs::control_default_workspace_and_session_override"] -notes = "Preferences dialog with Interface language, Documents and view (persistent default workspace with session override), Identity, Fill & Sign (flatten on save, off by default) and JavaScript; other categories still live in their tools, and it isn't generated from a schema yet." +evidence = ["crates/ui-egui/tests/javascript.rs::document_scripts_and_preferences", "crates/ui-egui/tests/ui.rs::preferences_selects_default_workspace_for_next_open", "crates/ui-egui/tests/control.rs::control_default_workspace_and_session_override", "apps/pdfcraft/src/settings.rs::xdg_preferences_use_config_instead_of_data", "apps/pdfcraft/src/settings.rs::migration_preserves_preferences_and_other_data"] +notes = "Preferences dialog with Interface language, Documents and view (persistent default workspace with session override), Identity, Fill & Sign (flatten on save, off by default) and JavaScript; other categories still live in their tools, and it isn't generated from a schema yet. XDG desktops store app.ron under XDG_CONFIG_HOME/pdfcraft or ~/.config/pdfcraft; existing data-folder preferences are copied without replacing configured preferences, with fallback on migration errors. Logs and recovery retain data locations. Portable/non-XDG locations are unchanged." [[feature]] id = "misc.prefs-general" @@ -9912,8 +9936,8 @@ milestone = "M14" status = "partial" commands = ["app.preferences"] tools = [] -evidence = ["crates/ui-egui/src/commands.rs::localized_shortcut_labels_follow_language_and_platform", "crates/ui-egui/src/i18n/mod.rs::keyboard_key_names_have_a_separate_context_and_english_fallback", "crates/ui-egui/tests/commands.rs::localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::arabic_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::arabic_lookups_are_in_display_order", "crates/ui-egui/src/i18n/mod.rs::arabic_plurals_follow_the_six_cldr_forms", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::arabic_labels_have_glyphs", "crates/ui-egui/src/bidi.rs::rtl_labels_keep_placeholders_whole", "crates/ui-egui/src/i18n/mod.rs::japanese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_history_and_diagnostics_preserve_user_values", "crates/ui-egui/tests/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/i18n/mod.rs::french_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::french_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::french_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::german_certificate_subject_has_a_distinct_context", "crates/ui-egui/src/widgets.rs::search_placeholder_never_overlaps_the_shortcut", "crates/ui-egui/src/home.rs::translated_card_description_leaves_room_for_the_action", "crates/ui-egui/tests/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/mod.rs::telugu_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/mod.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::hungarian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::ukrainian_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::ukrainian_integer_plurals_cover_teens_and_large_counts", "crates/ui-egui/src/i18n/mod.rs::ukrainian_calendar_uses_month_case_for_dates", "crates/ui-egui/src/i18n/mod.rs::ukrainian_catalog_does_not_leave_english_ui_text", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::ukrainian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::spanish_covers_what_the_engine_says", "crates/ui-egui/src/i18n/mod.rs::spanish_keeps_the_holes_in_what_the_engine_says", "crates/print/src/tests.rs::wordings_are_said"] -notes = "Shortcut modifiers and key names use a dedicated catalog context across toolbar, menus, palette, Help and tooltips, with English fallback and macOS symbols preserved. Preferences switches Auto (system language)/English/Japanese/Simplified Chinese/Traditional Chinese/Czech/Brazilian Portuguese/German/Spanish/French/Telugu/Hungarian/Ukrainian/Italian/Arabic and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te|hu|uk|it|ar and ui.state expose it. TSV catalogs per language (same system as PhotoCraft); every dialog, panel and notice goes through tl!. Japanese, Traditional Chinese, Simplified Chinese, German, Spanish, French and Hungarian cover commands, dialogs, panels, shortcuts and history labels; Czech and Brazilian Portuguese the menus. French locales (fr, fr_FR, fr_CA, fr_BE) select the French catalog, German locales (de, de_DE, de_AT, de_CH) the German one, Hungarian locales (hu, hu_HU) the Hungarian one. German search hints reserve shortcut space, Home cards bound translated text, certificate Subject has its own context, and inserted tool names keep their capitalization. The sentences the layers below word for themselves (accessibility findings and the saved report, PDF/A problems, signature details, page-range mistakes, edit labels) are handed over apart from the document's own values and go through the catalog too, through said(&i18n::words); Spanish translates them and the other catalogs fall back to English. OS error details, and faults rather than mistakes, stay as they are. Auto detects the Windows display language after explicitly named supported locale overrides; neutral C/POSIX launcher locales are skipped on Windows, with Chinese script/region mapping and English fallback. Arabic (ar and every ar-* locale) covers commands, dialogs and panels with the six CLDR plural forms; its labels are put into display order when the catalog loads, because egui joins Arabic letters but lays words out left to right. Missing: a right-to-left layout (Arabic labels sit in the left-to-right layout, a wrapped Arabic paragraph breaks at the wrong end, and text typed into a field is not reordered), Czech and Brazilian Portuguese dialogs, Chinese and Telugu font delivery in releases (the pinned craft-fonts revision has an Arabic face; its Noto Sans CJK SC is never embedded, AGENTS.md §1.1), the engine's sentences in the other catalogs, a full localization audit." +evidence = ["crates/ui-egui/src/commands.rs::localized_shortcut_labels_follow_language_and_platform", "crates/ui-egui/src/i18n/mod.rs::keyboard_key_names_have_a_separate_context_and_english_fallback", "crates/ui-egui/tests/commands.rs::localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::arabic_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::arabic_lookups_are_in_display_order", "crates/ui-egui/src/i18n/mod.rs::arabic_plurals_follow_the_six_cldr_forms", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::arabic_labels_have_glyphs", "crates/ui-egui/src/bidi.rs::rtl_labels_keep_placeholders_whole", "crates/ui-egui/src/i18n/mod.rs::japanese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_history_and_diagnostics_preserve_user_values", "crates/ui-egui/tests/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/i18n/mod.rs::french_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::french_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::french_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::german_certificate_subject_has_a_distinct_context", "crates/ui-egui/src/widgets.rs::search_placeholder_never_overlaps_the_shortcut", "crates/ui-egui/src/home.rs::translated_card_description_leaves_room_for_the_action", "crates/ui-egui/tests/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/mod.rs::telugu_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/mod.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::hungarian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::ukrainian_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::ukrainian_integer_plurals_cover_teens_and_large_counts", "crates/ui-egui/src/i18n/mod.rs::ukrainian_calendar_uses_month_case_for_dates", "crates/ui-egui/src/i18n/mod.rs::ukrainian_catalog_does_not_leave_english_ui_text", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::ukrainian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::spanish_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::spanish_covers_what_the_engine_says", "crates/ui-egui/src/i18n/mod.rs::spanish_keeps_the_holes_in_what_the_engine_says", "crates/print/src/tests.rs::wordings_are_said"] +notes = "Shortcut modifiers and key names use a dedicated catalog context across toolbar, menus, palette, Help and tooltips, with English fallback and macOS symbols preserved. Preferences switches Auto (system language)/English/Japanese/Simplified Chinese/Traditional Chinese/Czech/Brazilian Portuguese/German/Spanish/French/Telugu/Hungarian/Ukrainian/Italian/Arabic and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te|hu|uk|it|ar and ui.state expose it. TSV catalogs per language (same system as PhotoCraft); every dialog, panel and notice goes through tl!. Japanese, Traditional Chinese, Simplified Chinese, German, Spanish, French and Hungarian cover commands, dialogs, panels, shortcuts and history labels; Czech and Brazilian Portuguese the menus. French locales (fr, fr_FR, fr_CA, fr_BE) select the French catalog, German locales (de, de_DE, de_AT, de_CH) the German one, Hungarian locales (hu, hu_HU) the Hungarian one. German search hints reserve shortcut space, Home cards bound translated text, certificate Subject has its own context, and inserted tool names keep their capitalization. The sentences the layers below word for themselves (accessibility findings and the saved report, PDF/A problems, signature details, page-range mistakes, edit labels) are handed over apart from the document's own values and go through the catalog too, through said(&i18n::words); Spanish and Italian translate them and the other catalogs fall back to English. OS error details, and faults rather than mistakes, stay as they are. Auto detects the Windows display language after explicitly named supported locale overrides; neutral C/POSIX launcher locales are skipped on Windows, with Chinese script/region mapping and English fallback. Arabic (ar and every ar-* locale) covers commands, dialogs and panels with the six CLDR plural forms; its labels are put into display order when the catalog loads, because egui joins Arabic letters but lays words out left to right. Missing: a right-to-left layout (Arabic labels sit in the left-to-right layout, a wrapped Arabic paragraph breaks at the wrong end, and text typed into a field is not reordered), Czech and Brazilian Portuguese dialogs, Chinese and Telugu font delivery in releases (the pinned craft-fonts revision has an Arabic face; its Noto Sans CJK SC is never embedded, AGENTS.md §1.1), the engine's sentences in the other catalogs, a full localization audit." [[feature]] id = "misc.prefs-reading" @@ -10041,11 +10065,11 @@ title = "Localization (Fluent catalogs)" area = "N" tier = "P1" milestone = "M14" -status = "planned" +status = "partial" commands = [] tools = [] -evidence = [] -notes = "" +evidence = ["crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::date_languages_are_the_interface_languages", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language"] +notes = "The UI offers 22 non-English catalogs plus English fallback (entry counts vary by language), and the selected language persists. Partial because the languages are not all usable yet: Chinese shows tofu in releases (#826, #688), the release-pinned craft-fonts has no Telugu face, Arabic is not mirrored (misc.rtl-ui), and strings missing from a catalog fall back to English (localization-parity.md). The language can be set through the UI control channel (`set language`) but not through an automation tool, so pdfcraft-cli run and MCP cannot change it." [[feature]] id = "misc.rtl-ui" @@ -10065,11 +10089,11 @@ title = "Signed installers for macOS, Windows and Linux" area = "N" tier = "P1" milestone = "M14" -status = "planned" +status = "partial" commands = [] tools = [] evidence = [] -notes = "Windows MSI creates a Start menu shortcut, an optional (default on) desktop shortcut and an optional (default on, INSTALLPDFCONTEXTMENU) \"Edit with PdfCraft\" File Explorer menu entry for PDF files (#682), and confirms full-UI completion with Finish (#143); packaging/windows/test-msi.ps1 checks the compiled MSI and windows-arm64.yml checks installation/removal. PDF files use a dedicated embedded document icon when PdfCraft is chosen as the default reader (confirmed in Windows Explorer); first-page thumbnails remain unsupported. Signed installers across platforms remain planned." +notes = "Release builds provide signed and notarized macOS artifacts and code-signed Windows installers when signing credentials are configured (packaging/macos/package.sh; packaging/windows/sign.ps1; README.md). Linux AppImage, Flatpak, deb and rpm packages ship, but are not code-signed. Windows MSI creates a Start menu shortcut, an optional (default on) desktop shortcut, an optional (default on, INSTALLPDFCONTEXTMENU) \"Edit with PdfCraft\" File Explorer menu entry for PDF files (#682) and an optional (default on, INSTALLIMAGECONTEXTMENU) \"Create PDF with PdfCraft…\" entry for images; an update keeps whichever of the three the installed version has (#1058, #305). It confirms full-UI completion with Finish (#143); packaging/windows/test-msi.ps1 checks the compiled MSI and windows-arm64.yml checks installation/removal. PDF files use a dedicated embedded document icon when PdfCraft is chosen as the default reader (confirmed in Windows Explorer); first-page thumbnails remain unsupported." [[feature]] id = "misc.web-deploy" diff --git a/vendor/README.md b/vendor/README.md index c433193f3..fb6588c14 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -5,23 +5,28 @@ licence files unchanged. Every patch is marked `PdfCraft patch:` in the source, PdfCraft test, and should be proposed upstream. Remove the vendored copy once upstream releases the fix. Vendoring copyleft code is never allowed (plan/adr/0001). +A PdfCraft patch in `hayro-syntax::Page::base_dimensions` expresses the existing one-point floor as a raw reciprocal of `/UserUnit`, rounded upward only when f32 multiplication would fall below one point. Five `pdfcraft-render` regressions with prefix `raster::tests::raw_crop_` cover the observed dimensions, origins and rotations, units 41/47, bounded vector rasters and unchanged nearly-zero A4 fallback. Accepted page-unit policy and licences are unchanged. + | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi` -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `raster::tests::text_rendering_modes_other_than_invisible_draw_type3_glyphs_as_filled` -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range` | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours` +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable` | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable` | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `raster::tests::text_rendering_modes_other_than_invisible_draw_type3_glyphs_as_filled`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable` | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable` | -| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes` | -| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes` | +| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group with its background painted inside it as the initial backdrop, so a Multiply highlight blends with a dark reading-colours background instead of staying an opaque block (identity on white pages) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable` | +| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group with its background painted inside it as the initial backdrop, so a Multiply highlight blends with a dark reading-colours background instead of staying an opaque block (identity on white pages) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable` | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate` | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding` | -| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it. | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed` +| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height` +| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it. | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed` | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it` | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references` | -| winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests.; (11) on X11 without `Xft.dpi`, the scale factor comes from the X screen's configured DPI (its size in pixels and millimetres) before the output's physical density from RandR: Xorg can deliberately run a dense panel at 96 DPI, and only winit applications then looked zoomed; `Xft.dpi` still wins, `WINIT_X11_SCALE_FACTOR` still overrides, and a screen reporting no size keeps the RandR fallback. PdfCraft patch (#1150, replacing the launcher pin proposed in #908). The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean; (11) `pdfcraft` `tests/winit_x11_dpi.rs` keeps the patch in the build, and `util::randr` tests the policy (run them in the vendored crate); the scale itself needs an X11 session | +| winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests.; (11) on X11 without `Xft.dpi`, the scale factor comes from the X screen's configured DPI (its size in pixels and millimetres) before the output's physical density from RandR: Xorg can deliberately run a dense panel at 96 DPI, and only winit applications then looked zoomed; `Xft.dpi` still wins, `WINIT_X11_SCALE_FACTOR` still overrides, and a screen reporting no size keeps the RandR fallback. PdfCraft patch (#1150, replacing the launcher pin proposed in #908). (12) on macOS, `WinitView` answers `mouseDownCanMoveWindow` with the window's `movable_by_window_background` attribute (NO unless asked): NSView answers YES for a view that isn't opaque, so with the transparent title bar and full-size content view PdfCraft uses, AppKit moved the window for any drag that began in the 28 pt title-bar band before the app saw it, and document tabs (which live in that band) could not be dragged to reorder them. The empty strip still moves the window through `ViewportCommand::StartDrag`. PdfCraft patch. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build; (13) on Wayland, pixel-only wheel, wheel-tilt and continuous-source scroll events report `Moved` rather than starting an unterminated finger gesture, so egui follows modifier release instead of keeping Ctrl zoom latched (#991). Finger gestures and unknown sources retain their existing phase policy | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean; (11) `pdfcraft` `tests/winit_x11_dpi.rs` keeps the patch in the build, and `util::randr` tests the policy (run them in the vendored crate); the scale itself needs an X11 session; (12) `pdfcraft` `tests/winit_macos_title_bar_drag.rs` keeps the patch in the build; the drag itself needs macOS and a pointer; (13) `pdfcraft` `tests/winit_wayland_scroll.rs` compiles the production phase policy and exercises modifier release through egui; live COSMIC confirmation remains open | | egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::egui_wgpu_carries_the_surface_size_fit`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::egui_wgpu_returns_a_surface_it_cannot_configure_as_an_error`, which keeps it around `add_surface`'s configure | +| vello_common | 0.0.8 | Apache-2.0 OR MIT | (1) `clip::intersect` (the clip step every clipped path passes through) starts its row walk at the first row both paths have strips in, not the earlier of the two first rows. The walk visited every row from the top of the clip for each path (on the A1 sample's full-page canvas, about half its strip rows per stroke), and rows where one path has no strips produce no output, so the strips are unchanged (vello_common 0.1 starts at the later row too). Its cost grew with the page, and a full-page render of the A1 sample was about 30% faster; the output is byte-identical | (1) `clip::tests::intersect_walks_only_rows_both_paths_have_strips` counts the rows the walk visits (`cargo test --manifest-path vendor/vello_common/Cargo.toml`) and fails without the patch; the render crate's pixel tests run through the patched crate | Renderer startup errors: `egui-wgpu::RenderState::create` also wraps `Renderer::new` in `winit::catch_errors` when the winit feature is enabled. A rejected shader or render pipeline diff --git a/vendor/hayro-interpret/src/device.rs b/vendor/hayro-interpret/src/device.rs index 72d5a9809..36c64a23f 100644 --- a/vendor/hayro-interpret/src/device.rs +++ b/vendor/hayro-interpret/src/device.rs @@ -66,6 +66,13 @@ pub trait Device<'a> { fn actual_text(&mut self, _text: &[u8]) {} /// Called at the end of a marked content sequence (EMC). fn end_marked_content(&mut self) {} + /// PdfCraft patch (29): the page's annotations are about to be drawn (until + /// `end_annotations`), as page content finishes. Devices can use this to treat their + /// paints as annotation colours rather than document colours — the reading-colours + /// setting replaces document colours only, never the colours a comment was given. + fn begin_annotations(&mut self) {} + /// PdfCraft patch (29): the page's annotations are done; what follows is page content. + fn end_annotations(&mut self) {} } /// A device that discards all drawing operations. diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index 931b3388c..fe08fc284 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -175,6 +175,9 @@ pub fn interpret_page<'a>( if context.settings.render_annotations && let Some(annot_arr) = page.raw().get::>(ANNOTS) { + // PdfCraft patch (29): bracket the annotations so devices can tell their paints from + // the document's own (the reading-colours setting recolours the document, not comments). + device.begin_annotations(); for annot in annot_arr.iter::>() { let flags = annot.get::(F).unwrap_or(0); @@ -275,6 +278,7 @@ pub fn interpret_page<'a>( draw_highlight_without_appearance(&annot, context, device); } } + device.end_annotations(); } } diff --git a/vendor/hayro-interpret/src/x_object.rs b/vendor/hayro-interpret/src/x_object.rs index 68d97d555..1138b658f 100644 --- a/vendor/hayro-interpret/src/x_object.rs +++ b/vendor/hayro-interpret/src/x_object.rs @@ -60,6 +60,9 @@ pub(crate) struct FormXObject<'a> { pub(crate) matrix: Affine, pub(crate) bbox: [f32; 4], is_transparency_group: bool, + // PdfCraft patch: the group's /I and /K (ISO 32000-2 §11.6.6, both false by default). + is_isolated: bool, + is_knockout: bool, pub(crate) dict: Dict<'a>, resources: Dict<'a>, } @@ -77,12 +80,17 @@ impl<'a> FormXObject<'a> { .unwrap_or([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]), ); let bbox = dict.get::<[f32; 4]>(BBOX)?; - let is_transparency_group = dict.get::>(GROUP).is_some(); + let group = dict.get::>(GROUP); + let is_transparency_group = group.is_some(); + let is_isolated = group.as_ref().and_then(|g| g.get::(I)).unwrap_or(false); + let is_knockout = group.as_ref().and_then(|g| g.get::(K)).unwrap_or(false); Some(Self { decoded, matrix, is_transparency_group, + is_isolated, + is_knockout, bbox, dict: dict.clone(), resources, @@ -134,7 +142,26 @@ pub(crate) fn draw_form_xobject<'a, 'b>( context.pre_concat_affine(x_object.matrix); context.push_root_transform(); - if x_object.is_transparency_group { + // PdfCraft patch: a transparency group that is neither isolated nor knockout composites + // its elements with the backdrop already on the page (§11.4.7), so when it is entered at + // full opacity with no soft mask and Normal blending there is nothing for a layer of its + // own to add, and drawing straight onto the page is the exact result. A layer here was + // wrong, not just slower: a blend mode inside the group (the `/BM /Multiply` of a highlight + // appearance written by other editors) saw the layer's transparent backdrop instead of the + // text, and the layer then covered the page as an opaque block (#1968). An isolated group + // starts from a blank backdrop by definition, and a knockout group or one entered at partial + // opacity, under a soft mask or with a blend mode keeps its layer, where the group's + // elements still composite with a blank backdrop (an approximation of §11.4.7 for them). + let neutral = !x_object.is_isolated && !x_object.is_knockout && { + let state = &context.get().graphics_state; + state.non_stroke_alpha >= 1.0 + && state.stroke_alpha >= 1.0 + && state.soft_mask.is_none() + && state.blend_mode == BlendMode::Normal + }; + let own_layer = x_object.is_transparency_group && !neutral; + + if own_layer { device.push_transparency_group( context.get().graphics_state.non_stroke_alpha, std::mem::take(&mut context.get_mut().graphics_state.soft_mask), @@ -169,7 +196,7 @@ pub(crate) fn draw_form_xobject<'a, 'b>( device.pop_clip_path(); - if x_object.is_transparency_group { + if own_layer { device.pop_transparency_group(); } @@ -667,23 +694,38 @@ fn decode_raster( ctx.bits_per_component, )?; - let mut f32_data = apply_decode_array( + // PdfCraft patch (28): an image with few possible pixel values converts each of them once. + let mut rgb_data = if let Some(rgb) = rgb_via_lookup( &components, - &ctx.color_space, - ctx.bits_per_component, - &ctx.decode_arr, - )?; - - fix_image_length(&mut f32_data, ctx.width, &mut height, 0.0, &ctx.color_space)?; - - let mut rgb_data = get_rgb_data( - &f32_data, ctx.width, height, ctx.scale_factors, &ctx.color_space, + ctx.bits_per_component, + &ctx.decode_arr, obj.interpolate, - ); + ) { + height = rgb.height; + Some(rgb) + } else { + let mut f32_data = apply_decode_array( + &components, + &ctx.color_space, + ctx.bits_per_component, + &ctx.decode_arr, + )?; + + fix_image_length(&mut f32_data, ctx.width, &mut height, 0.0, &ctx.color_space)?; + + get_rgb_data( + &f32_data, + ctx.width, + height, + ctx.scale_factors, + &ctx.color_space, + obj.interpolate, + ) + }; if let Some(transfer_function) = &obj.transfer_function && let Some(rgb_data) = &mut rgb_data @@ -1034,27 +1076,136 @@ fn apply_decode_array( bits_per_component: u8, decode: &[(f32, f32)], ) -> Option> { - let interpolate = |n: f32, d_min: f32, d_max: f32| { - interpolate( - n, - 0.0, - 2.0_f32.powi(bits_per_component as i32) - 1.0, - d_min, - d_max, - ) - }; - let mut decoded_arr = vec![]; for pixel in components.chunks(color_space.num_components() as usize) { - for (component, (d_min, d_max)) in pixel.iter().zip(decode) { - decoded_arr.push(interpolate(*component as f32, *d_min, *d_max)); + for (component, range) in pixel.iter().zip(decode) { + decoded_arr.push(decode_sample(*component, bits_per_component, *range)); } } Some(decoded_arr) } +/// One sample mapped through its `/Decode` range (ISO 32000-2 §8.9.5.2). PdfCraft patch (28): +/// shared by [`apply_decode_array`] and [`rgb_via_lookup`], so both decode alike. +fn decode_sample(sample: u16, bits_per_component: u8, (d_min, d_max): (f32, f32)) -> f32 { + interpolate( + sample as f32, + 0.0, + 2.0_f32.powi(bits_per_component as i32) - 1.0, + d_min, + d_max, + ) +} + +/// PdfCraft patch (28): the most bits a pixel's samples may hold together for +/// [`rgb_via_lookup`] (a table of 65,536 colours). +const MAX_LOOKUP_BITS: usize = 16; + +/// PdfCraft patch (28): the RGB of an image whose pixels hold at most [`MAX_LOOKUP_BITS`] bits +/// of samples, converted once per possible pixel value rather than once per pixel, at the +/// height `fix_image_length` gives. `None` when the table would not pay off (the image has no +/// more pixels than it has entries) or does not apply; the caller then converts every pixel. +/// +/// The colour of a pixel depends only on its own samples, so the result is the per-pixel one, +/// except where a tint transform gives the wrong number of components. The per-pixel batch then +/// shifts the colours after that pixel. The table converts each entry on its own +/// ([`convert_each`]), so a wrong-sized entry changes only itself, and a value the image never +/// uses cannot change the colour of one it does. +/// A Separation or DeviceN tint transform is a PDF function, often a PostScript calculator +/// program: run once per pixel, an 8-bit spot-colour background of 3304 × 4105 took four +/// seconds and 430 MB to draw; looked up from 256 entries it takes milliseconds. +#[allow(clippy::too_many_arguments)] +fn rgb_via_lookup( + components: &[u16], + width: u32, + height: u32, + scale_factors: (f32, f32), + cs: &ColorSpace, + bits_per_component: u8, + decode: &[(f32, f32)], + interpolate: bool, +) -> Option { + let n = cs.num_components() as usize; + let bpc = usize::from(bits_per_component); + let bits = bpc.checked_mul(n)?; + // `apply_decode_array` decodes only the samples `/Decode` has a range for: leave a short + // array to it, and every sample size it doesn't read. + if n == 0 || bits == 0 || bits > MAX_LOOKUP_BITS || bpc > 16 || decode.len() < n { + return None; + } + // The length rules of `fix_image_length`: extra samples are dropped; missing ones shorten + // the image to the rows begun, the last row padded with decoded zeros. + let row_len = usize::try_from(width).ok()?.checked_mul(n)?; + let wanted = row_len.checked_mul(usize::try_from(height).ok()?)?; + let (height, used) = if wanted <= components.len() { + (height, wanted) + } else { + (u32::try_from(components.len().div_ceil(row_len)).ok()?, components.len()) + }; + let pixels = usize::try_from(width).ok()?.checked_mul(usize::try_from(height).ok()?)?; + let total = pixels.checked_mul(n)?; + let entries = 1usize << bits; + if pixels <= entries { + return None; + } + + // Every possible pixel value, its samples in order, decoded as `apply_decode_array` would. + let mask = (1usize << bpc) - 1; + let mut table_input = Vec::with_capacity(entries * n); + for code in 0..entries { + for (k, range) in decode.iter().take(n).enumerate() { + let sample = ((code >> (bpc * (n - 1 - k))) & mask) as u16; + table_input.push(decode_sample(sample, bits_per_component, *range)); + } + } + let mut table = vec![0; entries * 3]; + convert_each(cs, &table_input, n, &mut table); + + let mut data = vec![0; pixels.checked_mul(3)?]; + let whole = used / n; + let samples = components.get(..whole * n)?; + for (pixel, rgb) in samples.chunks_exact(n).zip(data.chunks_exact_mut(3)) { + let code = pixel.iter().fold(0usize, |code, s| (code << bpc) | (usize::from(*s) & mask)); + if let Some(colour) = table.get(code * 3..code * 3 + 3) { + rgb.copy_from_slice(colour); + } + } + // The pixels past the samples present (at most a row): converted as before, padding and all. + if whole < pixels { + let tail: Vec = (whole * n..total) + .map(|i| match (components.get(i), decode.get(i % n)) { + (Some(s), Some(range)) if i < used => decode_sample(*s, bits_per_component, *range), + _ => 0.0, + }) + .collect(); + let _ = cs.convert_f32(&tail, data.get_mut(whole * 3..)?, false); + } + + Some(RgbData { + data, + width, + height, + interpolate, + scale_factors, + }) +} + +/// PdfCraft patch (28): `input`, `n` decoded samples a pixel, converted into `output` (RGB) one +/// pixel at a time. A Separation or DeviceN tint transform may give more or fewer components +/// than its alternate space takes (a Type 4 program's count is only warned about): converted as +/// one batch, such a result shifts every pixel after it; converted alone, it affects only its +/// own. A failed conversion leaves black, as in `get_rgb_data`. +fn convert_each(cs: &ColorSpace, input: &[f32], n: usize, output: &mut [u8]) { + if n == 0 { + return; + } + for (pixel, rgb) in input.chunks_exact(n).zip(output.chunks_exact_mut(3)) { + let _ = cs.convert_f32(pixel, rgb, false); + } +} + /// PdfCraft patch: whether an image or mask of `width` × `height` may be decoded (at most /// 2^28 pixels, the renderer's `MAX_IMAGE_PIXELS`). pub fn image_size_ok(width: u32, height: u32) -> bool { diff --git a/vendor/hayro-syntax/src/filter/ascii_85.rs b/vendor/hayro-syntax/src/filter/ascii_85.rs index 5a18cf835..943bfeb74 100644 --- a/vendor/hayro-syntax/src/filter/ascii_85.rs +++ b/vendor/hayro-syntax/src/filter/ascii_85.rs @@ -4,7 +4,9 @@ use crate::reader::Reader; use crate::trivia::is_white_space_character; use alloc::vec::Vec; -pub(crate) fn decode(data: &[u8]) -> Option> { +/// PdfCraft patch: `limit` is the most bytes produced (see `filter::decode_limit`); each `z` +/// expands one input byte to four. +pub(crate) fn decode(data: &[u8], limit: usize) -> Option> { const POW_85: [u32; 5] = [52200625, 614125, 7225, 85, 1]; let mut reader = Reader::new(data); @@ -50,15 +52,21 @@ pub(crate) fn decode(data: &[u8]) -> Option> { .checked_add(digits[3].checked_mul(POW_85[3])?)? .checked_add(digits[4])?; - decoded.extend_from_slice(&value.to_be_bytes()[..output_len]); + let left = limit.saturating_sub(decoded.len()); + decoded.extend_from_slice(&value.to_be_bytes()[..output_len.min(left)]); group.clear(); Some(()) }; - let mut decoded = Vec::with_capacity(data.len() * 4 / 5); + let mut decoded = Vec::with_capacity((data.len() / 5 * 4).min(limit)); let mut group = Vec::with_capacity(5); loop { + // PdfCraft patch: output cut at the limit is reported, never dropped silently. + if decoded.len() >= limit { + warn!("ASCII85 stream stopped at its decode limit of {limit} bytes"); + return Some(decoded); + } let Some(b) = read_byte() else { // Be lenient and accept what we have (see PDFBOX-5910). flush_group(&mut group, &mut decoded)?; @@ -76,7 +84,8 @@ pub(crate) fn decode(data: &[u8]) -> Option> { } b'z' => { flush_group(&mut group, &mut decoded)?; - decoded.extend_from_slice(&[0, 0, 0, 0]); + let left = limit.saturating_sub(decoded.len()); + decoded.extend(core::iter::repeat_n(0, left.min(4))); } b'~' => { // Technically requires a '>', but there is a PDF where it isn't diff --git a/vendor/hayro-syntax/src/filter/ccitt.rs b/vendor/hayro-syntax/src/filter/ccitt.rs index 98924f2db..687d08714 100644 --- a/vendor/hayro-syntax/src/filter/ccitt.rs +++ b/vendor/hayro-syntax/src/filter/ccitt.rs @@ -15,7 +15,12 @@ pub(crate) fn decode( ) -> Option> { let k = params.get::(K).unwrap_or(0); - let rows = params.get::(ROWS).unwrap_or(image_params.height); + // PdfCraft patch: `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11, and + // the default), not zero rows. Scanners write it on Group 4 pages, which rendered blank. + let rows = params + .get::(ROWS) + .filter(|&rows| rows != 0) + .unwrap_or(image_params.height); let end_of_block = params.get::(END_OF_BLOCK).unwrap_or(true); let settings = DecodeSettings { diff --git a/vendor/hayro-syntax/src/filter/mod.rs b/vendor/hayro-syntax/src/filter/mod.rs index 22bebf6d3..06bda2948 100644 --- a/vendor/hayro-syntax/src/filter/mod.rs +++ b/vendor/hayro-syntax/src/filter/mod.rs @@ -95,7 +95,7 @@ impl Filter { Self::AsciiHexDecode => ascii_hex::decode(data) .map(FilterResult::from_data) .ok_or(DecodeFailure::StreamDecode), - Self::Ascii85Decode => ascii_85::decode(data) + Self::Ascii85Decode => ascii_85::decode(data, limit) .map(FilterResult::from_data) .ok_or(DecodeFailure::StreamDecode), Self::RunLengthDecode => run_length::decode(data, limit) diff --git a/vendor/hayro-syntax/src/page.rs b/vendor/hayro-syntax/src/page.rs index 7d63931a3..d7f0ec953 100644 --- a/vendor/hayro-syntax/src/page.rs +++ b/vendor/hayro-syntax/src/page.rs @@ -558,9 +558,17 @@ impl<'a> Page<'a> { { (A4.width() as f32, A4.height() as f32) } else { + // PdfCraft patch: retain a one-point floor after UserUnit, not one raw unit. + // The stored unit is finite and within 1..=75000. Round upward only if the + // reciprocal's f32 product would fall below one and trigger a placeholder. + let mut raw_floor = 1.0 / self.user_unit; + if raw_floor * self.user_unit < 1.0 { + raw_floor = raw_floor.next_up(); + } + let raw_floor = f64::from(raw_floor); ( - crop_box.width().max(1.0) as f32, - crop_box.height().max(1.0) as f32, + crop_box.width().max(raw_floor) as f32, + crop_box.height().max(raw_floor) as f32, ) } } diff --git a/vendor/hayro/src/lib.rs b/vendor/hayro/src/lib.rs index def102b42..43381ab67 100644 --- a/vendor/hayro/src/lib.rs +++ b/vendor/hayro/src/lib.rs @@ -254,6 +254,16 @@ pub fn render_into<'a>( interpreter_settings.preview, ); + // PdfCraft patch (18): the page renders as one transparency group with the background + // painted *inside* it, as the initial backdrop. A group isolates what it wraps from what + // was painted before it, so with the background outside, a Multiply highlight blended + // only with the group's transparent backdrop and stayed its own colour — over a dark + // reading-colours background the whole quad became one flat bar and the words under it + // vanished. Inside the group the highlight multiplies with the background too: the words + // keep the highlight's colour and the band between them takes the background (white + // backgrounds are unaffected — multiplying with white is the identity). + device.push_transparency_group(1.0, None, BlendMode::Normal); + device.ctx.set_paint(render_settings.bg_color); device .ctx @@ -265,12 +275,10 @@ pub fn render_into<'a>( fill: FillRule::NonZero, }); - device.push_transparency_group(1.0, None, BlendMode::Normal); interpret_page(page, &mut state, &mut device); - device.pop_transparency_group(); - device.pop_clip_path(); + device.pop_transparency_group(); let mut resources = vello_cpu::Resources::default(); device.ctx.render_to_buffer( diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index 23fc2e36c..0619b4c6d 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -432,6 +432,9 @@ pub(crate) struct Renderer { /// PdfCraft patch: when set, solid text and line-art paint colours are remapped /// while rendering (the "reading colours" view setting). color_replacement: Option, + /// PdfCraft patch (17): while the page's annotations are being drawn, paints keep their + /// colours — reading colours replace document colours, never annotation colours. + in_annotations: bool, pub(crate) inside_pattern: bool, /// Soft masks drawn during this render. PdfCraft note: it belongs to one `Renderer` (one /// render, every nested renderer starting empty) and so to one `preview` setting: a mask @@ -466,6 +469,7 @@ impl Renderer { Self { ctx: RenderContext::new_with(width, height, settings), color_replacement, + in_annotations: false, inside_pattern: false, soft_mask_cache: HashMap::default(), outline_cache: cache.outline_cache.clone(), @@ -573,6 +577,7 @@ impl Renderer { // PdfCraft patch: this renders an image's alpha mask, which must keep its // original values, so it is never remapped by the reading-colours setting. color_replacement: None, + in_annotations: false, inside_pattern: false, soft_mask_cache: HashMap::default(), outline_cache: self.outline_cache.clone(), @@ -1023,6 +1028,11 @@ impl Renderer { let Some(replacement) = self.color_replacement else { return paint.clone(); }; + // PdfCraft patch (17): annotations keep the colours they were given; reading colours + // replace document colours only (issue #1548). + if self.in_annotations { + return paint.clone(); + } match paint { Paint::Color(c) => { let mapped = replacement.map(c.to_rgba().to_rgba8(), is_text); @@ -1075,8 +1085,13 @@ impl Renderer { // become native gradients instead of a texture sampled pixel by pixel. // Upstream also shifts by half a pixel for vello_cpu 0.3; 0.0.8 already // evaluates gradients at pixel centres, like the texture below. - if let Some(native) = - encoded.as_svg_gradient(bbox, NATIVE_GRADIENT_TOLERANCE) + // PdfCraft patch (#2128): vello_cpu 0.0.8 copies an opaque gradient + // over fully covered tiles without applying the mask or blend mode, and + // a gradient can't be flagged transparent like the texture below, so + // under a soft mask or blend mode the shading is sampled instead. + if !self.force_images_may_have_transparency() + && let Some(native) = + encoded.as_svg_gradient(bbox, NATIVE_GRADIENT_TOLERANCE) { self.ctx .set_paint_transform(path_transform.inverse() * native.transform); @@ -1149,6 +1164,7 @@ impl Renderer { // PdfCraft patch: a pattern cell is painted as one artwork, so its // content is never remapped by the reading-colours setting. color_replacement: None, + in_annotations: false, cur_mask: None, inside_pattern: true, soft_mask_cache: HashMap::default(), @@ -1464,6 +1480,7 @@ impl<'a> Device<'a> for Renderer { // coverage values, so its content is never remapped by // the reading-colours setting. color_replacement: None, + in_annotations: false, inside_pattern: false, soft_mask_cache: HashMap::default(), outline_cache: self.outline_cache.clone(), @@ -1608,6 +1625,16 @@ impl<'a> Device<'a> for Renderer { } } + /// PdfCraft patch (17): the reading-colours mapping is suspended for annotations. + fn begin_annotations(&mut self) { + self.in_annotations = true; + } + + /// PdfCraft patch (17): back to document content; reading colours apply again. + fn end_annotations(&mut self) { + self.in_annotations = false; + } + fn draw_rect( &mut self, rect: &Rect, @@ -1749,6 +1776,7 @@ fn draw_soft_mask(mask: &SoftMask<'_>, settings: RenderSettings, width: u16, hei // PdfCraft patch: a soft mask must keep its original luminance values, so its // content is never remapped by the reading-colours setting. color_replacement: None, + in_annotations: false, inside_pattern: false, cur_mask: None, soft_mask_cache: HashMap::default(), diff --git a/vendor/vello_common/CHANGELOG.md b/vendor/vello_common/CHANGELOG.md new file mode 100644 index 000000000..912b7648a --- /dev/null +++ b/vendor/vello_common/CHANGELOG.md @@ -0,0 +1,246 @@ + + +# Changelog + +## [Unreleased] + +This release has an [MSRV][] of 1.88. + +## [0.0.8][] - 2026-05-15 + +This release has an [MSRV][] of 1.88. + +### Added + +- `TextureId`, a handle for user-provided textures resolved at render-time, and `EncodedExternalTexture` for encoding draws that sample from them. ([#1552][] by [@tomcur][]) +- `RectU16`, a shared axis-aligned rectangle type for `u16` coordinate bounding boxes. ([#1559][] by [@tomcur][]) + +### Changed + +- `WideTilesBbox` now uses `RectU16` internally, its `bbox` field is private, and `WideTilesBbox::new` now takes four `u16` coordinates instead of a `[u16; 4]`. ([#1559][] by [@tomcur][]) +- Renamed image transparency hint APIs from `may_have_opacities` and `*_opacity_hint` to `may_have_transparency` and `*_transparency_hint`. ([#1613][] by [@upsuper][]) +- Gradient LUTs no longer reserve an extra transparent entry for undefined radial-gradient regions; `EncodedGradient` now tracks whether those regions are possible. ([#1529][] by [@LaurenzV][]) + +### Removed + +- Public `glyph` and `colr` modules and the `text` feature; sparse-strips text rendering now lives in `glifo` and the renderer crates. ([#1562][] by [@LaurenzV][]) +- Support for recordings, including the shared recording API. + This decision was made due to a number of downsides that came with the implementation. + See the corresponding PR and Zulip thread for more information. ([#1611][] by [@LaurenzV][]) + +### Fixed + +- Handling of zero-area rectangles in the rectangle fast path, avoiding out-of-viewport strips and downstream assertions. ([#1537][] by [@LaurenzV][]) +- Invalid gradients with NaN or out-of-range final stop offsets now fall back to the first color instead of passing validation. ([#1531][] by [@LaurenzV][]) +- Implicit subpaths that continue after `ClosePath` without an explicit `MoveTo`. ([#1544][] by [@tomcur][]) +- Negative-sized rectangles during strip generation. ([#1589][] by [@LaurenzV][]) + +### Optimized + +- Rendering performance for paths with line segments fully or partly left of the viewport by culling invisible left-of-viewport line work while preserving winding contributions. ([#1368][] by [@b0nes164][]) +- Performance of clipped drawing by culling geometry outside the clip bounding box during flattening. ([#1519][] by [@tomcur][]) +- Flattening and tiling performance through codegen improvements in the sparse strip pipeline. ([#1600][], [#1616][], [#1634][] by [@LaurenzV][]) + +## [0.0.7][] - 2026-03-24 + +This release has an [MSRV][] of 1.92. + +### Added + +- Support for image tinting. ([#1460][] by [@grebmeg][]) +- `RenderState` abstraction. ([#1486][] by [@LaurenzV][]) +- Helpers for rendering filters. ([#1446][] by [@LaurenzV][]) +- `ImageCache` and `MultiAtlasManager`. ([#1457][] by [@grebmeg][]) + +### Changed + +- Replaced custom blend mode representation with peniko `Mix` for filter effects. ([#1410][] by [@LaurenzV][]) + +### Removed + +- Process Row Incremental Macro and Perfect Bit. ([#1384][] by [@b0nes164][]) +- `tolerance` parameter from the flattening method. ([#1399][] by [@LaurenzV][]) +- Command annotation logic. ([#1438][] by [@LaurenzV][]) + +### Fixed + +- Double-scaling of non-hinted COLR glyphs. ([#1370][] by [@conor-93][]) +- Handling of `pop_clip` commands and properly resetting wide tiles. ([#1443][] by [@LaurenzV][], [#1447][] by [@tomcur][]) +- Layer ranges not being updated when a wide tile is reset with a background color. ([#1478][] by [@LaurenzV][]) + +### Optimized + +- Bézier flattening performance by catching more Béziers whose chords are immediately within rendering tolerance. ([#1216][] by [@tomcur][]) +- Flattening and tiling performance by culling out-of-viewport Béziers before flattening. ([#1341][] by [@tomcur][]) +- Rendering performance of scenes including blend layers by ensuring no commands are generated for wide tiles without layer content. ([#1403][] by [@tomcur][]) +- Scenes including blend layers by only allocating layer scratch buffers for a wide tile upon draw commands being performed in that wide tile. ([#1414][] by [@tomcur][]) +- Analytic anti-aliasing. ([#1426][], [#1442][] by [@tomcur][]) +- Filter application to layers. ([#1444][] by [@LaurenzV][]) +- Rendering pixel-aligned rectangles. ([#1453][] by [@grebmeg][]) +- Coarse rasterization and scheduling of scenes without layers. ([#1454][] by [@LaurenzV][]) +- Resetting wide tiles. ([#1484][] by [@LaurenzV][]) + +## [0.0.6][] - 2026-01-15 + +This release has an [MSRV][] of 1.88. + +### Changed + +- Breaking change: Updated Peniko to [v0.6.0](https://github.com/linebender/peniko/releases/tag/v0.6.0). ([#1349][] by [@DJMcNab][]) + - This also updates Kurbo to [v0.13.0](https://github.com/linebender/kurbo/releases/tag/v0.13.0). +- Upgraded Skrifa to v0.40.0. ([#1353][] by [@waywardmonkeys][]) +- Upgraded Hashbrown to v0.16.1. ([#1354][] by [@waywardmonkeys][]) + +### Optimized + +- Alpha blending for fully opaque image fills. ([#1329][] by [@grebmeg][]) + +See also the [vello_hybrid 0.0.6](../vello_hybrid/CHANGELOG.md#006---2026-01-15) and [vello_cpu 0.0.6](../vello_cpu/CHANGELOG.md#006---2026-01-15) releases. + +## [0.0.5][] - 2026-01-08 + +This release has an [MSRV][] of 1.88. + +### Added + +- A new module `clip` allowing for the possibility to intersect two strips to create a new strip representing their intersection. ([#1203][] by [@LaurenzV]) +- An `extend` method to `StripStorage` to extend its alphas/strips from another `StripStorage`. ([#1203][] by [@LaurenzV]) +- A new trait for approximate integer division by 255. ([#1203][] by [@LaurenzV]) +- A `from_parts` method for masks. ([#1237][] by [@LaurenzV]) +- Initial support for image filters. ([#1286][] by [@grebmeg]) + +### Changed + +- `WideTile::generate` now takes an additional `BlendMode` as a parameter. ([#1159][] by [@LaurenzV]) +- `CmdFill` and `CmdAlphaFill` now store a `BlendMode` instead of `Option`. ([#1159][] by [@LaurenzV]) +- `Strip` now implements `PartialEq` and `Eq`. ([#1203][] by [@LaurenzV]) +- `Strip` now has a `is_sentinel` method. ([#1203][] by [@LaurenzV]) +- `StripStorage` now implements `PartialEq` and `Eq`. ([#1203][] by [@LaurenzV]) +- The `generate_filled_path` method of `StripGenerator` now takes an optional clip path as input. ([#1203][] by [@LaurenzV]) +- The `generate` method of `Wide` now takes an optional mask as an additional argument. ([#1237][] by [@LaurenzV]) +- `CmdFill` and `CmdAlphaFill` now store an optional mask. ([#1237][] by [@LaurenzV]) +- Various changes to the logic for computing tile intersections and representation of tiles. ([#1293][], [#1317][], [#1318][] by [@b0nes164]) +- Support for computing data necessary to implement multi-sampled anti-aliasing. ([#1319][], by [@b0nes164]) + +### Optimized + +- Gradient rendering. ([#1301][] by [@valadaptive]) +- Numerous performance and memory-efficiency improvements. ([#1325][] by [@LaurenzV], [#1327][] by [@grebmeg], [#1336][] by [@tomcur], [#1338][] by [@taj-p]) + +See also the [vello_hybrid 0.0.5](../vello_hybrid/CHANGELOG.md#005---2026-01-08) and [vello_cpu 0.0.5](../vello_cpu/CHANGELOG.md#005---2026-01-08) releases. + +## [0.0.4][] - 2025-10-17 + +This release has an [MSRV][] of 1.86. + +No changelog was kept for this release. + +See also the [vello_hybrid 0.0.4](../vello_hybrid/CHANGELOG.md#004---2025-10-17) and [vello_cpu 0.0.4](../vello_cpu/CHANGELOG.md#004---2025-10-17) releases. + +## [0.0.3][] - 2025-10-04 + +This release has an [MSRV][] of 1.86. + +No changelog was kept for this release. + +See also the [vello_cpu 0.0.3](../vello_cpu/CHANGELOG.md#003---2025-10-04) release. + +## [0.0.2][] - 2025-09-22 + +This release has an [MSRV][] of 1.85. + +No changelog was kept for this release. + +See also the [vello_cpu 0.0.2](../vello_cpu/CHANGELOG.md#002---2025-09-22) release. + +## [0.0.1][] - 2025-05-10 + +This release has an [MSRV][] of 1.85. + +This is the initial release. No changelog was kept for this release. + +See also the [vello_cpu 0.0.1](../vello_cpu/CHANGELOG.md#001---2025-05-10) release. + +[@b0nes164]: https://github.com/b0nes164 +[@conor-93]: https://github.com/conor-93 +[@DJMcNab]: https://github.com/waywardmonkeys +[@grebmeg]: https://github.com/grebmeg +[@LaurenzV]: https://github.com/LaurenzV +[@taj-p]: https://github.com/taj-p +[@tomcur]: https://github.com/tomcur +[@upsuper]: https://github.com/upsuper +[@valadaptive]: https://github.com/valadaptive +[@waywardmonkeys]: https://github.com/waywardmonkeys + +[#1159]: https://github.com/linebender/vello/pull/1159 +[#1203]: https://github.com/linebender/vello/pull/1203 +[#1216]: https://github.com/linebender/vello/pull/1216 +[#1237]: https://github.com/linebender/vello/pull/1237 +[#1286]: https://github.com/linebender/vello/pull/1286 +[#1293]: https://github.com/linebender/vello/pull/1293 +[#1301]: https://github.com/linebender/vello/pull/1301 +[#1317]: https://github.com/linebender/vello/pull/1317 +[#1318]: https://github.com/linebender/vello/pull/1318 +[#1319]: https://github.com/linebender/vello/pull/1319 +[#1325]: https://github.com/linebender/vello/pull/1325 +[#1327]: https://github.com/linebender/vello/pull/1327 +[#1329]: https://github.com/linebender/vello/pull/1329 +[#1336]: https://github.com/linebender/vello/pull/1327 +[#1338]: https://github.com/linebender/vello/pull/1327 +[#1341]: https://github.com/linebender/vello/pull/1341 +[#1349]: https://github.com/linebender/vello/pull/1349 +[#1353]: https://github.com/linebender/vello/pull/1353 +[#1354]: https://github.com/linebender/vello/pull/1354 +[#1368]: https://github.com/linebender/vello/pull/1368 +[#1399]: https://github.com/linebender/vello/pull/1399 +[#1403]: https://github.com/linebender/vello/pull/1403 +[#1414]: https://github.com/linebender/vello/pull/1414 +[#1370]: https://github.com/linebender/vello/pull/1370 +[#1384]: https://github.com/linebender/vello/pull/1384 +[#1410]: https://github.com/linebender/vello/pull/1410 +[#1426]: https://github.com/linebender/vello/pull/1426 +[#1438]: https://github.com/linebender/vello/pull/1438 +[#1442]: https://github.com/linebender/vello/pull/1442 +[#1443]: https://github.com/linebender/vello/pull/1443 +[#1444]: https://github.com/linebender/vello/pull/1444 +[#1446]: https://github.com/linebender/vello/pull/1446 +[#1447]: https://github.com/linebender/vello/pull/1447 +[#1453]: https://github.com/linebender/vello/pull/1453 +[#1454]: https://github.com/linebender/vello/pull/1454 +[#1457]: https://github.com/linebender/vello/pull/1457 +[#1460]: https://github.com/linebender/vello/pull/1460 +[#1478]: https://github.com/linebender/vello/pull/1478 +[#1484]: https://github.com/linebender/vello/pull/1484 +[#1486]: https://github.com/linebender/vello/pull/1486 +[#1519]: https://github.com/linebender/vello/pull/1519 +[#1529]: https://github.com/linebender/vello/pull/1529 +[#1531]: https://github.com/linebender/vello/pull/1531 +[#1537]: https://github.com/linebender/vello/pull/1537 +[#1544]: https://github.com/linebender/vello/pull/1544 +[#1552]: https://github.com/linebender/vello/pull/1552 +[#1559]: https://github.com/linebender/vello/pull/1559 +[#1562]: https://github.com/linebender/vello/pull/1562 +[#1589]: https://github.com/linebender/vello/pull/1589 +[#1600]: https://github.com/linebender/vello/pull/1600 +[#1611]: https://github.com/linebender/vello/pull/1611 +[#1613]: https://github.com/linebender/vello/pull/1613 +[#1616]: https://github.com/linebender/vello/pull/1616 +[#1634]: https://github.com/linebender/vello/pull/1634 + +[Unreleased]: https://github.com/linebender/vello/compare/sparse-strips-v0.0.8...HEAD +[0.0.8]: https://github.com/linebender/vello/compare/sparse-strips-v0.0.7...sparse-strips-v0.0.8 +[0.0.7]: https://github.com/linebender/vello/compare/sparse-strips-v0.0.6...sparse-strips-v0.0.7 +[0.0.6]: https://github.com/linebender/vello/compare/sparse-strips-v0.0.5...sparse-strips-v0.0.6 +[0.0.5]: https://github.com/linebender/vello/compare/sparse-stips-v0.0.4...sparse-strips-v0.0.5 +[0.0.4]: https://github.com/linebender/vello/compare/sparse-stips-v0.0.3...sparse-strips-v0.0.4 +[0.0.3]: https://github.com/linebender/vello/compare/sparse-stips-v0.0.2...sparse-strips-v0.0.3 +[0.0.2]: https://github.com/linebender/vello/compare/sparse-strips-v0.0.1...sparse-stips-v0.0.2 +[0.0.1]: https://github.com/linebender/vello/compare/ca6b1e4c7f5b0d95953c3b524f5d3952d5669c5a...sparse-strips-v0.0.1 + +[MSRV]: README.md#minimum-supported-rust-version-msrv diff --git a/vendor/vello_common/Cargo.lock b/vendor/vello_common/Cargo.lock new file mode 100644 index 000000000..72d0743ff --- /dev/null +++ b/vendor/vello_common/Cargo.lock @@ -0,0 +1,310 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", + "libm", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fearless_simd" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76258897e51fd156ee03b6246ea53f3e0eb395d0b327e9961c4fc4c8b2fa151a" +dependencies = [ + "libm", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "libm", + "polycool", + "smallvec", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", + "libm", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "vello_common" +version = "0.0.8" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "hashbrown", + "libm", + "log", + "peniko", + "png", + "roxmltree", + "smallvec", + "thiserror", +] diff --git a/vendor/vello_common/Cargo.toml b/vendor/vello_common/Cargo.toml new file mode 100644 index 000000000..988ffb939 --- /dev/null +++ b/vendor/vello_common/Cargo.toml @@ -0,0 +1,158 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +rust-version = "1.88" +name = "vello_common" +version = "0.0.8" +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Core data structures and utilities shared across the Vello rendering, including geometry processing and tiling logic." +readme = "README.md" +keywords = [ + "2d", + "vector-graphics", +] +categories = [ + "rendering", + "graphics", +] +license = "Apache-2.0 OR MIT" +repository = "https://github.com/linebender/vello" +resolver = "2" + +[package.metadata.docs.rs] +all-features = true +default-target = "x86_64-unknown-linux-gnu" +targets = [] + +[features] +default = [ + "std", + "png", +] +libm = [ + "peniko/libm", + "dep:libm", + "fearless_simd/libm", +] +multithreading = ["std"] +pico_svg = ["dep:roxmltree"] +png = [ + "std", + "dep:png", +] +probe = [] +std = [ + "peniko/std", + "fearless_simd/std", +] + +[lib] +name = "vello_common" +path = "src/lib.rs" + +[dependencies.bytemuck] +version = "1.25.0" +features = [ + "derive", + "derive", +] + +[dependencies.fearless_simd] +version = "0.4.0" +default-features = false + +[dependencies.guillotiere] +version = "0.7.0" +default-features = false + +[dependencies.hashbrown] +version = "0.17.1" +features = [ + "default-hasher", + "raw-entry", +] +default-features = false + +[dependencies.libm] +version = "0.2.16" +optional = true + +[dependencies.log] +version = "0.4.29" + +[dependencies.peniko] +version = "0.6.1" +features = ["bytemuck"] +default-features = false + +[dependencies.png] +version = "0.18.1" +optional = true + +[dependencies.roxmltree] +version = "0.20.0" +optional = true + +[dependencies.smallvec] +version = "1.15.1" + +[dependencies.thiserror] +version = "2.0.18" +default-features = false + +[lints.clippy] +allow_attributes_without_reason = "warn" +cargo_common_metadata = "warn" +cast_possible_truncation = "warn" +collection_is_never_read = "warn" +dbg_macro = "warn" +debug_assert_with_mut_call = "warn" +default_trait_access = "warn" +doc_markdown = "warn" +fn_to_numeric_cast_any = "warn" +infinite_loop = "warn" +large_stack_arrays = "warn" +mismatching_type_param_order = "warn" +missing_assert_message = "warn" +missing_fields_in_debug = "warn" +negative_feature_names = "warn" +redundant_feature_names = "warn" +same_functions_in_if_condition = "warn" +semicolon_if_nothing_returned = "warn" +should_panic_without_expect = "warn" +todo = "warn" +too_many_arguments = "allow" +unseparated_literal_suffix = "warn" +use_self = "warn" +wildcard_dependencies = "warn" + +[lints.rust] +elided_lifetimes_in_paths = "warn" +keyword_idents_2024 = "forbid" +missing_debug_implementations = "warn" +missing_docs = "warn" +non_ascii_idents = "forbid" +non_local_definitions = "forbid" +trivial_numeric_casts = "warn" +unnameable_types = "warn" +unreachable_pub = "warn" +unsafe_op_in_unsafe_fn = "forbid" +unused_import_braces = "warn" +unused_lifetimes = "warn" +unused_macro_rules = "warn" +unused_qualifications = "warn" diff --git a/vendor/vello_common/Cargo.toml.orig b/vendor/vello_common/Cargo.toml.orig new file mode 100644 index 000000000..fa356c16d --- /dev/null +++ b/vendor/vello_common/Cargo.toml.orig @@ -0,0 +1,53 @@ +[package] +name = "vello_common" +# When updating, also update the version in the workspace dependency in the root Cargo.toml +version = "0.0.8" +description = "Core data structures and utilities shared across the Vello rendering, including geometry processing and tiling logic." +categories = ["rendering", "graphics"] +keywords = ["2d", "vector-graphics"] +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[package.metadata.docs.rs] +all-features = true +# There are no platform specific docs. +default-target = "x86_64-unknown-linux-gnu" +targets = [] + +[dependencies] +bytemuck = { workspace = true, features = ["derive"] } +peniko = { workspace = true, features = ["bytemuck"] } +fearless_simd = { workspace = true } +hashbrown = { workspace = true, features = ["raw-entry"] } +png = { workspace = true, optional = true } +roxmltree = { version = "0.20.0", optional = true } +smallvec = { workspace = true } +thiserror = { workspace = true, default-features = false } +guillotiere = { workspace = true } +libm = { version = "0.2.16", optional = true } +log = { workspace = true } + +[features] +# If adding new features, also document in `src/lib.rs` +default = ["std", "png"] +# Get floating point functions from the standard library (likely using your target's libc). +std = ["peniko/std", "fearless_simd/std"] +# Use floating point implementations from libm. +libm = ["peniko/libm", "dep:libm", "fearless_simd/libm"] +# Allow loading Pixmap from PNG, and drawing png glyphs. +png = ["std", "dep:png"] +# Add helpers for drawing a probe scene. +probe = [] +# Enable multi-threaded rendering. +multithreading = ["std"] +# Development only features + +# Enable a simple version of SVG drawing. +# This is only intended for development of Vello CPU itself, +# and is likely to be removed/moved. +pico_svg = ["dep:roxmltree"] + +[lints] +workspace = true diff --git a/vendor/vello_common/LICENSE-APACHE b/vendor/vello_common/LICENSE-APACHE new file mode 100644 index 000000000..d9a10c0d8 --- /dev/null +++ b/vendor/vello_common/LICENSE-APACHE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/vendor/vello_common/LICENSE-MIT b/vendor/vello_common/LICENSE-MIT new file mode 100644 index 000000000..48ff8e414 --- /dev/null +++ b/vendor/vello_common/LICENSE-MIT @@ -0,0 +1,25 @@ +Copyright 2020 the Vello Authors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/vendor/vello_common/README.md b/vendor/vello_common/README.md new file mode 100644 index 000000000..27fa3f770 --- /dev/null +++ b/vendor/vello_common/README.md @@ -0,0 +1,100 @@ +
+ +# Vello Common + +**Shared data structures** + +[![Latest published version.](https://img.shields.io/crates/v/vello_common.svg)](https://crates.io/crates/vello_common) +[![Documentation build status.](https://img.shields.io/docsrs/vello_common.svg)](https://docs.rs/vello_common) +[![Apache 2.0 or MIT license.](https://img.shields.io/badge/license-Apache--2.0_OR_MIT-blue.svg)](#license) +\ +[![Linebender Zulip chat.](https://img.shields.io/badge/Linebender-%23vello-blue?logo=Zulip)](https://xi.zulipchat.com/#narrow/channel/197075-vello) +[![GitHub Actions CI status.](https://img.shields.io/github/actions/workflow/status/linebender/vello/ci.yml?logo=github&label=CI)](https://github.com/linebender/vello/actions) +[![Dependency staleness status.](https://deps.rs/crate/vello_common/latest/status.svg)](https://deps.rs/crate/vello_common) + +
+ + + + + +[libm]: https://crates.io/crates/libm +[crate::pixmap::Pixmap]: https://docs.rs/vello_common/latest/vello_common/pixmap/struct.Pixmap.html + + +This crate includes common geometry representations, tiling logic, and other fundamental components used by both [Vello CPU][vello_cpu] and Vello Hybrid. + +## Usage + +This crate should not be used on its own, and you should instead use one of the renderers which use it. +At the moment, only [Vello CPU][vello_cpu] is published, and you probably want to use that. + +We also develop [Vello](https://crates.io/crates/vello), which makes use of the GPU for 2D rendering and has higher performance than Vello CPU. +Vello CPU is being developed as part of work to address shortcomings in Vello. +Vello does not use this crate. + +## Features + +- `std` (enabled by default): Get floating point functions from the standard library + (likely using your target's libc). +- `libm`: Use floating point implementations from [libm][]. +- `png` (enabled by default): Allow loading [`Pixmap`][crate::pixmap::Pixmap]s from PNG images. + Also required for rendering glyphs with an embedded PNG. + Implies `std`. + +At least one of `std` and `libm` is required; `std` overrides `libm`. + +## Contents + +- Shared data structures for paths, tiles, and strips +- Geometry processing utilities +- Common logic for rendering stages + +This crate acts as a foundation for `vello_cpu` and `vello_hybrid`, providing essential components to minimize duplication. + +[vello_cpu]: https://crates.io/crates/vello_cpu + + + +## Minimum supported Rust Version (MSRV) + +This version of Vello Common has been verified to compile with **Rust 1.92** and later. + +Future versions of Vello Common might increase the Rust version requirement. +It will not be treated as a breaking change and as such can even happen with small patch releases. + +
+Click here if compiling fails. + +As time has passed, some of Vello Common's dependencies could have released versions with a higher Rust requirement. +If you encounter a compilation issue due to a dependency and don't want to upgrade your Rust toolchain, then you could downgrade the dependency. + +```sh +# Use the problematic dependency's name and version +cargo update -p package_name --precise 0.1.1 +``` + +
+ +## Community + +Discussion of Vello Common development happens in the [Linebender Zulip](https://xi.zulipchat.com/), specifically the [#vello channel](https://xi.zulipchat.com/#narrow/channel/197075-vello). +All public content can be read without logging in. + +Contributions are welcome by pull request. +The [Rust code of conduct] applies. + +## License + +Licensed under either of + +- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or ) +- MIT license ([LICENSE-MIT](LICENSE-MIT) or ) + +at your option. + +[Rust code of conduct]: https://www.rust-lang.org/policies/code-of-conduct diff --git a/vendor/vello_common/assets/README.md b/vendor/vello_common/assets/README.md new file mode 100644 index 000000000..f7fc3bace --- /dev/null +++ b/vendor/vello_common/assets/README.md @@ -0,0 +1,2 @@ +In order to regenerate `probe.png` and `probe.rgba`, please run +`cargo run --bin regenerate_probe_reference` inside of `vello_sparse_tests`. diff --git a/vendor/vello_common/assets/probe.png b/vendor/vello_common/assets/probe.png new file mode 100644 index 000000000..8464880a0 Binary files /dev/null and b/vendor/vello_common/assets/probe.png differ diff --git a/vendor/vello_common/assets/probe.rgba b/vendor/vello_common/assets/probe.rgba new file mode 100644 index 000000000..35238209a Binary files /dev/null and b/vendor/vello_common/assets/probe.rgba differ diff --git a/vendor/vello_common/src/blurred_rounded_rect.rs b/vendor/vello_common/src/blurred_rounded_rect.rs new file mode 100644 index 000000000..13f62e33f --- /dev/null +++ b/vendor/vello_common/src/blurred_rounded_rect.rs @@ -0,0 +1,19 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Blurred, rounded rectangles. +use crate::color::{AlphaColor, Srgb}; +use crate::kurbo::Rect; + +/// A blurred, rounded rectangle. +#[derive(Debug)] +pub struct BlurredRoundedRectangle { + /// The base rectangle to use for the blur effect. + pub rect: Rect, + /// The color of the blurred rectangle. + pub color: AlphaColor, + /// The radius of the rounded rectangle's corners. + pub radius: f32, + /// The standard deviation of the blur effect. + pub std_dev: f32, +} diff --git a/vendor/vello_common/src/clip.rs b/vendor/vello_common/src/clip.rs new file mode 100644 index 000000000..c50a0ff9d --- /dev/null +++ b/vendor/vello_common/src/clip.rs @@ -0,0 +1,996 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Managing clipping state. + +use crate::geometry::RectU16; +use crate::kurbo::{Affine, BezPath, PathEl, Rect}; +use crate::strip::Strip; +use crate::strip_generator::{GenerationMode, StripGenerator, StripStorage}; +use crate::tile::Tile; +use crate::util::normalized_mul_u8x16; +use alloc::vec; +use alloc::vec::Vec; +use fearless_simd::{Level, Simd, SimdBase, dispatch, u8x16}; +use peniko::Fill; + +#[cfg(not(feature = "std"))] +use peniko::kurbo::common::FloatFuncs as _; + +#[derive(Debug)] +struct ClipData { + alpha_start: u32, + strip_start: u32, + + /// A coarse bounding box of the clip path in pixel coordinates. + /// + /// These bounds have already been intersected with the viewport. + bbox: RectU16, +} + +impl ClipData { + fn to_path_data_ref<'a>(&self, storage: &'a StripStorage) -> PathDataRef<'a> { + PathDataRef { + strips: storage + .strips + .get(self.strip_start as usize..) + .unwrap_or(&[]), + alphas: storage + .alphas + .get(self.alpha_start as usize..) + .unwrap_or(&[]), + bbox: self.bbox, + } + } +} + +/// A context for managing clip stacks. +#[derive(Debug)] +pub struct ClipContext { + storage: StripStorage, + temp_storage: StripStorage, + clip_stack: Vec, +} + +impl Default for ClipContext { + fn default() -> Self { + Self::new() + } +} + +impl ClipContext { + /// Create a new clip context. + #[inline] + pub fn new() -> Self { + let mut main_storage = StripStorage::default(); + main_storage.set_generation_mode(GenerationMode::Append); + Self { + storage: main_storage, + temp_storage: StripStorage::default(), + clip_stack: vec![], + } + } + + /// Reset the clip context. + #[inline] + pub fn reset(&mut self) { + self.clip_stack.clear(); + self.storage.clear(); + self.temp_storage.clear(); + } + + /// Get the data of the current clip path. + #[inline] + pub fn get(&self) -> Option> { + self.clip_stack + .last() + .map(|c| c.to_path_data_ref(&self.storage)) + } + + /// Push a new clip path to the stack. + #[inline] + pub fn push_clip( + &mut self, + clip_path: &BezPath, + strip_generator: &mut StripGenerator, + fill_rule: Fill, + transform: Affine, + aliasing_threshold: Option, + ) { + self.temp_storage.clear(); + + let alpha_start = self.storage.alphas.len() as u32; + let strip_start = self.storage.strips.len() as u32; + + // Calculate a coarse bounding box of the path. If the path is empty, the bounding box is + // an infinite and inversed `kurbo::Rect`. This is harmless in practice: an empty path does + // not produce any strips. + // + // Note this iterates `clip_path`, which means we iterate it twice: once here, and once in + // flattening. If we ever take an iterator instead, or want to prevent iterating twice, we + // could move this calculation into flattening (perhaps with a const-generic as to not + // pessimize calls that don't require the bbox). + let path_bbox = control_point_bbox(clip_path, transform); + let mut bbox = RectU16::new( + path_bbox.x0 as u16, + path_bbox.y0 as u16, + path_bbox.x1.ceil() as u16, + path_bbox.y1.ceil() as u16, + ); + + // Intersect with the existing clip bounding box, or the viewport if this is the outermost + // clip. + if let Some(existing) = self.clip_stack.last() { + bbox = bbox.intersect(existing.bbox); + } else { + bbox.x1 = bbox.x1.min(strip_generator.width()); + bbox.y1 = bbox.y1.min(strip_generator.height()); + } + + let clip_data = ClipData { + alpha_start, + strip_start, + bbox, + }; + + let existing_clip = self + .clip_stack + .last() + .map(|c| c.to_path_data_ref(&self.storage)); + + strip_generator.generate_filled_path( + clip_path, + fill_rule, + transform, + aliasing_threshold, + &mut self.temp_storage, + existing_clip, + ); + + self.storage.extend(&self.temp_storage); + self.clip_stack.push(clip_data); + } + + /// Pop the least recent clip path. + #[inline] + pub fn pop_clip(&mut self) { + let data = self.clip_stack.pop().expect("clip stack underflowed"); + self.storage.strips.truncate(data.strip_start as usize); + self.storage.alphas.truncate(data.alpha_start as usize); + } +} + +/// Compute a conservative bounding box for the transformed path by computing the bounding box of +/// the transformed control points. +/// +/// If `path` is empty, this returns an infinite, inversed [`Rect`] (`left` > `right` and `top` > `bottom`). +fn control_point_bbox(path: &BezPath, transform: Affine) -> Rect { + // Start with an infinite, inversed rectangle. Adding the first point immediately collapses it + // without branching. + let mut bbox = Rect::new( + f64::INFINITY, + f64::INFINITY, + f64::NEG_INFINITY, + f64::NEG_INFINITY, + ); + for el in path.iter() { + match el { + PathEl::MoveTo(p) | PathEl::LineTo(p) => { + bbox = bbox.union_pt(transform * p); + } + PathEl::QuadTo(p1, p2) => { + bbox = bbox.union_pt(transform * p1); + bbox = bbox.union_pt(transform * p2); + } + PathEl::CurveTo(p1, p2, p3) => { + bbox = bbox.union_pt(transform * p1); + bbox = bbox.union_pt(transform * p2); + bbox = bbox.union_pt(transform * p3); + } + PathEl::ClosePath => {} + } + } + bbox +} + +/// Borrowed data of a stripped path. +#[derive(Clone, Copy, Debug)] +pub struct PathDataRef<'a> { + /// The strips. + pub strips: &'a [Strip], + /// The alpha buffer. + pub alphas: &'a [u8], + + /// A coarse bounding box of the clip path in pixel coordinates. + /// + /// These bounds have already been intersected with the viewport. + pub bbox: RectU16, +} + +/// Compute the sparse strips representation of a path that results +/// from intersecting the two input paths. This can be used to implement +/// clip paths. +pub fn intersect( + level: Level, + path_1: PathDataRef<'_>, + path_2: PathDataRef<'_>, + target: &mut StripStorage, +) { + dispatch!(level, simd => intersect_impl(simd, path_1, path_2, target)); +} + +// PdfCraft patch: rows walked by `intersect_impl` on this thread (tests only). +#[cfg(test)] +std::thread_local! { + static ROWS_WALKED: core::cell::Cell = const { core::cell::Cell::new(0) }; +} + +/// The implementation of the clipping algorithm using sparse strips. Conceptually, it is relatively +/// simple: We iterate over each strip and fill region of the two paths in lock step and determine +/// all overlaps between the two. For each overlap, we proceed depending on what kind of region +/// we have in the first path and the second one. +/// - In case we have two fill regions, the overlap region will also be filled. +/// - In case we have one strip and one fill region, the overlap region will copy the alpha mask of the strip region. +/// - Finally, if we have two strip regions, we combine the alpha masks of both. +/// - All regions that are not filled in either path are simply ignored. +/// +/// This is all that this method does. It just looks more complicated as the logic for iterating +/// in lock step is a bit tricky. +fn intersect_impl( + simd: S, + path_1: PathDataRef<'_>, + path_2: PathDataRef<'_>, + target: &mut StripStorage, +) { + // In case either path is empty, the clip path should be empty. + if path_1.strips.is_empty() || path_2.strips.is_empty() { + return; + } + + // Ignore any y values that are outside the bounding box of either of the two paths, as + // those are guaranteed to have neither fill nor strip regions. + // PdfCraft patch: start at the later first row. Above it one path has no strips, so no row + // there yields output, but the walk visited each of them, from the top of the clip, for every + // path drawn (on the A1 sample's full-page canvas, about half its strip rows per stroke). + let mut cur_y = path_1.strips[0].strip_y().max(path_2.strips[0].strip_y()); + let end_y = path_1.strips[path_1.strips.len() - 1] + .strip_y() + .min(path_2.strips[path_2.strips.len() - 1].strip_y()); + + let mut path_1_idx = 0; + let mut path_2_idx = 0; + let mut strip_state = None; + + // Iterate over each strip row and handle them. + while cur_y <= end_y { + // PdfCraft patch: counts the rows walked, for the test that pins where the walk starts. + #[cfg(test)] + ROWS_WALKED.with(|rows| rows.set(rows.get() + 1)); + // For each row, we create two iterators that alternatingly yield the strips and fill + // regions in that row, until the last strip has been reached. + let mut p1_iter = RowIterator::new(path_1, &mut path_1_idx, cur_y); + let mut p2_iter = RowIterator::new(path_2, &mut path_2_idx, cur_y); + + let mut p1_region = p1_iter.next(); + let mut p2_region = p2_iter.next(); + + // If at least one region is none, it means that we reached the end of the row + // for that path, meaning that we exceeded the bounding box of that path and no + // additional strips should be generated for that row, even if the other path might + // still have more strips left. They will all be clipped away. So only consider it + // if both paths have a region left. + while let (Some(region_1), Some(region_2)) = (p1_region, p2_region) { + match region_1.overlap_relationship(®ion_2) { + // This means there is no overlap between the regions, so we need to advance + // the iterator of the region that is further behind. + OverlapRelationship::Advance(advance) => { + match advance { + Advance::Left => p1_region = p1_iter.next(), + Advance::Right => p2_region = p2_iter.next(), + }; + + continue; + } + // We have an overlap! + OverlapRelationship::Overlap(overlap) => { + match (region_1, region_2) { + // Both regions are a fill. Flush the current strip and start a new + // one at the end of the overlap region setting `fill_gap` to true, + // so that the whole area before that will be filled with a sparse + // fill. + (Region::Fill(_), Region::Fill(_)) => { + flush_strip(&mut strip_state, &mut target.strips, cur_y); + start_strip(&mut strip_state, &target.alphas, overlap.end, true); + } + // One fill one strip, so we simply use the alpha mask from the strip region. + (Region::Strip(s), Region::Fill(_)) + | (Region::Fill(_), Region::Strip(s)) => { + // If possible, don't create a new strip but just extend the current one. + if should_create_new_strip(&strip_state, &target.alphas, overlap.start) + { + flush_strip(&mut strip_state, &mut target.strips, cur_y); + start_strip(&mut strip_state, &target.alphas, overlap.start, false); + } + + let s_alphas = &s.alphas[(overlap.start - s.start) as usize * 4..] + [..overlap.width() as usize * 4]; + target.alphas.extend_from_slice(s_alphas); + } + // Two strips, we need to multiply the opacity masks from both paths. + (Region::Strip(s_region_1), Region::Strip(s_region_2)) => { + // Once again, only create a new strip if we can't extend the current one. + if should_create_new_strip(&strip_state, &target.alphas, overlap.start) + { + flush_strip(&mut strip_state, &mut target.strips, cur_y); + start_strip(&mut strip_state, &target.alphas, overlap.start, false); + } + + let num_blocks = overlap.width() / Tile::HEIGHT; + + // Get the right alpha values for the specific position. + let s1_alphas = s_region_1.alphas + [(overlap.start - s_region_1.start) as usize * 4..] + .chunks_exact(16) + .take(num_blocks as usize); + let s2_alphas = s_region_2.alphas + [(overlap.start - s_region_2.start) as usize * 4..] + .chunks_exact(16) + .take(num_blocks as usize); + + for (s1_alpha, s2_alpha) in s1_alphas.zip(s2_alphas) { + let s1 = u8x16::from_slice(simd, s1_alpha); + let s2 = u8x16::from_slice(simd, s2_alpha); + + // Combine them. + let res = simd.narrow_u16x16(normalized_mul_u8x16(s1, s2)); + target.alphas.extend(res.as_slice()); + } + } + } + + // Advance the iterator of the path whose region's end is further behind. + match overlap.advance { + Advance::Left => p1_region = p1_iter.next(), + Advance::Right => p2_region = p2_iter.next(), + }; + } + } + } + + // Flush the strip before advancing to the next strip row. + flush_strip(&mut strip_state, &mut target.strips, cur_y); + cur_y += 1; + } + + // Push the sentinel strip, if one wasn't already pushed. + if !target.strips.last().is_some_and(Strip::is_sentinel) { + target.strips.push(Strip::new( + u16::MAX, + end_y * Tile::HEIGHT, + target.alphas.len() as u32, + false, + )); + } +} + +/// An overlap between two regions. +struct Overlap { + /// The start x coordinate. + start: u16, + /// The end x coordinate. + end: u16, + /// Whether the left or right region iterator should be advanced next. + advance: Advance, +} + +impl Overlap { + fn width(&self) -> u16 { + self.end - self.start + } +} + +enum Advance { + Left, + Right, +} + +/// The relationship between two regions. +enum OverlapRelationship { + /// There is no overlap between the regions, advance the region iterator on the given side. + Advance(Advance), + /// There is an overlap between the regions. + Overlap(Overlap), +} + +#[derive(Debug, Clone, Copy)] +struct FillRegion { + start: u16, + width: u16, +} + +#[derive(Debug, Clone, Copy)] +struct StripRegion<'a> { + start: u16, + width: u16, + alphas: &'a [u8], +} + +#[derive(Debug, Clone, Copy)] +enum Region<'a> { + Fill(FillRegion), + Strip(StripRegion<'a>), +} + +impl Region<'_> { + #[inline(always)] + fn start(&self) -> u16 { + match self { + Region::Fill(fill) => fill.start, + Region::Strip(strip) => strip.start, + } + } + + #[inline(always)] + fn width(&self) -> u16 { + match self { + Region::Fill(fill) => fill.width, + Region::Strip(strip) => strip.width, + } + } + + #[inline(always)] + fn end(&self) -> u16 { + self.start() + self.width() + } + + fn overlap_relationship(&self, other: &Region<'_>) -> OverlapRelationship { + if self.end() <= other.start() { + OverlapRelationship::Advance(Advance::Left) + } else if self.start() >= other.end() { + OverlapRelationship::Advance(Advance::Right) + } else { + let start = self.start().max(other.start()); + let end = self.end().min(other.end()); + + let shift = if self.end() <= other.end() { + Advance::Left + } else { + Advance::Right + }; + + OverlapRelationship::Overlap(Overlap { + advance: shift, + start, + end, + }) + } + } +} + +/// An iterator of strip and fill regions of a single strip row. +struct RowIterator<'a> { + /// The path in question. + input: PathDataRef<'a>, + /// The strip row we want to iterate over. + strip_y: u16, + /// The index of the current strip. + cur_idx: &'a mut usize, + /// Whether the iterator should yield a strip next or not. + /// When iterating over a row, we alternate between emitting strips and filled regions (unless + /// the region between two strips is not filled), so this flag acts as a toggle to store what + /// should be yielded next. + on_strip: bool, +} + +impl<'a> RowIterator<'a> { + fn new(input: PathDataRef<'a>, cur_idx: &'a mut usize, strip_y: u16) -> Self { + // Forward the index until we have found the right strip. + while input.strips[*cur_idx].strip_y() < strip_y { + *cur_idx += 1; + } + + Self { + input, + cur_idx, + strip_y, + on_strip: true, + } + } + + #[inline(always)] + fn cur_strip(&self) -> &Strip { + &self.input.strips[*self.cur_idx] + } + + #[inline(always)] + fn next_strip(&self) -> &Strip { + &self.input.strips[*self.cur_idx + 1] + } + + #[inline(always)] + fn cur_strip_width(&self) -> u16 { + let cur = self.cur_strip(); + let next = self.next_strip(); + ((next.alpha_idx() - cur.alpha_idx()) / Tile::HEIGHT as u32) as u16 + } + + #[inline(always)] + fn cur_strip_alphas(&self) -> &'a [u8] { + let cur = self.cur_strip(); + let next = self.next_strip(); + &self.input.alphas[cur.alpha_idx() as usize..next.alpha_idx() as usize] + } + + fn cur_strip_fill_area(&self) -> Option { + let next = self.next_strip(); + + // Note that if the next strip happens to be on the next line, it will always have + // zero winding so we don't need to special case this. + if next.fill_gap() { + let cur = self.cur_strip(); + let x = cur.x + self.cur_strip_width(); + let width = next.x - x; + + (width > 0).then_some(FillRegion { start: x, width }) + } else { + None + } + } +} + +impl<'a> Iterator for RowIterator<'a> { + type Item = Region<'a>; + + #[inline(always)] + fn next(&mut self) -> Option { + // If we are currently not on a strip, we want to yield a filled region in case there is one. + if !self.on_strip { + // Flip boolean flag so we will yield a strip in the next iteration. + self.on_strip = true; + + // if we have a filled area, yield it and return. Otherwise, do nothing and we will + // instead yield the next strip below. In any case, we need to advance the current index + // so that we point to the next strip now. + if let Some(fill_area) = self.cur_strip_fill_area() { + *self.cur_idx += 1; + + return Some(Region::Fill(fill_area)); + } else { + *self.cur_idx += 1; + } + } + + // If we reached this point, we will yield a strip this iteration, so toggle the flag + // so that in the next iteration, we yield a filled region instead. + self.on_strip = false; + + // If the current strip is sentinel or not within our target row, terminate. + if self.cur_strip().is_sentinel() || self.cur_strip().strip_y() != self.strip_y { + return None; + } + + // Calculate the dimensions of the strip and yield it. + let x = self.cur_strip().x; + let width = self.cur_strip_width(); + let alphas = self.cur_strip_alphas(); + + Some(Region::Strip(StripRegion { + start: x, + width, + alphas, + })) + } +} + +/// The data of the current strip we are building. +struct StripState { + x: u16, + alpha_idx: u32, + fill_gap: bool, +} + +fn flush_strip(strip_state: &mut Option, strips: &mut Vec, cur_y: u16) { + if let Some(state) = core::mem::take(strip_state) { + strips.push(Strip::new( + state.x, + cur_y * Tile::HEIGHT, + state.alpha_idx, + state.fill_gap, + )); + } +} + +#[inline(always)] +fn start_strip(strip_data: &mut Option, alphas: &[u8], x: u16, fill_gap: bool) { + *strip_data = Some(StripState { + x, + alpha_idx: alphas.len() as u32, + fill_gap, + }); +} + +fn should_create_new_strip( + strip_state: &Option, + alphas: &[u8], + overlap_start: u16, +) -> bool { + // Returns false in case we can append to the currently built strip. + strip_state.as_ref().is_none_or(|state| { + let width = ((alphas.len() as u32 - state.alpha_idx) / Tile::HEIGHT as u32) as u16; + let strip_end = state.x + width; + + strip_end < overlap_start - 1 + }) +} + +#[cfg(test)] +mod tests { + use crate::clip::{PathDataRef, Region, RowIterator, intersect}; + use crate::geometry::RectU16; + use crate::strip::Strip; + use crate::strip_generator::StripStorage; + use crate::tile::Tile; + use fearless_simd::Level; + use std::vec; + + #[test] + fn intersect_partly_overlapping_strips() { + let path_1 = StripBuilder::new().add_strip(0, 0, 32, false).finish(); + + let path_2 = StripBuilder::new().add_strip(8, 0, 44, false).finish(); + + let expected = StripBuilder::new().add_strip(8, 0, 32, false).finish(); + + run_test(expected, path_1, path_2); + } + + // PdfCraft patch: the walk starts at the first row both paths have strips in. Starting at the + // earlier first row walked every row between the top of the clip and this one for each path. + #[test] + fn intersect_walks_only_rows_both_paths_have_strips() { + let path_1 = StripBuilder::new().add_strip(0, 200, 8, false).finish(); + let path_2 = StripBuilder::new() + .add_strip(0, 0, 8, false) + .add_strip(0, 200, 8, false) + .finish(); + let expected = StripBuilder::new().add_strip(0, 200, 8, false).finish(); + + super::ROWS_WALKED.with(|rows| rows.set(0)); + run_test(expected, path_1, path_2); + let walked = super::ROWS_WALKED.with(core::cell::Cell::get); + assert_eq!(walked, 1, "the walk visited {walked} rows; it should visit only row 200"); + } + + #[test] + fn intersect_multiple_overlapping_strips() { + let path_1 = StripBuilder::new() + .add_strip(0, 1, 4, false) + .add_strip(12, 1, 20, true) + .add_strip(28, 1, 32, false) + .add_strip(44, 1, 52, true) + .finish(); + + let path_2 = StripBuilder::new() + .add_strip(4, 1, 8, false) + .add_strip(16, 1, 20, true) + .add_strip(24, 1, 28, false) + .add_strip(32, 1, 36, false) + .add_strip(44, 1, 48, true) + .finish(); + + let expected = StripBuilder::new() + .add_strip(4, 1, 8, false) + .add_strip(12, 1, 20, true) + .add_strip(32, 1, 36, false) + .add_strip(44, 1, 48, true) + .finish(); + + run_test(expected, path_1, path_2); + } + + #[test] + fn multiple_rows() { + let path_1 = StripBuilder::new() + .add_strip(0, 0, 4, false) + .add_strip(16, 0, 20, true) + .add_strip(4, 1, 8, false) + .add_strip(12, 1, 24, true) + .add_strip(4, 2, 8, false) + .add_strip(16, 2, 32, true) + .finish(); + + let path_2 = StripBuilder::new() + .add_strip(0, 2, 4, false) + .add_strip(16, 2, 24, true) + .add_strip(8, 3, 12, false) + .add_strip(16, 3, 28, true) + .finish(); + + let expected = StripBuilder::new() + .add_strip(4, 2, 8, false) + .add_strip(16, 2, 24, true) + .finish(); + + run_test(expected, path_1, path_2); + } + + #[test] + fn alpha_buffer_correct_width() { + let path_1 = StripBuilder::new() + .add_strip(0, 0, 4, false) + .add_strip(0, 1, 12, false) + .finish(); + + let path_2 = StripBuilder::new() + .add_strip(4, 0, 8, false) + .add_strip(0, 1, 4, false) + .add_strip(12, 1, 16, true) + .finish(); + + let expected = StripBuilder::new().add_strip(0, 1, 12, false).finish(); + + run_test(expected, path_1, path_2); + } + + #[test] + fn row_iterator_abort_next_line() { + let path_1 = StripBuilder::new() + .add_strip(0, 0, 4, false) + .add_strip(0, 1, 4, false) + .finish(); + + let path_ref = PathDataRef { + strips: &path_1.strips, + alphas: &path_1.alphas, + bbox: RectU16::new(0, 0, u16::MAX, u16::MAX), + }; + + let mut idx = 0; + let mut iter = RowIterator::new(path_ref, &mut idx, 0); + + assert!(iter.next().is_some()); + assert!(iter.next().is_none()); + } + + #[test] + fn row_iterator_sentinel_fill_gap() { + let path = StripBuilder::new() + .add_strip(0, 0, Tile::WIDTH, false) + .finish_with_fill_gap_sentinel(); + let path_ref = path_ref(&path); + + let mut idx = 0; + let mut iter = RowIterator::new(path_ref, &mut idx, 0); + + assert_strip_region(iter.next(), 0, Tile::WIDTH); + assert_fill_region(iter.next(), Tile::WIDTH, u16::MAX - Tile::WIDTH); + assert!(iter.next().is_none()); + } + + #[test] + fn intersect_strip_with_sentinel_fill_gap() { + let path_1 = StripBuilder::new() + .add_strip(0, 0, Tile::WIDTH, false) + .finish_with_fill_gap_sentinel(); + let path_2 = StripBuilder::new().add_strip(8, 0, 12, false).finish(); + let expected = StripBuilder::new().add_strip(8, 0, 12, false).finish(); + + run_test(expected, path_1, path_2); + } + + #[test] + fn intersect_two_sentinel_fill_gaps() { + let path_1 = StripBuilder::new() + .add_strip(0, 0, 8, false) + .finish_with_fill_gap_sentinel(); + let path_2 = StripBuilder::new() + .add_strip(4, 0, 12, false) + .finish_with_fill_gap_sentinel(); + let expected = StripBuilder::new() + .add_strip(4, 0, 12, false) + .finish_with_fill_gap_sentinel(); + + run_test(expected, path_1, path_2); + } + + #[test] + fn row_iterator_fill_gap_stops_at_row_boundary() { + let mut path = StripBuilder::new() + .add_strip(0, 0, 4, false) + .finish_with_fill_gap_sentinel(); + let idx = path.alphas.len(); + path.strips + .push(Strip::new(0, Tile::HEIGHT, idx as u32, false)); + path.alphas + .extend([0; Tile::HEIGHT as usize * Tile::WIDTH as usize]); + path.strips.push(Strip::new( + u16::MAX, + Tile::HEIGHT, + path.alphas.len() as u32, + false, + )); + + let path_ref = path_ref(&path); + let mut idx = 0; + let mut iter = RowIterator::new(path_ref, &mut idx, 0); + + assert_strip_region(iter.next(), 0, 4); + assert_fill_region(iter.next(), 4, u16::MAX - 4); + assert!(iter.next().is_none()); + + let mut iter = RowIterator::new(path_ref, &mut idx, 1); + + assert_strip_region(iter.next(), 0, Tile::WIDTH); + assert!(iter.next().is_none()); + } + + #[test] + fn row_iterator_adjacent_unmerged_strips_no_fill() { + let path = StripBuilder::new() + .add_strip(0, 0, 4, false) + .add_strip(4, 0, 8, false) + .finish(); + let path_ref = path_ref(&path); + + let mut idx = 0; + let mut iter = RowIterator::new(path_ref, &mut idx, 0); + + assert_strip_region(iter.next(), 0, 4); + assert_strip_region(iter.next(), 4, 4); + assert!(iter.next().is_none()); + } + + #[test] + fn row_iterator_adjacent_unmerged_strips_with_fill_gap() { + let path = StripBuilder::new() + .add_strip(0, 0, 4, false) + .add_strip(4, 0, 8, true) + .finish(); + let path_ref = path_ref(&path); + + let mut idx = 0; + let mut iter = RowIterator::new(path_ref, &mut idx, 0); + + assert_strip_region(iter.next(), 0, 4); + assert_strip_region(iter.next(), 4, 4); + assert!(iter.next().is_none()); + } + + #[test] + fn intersect_adjacent_unmerged_strips() { + let path = StripBuilder::new() + .add_strip(0, 0, 4, false) + .add_strip(4, 0, 8, true) + .finish(); + let cover = StripBuilder::new().add_strip(0, 0, 8, false).finish(); + let expected = StripBuilder::new().add_strip(0, 0, 8, false).finish(); + + run_test(expected, path, cover); + } + + #[test] + fn row_iterator_zero_width_alpha_region() { + let mut path = StripStorage::default(); + path.strips.push(Strip::new(8, 0, 0, false)); + path.strips.push(Strip::new(16, 0, 0, true)); + path.strips.push(Strip::new(u16::MAX, 0, 0, false)); + let path_ref = path_ref(&path); + + let mut idx = 0; + let iter = RowIterator::new(path_ref, &mut idx, 0); + + assert_eq!(iter.cur_strip_width(), 0); + let fill = iter.cur_strip_fill_area().unwrap(); + assert_eq!(fill.start, 8); + assert_eq!(fill.width, 8); + + let cover = StripBuilder::new().add_strip(0, 0, 20, false).finish(); + let expected = StripBuilder::new().add_strip(8, 0, 16, false).finish(); + + run_test(expected, path, cover); + } + + fn run_test(expected: StripStorage, path_1: StripStorage, path_2: StripStorage) { + let mut write_target = StripStorage::default(); + + let path_1 = path_ref(&path_1); + let path_2 = path_ref(&path_2); + + intersect(Level::new(), path_1, path_2, &mut write_target); + + assert_eq!(write_target, expected); + } + + fn path_ref(path: &StripStorage) -> PathDataRef<'_> { + PathDataRef { + strips: &path.strips, + alphas: &path.alphas, + bbox: RectU16::new(0, 0, u16::MAX, u16::MAX), + } + } + + fn assert_strip_region(region: Option>, start: u16, width: u16) { + match region { + Some(Region::Strip(strip)) => { + assert_eq!(strip.start, start); + assert_eq!(strip.width, width); + assert_eq!(strip.alphas.len(), (width * Tile::HEIGHT) as usize); + } + other => panic!("expected strip region, got {other:?}"), + } + } + + fn assert_fill_region(region: Option>, start: u16, width: u16) { + match region { + Some(Region::Fill(fill)) => { + assert_eq!(fill.start, start); + assert_eq!(fill.width, width); + } + other => panic!("expected fill region, got {other:?}"), + } + } + + struct StripBuilder { + storage: StripStorage, + } + + impl StripBuilder { + fn new() -> Self { + Self { + storage: StripStorage::default(), + } + } + + fn add_strip(self, x: u16, strip_y: u16, end: u16, fill_gap: bool) -> Self { + let width = end - x; + self.add_strip_with( + x, + strip_y, + end, + fill_gap, + &vec![0; (width * Tile::HEIGHT) as usize], + ) + } + + fn add_strip_with( + mut self, + x: u16, + strip_y: u16, + end: u16, + fill_gap: bool, + alphas: &[u8], + ) -> Self { + let width = end - x; + assert_eq!(alphas.len(), (width * Tile::HEIGHT) as usize); + let idx = self.storage.alphas.len(); + self.storage + .strips + .push(Strip::new(x, strip_y * Tile::HEIGHT, idx as u32, fill_gap)); + self.storage.alphas.extend_from_slice(alphas); + + self + } + + fn finish(mut self) -> StripStorage { + let last_y = self.storage.strips.last().unwrap().y; + let idx = self.storage.alphas.len(); + + self.storage + .strips + .push(Strip::new(u16::MAX, last_y, idx as u32, false)); + + self.storage + } + + fn finish_with_fill_gap_sentinel(mut self) -> StripStorage { + let last_y = self.storage.strips.last().unwrap().y; + let idx = self.storage.alphas.len(); + + self.storage + .strips + .push(Strip::new(u16::MAX, last_y, idx as u32, true)); + + self.storage + } + } +} diff --git a/vendor/vello_common/src/coarse.rs b/vendor/vello_common/src/coarse.rs new file mode 100644 index 000000000..14f9b3a9a --- /dev/null +++ b/vendor/vello_common/src/coarse.rs @@ -0,0 +1,2385 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Generating and processing wide tiles. + +use crate::color::palette::css::TRANSPARENT; +use crate::encode::EncodedPaint; +use crate::filter_effects::Filter; +use crate::geometry::RectU16; +use crate::kurbo::{Affine, Rect}; +use crate::mask::Mask; +use crate::paint::{Paint, PremulColor}; +use crate::peniko::{BlendMode, Compose, Mix}; +use crate::render_graph::{DependencyKind, LayerId, RenderGraph, RenderNodeKind}; +use crate::{strip::Strip, tile::Tile}; +use alloc::vec; +use alloc::{boxed::Box, vec::Vec}; +#[cfg(debug_assertions)] +use alloc::{format, string::String}; +use core::ops::Range; +use hashbrown::HashMap; +#[cfg(not(feature = "std"))] +use peniko::kurbo::common::FloatFuncs as _; + +#[derive(Debug)] +struct Layer { + /// The layer's ID. + layer_id: LayerId, + /// Whether the layer has a clip associated with it. + clip: bool, + /// The blend mode with which this layer should be blended into + /// the previous layer. + blend_mode: BlendMode, + /// An opacity to apply to the whole layer before blending it + /// into the backdrop. + opacity: f32, + /// A mask to apply to the layer before blending it back into + /// the backdrop. + mask: Option, + /// A filter effect to apply to the layer before other operations. + filter: Option, + /// Bounding box of wide tiles containing geometry. + /// Starts with inverted bounds, shrinks to actual content during drawing. + wtile_bbox: WideTilesBbox, +} + +impl Layer { + /// Whether the layer actually requires allocating a new scratch buffer + /// for drawing its contents. + fn needs_buf(&self) -> bool { + self.blend_mode.mix != Mix::Normal + || self.blend_mode.compose != Compose::SrcOver + || self.opacity != 1.0 + || self.mask.is_some() + || self.filter.is_some() + || !self.clip + } +} + +/// `MODE_CPU` allows compile time optimizations to be applied to wide tile draw command generation +/// specific to `vello_cpu`. +pub const MODE_CPU: u8 = 0; +/// `MODE_HYBRID` allows compile time optimizations to be applied to wide tile draw command +/// generation specific for `vello_hybrid`. +pub const MODE_HYBRID: u8 = 1; + +/// The index that in `push_buf_indices` is reserved for indicating the target is the final +/// destination surface. +const TARGET_SURFACE_PUSH_BUF_IDX: usize = usize::MAX; + +#[derive(Debug)] +struct LayerKindAndOccupiedTiles { + /// The kind of layer needing a scratch buffer. This is tracked to allow lazy buffer pushing to + /// know for which layer kind to push. + kind: LayerKind, + /// The indices into [`Wide::tiles`] of wide tiles that occupy this layer. These are the wide + /// tiles that have pushed a buffer and need to be popped when the layer is popped. + occupied_tiles: Vec, +} + +/// For layers that require allocating a new scratch buffer for drawing its contents, this stores +/// the layer kind and the occupied wide tiles. +/// +/// The wide tile push buffer command [`WideTile::push_buf`] is performed lazily upon actually +/// being drawn into. +/// +/// See [`LayerKindAndOccupiedTiles`]. +/// +/// This is a small wrapper around [`Vec`] so we can keep the +/// [`LayerKindAndOccupiedTiles::occupied_tiles`] allocations around. +#[derive(Debug, Default)] +struct NeedsBufLayerStack { + stack: Vec, + /// The actual length of [`Self::stack`], as we never actually shrink it (see the note above + /// about keeping allocations around). + len: usize, +} + +impl NeedsBufLayerStack { + #[inline] + fn clear(&mut self) { + self.len = 0; + } + + #[inline] + fn push(&mut self, kind: LayerKind) { + if self.len == self.stack.len() { + self.stack.push(LayerKindAndOccupiedTiles { + kind, + occupied_tiles: vec![], + }); + } else { + self.stack[self.len].occupied_tiles.clear(); + self.stack[self.len].kind = kind; + } + self.len += 1; + } + + #[inline] + fn pop(&mut self) { + debug_assert!(self.len > 0, "Called `pop` at the root"); + self.len -= 1; + } + + #[inline] + fn last(&self) -> Option<&LayerKindAndOccupiedTiles> { + self.len.checked_sub(1).map(|idx| &self.stack[idx]) + } +} + +/// A container for wide tiles. +#[derive(Debug)] +pub struct Wide { + /// The width of the container. + width: u16, + /// The height of the container. + height: u16, + /// The wide tiles in the container. + tiles: Vec>, + /// Shared command properties, referenced by index from fill and clip commands. + pub attrs: CommandAttrs, + /// The stack of layers. + layer_stack: Vec, + /// The stack of layer kinds and occupied tiles for layers that require buffers. + /// + /// This contains exactly one entry for each layer with [`Layer::needs_buf`] in + /// [`Self::layer_stack`]. + layers_needing_buf_stack: NeedsBufLayerStack, + /// The stack of active clip regions. + clip_stack: Vec, + /// Stack of filter layer node IDs for render graph dependency tracking. + /// Initialized with node 0 (the root node representing the final output). + /// As layers with filters are pushed, their node IDs are added to this stack. + filter_node_stack: Vec, + /// Count of nested filtered layers with clip paths. + /// When > 0, command generation uses full viewport bounds instead of clip bounds + /// to ensure filter effects can process the full layer before applying the clip. + clipped_filter_layer_depth: u32, + /// Global batch counter, incremented each time we transition from fast strip rendering + /// to a set of strips passing through coarse rasterization. + batch_count: u32, + /// Whether to enable the optimization that allows fills with an opaque color to clear + /// all previous commands if it spans the whole wide tile. + /// + /// This needs to be disabled for the interleaved rendering path in `vello_hybrid`, because + /// the background is applied as the very first operation, and we have no way of clearing + /// strips in the fast path that would affect the area of the wide tile. + enable_bg_optimization: bool, + /// Whether at least one of the wide tiles has been mutated and thus they + /// need to be reset. + tiles_dirty: bool, +} + +/// A clip region. +#[derive(Debug)] +struct Clip { + /// The intersected bounding box after clip + pub clip_bbox: WideTilesBbox, + /// The rendered path in sparse strip representation + pub strips: Box<[Strip]>, + /// The index of the thread that owns the alpha buffer. + /// Always 0 in single-threaded mode. + pub thread_idx: u8, +} + +/// An axis-aligned bounding box in wide tile coordinates. +/// +/// This is a wrapper around [`RectU16`], adding some utilities useful for converting between +/// wide-tile space and pixel-space. `(x0, y0)` is the top-left corner and `(x1, y1)` is the +/// bottom-right corner, both in wide tile units. +#[derive(Debug, Clone, Copy)] +pub struct WideTilesBbox { + /// The bounding box. + bbox: RectU16, +} + +impl WideTilesBbox { + /// A empty rectangle with all coordinates set to zero. + pub const ZERO: Self = Self { + bbox: RectU16::ZERO, + }; + + /// An empty, maximally inverted rectangle, useful as a starting value for incremental union + /// operations. + /// + /// Has `(x0, y0) = (u16::MAX, u16::MAX)` and `(x1, y1) = (0, 0)`. + const INVERTED: Self = Self { + bbox: RectU16::INVERTED, + }; + + /// Create a new bounding box from wide tile coordinates. + pub fn new(x0: u16, y0: u16, x1: u16, y1: u16) -> Self { + Self { + bbox: RectU16::new(x0, y0, x1, y1), + } + } + + /// Get the x0 coordinate of the bounding box. + #[inline(always)] + pub fn x0(&self) -> u16 { + self.bbox.x0 + } + + /// Get the y0 coordinate of the bounding box. + #[inline(always)] + pub fn y0(&self) -> u16 { + self.bbox.y0 + } + + /// Get the x1 coordinate of the bounding box. + #[inline(always)] + pub fn x1(&self) -> u16 { + self.bbox.x1 + } + + /// Get the y1 coordinate of the bounding box. + #[inline(always)] + pub fn y1(&self) -> u16 { + self.bbox.y1 + } + + /// Returns `true` if the bounding box has zero area. + #[inline(always)] + pub fn is_empty(&self) -> bool { + self.bbox.is_empty() + } + + /// Get the width of the bounding box in tiles (x1 - x0). + #[inline(always)] + pub fn width_tiles(&self) -> u16 { + self.bbox.width() + } + + /// Get the width of the bounding box in pixels. + #[inline(always)] + pub fn width_px(&self) -> u16 { + self.width_tiles() * WideTile::WIDTH + } + + /// Get the height of the bounding box in tiles (y1 - y0). + #[inline(always)] + pub fn height_tiles(&self) -> u16 { + self.bbox.height() + } + + /// Get the height of the bounding box in pixels. + #[inline(always)] + pub fn height_px(&self) -> u16 { + self.height_tiles() * Tile::HEIGHT + } + + /// Check if a point (x, y) is contained within this bounding box. + /// + /// Returns `true` if x0 <= x < x1 and y0 <= y < y1. + #[inline(always)] + pub fn contains(&self, x: u16, y: u16) -> bool { + self.bbox.contains(x, y) + } + + /// Calculate the intersection of this bounding box with another. + #[inline(always)] + pub(crate) fn intersect(self, other: Self) -> Self { + Self { + bbox: self.bbox.intersect(other.bbox), + } + } + + /// Update this bounding box to include another bounding box (union in place). + #[inline(always)] + pub(crate) fn union(&mut self, other: Self) { + self.bbox.union(other.bbox); + } + + /// Update the bbox to include the given tile coordinates. + #[inline(always)] + pub(crate) fn include_tile(&mut self, wtile_x: u16, wtile_y: u16) { + self.bbox.x0 = self.bbox.x0.min(wtile_x); + self.bbox.y0 = self.bbox.y0.min(wtile_y); + self.bbox.x1 = self.bbox.x1.max(wtile_x + 1); + self.bbox.y1 = self.bbox.y1.max(wtile_y + 1); + } + + /// Scale this bounding box by the given scale factors. + /// + /// Multiplies each coordinate by the corresponding scale factor to convert + /// from one coordinate system to another. + #[inline(always)] + pub fn scale(&self, scale_x: u16, scale_y: u16) -> [u32; 4] { + [ + u32::from(self.x0()) * u32::from(scale_x), + u32::from(self.y0()) * u32::from(scale_y), + u32::from(self.x1()) * u32::from(scale_x), + u32::from(self.y1()) * u32::from(scale_y), + ] + } + + /// Expands the bounding box outward by the given pixel amounts in each direction. + /// + /// Pixel values are converted to tile coordinates (rounding up) and clamped to the + /// valid range `[0, max_x)` × `[0, max_y)`. The result is a new bounding box in + /// wide tile coordinates. + pub fn expand_by_pixels(&self, expansion: Rect, max_x: u16, max_y: u16) -> Self { + // The expansion rect is centered at origin: + // - Negative coordinates (x0, y0) represent left/top expansion + // - Positive coordinates (x1, y1) represent right/bottom expansion + let left_px = (-expansion.x0).max(0.0).ceil() as u16; + let top_px = (-expansion.y0).max(0.0).ceil() as u16; + let right_px = expansion.x1.max(0.0).ceil() as u16; + let bottom_px = expansion.y1.max(0.0).ceil() as u16; + + // Convert pixel expansion to tile expansion (round up) + let left_tiles = left_px.div_ceil(WideTile::WIDTH); + let top_tiles = top_px.div_ceil(Tile::HEIGHT); + let right_tiles = right_px.div_ceil(WideTile::WIDTH); + let bottom_tiles = bottom_px.div_ceil(Tile::HEIGHT); + + Self::new( + self.x0().saturating_sub(left_tiles), + self.y0().saturating_sub(top_tiles), + (self.x1() + right_tiles).min(max_x), + (self.y1() + bottom_tiles).min(max_y), + ) + } +} + +impl Wide { + /// Create a new container for wide tiles. + pub fn new(width: u16, height: u16) -> Self { + Self::new_internal(width, height, true) + } +} + +impl Wide { + /// Create a new container for wide tiles. + pub fn new(width: u16, height: u16, enable_bg_optimization: bool) -> Self { + Self::new_internal(width, height, enable_bg_optimization) + } + + /// Record a coarse batch boundary. + /// + /// Each tile lazily emits the pending `BatchEnd` markers the next + /// time it receives a command. + #[inline(always)] + pub fn end_batch(&mut self) { + self.batch_count += 1; + } +} + +impl Wide { + /// Create a new container for wide tiles. + fn new_internal(width: u16, height: u16, enable_bg_optimization: bool) -> Self { + let width_tiles = width.div_ceil(WideTile::WIDTH); + let height_tiles = height.div_ceil(Tile::HEIGHT); + let mut tiles = Vec::with_capacity(usize::from(width_tiles) * usize::from(height_tiles)); + + for h in 0..height_tiles { + for w in 0..width_tiles { + tiles.push(WideTile::::new_internal( + w * WideTile::WIDTH, + h * Tile::HEIGHT, + )); + } + } + + Self { + tiles, + width, + height, + attrs: CommandAttrs::default(), + enable_bg_optimization, + layer_stack: vec![], + clip_stack: vec![], + // Start with root node 0. + filter_node_stack: vec![0], + clipped_filter_layer_depth: 0, + layers_needing_buf_stack: NeedsBufLayerStack::default(), + batch_count: 0, + tiles_dirty: false, + } + } + + /// Whether there are any existing layers that haven't been popped yet. + pub fn has_layers(&self) -> bool { + !self.layer_stack.is_empty() + } + + /// Reset all tiles in the container. + pub fn reset(&mut self) { + if self.tiles_dirty { + for tile in &mut self.tiles { + tile.reset(); + } + + self.tiles_dirty = false; + } + + self.attrs.clear(); + self.layer_stack.clear(); + self.layers_needing_buf_stack.clear(); + self.clip_stack.clear(); + self.filter_node_stack.truncate(1); + self.clipped_filter_layer_depth = 0; + self.batch_count = 0; + } + + /// Return the number of horizontal tiles. + pub fn width_tiles(&self) -> u16 { + self.width.div_ceil(WideTile::WIDTH) + } + + /// Return the number of vertical tiles. + pub fn height_tiles(&self) -> u16 { + self.height.div_ceil(Tile::HEIGHT) + } + + /// Get the index of the wide tile at the given coordinates. + /// + /// Panics if the coordinates are out-of-range. + #[inline] + pub fn get_idx(&self, x: u16, y: u16) -> usize { + assert!( + x < self.width_tiles() && y < self.height_tiles(), + "attempted to access out-of-bounds wide tile" + ); + + usize::from(y) * usize::from(self.width_tiles()) + usize::from(x) + } + + /// Get the index of the wide tile at the given coordinates. + /// + /// Panics if the coordinates are out-of-range. + pub fn get(&self, x: u16, y: u16) -> &WideTile { + let idx = self.get_idx(x, y); + &self.tiles[idx] + } + + /// Get mutable access to the wide tile at the given coordinates. + /// + /// Panics if the coordinates are out-of-range. + fn get_mut(&mut self, x: u16, y: u16) -> &mut WideTile { + let idx = self.get_idx(x, y); + &mut self.tiles[idx] + } + + /// Return a reference to all wide tiles. + pub fn tiles(&self) -> &[WideTile] { + self.tiles.as_slice() + } + + /// Get the current layer Id. + #[inline(always)] + pub fn get_current_layer_id(&self) -> LayerId { + self.layer_stack.last().map_or(0, |l| l.layer_id) + } + + /// Update the bounding box of the current layer to include the given tile. + /// Should be called whenever a command is generated for a tile. + #[inline] + fn update_current_layer_bbox(&mut self, wtile_x: u16, wtile_y: u16) { + if let Some(layer) = self.layer_stack.last_mut() { + layer.wtile_bbox.include_tile(wtile_x, wtile_y); + } + } + + /// Generate wide tile commands from the strip buffer. + /// + /// This method processes a buffer of strips that represent a path, applies the fill rule, + /// and generates appropriate drawing commands for each affected wide tile. + /// + /// # Algorithm overview: + /// 1. For each strip in the buffer: + /// - Calculate its position and width in pixels + /// - Determine which wide tiles the strip intersects + /// - Generate alpha fill commands for the intersected wide tiles + /// 2. For active fill regions (determined by fill rule): + /// - Generate solid fill commands for the regions between strips + pub fn generate( + &mut self, + strip_buf: &[Strip], + paint: Paint, + blend_mode: BlendMode, + thread_idx: u8, + mask: Option, + encoded_paints: &[EncodedPaint], + ) { + if strip_buf.is_empty() { + return; + } + + self.tiles_dirty = true; + let alpha_base_idx = strip_buf[0].alpha_idx(); + + // Create shared attributes for all commands from this path + let attrs_idx = self.attrs.fill.len() as u32; + self.attrs.fill.push(FillAttrs { + thread_idx, + paint, + blend_mode, + mask, + alpha_base_idx, + }); + + // Get current clip bounding box or full viewport if no clip is active + let bbox = self.active_bbox(); + + // Save current_layer_id and batch_count to avoid borrowing issues + let current_layer_id = self.get_current_layer_id(); + let batch_count = self.batch_count; + + for i in 0..strip_buf.len() - 1 { + let strip = &strip_buf[i]; + + debug_assert!( + strip.y < self.height, + "Strips below the viewport should have been culled prior to this stage." + ); + + // Don't render strips that are outside the viewport width + if strip.x >= self.width { + continue; + } + + let next_strip = &strip_buf[i + 1]; + let x0 = strip.x; + let strip_y = strip.strip_y(); + + // Skip strips outside the current clip bounding box + if strip_y < bbox.y0() { + continue; + } + if strip_y >= bbox.y1() { + // The rest of our strips must be outside the clip, so we can break early. + break; + } + + // Calculate the width of the strip in columns + let mut col = strip.alpha_idx() / u32::from(Tile::HEIGHT); + let next_col = next_strip.alpha_idx() / u32::from(Tile::HEIGHT); + // Can potentially be 0 if strip only changes winding without covering pixels + let strip_width = next_col.saturating_sub(col) as u16; + let x1 = x0.saturating_add(strip_width); + + // Calculate which wide tiles this strip intersects + let wtile_x0 = (x0 / WideTile::WIDTH).max(bbox.x0()); + // It's possible that a strip extends into a new wide tile, but we don't actually + // have as many wide tiles (e.g. because the pixmap width is only 512, but + // strip ends at 513), so take the minimum between the rounded values and `width_tiles`. + let wtile_x1 = x1 + .div_ceil(WideTile::WIDTH) + .min(bbox.x1()) + .min(WideTile::MAX_WIDE_TILE_COORD); + + // Adjust column starting position if needed to respect clip boundaries + let mut x = x0; + let clip_x = bbox.x0() * WideTile::WIDTH; + if clip_x > x { + col += u32::from(clip_x - x); + x = clip_x; + } + + // Generate alpha fill commands for each wide tile intersected by this strip + for wtile_x in wtile_x0..wtile_x1 { + let x_wtile_rel = x % WideTile::WIDTH; + // Restrict the width of the fill to the width of the wide tile + let width = x1.min((wtile_x + 1) * WideTile::WIDTH) - x; + let cmd = CmdAlphaFill { + x: x_wtile_rel, + width, + alpha_offset: col * u32::from(Tile::HEIGHT) - alpha_base_idx, + attrs_idx, + }; + x += width; + col += u32::from(width); + let idx = self.get_idx(wtile_x, strip_y); + self.tiles[idx].strip( + batch_count, + idx, + &mut self.layers_needing_buf_stack, + cmd, + current_layer_id, + ); + self.update_current_layer_bbox(wtile_x, strip_y); + } + + // Determine if the region between this strip and the next should be filled. + let active_fill = next_strip.fill_gap(); + + // If region should be filled and both strips are on the same row, + // generate fill commands for the region between them + if active_fill && strip_y == next_strip.strip_y() { + // Clamp the fill to the clip bounding box + x = x1.max(bbox.x0() * WideTile::WIDTH); + let x2 = next_strip.x.min( + self.width + .checked_next_multiple_of(WideTile::WIDTH) + .unwrap_or(u16::MAX), + ); + let wfxt0 = (x1 / WideTile::WIDTH).max(bbox.x0()); + let wfxt1 = x2 + .div_ceil(WideTile::WIDTH) + .min(bbox.x1()) + .min(WideTile::MAX_WIDE_TILE_COORD); + + // Compute fill hint based on paint type + let fill_attrs = &self.attrs.fill[attrs_idx as usize]; + let fill_hint = if fill_attrs.mask.is_none() && self.enable_bg_optimization { + match &fill_attrs.paint { + Paint::Solid(s) if s.is_opaque() => FillHint::OpaqueSolid(*s), + Paint::Indexed(idx) => match encoded_paints.get(idx.index()) { + Some(EncodedPaint::Image(img)) + if !img.may_have_transparency + && img.sampler.alpha == 1.0 + && img.tint.is_none_or(|t| t.color.components[3] >= 1.0) => + { + FillHint::OpaqueImage + } + Some(EncodedPaint::ExternalTexture(img)) + if !img.may_have_transparency + && img.sampler.alpha == 1.0 + && img.tint.is_none_or(|t| t.color.components[3] >= 1.0) => + { + FillHint::OpaqueImage + } + _ => FillHint::None, + }, + _ => FillHint::None, + } + } else { + FillHint::None + }; + + // Generate fill commands for each wide tile in the fill region + for wtile_x in wfxt0..wfxt1 { + let x_wtile_rel = x % WideTile::WIDTH; + let width = x2.min( + (wtile_x + .checked_add(1) + .unwrap_or(WideTile::MAX_WIDE_TILE_COORD)) + * WideTile::WIDTH, + ) - x; + x += width; + let idx = self.get_idx(wtile_x, strip_y); + self.tiles[idx].fill( + batch_count, + idx, + &mut self.layers_needing_buf_stack, + x_wtile_rel, + width, + attrs_idx, + current_layer_id, + fill_hint, + ); + // TODO: This bbox update might be redundant since filled regions are always + // bounded by strip regions (which already update the bbox). Consider removing + // this in a follow-up with proper benchmarks to verify correctness. + self.update_current_layer_bbox(wtile_x, strip_y); + } + } + } + } + + /// Push a new layer with the given properties. + /// + /// Rendering will be directed to the layer storage identified by `layer_id`. + /// This is used for filter effects that require access to a fully-rendered layer. + /// + /// If `filter` is Some, builds render graph nodes for filter effects. + pub fn push_layer( + &mut self, + layer_id: LayerId, + clip_path: Option>>, + blend_mode: BlendMode, + mask: Option, + opacity: f32, + filter: Option, + transform: Affine, + render_graph: &mut RenderGraph, + thread_idx: u8, + ) { + self.tiles_dirty = true; + + // Some explanations about what is going on here: We support the concept of + // layers, where a user can push a new layer (with certain properties), draw some + // stuff, and finally pop the layer, as part of which the layer as a whole will be + // blended into the previous layer. + // There are 3 "straightforward" properties that can be set for each layer: + // 1) The blend mode that should be used to blend the layer into the backdrop. + // 2) A mask that will be applied to the whole layer in the very end before blending. + // 3) An optional opacity that will be applied to the whole layer before blending (this + // could in theory be simulated with an alpha mask, but since it's a common operation and + // we only have a single opacity, this can easily be optimized. + // + // Finally, you can also add a clip path to the layer. However, clipping has its own + // more complicated logic for pushing/popping buffers where drawing is also suppressed + // in clipped-out wide tiles. Because of this, in case we have one of the above properties + // AND a clipping path, we will actually end up pushing two buffers, the first one handles + // the three properties and the second one is just for clip paths. That is a bit wasteful + // and I believe it should be possible to process them all in just one go, but for now + // this is good enough, and it allows us to implement blending without too deep changes to + // the original clipping implementation. + + // Build render graph node ONLY if we have a filter. + // The render graph tracks dependencies and execution order for filter effects. + if let Some(filter) = &filter { + // Create a FilterLayer node that combines render + filter + other operations + let child_node = render_graph.add_node(RenderNodeKind::FilterLayer { + layer_id, + filter: filter.clone(), + // Bounding box starts inverted and will be updated in pop_layer with actual bounds + wtile_bbox: WideTilesBbox::INVERTED, + transform, + }); + + // Connect to parent node if there is one + if let Some(&parent_node) = self.filter_node_stack.last() { + render_graph.add_edge( + child_node, + parent_node, + DependencyKind::Sequential { layer_id }, + ); + } + + // Push this filter node onto the stack so subsequent filters depend on it + self.filter_node_stack.push(child_node); + } + + let has_filter = filter.is_some(); + let has_clip = clip_path.is_some(); + let layer_kind = if has_filter { + LayerKind::Filtered(layer_id) + } else { + LayerKind::Regular(layer_id) + }; + + // Filtered layers with clipping require special handling: normally, tiles with + // zero-winding clips suppress all drawing. However, filters need the full layer + // content rendered (including zero-clipped areas) before applying the clip as a mask. + // When this flag is true, we generate explicit drawing commands instead of just counters. + let in_clipped_filter_layer = has_filter && has_clip; + + // Increment the depth counter so that active_bbox() returns the full viewport + // instead of the clipped bbox. This ensures command generation covers all tiles, + // allowing the filter to process the entire layer before the clip is applied. + if in_clipped_filter_layer { + self.clipped_filter_layer_depth += 1; + } + + let layer = Layer { + layer_id, + clip: has_clip, + blend_mode, + opacity, + mask, + filter, + wtile_bbox: WideTilesBbox::INVERTED, + }; + + // In case we do blending, masking, opacity, or filtering, push one buffer per wide tile. + // + // Layers require buffers for different reasons: + // - Blending, opacity, and masking: Need to composite results with the backdrop + // - Filtering: Content must be rendered to a buffer before applying filter effects + // + // The layer_kind parameter distinguishes how buffers are managed: + // - Regular layers use the local blend_buf stack + // - Filtered layers are materialized in persistent layer storage for filter processing + // - Clip layers have special handling for clipping operations + if layer.needs_buf() { + let batch_count = self.batch_count; + + self.layers_needing_buf_stack.push(layer_kind); + + // We eagerly push buffers for the current active bbox if this is a destructive blend + // or the entire viewport if we are (nested in) a clipped filter layer. + // + // TODO: We may be able to do away with some or all of this eager pushing in the + // future, but for now these types of layers need buffers for all wide tiles regardless + // of whether they're drawn on. + if layer.blend_mode.is_destructive() || self.clipped_filter_layer_depth > 0 { + let active_bbox = if self.clipped_filter_layer_depth > 0 { + self.full_viewport_bbox() + } else { + self.active_bbox() + }; + + for y in active_bbox.y0()..active_bbox.y1() { + for x in active_bbox.x0()..active_bbox.x1() { + let idx = self.get_idx(x, y); + self.tiles[idx].ensure_layer_stack_bufs( + idx, + &mut self.layers_needing_buf_stack, + batch_count, + ); + // Mark tiles that are in a clipped filter layer so they generate + // explicit clip commands for proper filter processing. + self.tiles[idx].in_clipped_filter_layer = in_clipped_filter_layer; + } + } + } + } + + // If we have a clip path, push another buffer in the affected wide tiles. + // Note that it is important that we FIRST push the buffer for blending etc. and + // only then for clipping, otherwise we will use the empty clip buffer as the backdrop + // for blending! + if let Some(clip) = clip_path { + self.push_clip(clip, layer_id, thread_idx); + } + + self.layer_stack.push(layer); + } + + /// Pop a previously pushed layer. + /// + /// This method finalizes the layer by: + /// - Expanding the bounding box if filter effects are present + /// - Updating the parent layer's bounding box to include this layer's bounds + /// - Completing render graph nodes for filter effects + /// - Generating filter commands for each tile + /// - Popping any associated clip + /// - Applying mask, opacity, and blend mode operations if needed + pub fn pop_layer(&mut self, render_graph: &mut RenderGraph) { + self.tiles_dirty = true; + + // This method basically unwinds everything we did in `push_layer`. + let mut layer = self.layer_stack.pop().unwrap(); + let batch_count = self.batch_count; + + if let Some(filter) = &layer.filter { + let mut final_bbox = WideTilesBbox::INVERTED; + + // Update render graph node with final bounding box + if let Some(node_id) = self.filter_node_stack.pop() { + // Get the transform from the FilterLayer node and scale the expansion by it + if let Some(node) = render_graph.nodes.get_mut(node_id) + && let RenderNodeKind::FilterLayer { + wtile_bbox, + transform, + .. + } = &mut node.kind + { + // Calculate expansion in device/pixel space, accounting for the full transform. + // This ensures that rotated filters (e.g., drop shadows) have correct bounds. + let expansion = filter.bounds_expansion(transform); + let expanded_bbox = layer.wtile_bbox.expand_by_pixels( + expansion, + self.width_tiles(), + self.height_tiles(), + ); + let clip_bbox = self.active_bbox(); + final_bbox = expanded_bbox.intersect(clip_bbox); + + // Update both the local layer and the render graph node + layer.wtile_bbox = final_bbox; + *wtile_bbox = final_bbox; + } + // Record this node in execution order (children before parents) + render_graph.record_node_for_execution(node_id); + } + + // Generate filter commands for each tile (used for non-graph path rendering) + // Apply filter BEFORE clipping (per SVG spec: filter → clip → mask → opacity → blend) + // Also ensure that each wide tile in the filter bbox (out of which some might not + // have been drawn on) has a `PushBuf` command. + for x in final_bbox.x0()..final_bbox.x1() { + for y in final_bbox.y0()..final_bbox.y1() { + let idx = self.get_idx(x, y); + self.tiles[idx].ensure_layer_stack_bufs( + idx, + &mut self.layers_needing_buf_stack, + batch_count, + ); + + // Note that unlike commands like "mask" or "opacity" which only need to be applied + // to wide tiles with drawing commands, filter commands always need to be applied + // to all wide tiles in the filter bounding-box, because it's possible that after + // applying a filter (like gaussian blur), wide tiles will take on a new color + // even though nothing had been drawn there originally. + self.tiles[idx].filter(layer.layer_id, filter.clone()); + } + } + } + + // Union this layer's bbox into the parent layer's bbox. + // This ensures the parent knows about all tiles used by this child layer, + // which is important for filter effects that may expand beyond the original content bounds. + if let Some(parent_layer) = self.layer_stack.last_mut() { + parent_layer.wtile_bbox.union(layer.wtile_bbox); + } + + if layer.clip { + self.pop_clip(); + } + + if layer.needs_buf() { + for &tile_idx in self + .layers_needing_buf_stack + .last() + .unwrap() + .occupied_tiles + .iter() + { + let tile = &mut self.tiles[tile_idx]; + + // Optimization: If no drawing happened since the last `PushBuf`, then we don't + // need to do any masking or buffer-wide opacity work. Even though we push buffers + // lazily, this can still happen: e.g., filter layers with clips and destructive blends + // currently push layers eagerly. + let has_draw_commands = !matches!(tile.cmds.last().unwrap(), &Cmd::PushBuf(..)); + if has_draw_commands { + if let Some(mask) = layer.mask.clone() { + tile.mask(mask); + } + tile.opacity(layer.opacity); + } + // We only need to blend if there are draw commands, unless this is destructive + // blending, in which case we always blend. + if has_draw_commands || layer.blend_mode.is_destructive() { + tile.blend(layer.blend_mode); + } + tile.pop_buf(); + } + self.layers_needing_buf_stack.pop(); + } + + let in_clipped_filter_layer = layer.filter.is_some() && layer.clip; + // Decrement the depth counter after popping a filtered layer with clip + if in_clipped_filter_layer { + self.clipped_filter_layer_depth -= 1; + } + } + + /// Adds a clipping region defined by the provided strips. + /// + /// This method takes a vector of strips representing a clip path, calculates the + /// intersection with the current clip region, and updates the clip stack. + /// + /// # Algorithm overview: + /// 1. Calculate bounding box of the clip path + /// 2. Intersect with current clip bounding box + /// 3. For each tile in the intersected bounding box: + /// - If covered by zero winding: `push_zero_clip` + /// - If fully covered by non-zero winding: do nothing (clip is a no-op) + /// - If partially covered: `push_clip` + fn push_clip(&mut self, strips: impl Into>, layer_id: LayerId, thread_idx: u8) { + let strips = strips.into(); + let n_strips = strips.len(); + + // Calculate the bounding box of the clip path in strip coordinates + let path_bbox = if n_strips <= 1 { + WideTilesBbox::ZERO + } else { + // Calculate the y range from first to last strip in wide tile coordinates + let wtile_y0 = strips[0].strip_y(); + let wtile_y1 = strips[n_strips.saturating_sub(1)].strip_y() + 1; + + // Calculate the x range by examining all strips in wide tile coordinates + let mut wtile_x0 = strips[0].x / WideTile::WIDTH; + let mut wtile_x1 = wtile_x0; + for i in 0..n_strips.saturating_sub(1) { + let strip = &strips[i]; + let next_strip = &strips[i + 1]; + let width = + ((next_strip.alpha_idx() - strip.alpha_idx()) / u32::from(Tile::HEIGHT)) as u16; + let x = strip.x; + wtile_x0 = wtile_x0.min(x / WideTile::WIDTH); + wtile_x1 = wtile_x1.max((x + width).div_ceil(WideTile::WIDTH)); + } + WideTilesBbox::new(wtile_x0, wtile_y0, wtile_x1, wtile_y1) + }; + + let parent_bbox = self.active_bbox(); + // Determine which tiles need clip processing: + // - For clipped filter layers: active_bbox() returns the full viewport, so parent_bbox + // already covers all tiles. We need to process all of them because the filter needs + // the entire layer rendered, and tiles outside the clip path must get `PushZeroClip` + // commands to properly suppress their content after filtering. + // - For normal clips: Intersect with the path bounds to only process tiles that are + // actually affected by the clip path, avoiding unnecessary work. + let clip_bbox = if self.clipped_filter_layer_depth > 0 { + // Use parent_bbox as-is (full viewport) to process all tiles + parent_bbox + } else { + // Optimize by processing only the intersection of parent and path bounds + parent_bbox.intersect(path_bbox) + }; + + let mut cur_wtile_x = clip_bbox.x0(); + let mut cur_wtile_y = clip_bbox.y0(); + let batch_count = self.batch_count; + + // Process strips to determine the clipping state for each wide tile + for i in 0..n_strips.saturating_sub(1) { + let strip = &strips[i]; + let strip_y = strip.strip_y(); + + // Skip strips before current wide tile row + if strip_y < cur_wtile_y { + continue; + } + + // Process wide tiles in rows before this strip's row + // These wide tiles are all zero-winding (outside the path) + while cur_wtile_y < strip_y.min(clip_bbox.y1()) { + for wtile_x in cur_wtile_x..clip_bbox.x1() { + self.get_mut(wtile_x, cur_wtile_y).push_zero_clip(layer_id); + } + // Reset x to the left edge of the clip bounding box + cur_wtile_x = clip_bbox.x0(); + // Move to the next row + cur_wtile_y += 1; + } + + // If we've reached the bottom of the clip bounding box, stop processing. + // Note that we are explicitly checking >= instead of ==, so that we abort if the clipping box + // is zero-area (see issue 1072). + if cur_wtile_y >= clip_bbox.y1() { + break; + } + + // Process wide tiles to the left of this strip in the same row + let x = strip.x; + let wtile_x_clamped = (x / WideTile::WIDTH).min(clip_bbox.x1()); + if cur_wtile_x < wtile_x_clamped { + // If winding is zero or doesn't match fill rule, these wide tiles are outside the path + let is_inside = strip.fill_gap(); + if !is_inside { + for wtile_x in cur_wtile_x..wtile_x_clamped { + self.get_mut(wtile_x, cur_wtile_y).push_zero_clip(layer_id); + } + } + // If winding is nonzero, then wide tiles covered entirely + // by sparse fill are no-op (no clipping is applied). + cur_wtile_x = wtile_x_clamped; + } + + // Process wide tiles covered by the strip - these need actual clipping + let next_strip = &strips[i + 1]; + let width = + ((next_strip.alpha_idx() - strip.alpha_idx()) / u32::from(Tile::HEIGHT)) as u16; + let wtile_x1 = (x + width).div_ceil(WideTile::WIDTH).min(clip_bbox.x1()); + if cur_wtile_x < wtile_x1 { + for wtile_x in cur_wtile_x..wtile_x1 { + let idx = self.get_idx(wtile_x, cur_wtile_y); + self.tiles[idx].push_clip( + idx, + &mut self.layers_needing_buf_stack, + layer_id, + batch_count, + ); + } + cur_wtile_x = wtile_x1; + } + } + + // Process any remaining wide tiles in the bounding box (all zero-winding) + while cur_wtile_y < clip_bbox.y1() { + for wtile_x in cur_wtile_x..clip_bbox.x1() { + self.get_mut(wtile_x, cur_wtile_y).push_zero_clip(layer_id); + } + cur_wtile_x = clip_bbox.x0(); + cur_wtile_y += 1; + } + + self.clip_stack.push(Clip { + clip_bbox, + strips, + thread_idx, + }); + } + + /// Get the bounding box of the current clip region or the entire viewport if no clip regions are active. + fn active_bbox(&self) -> WideTilesBbox { + // When in a clipped filter layer, use full viewport to allow + // filter to process the complete layer before applying clip as mask + if self.clipped_filter_layer_depth > 0 { + return self.full_viewport_bbox(); + } + + self.clip_stack + .last() + .map(|top| top.clip_bbox) + .unwrap_or_else(|| self.full_viewport_bbox()) + } + + /// Returns the bounding box covering the entire viewport in wide tile coordinates. + fn full_viewport_bbox(&self) -> WideTilesBbox { + WideTilesBbox::new(0, 0, self.width_tiles(), self.height_tiles()) + } + + /// Removes the most recently added clip region. + /// + /// This is the inverse operation of `push_clip`, carefully undoing all the clipping + /// operations while also handling any rendering needed for the clip region itself. + /// + /// # Algorithm overview: + /// 1. Retrieve the top clip from the stack + /// 2. For each wide tile in the clip's bounding box: + /// - If covered by zero winding: `pop_zero_clip` + /// - If fully covered by non-zero winding: do nothing (was no-op) + /// - If partially covered: render the clip and `pop_clip` + /// + /// This operation must be symmetric with `push_clip` to maintain a balanced clip stack. + fn pop_clip(&mut self) { + let Clip { + clip_bbox, + strips, + thread_idx, + } = self.clip_stack.pop().unwrap(); + let n_strips = strips.len(); + + // Compute base alpha index and create shared clip attributes + // Note: It's possible that the clip-path has zero strips. However, we cannot exit early + // in this case because we need to potentially pop zero clips further below. Therefore, + // we simply use a dummy alpha index of 0 in this case. + let clip_attrs_idx = self.attrs.clip.len() as u32; + let alpha_base_idx; + if n_strips == 0 { + alpha_base_idx = 0; + } else { + alpha_base_idx = strips[0].alpha_idx(); + self.attrs.clip.push(ClipAttrs { + thread_idx, + alpha_base_idx, + }); + }; + + let mut cur_wtile_x = clip_bbox.x0(); + let mut cur_wtile_y = clip_bbox.y0(); + let mut pop_pending = false; + + // Process each strip to determine the clipping state for each tile + for i in 0..n_strips.saturating_sub(1) { + let strip = &strips[i]; + let strip_y = strip.strip_y(); + + // Skip strips before current tile row + if strip_y < cur_wtile_y { + continue; + } + + // Process tiles in rows before this strip's row + // These tiles all had zero-winding clips + while cur_wtile_y < strip_y.min(clip_bbox.y1()) { + // Handle any pending clip pop from previous iteration + if core::mem::take(&mut pop_pending) { + self.get_mut(cur_wtile_x, cur_wtile_y).pop_clip(); + cur_wtile_x += 1; + } + + // Pop zero clips for all remaining tiles in this row + for wtile_x in cur_wtile_x..clip_bbox.x1() { + self.get_mut(wtile_x, cur_wtile_y).pop_zero_clip(); + } + cur_wtile_x = clip_bbox.x0(); + cur_wtile_y += 1; + } + + // If we've reached the bottom of the clip bounding box, stop processing + // Note that we are explicitly checking >= instead of ==, so that we abort if the clipping box + // is zero-area (see issue 1072). + if cur_wtile_y >= clip_bbox.y1() { + break; + } + + // Process tiles to the left of this strip in the same row + let x0 = strip.x; + let wtile_x_clamped = (x0 / WideTile::WIDTH).min(clip_bbox.x1()); + if cur_wtile_x < wtile_x_clamped { + // Handle any pending clip pop from previous iteration + if core::mem::take(&mut pop_pending) { + self.get_mut(cur_wtile_x, cur_wtile_y).pop_clip(); + cur_wtile_x += 1; + } + + // Pop zero clips for tiles that had zero winding or didn't match fill rule + // TODO: The winding check is probably not needed; if there was a fill, + // the logic below should have advanced wtile_x. + let is_inside = strip.fill_gap(); + if !is_inside { + for wtile_x in cur_wtile_x..wtile_x_clamped { + self.get_mut(wtile_x, cur_wtile_y).pop_zero_clip(); + } + } + cur_wtile_x = wtile_x_clamped; + } + + // Process tiles covered by the strip - render clip content and pop + let next_strip = &strips[i + 1]; + let strip_width = + ((next_strip.alpha_idx() - strip.alpha_idx()) / u32::from(Tile::HEIGHT)) as u16; + let mut clipped_x1 = x0 + strip_width; + let wtile_x0 = (x0 / WideTile::WIDTH).max(clip_bbox.x0()); + let wtile_x1 = clipped_x1.div_ceil(WideTile::WIDTH).min(clip_bbox.x1()); + + // Calculate starting position and column for alpha mask + let mut x = x0; + let mut col = strip.alpha_idx() / u32::from(Tile::HEIGHT); + let clip_x = clip_bbox.x0() * WideTile::WIDTH; + if clip_x > x { + col += u32::from(clip_x - x); + x = clip_x; + clipped_x1 = clip_x.max(clipped_x1); + } + + // Render clip strips for each affected tile and mark for popping + for wtile_x in wtile_x0..wtile_x1 { + // If we've moved past tile_x and have a pending pop, do it now + if cur_wtile_x < wtile_x && core::mem::take(&mut pop_pending) { + self.get_mut(cur_wtile_x, cur_wtile_y).pop_clip(); + } + + // Calculate the portion of the strip that affects this tile + let x_rel = x % WideTile::WIDTH; + let width = clipped_x1.min((wtile_x + 1) * WideTile::WIDTH) - x; + + // Create clip strip command for rendering the partial coverage + let cmd = CmdClipAlphaFill { + x: x_rel, + width, + alpha_offset: col * u32::from(Tile::HEIGHT) - alpha_base_idx, + attrs_idx: clip_attrs_idx, + }; + x += width; + col += u32::from(width); + + // Apply the clip strip command and update state + self.get_mut(wtile_x, cur_wtile_y).clip_strip(cmd); + cur_wtile_x = wtile_x; + + // Only request a pop if the x coordinate is actually inside the bounds. + if cur_wtile_x < clip_bbox.x1() { + pop_pending = true; + } + } + + // Handle fill regions between strips based on fill rule + let is_inside = next_strip.fill_gap(); + if is_inside && strip_y == next_strip.strip_y() { + if cur_wtile_x >= clip_bbox.x1() { + continue; + } + + let x2 = next_strip.x; + let clipped_x2 = x2.min((cur_wtile_x + 1) * WideTile::WIDTH); + let width = clipped_x2.saturating_sub(clipped_x1); + + // If there's a gap, fill it. Only do this if the fill wouldn't cover the + // whole tile, as such clips are skipped by the `push_clip` function. See + // + if width > 0 && width < WideTile::WIDTH { + let x_rel = clipped_x1 % WideTile::WIDTH; + self.get_mut(cur_wtile_x, cur_wtile_y) + .clip_fill(x_rel, width); + } + + // If the next strip is a sentinel, skip the fill + // It's a sentinel in the row if there is non-zero winding for the sparse fill + // Look more into this in the strip.rs render function + if x2 == u16::MAX { + continue; + } + + // If fill extends to next tile, pop current and handle next + if x2 > (cur_wtile_x + 1) * WideTile::WIDTH { + if core::mem::take(&mut pop_pending) { + self.get_mut(cur_wtile_x, cur_wtile_y).pop_clip(); + } + + let width2 = x2 % WideTile::WIDTH; + cur_wtile_x = x2 / WideTile::WIDTH; + + // If the strip is outside the clipping box, we don't need to do any + // filling, so we continue (also to prevent out-of-bounds access). + if cur_wtile_x >= clip_bbox.x1() { + continue; + } + + if width2 > 0 { + // An important thing to note: Note that we are only applying + // `clip_fill` to the wide tile that is actually covered by the next + // strip, and not the ones in-between! For example, if the first strip + // is in wide tile 1 and the second in wide tile 4, we will do a clip + // fill in wide tile 1 and 4, but not in 2 and 3. The reason for this is + // that any tile in-between is fully covered and thus no clipping is + // necessary at all. See also the `push_clip` function, where we don't + // push a new buffer for such tiles. + self.get_mut(cur_wtile_x, cur_wtile_y).clip_fill(0, width2); + } + } + } + } + + // Handle any pending clip pop from the last iteration + if core::mem::take(&mut pop_pending) { + self.get_mut(cur_wtile_x, cur_wtile_y).pop_clip(); + cur_wtile_x += 1; + } + + // Process any remaining tiles in the bounding box (all zero-winding) + while cur_wtile_y < clip_bbox.y1() { + for wtile_x in cur_wtile_x..clip_bbox.x1() { + self.get_mut(wtile_x, cur_wtile_y).pop_zero_clip(); + } + cur_wtile_x = clip_bbox.x0(); + cur_wtile_y += 1; + } + } +} + +/// A wide tile. +#[derive(Debug)] +pub struct WideTile { + /// The x coordinate of the wide tile. + pub x: u16, + /// The y coordinate of the wide tile. + pub y: u16, + /// The background of the tile. + pub bg: PremulColor, + /// The draw commands of the tile. + pub cmds: Vec, + /// The number of zero-winding clips. + n_zero_clip: usize, + /// The number of non-zero-winding clips. + n_clip: usize, + /// The number of pushed buffers, including buffers for clips. + /// + /// Note not all layers require their own buffers; see [`Layer::needs_buf`]. + n_bufs: usize, + /// True when this tile is in a filtered layer with clipping applied. + /// When set, clip operations generate explicit commands instead of just + /// tracking counters, allowing filters to process clipped content correctly. + in_clipped_filter_layer: bool, + /// Maps layer Id to command ranges for this tile. + pub layer_cmd_ranges: HashMap, + /// Vector of layer IDs this tile participates in. + layer_ids: Vec, + /// Tracks the index into `cmds` of each `Start`/`PushBuf` command on the current stack. + /// + /// Only used in `HYBRID` mode. + push_buf_indices: Vec, + /// Whether at least one filter layer has been pushed on this tile. + had_filter_layer: bool, + /// Indicates whether the main target surface is used as a blend target for a non + /// src-over blending operation. + /// + /// This will only be set in `HYBRID` mode. + surface_is_blend_target: bool, + /// Watermark: the batch count at which this tile last emitted `BatchEnd` markers. + /// Only meaningful in `MODE_HYBRID`. + last_batch_end: u32, +} + +impl WideTile { + /// The width of a wide tile in pixels. + pub const WIDTH: u16 = 256; + /// The maximum coordinate of a wide tile. + pub const MAX_WIDE_TILE_COORD: u16 = u16::MAX / Self::WIDTH; +} + +impl WideTile { + /// Create a new wide tile. + pub fn new(width: u16, height: u16) -> Self { + Self::new_internal(width, height) + } +} + +impl WideTile { + /// Create a new wide tile. + pub fn new(width: u16, height: u16) -> Self { + Self::new_internal(width, height) + } +} + +impl WideTile { + /// Create a new wide tile. + fn new_internal(x: u16, y: u16) -> Self { + let mut layer_cmd_ranges = HashMap::new(); + layer_cmd_ranges.insert(0, LayerCommandRanges::default()); + Self { + x, + y, + bg: PremulColor::from_alpha_color(TRANSPARENT), + cmds: vec![], + n_zero_clip: 0, + n_clip: 0, + n_bufs: 0, + in_clipped_filter_layer: false, + had_filter_layer: false, + layer_cmd_ranges, + layer_ids: vec![LayerKind::Regular(0)], + push_buf_indices: vec![TARGET_SURFACE_PUSH_BUF_IDX], + surface_is_blend_target: false, + last_batch_end: 0, + } + } + + #[inline] + fn reset(&mut self) { + self.bg = PremulColor::from_alpha_color(TRANSPARENT); + self.cmds.clear(); + self.n_zero_clip = 0; + self.n_clip = 0; + self.n_bufs = 0; + self.in_clipped_filter_layer = false; + self.had_filter_layer = false; + self.layer_ids.truncate(1); + self.layer_cmd_ranges.clear(); + self.layer_cmd_ranges + .insert(0, LayerCommandRanges::default()); + self.push_buf_indices.clear(); + // We can't use 0 here, because then we have no way of distinguishing it from a + // user-supplied `PushBuf` at position 0. + self.push_buf_indices.push(TARGET_SURFACE_PUSH_BUF_IDX); + self.surface_is_blend_target = false; + self.last_batch_end = 0; + } + + /// Push all layer buffers that have not yet been pushed for this tile. + /// Also emit `BatchEnd` commands in case we are in HYBRID mode. + /// + /// We push wide tile layer buffers lazily. This is called by wide tile draw methods to ensure + /// all layer stack buffers are pushed for the wide tile. Calling this multiple times won't + /// push additional buffers beyond the first call (until the layer stack changes). + /// + /// The `tile_idx` parameter is this tile's index in [`Wide::tiles`]. + #[inline(always)] + fn ensure_layer_stack_bufs( + &mut self, + tile_idx: usize, + layers: &mut NeedsBufLayerStack, + batch_count: u32, + ) { + // First emit `BatchEnd` commands if necessary. + if MODE == MODE_HYBRID && self.last_batch_end < batch_count { + let count = (batch_count - self.last_batch_end) as usize; + self.cmds.extend(core::iter::repeat_n(Cmd::BatchEnd, count)); + self.last_batch_end = batch_count; + } + + // Then, take care of emitting `PushBuf` commands that haven't been emitted yet. + + // `layers` tracks the number of layers that require scratch buffers, excluding those + // required for clips: clip buffers are handled separately. The scratch buffer stack for + // this tile is `self.n_bufs`, of which `self.n_clip` are for clips. + let layer_bufs = self.n_bufs - self.n_clip; + debug_assert!( + layer_bufs <= layers.len, + "tile `layer_buf_depth` exceeds active layer stack" + ); + // It may be quite likely that no buffers need to be pushed: e.g. a tile that has content + // may well have more than one call to `ensure_layer_stack_bufs`, and layers needing + // buffers are not necessarily the most common case. As such, we keep this check inlined + // (because the function itself is inlined), and if buffers are needed do an explicit + // function call that is not inlined. That keeps the generated code size small at our call + // sites. + (layer_bufs < layers.len).then( + #[inline(never)] + || { + for layer in &mut layers.stack[layer_bufs..layers.len] { + layer.occupied_tiles.push(tile_idx); + self.push_buf(layer.kind); + } + }, + ); + } + + /// Fill a rectangular region with a paint. + /// + /// Generates fill commands unless the tile is in a zero-clip region (fully clipped out). + /// For clipped filter layers, commands are always generated since filters need the full + /// layer content rendered before applying the clip as a mask. + /// + /// The `fill_hint` parameter is pre-computed by the caller based on paint type: + /// - `OpaqueSolid(color)`: Paint is an opaque solid color, can replace background + /// - `OpaqueImage`: Paint is an opaque image, can clear previous commands + /// - `None`: No optimization available + fn fill( + &mut self, + batch_count: u32, + tile_idx: usize, + layers: &mut NeedsBufLayerStack, + x: u16, + width: u16, + attrs_idx: u32, + current_layer_id: LayerId, + fill_hint: FillHint, + ) { + if !self.is_zero_clip() || self.in_clipped_filter_layer { + self.ensure_layer_stack_bufs(tile_idx, layers, batch_count); + + // Check if we can apply overdraw elimination optimization. + // This requires filling the entire tile width with no clip/buffer stack. + // + // Note that we could be more aggressive in optimizing a whole-tile opaque fill + // even with a clip stack. It would be valid to elide all drawing commands from + // the enclosing clip push up to the fill. Further, we could extend the clip + // push command to include a background color, rather than always starting with + // a transparent buffer. Lastly, a sequence of push(bg); strip/fill; pop could + // be replaced with strip/fill with the color (the latter is true even with a + // non-opaque color). + // + // However, the extra cost of tracking such optimizations may outweigh the + // benefit, especially in hybrid mode with GPU painting. + + let can_override = x == 0 + && width == WideTile::WIDTH + && self.n_clip == 0 + && self.n_bufs == 0 + // TODO: Relax this condition to only trigger for filter graphs that contain at + // least 1 primitive filter that has non-local effects (see + // https://github.com/linebender/vello/pull/1526#discussion_r3007377869) + && !self.had_filter_layer; + + if can_override { + match fill_hint { + FillHint::OpaqueSolid(color) => { + self.cmds.clear(); + self.bg = color; + + // We need to invalidate the ranges of all layers that have been drawn so far + // in that wide tile. + self.layer_cmd_ranges.clear(); + self.layer_cmd_ranges + .insert(current_layer_id, LayerCommandRanges::default()); + return; + } + FillHint::OpaqueImage => { + // Opaque image: clear previous commands but still emit the fill. + self.cmds.clear(); + self.bg = PremulColor::from_alpha_color(TRANSPARENT); + + // We need to invalidate the ranges of all layers that have been drawn so far + // in that wide tile. + self.layer_cmd_ranges.clear(); + self.layer_cmd_ranges + .insert(current_layer_id, LayerCommandRanges::default()); + // Fall through to emit the fill command below, as opposed to + // solid paints where we have a return statement. + } + FillHint::None => {} + } + } + + self.record_fill_cmd(current_layer_id, self.cmds.len()); + self.cmds.push(Cmd::Fill(CmdFill { + x, + width, + attrs_idx, + })); + } + } + + /// Fill a region using an alpha mask from a strip. + /// + /// Generates alpha fill commands unless the tile is in a zero-clip region (fully clipped out). + /// For clipped filter layers, commands are always generated since filters need the full + /// layer content rendered before applying the clip as a mask. + fn strip( + &mut self, + batch_count: u32, + tile_idx: usize, + layers: &mut NeedsBufLayerStack, + cmd_strip: CmdAlphaFill, + current_layer_id: LayerId, + ) { + if !self.is_zero_clip() || self.in_clipped_filter_layer { + self.ensure_layer_stack_bufs(tile_idx, layers, batch_count); + + self.record_fill_cmd(current_layer_id, self.cmds.len()); + self.cmds.push(Cmd::AlphaFill(cmd_strip)); + } + } + + /// Adds a new clip region to the current wide tile. + /// + /// Pushes a clip buffer unless the tile is in a zero-clip region (fully clipped out). + /// For clipped filter layers, clip buffers are always pushed since filters need explicit + /// clip state to process the full layer before applying the clip as a mask. + fn push_clip( + &mut self, + tile_idx: usize, + layers: &mut NeedsBufLayerStack, + layer_id: LayerId, + batch_count: u32, + ) { + if !self.is_zero_clip() || self.in_clipped_filter_layer { + self.ensure_layer_stack_bufs(tile_idx, layers, batch_count); + self.push_buf(LayerKind::Clip(layer_id)); + self.n_clip += 1; + } + } + + /// Removes the most recently added clip region from the current wide tile. + /// + /// Pops a clip buffer unless the tile is in a zero-clip region (fully clipped out). + /// For clipped filter layers, clip buffers are always popped since filters need explicit + /// clip state to process the full layer before applying the clip as a mask. + fn pop_clip(&mut self) { + if !self.is_zero_clip() || self.in_clipped_filter_layer { + self.pop_buf(); + self.n_clip -= 1; + } + } + + /// Adds a zero-winding clip region to the stack. + /// + /// Zero-winding clips represent tiles completely outside the clip path. + /// Normally these just increment a counter to suppress drawing, but for + /// clipped filter layers we generate explicit commands so filters can + /// process the entire layer before applying the clip as a mask. + fn push_zero_clip(&mut self, layer_id: LayerId) { + if self.in_clipped_filter_layer { + // Generate explicit command for filter processing + self.cmds.push(Cmd::PushZeroClip(layer_id)); + } + self.n_zero_clip += 1; + } + + /// Removes the most recently added zero-winding clip region. + fn pop_zero_clip(&mut self) { + if self.in_clipped_filter_layer { + // Generate explicit command for filter processing + self.cmds.push(Cmd::PopZeroClip); + } + self.n_zero_clip -= 1; + } + + /// Checks if the current clip region is a zero-winding clip. + fn is_zero_clip(&mut self) -> bool { + self.n_zero_clip > 0 + } + + /// Applies a clip strip operation with the given parameters. + /// + /// Note: Unlike content operations (`strip`, `push_clip`, etc.), clip operations don't need + /// the `|| self.in_clipped_filter_layer` check. Filter effects need full layer *content* + /// rendered (even in zero-clip areas). + fn clip_strip(&mut self, cmd_clip_strip: CmdClipAlphaFill) { + if (!self.is_zero_clip()) && !matches!(self.cmds.last(), Some(Cmd::PushBuf(..))) { + self.cmds.push(Cmd::ClipStrip(cmd_clip_strip)); + } + } + + /// Applies a clip fill operation at the specified position and width. + fn clip_fill(&mut self, x: u16, width: u16) { + if (!self.is_zero_clip()) && !matches!(self.cmds.last(), Some(Cmd::PushBuf(..))) { + self.cmds.push(Cmd::ClipFill(CmdClipFill { x, width })); + } + } + + /// Records the fill command for a specific layer. + fn record_fill_cmd(&mut self, layer_id: LayerId, cmd_idx: usize) { + self.layer_cmd_ranges.entry(layer_id).and_modify(|ranges| { + ranges.full_range.end = cmd_idx + 1; + if ranges.render_range.is_empty() { + ranges.render_range = cmd_idx..cmd_idx + 1; + } else { + ranges.render_range.end = cmd_idx + 1; + } + }); + } + + /// Whether the set of pushed commands so far indicates that the surface is used as + /// a blend target. + pub fn surface_is_blend_target(&self) -> bool { + self.surface_is_blend_target + } + + /// Push a buffer for a new layer. + /// + /// Different layer kinds are handled differently: + /// - Regular layers: Use local `blend_buf` stack for temporary storage + /// - Filtered layers: Materialized in persistent layer storage for filter processing + /// - Clip layers: Special handling for clipping operations + #[inline(always)] + fn push_buf(&mut self, layer_kind: LayerKind) { + let top_layer = layer_kind.id(); + if matches!(layer_kind, LayerKind::Filtered(_)) { + self.layer_cmd_ranges.insert( + top_layer, + LayerCommandRanges { + full_range: self.cmds.len()..self.cmds.len() + 1, + // Start with empty render_range; will be updated by `record_fill_cmd` and `pop_buf`. + render_range: self.cmds.len() + 1..self.cmds.len() + 1, + }, + ); + } else if matches!(layer_kind, LayerKind::Clip(_)) { + self.layer_cmd_ranges.entry(top_layer).and_modify(|ranges| { + ranges.full_range.end = self.cmds.len() + 1; + // Start with empty render_range; will be updated by `record_fill_cmd` and `pop_buf`. + ranges.render_range = self.cmds.len() + 1..self.cmds.len() + 1; + }); + } + + if MODE == MODE_HYBRID { + self.push_buf_indices.push(self.cmds.len()); + } + + self.cmds.push(Cmd::PushBuf(layer_kind, false)); + self.layer_ids.push(layer_kind); + self.n_bufs += 1; + } + + /// Pop the most recent buffer. + #[inline(always)] + fn pop_buf(&mut self) { + if MODE == MODE_HYBRID { + self.push_buf_indices.pop(); + } + + let top_layer = self.layer_ids.pop().unwrap(); + let mut next_layer = *self.layer_ids.last().unwrap(); + + if matches!(self.cmds.last(), Some(&Cmd::PushBuf(..))) { + // Optimization: If no drawing happened between the last `PushBuf`, + // we can just pop it instead. + self.cmds.pop(); + } else { + self.layer_cmd_ranges + .entry(top_layer.id()) + .and_modify(|ranges| { + ranges.full_range.end = self.cmds.len() + 1; + }); + if top_layer.id() == next_layer.id() { + next_layer = *self + .layer_ids + .get(self.layer_ids.len().saturating_sub(2)) + .unwrap(); + } + + self.layer_cmd_ranges + .entry(next_layer.id()) + .and_modify(|ranges| { + ranges.full_range.end = self.cmds.len() + 1; + ranges.render_range.end = self.cmds.len() + 1; + }); + self.cmds.push(Cmd::PopBuf); + } + self.n_bufs -= 1; + } + + /// Apply an opacity to the whole buffer. + fn opacity(&mut self, opacity: f32) { + if opacity != 1.0 { + self.cmds.push(Cmd::Opacity(opacity)); + } + } + + /// Apply a filter effect to the whole buffer. + pub fn filter(&mut self, layer_id: LayerId, filter: Filter) { + self.had_filter_layer = true; + self.cmds.push(Cmd::Filter(layer_id, filter)); + } + + /// Apply a mask to the whole buffer. + fn mask(&mut self, mask: Mask) { + self.cmds.push(Cmd::Mask(mask)); + } + + /// Blend the current buffer into the previous buffer in the stack. + fn blend(&mut self, blend_mode: BlendMode) { + #[allow(clippy::collapsible_if, reason = "better expresses intent")] + if MODE == MODE_HYBRID { + // Whether we use a non-default blend mode to blend into the destination. + let blends_into_dest = + blend_mode.mix != Mix::Normal || blend_mode.compose != Compose::SrcOver; + + if blends_into_dest && self.push_buf_indices.len() >= 2 { + let nos_idx = self.push_buf_indices[self.push_buf_indices.len() - 2]; + + if nos_idx == TARGET_SURFACE_PUSH_BUF_IDX { + self.surface_is_blend_target = true; + } else { + match &mut self.cmds[nos_idx] { + Cmd::PushBuf(_, is_blend_target) => *is_blend_target = true, + // Anything else shouldn't be possible. + _ => unreachable!(), + } + } + } + } + + self.cmds.push(Cmd::Blend(blend_mode)); + } +} + +/// Debug utilities for wide tiles. +/// +/// These methods are only available in debug builds (`debug_assertions`). +/// They provide introspection into the command buffer for debugging and logging purposes. +#[cfg(debug_assertions)] +impl WideTile { + /// Lists all commands in this wide tile with their indices and names. + /// + /// Returns a formatted string with each command on a new line, showing its index + /// and human-readable name. This is useful for debugging and understanding the + /// command sequence. + /// + /// # Example + /// + /// ```ignore + /// let commands = wide_tile.list_commands(); + /// println!("{}", commands); + /// // Output: + /// // 0: PushBuf(Regular) + /// // 1: FillPath + /// // 2: PushZeroClip + /// // 3: FillPath + /// // 4: PopBuf + /// ``` + #[allow(dead_code, reason = "useful for debugging")] + pub fn list_commands(&self) -> String { + self.cmds + .iter() + .enumerate() + .map(|(i, cmd)| format!("{}: {}", i, cmd.name())) + .collect::>() + .join("\n") + } +} + +/// Optimization hint for fill operations, computed in `Wide::generate` and passed to `WideTile::fill`. +/// +/// This enum communicates whether a fill operation can benefit from overdraw elimination: +/// - For opaque solid colors: we can set the background color directly and skip the fill +/// - For opaque images: we can clear previous commands but still need to emit the fill +#[derive(Debug, Clone, Copy)] +pub enum FillHint { + /// No optimization possible, emit fill command normally. + None, + /// Paint is an opaque solid color - can replace background if conditions are met. + OpaqueSolid(PremulColor), + /// Paint is an opaque image - can clear previous commands if conditions are met. + OpaqueImage, +} + +/// Distinguishes between different types of layers and their storage strategies. +/// +/// Each layer kind determines how the layer's content is stored and processed: +/// - Regular layers are blended on-the-fly using a temporary buffer stack +/// - Filtered layers are materialized in persistent storage for filter processing +/// - Clip layers are special buffers used for clipping operations +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LayerKind { + /// Regular layer using local `blend_buf` stack for temporary storage. + Regular(LayerId), + /// Filtered layer materialized in persistent `layer_manager` storage. + Filtered(LayerId), + /// Clip layer for clipping operations. + Clip(LayerId), +} + +impl LayerKind { + /// Get the underlying layer ID. + /// + /// All layer kinds contain a layer ID that uniquely identifies the layer. + pub fn id(&self) -> LayerId { + match self { + Self::Regular(id) | Self::Filtered(id) | Self::Clip(id) => *id, + } + } +} + +/// A drawing command for wide tiles. +/// +/// Commands are executed in order to render the final image. They include +/// drawing operations (`Fill`, `AlphaFill`), layer management (`PushBuf`, `PopBuf`), +/// clipping operations (`ClipFill`, `ClipStrip`), and post-processing effects +/// (`Filter`, `Blend`, `Opacity`, `Mask`). +#[derive(Clone, Debug, PartialEq)] +pub enum Cmd { + /// Fill a rectangular region with a solid color or paint. + Fill(CmdFill), + /// Fill a region with a paint, modulated by an alpha mask. + AlphaFill(CmdAlphaFill), + /// Pushes a new buffer for drawing. + /// Regular layers use the local `blend_buf` stack. + /// Filtered layers are materialized in persistent layer storage. + /// + /// The second argument indicates whether the layer that is about to be pushed + /// will be used as a destination for a blending operation with a non-default blend mode. + /// This information is only needed by `vello_hybrid` for scheduling purposes. + PushBuf(LayerKind, bool), + /// Pops the most recent buffer and blends it into the previous buffer. + PopBuf, + /// A fill command within a clipping region. + /// + /// This command will blend the contents of the current buffer within the clip fill region + /// into the previous buffer in the stack. + ClipFill(CmdClipFill), + /// A fill command with alpha mask within a clipping region. + /// + /// This command will blend the contents of the current buffer within the clip fill region + /// into the previous buffer in the stack, with an additional alpha mask. + ClipStrip(CmdClipAlphaFill), + /// Marks entry into a zero-winding clip region for a clipped filter layer. + /// + /// Zero-winding clips represent tiles completely outside the clip path. For clipped + /// filter layers, this command allows the filter to process the full layer content + /// before applying the clip as a mask (per SVG spec: filter → clip → mask → blend). + PushZeroClip(LayerId), + /// Marks exit from a zero-winding clip region for a clipped filter layer. + PopZeroClip, + /// Apply a filter effect to a layer's contents. + /// + /// This command applies a filter (e.g., blur, drop shadow) to the specified layer's + /// rendered content. Per the SVG specification, filters are applied before clipping, + /// masking, blending, and opacity operations. + Filter(LayerId, Filter), + /// Blend the current buffer into the previous buffer. + /// + /// This command blends the contents of the current buffer into the previous buffer + /// using the specified blend mode (e.g., multiply, screen, overlay). + Blend(BlendMode), + /// Apply uniform opacity to the current buffer. + /// + /// Multiplies the alpha channel of all pixels in the buffer by the given opacity value. + Opacity(f32), + /// Apply a mask to the current buffer. + /// + /// Modulates the alpha channel of the buffer using the provided mask. + Mask(Mask), + /// Marks a boundary between rendering fast path strips and coarse rasterized strips. + /// + /// Only meaningful in `MODE_HYBRID`. + // TODO: Consider removing this, see https://github.com/linebender/vello/pull/1479#discussion_r2869917343. + BatchEnd, +} + +#[cfg(debug_assertions)] +impl Cmd { + /// Returns a human-readable name for this command. + /// + /// This is useful for debugging, logging, and displaying command information + /// in a user-friendly format. To get detailed paint information, use `name_with_attrs` + /// which can look up the paint from the command attributes. + /// + /// **Note:** This method is only available in debug builds (`debug_assertions`). + pub fn name(&self) -> &'static str { + match self { + Self::Fill(_) => "FillPath", + Self::AlphaFill(_) => "AlphaFillPath", + Self::PushBuf(layer_kind, needs_temp) => match (layer_kind, needs_temp) { + (LayerKind::Regular(_), false) => "PushBuf(Regular, false)", + (LayerKind::Regular(_), true) => "PushBuf(Regular, true)", + (LayerKind::Filtered(_), false) => "PushBuf(Filtered, false)", + (LayerKind::Filtered(_), true) => "PushBuf(Filtered, true)", + (LayerKind::Clip(_), false) => "PushBuf(Clip, false)", + (LayerKind::Clip(_), true) => "PushBuf(Clip, true)", + }, + Self::PopBuf => "PopBuf", + Self::ClipFill(_) => "ClipPathFill", + Self::ClipStrip(_) => "ClipPathStrip", + Self::PushZeroClip(_) => "PushZeroClip", + Self::PopZeroClip => "PopZeroClip", + Self::Filter(_, _) => "Filter", + Self::Blend(_) => "Blend", + Self::Opacity(_) => "Opacity", + Self::Mask(_) => "Mask", + Self::BatchEnd => "BatchEnd", + } + } + + /// Returns a human-readable name for this command with detailed paint information. + /// + /// This variant looks up paint details from the command attributes for fill commands. + /// + /// **Note:** This method is only available in debug builds (`debug_assertions`). + pub fn name_with_attrs( + &self, + fill_attrs: &[FillAttrs], + encoded_paints: &[EncodedPaint], + ) -> String { + match self { + Self::Fill(cmd) => { + if let Some(attrs) = fill_attrs.get(cmd.attrs_idx as usize) { + format!("FillPath({})", paint_name(&attrs.paint, encoded_paints)) + } else { + format!("FillPath(attrs_idx={})", cmd.attrs_idx) + } + } + Self::AlphaFill(cmd) => { + if let Some(attrs) = fill_attrs.get(cmd.attrs_idx as usize) { + format!( + "AlphaFillPath({})", + paint_name(&attrs.paint, encoded_paints) + ) + } else { + format!("AlphaFillPath(attrs_idx={})", cmd.attrs_idx) + } + } + _ => self.name().into(), + } + } +} + +/// Returns a human-readable description of a paint. +#[cfg(debug_assertions)] +fn paint_name(paint: &Paint, encoded_paints: &[EncodedPaint]) -> String { + match paint { + Paint::Solid(color) => { + let rgba = color.as_premul_rgba8(); + format!( + "Solid(#{:02x}{:02x}{:02x}{:02x})", + rgba.r, rgba.g, rgba.b, rgba.a + ) + } + Paint::Indexed(idx) => { + let index = idx.index(); + if let Some(encoded) = encoded_paints.get(index) { + let kind = match encoded { + EncodedPaint::Gradient(g) => match &g.kind { + crate::encode::EncodedKind::Linear(_) => "LinearGradient", + crate::encode::EncodedKind::Radial(_) => "RadialGradient", + crate::encode::EncodedKind::Sweep(_) => "SweepGradient", + }, + EncodedPaint::Image(_) => "Image", + EncodedPaint::ExternalTexture(_) => "ExternalTexture", + EncodedPaint::BlurredRoundedRect(_) => "BlurredRoundedRect", + }; + format!("{}[{}]", kind, index) + } else { + format!("Indexed({})", index) + } + } + } +} + +/// Shared attributes for alpha fill commands. +#[derive(Debug, Clone, PartialEq)] +pub struct FillAttrs { + /// The index of the thread that owns the alpha buffer + /// containing the mask values at `alpha_idx`. + /// Always 0 in single-threaded mode. + pub thread_idx: u8, + /// The paint (color, gradient, etc.) to fill the region with. + // TODO: Store premultiplied colors as indexed paints as well, to reduce + // memory overhead? Or get rid of indexed paints and inline all paints? + pub paint: Paint, + /// The blend mode to apply before drawing the contents. + pub blend_mode: BlendMode, + /// A mask to apply to the command. + pub mask: Option, + /// Base index into the alpha buffer for this path's commands. + /// Commands store a relative offset that is added to this base. + alpha_base_idx: u32, +} + +impl FillAttrs { + /// Compute the absolute alpha buffer index from a relative offset. + pub fn alpha_idx(&self, offset: u32) -> u32 { + self.alpha_base_idx + offset + } +} + +/// Shared attributes for clip alpha fill commands. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClipAttrs { + /// The index of the thread that owns the alpha buffer + /// containing the mask values at `alpha_idx`. + /// Always 0 in single-threaded mode. + pub thread_idx: u8, + /// Base index into the alpha buffer for this clip path's commands. + /// Commands store a relative offset that is added to this base. + alpha_base_idx: u32, +} + +impl ClipAttrs { + /// Compute the absolute alpha buffer index from a relative offset. + pub fn alpha_idx(&self, offset: u32) -> u32 { + self.alpha_base_idx + offset + } +} + +/// Container for shared command attributes. +/// +/// This struct holds the shared attributes for fill and clip commands, +/// allowing them to be passed together to functions that need both. +#[derive(Debug, Default, Clone)] +pub struct CommandAttrs { + /// Shared attributes for fill commands, indexed by `attrs_idx` in `CmdFill`/`CmdAlphaFill`. + pub fill: Vec, + /// Shared attributes for clip commands, indexed by `attrs_idx` in `CmdClipAlphaFill`. + pub clip: Vec, +} + +impl CommandAttrs { + /// Clear all attributes. + pub fn clear(&mut self) { + self.fill.clear(); + self.clip.clear(); + } +} + +/// Fill a consecutive horizontal region of a wide tile. +/// +/// This command fills a rectangular region with the specified paint. +/// The region starts at x-coordinate `x` and extends for `width` pixels +/// horizontally, spanning the full height of the wide tile. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CmdFill { + /// The horizontal start position relative to the wide tile's left edge, in pixels. + pub x: u16, + /// The width of the filled region in pixels. + pub width: u16, + /// Index into the command attributes array. + pub attrs_idx: u32, +} + +/// Fill a consecutive horizontal region with an alpha mask. +/// +/// Similar to `CmdFill`, but modulates the paint by an alpha mask stored +/// in a separate buffer. This is used for anti-aliased edges and partial +/// coverage from path rasterization. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CmdAlphaFill { + /// The horizontal start position relative to the wide tile's left edge, in pixels. + pub x: u16, + /// The width of the filled region in pixels. + pub width: u16, + /// Relative offset to the alpha buffer location. + /// Use `FillAttrs::alpha_idx(alpha_offset)` to compute the absolute index. + pub alpha_offset: u32, + /// Index into the command attributes array. + pub attrs_idx: u32, +} + +/// Fill operation within a clipping region. +/// +/// This command copies a horizontal region from the top of the clip buffer stack +/// to the next buffer on the stack, effectively rendering the clipped content. +/// Unlike `CmdFill`, this doesn't fill with a paint but transfers existing content. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CmdClipFill { + /// The horizontal start position relative to the wide tile's left edge, in pixels. + pub x: u16, + /// The width of the region to copy in pixels. + pub width: u16, +} + +/// Alpha-masked fill operation within a clipping region. +/// +/// This command composites a horizontal region from the top of the clip buffer stack +/// to the next buffer, modulated by an alpha mask. This is used for anti-aliased +/// clip edges. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CmdClipAlphaFill { + /// The horizontal start position relative to the wide tile's left edge, in pixels. + pub x: u16, + /// The width of the region to composite in pixels. + pub width: u16, + /// Relative offset to the alpha buffer location. + /// Use `ClipAttrs::alpha_idx(alpha_offset)` to compute the absolute index. + pub alpha_offset: u32, + /// Index into the clip attributes array. + pub attrs_idx: u32, +} + +/// Ranges of commands for a specific layer in a specific tile. +/// +/// This structure tracks two different ranges of commands: +/// - The full range includes all layer operations (push, draw, pop) +/// - The render range includes only the actual drawing commands +#[derive(Debug, Clone, Default)] +pub struct LayerCommandRanges { + /// Full range including `PushBuf`, all commands, and `PopBuf`. + pub full_range: Range, + /// Range containing only fill commands (`Fill`, `AlphaFill`). + /// This is the range to replace when sampling from a filtered layer. + pub render_range: Range, +} + +impl LayerCommandRanges { + /// Clear the full range and render range. + #[inline] + pub fn clear(&mut self) { + self.full_range = 0..0; + self.render_range = 0..0; + } +} + +#[cfg(test)] +mod tests { + use crate::coarse::{FillHint, LayerKind, MODE_CPU, NeedsBufLayerStack, Wide, WideTile}; + use crate::kurbo::Affine; + use crate::paint::{Paint, PremulColor}; + use crate::peniko; + use crate::peniko::{BlendMode, Compose, Mix}; + use crate::render_graph::RenderGraph; + use crate::strip::Strip; + use alloc::{boxed::Box, vec}; + + #[test] + fn optimize_empty_layers() { + let mut wide = WideTile::::new(0, 0); + wide.push_buf(LayerKind::Regular(0)); + wide.pop_buf(); + + assert!(wide.cmds.is_empty()); + } + + #[test] + fn basic_layer() { + let mut wide = WideTile::::new(0, 0); + let mut layers = NeedsBufLayerStack::default(); + layers.push(LayerKind::Regular(0)); + wide.push_buf(LayerKind::Regular(0)); + wide.fill(0, 0, &mut layers, 0, 10, 0, 0, FillHint::None); + wide.fill(0, 0, &mut layers, 10, 10, 0, 0, FillHint::None); + wide.pop_buf(); + + assert_eq!(wide.cmds.len(), 4); + } + + #[test] + fn dont_inline_blend_with_two_fills() { + let blend_mode = BlendMode::new(Mix::Lighten, Compose::SrcOver); + + let mut wide = WideTile::::new(0, 0); + let mut layers = NeedsBufLayerStack::default(); + layers.push(LayerKind::Regular(0)); + wide.push_buf(LayerKind::Regular(0)); + wide.fill(0, 0, &mut layers, 0, 10, 0, 0, FillHint::None); + wide.fill(0, 0, &mut layers, 10, 10, 0, 0, FillHint::None); + wide.blend(blend_mode); + wide.pop_buf(); + + assert_eq!(wide.cmds.len(), 5); + } + + #[test] + fn dont_inline_destructive_blend() { + let blend_mode = BlendMode::new(Mix::Lighten, Compose::Clear); + + let mut wide = WideTile::::new(0, 0); + let mut layers = NeedsBufLayerStack::default(); + layers.push(LayerKind::Regular(0)); + wide.push_buf(LayerKind::Regular(0)); + wide.fill(0, 0, &mut layers, 0, 10, 0, 0, FillHint::None); + wide.blend(blend_mode); + wide.pop_buf(); + + assert_eq!(wide.cmds.len(), 4); + } + + #[test] + fn tile_coordinates() { + let wide = Wide::::new(1000, 258); + + let tile_1 = wide.get(1, 3); + assert_eq!(tile_1.x, 256); + assert_eq!(tile_1.y, 12); + + let tile_2 = wide.get(2, 15); + assert_eq!(tile_2.x, 512); + assert_eq!(tile_2.y, 60); + } + + #[test] + fn reset_clears_layer_and_clip_stacks() { + type ClipPath = Option>; + + let mut wide = Wide::::new(1000, 258); + let mut render_graph = RenderGraph::new(); + let no_clip_path: ClipPath = None; + wide.push_layer( + 1, + no_clip_path, + BlendMode::default(), + None, + 0.5, + None, + Affine::IDENTITY, + &mut render_graph, + 0, + ); + + assert_eq!(wide.layer_stack.len(), 1); + assert_eq!(wide.clip_stack.len(), 0); + + let strip = Strip::new(2, 2, 0, true); + let clip_path = Some(vec![strip].into_boxed_slice()); + wide.push_layer( + 2, + clip_path, + BlendMode::default(), + None, + 0.09, + None, + Affine::IDENTITY, + &mut render_graph, + 0, + ); + + assert_eq!(wide.layer_stack.len(), 2); + assert_eq!(wide.clip_stack.len(), 1); + assert_eq!(wide.tiles[0].n_bufs, 0); + + wide.reset(); + + assert_eq!(wide.layer_stack.len(), 0); + assert_eq!(wide.clip_stack.len(), 0); + assert_eq!(wide.tiles[0].n_bufs, 0); + } + + #[test] + fn tiles_dirty_flag() { + type ClipPath = Option>; + + let mut wide = Wide::::new(256, 4); + let mut render_graph = RenderGraph::new(); + + assert!(!wide.tiles_dirty); + + let strips = [Strip::new(0, 0, 0, false), Strip::new(10, 0, 4, true)]; + wide.generate( + &strips, + Paint::Solid(PremulColor::from_alpha_color( + peniko::color::palette::css::RED, + )), + BlendMode::default(), + 0, + None, + &[], + ); + assert!(wide.tiles_dirty); + + wide.reset(); + assert!(!wide.tiles_dirty); + + let no_clip: ClipPath = None; + wide.push_layer( + 1, + no_clip, + BlendMode::default(), + None, + 1.0, + None, + Affine::IDENTITY, + &mut render_graph, + 0, + ); + assert!(wide.tiles_dirty); + + wide.pop_layer(&mut render_graph); + assert!(wide.tiles_dirty); + + wide.reset(); + assert!(!wide.tiles_dirty); + + // Generating with empty strips does not need to mark it as dirty. + wide.generate( + &[], + Paint::Solid(PremulColor::from_alpha_color( + peniko::color::palette::css::RED, + )), + BlendMode::default(), + 0, + None, + &[], + ); + assert!(!wide.tiles_dirty); + } +} diff --git a/vendor/vello_common/src/encode.rs b/vendor/vello_common/src/encode.rs new file mode 100644 index 000000000..5161fe021 --- /dev/null +++ b/vendor/vello_common/src/encode.rs @@ -0,0 +1,1354 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Paints for drawing shapes. + +use crate::TextureId; +use crate::blurred_rounded_rect::BlurredRoundedRectangle; +use crate::color::palette::css::BLACK; +use crate::color::{ColorSpaceTag, HueDirection, Srgb, gradient}; +use crate::geometry::RectU16; +use crate::kurbo::{Affine, Point, Vec2}; +use crate::math::{FloatExt, compute_erf7}; +use crate::paint::{Image, ImageSource, IndexedPaint, Paint, PremulColor, Tint}; +use crate::peniko::{ColorStop, ColorStops, Extend, Gradient, GradientKind, ImageQuality}; +use alloc::borrow::Cow; +use alloc::fmt::Debug; +use alloc::vec; +use alloc::vec::Vec; +#[cfg(not(feature = "multithreading"))] +use core::cell::OnceCell; +use core::hash::{Hash, Hasher}; +use fearless_simd::{Simd, SimdBase, SimdFloat, f32x4, f32x16, mask32x4, mask32x16}; +use peniko::color::cache_key::{BitEq, BitHash, CacheKey}; +use peniko::color::gradient_unpremultiplied; +use peniko::{ + ImageSampler, InterpolationAlphaSpace, LinearGradientPosition, RadialGradientPosition, + SweepGradientPosition, +}; +use smallvec::ToSmallVec; +// So we can just use `OnceCell` regardless of which feature is activated. +#[cfg(feature = "multithreading")] +use std::sync::OnceLock as OnceCell; + +use crate::simd::{Splat4thExt, element_wise_splat}; +#[cfg(not(feature = "std"))] +use peniko::kurbo::common::FloatFuncs as _; + +const DEGENERATE_THRESHOLD: f32 = 1.0e-6; +const NUDGE_VAL: f32 = 1.0e-7; +#[cfg(feature = "std")] +fn exp(val: f32) -> f32 { + val.exp() +} + +#[cfg(not(feature = "std"))] +fn exp(val: f32) -> f32 { + #[cfg(feature = "libm")] + return libm::expf(val); + #[cfg(not(feature = "libm"))] + compile_error!("vello_common requires either the `std` or `libm` feature"); +} + +/// A trait for encoding paints. +pub trait EncodeExt: private::Sealed { + /// Encode the paint and push it into a vector of encoded paints, returning + /// the corresponding paint in the process. This will also validate the paint. + fn encode_into( + &self, + paints: &mut Vec, + transform: Affine, + tint: Option, + ) -> Paint; +} + +impl EncodeExt for Gradient { + /// Encode the gradient into a paint. + fn encode_into( + &self, + paints: &mut Vec, + transform: Affine, + _tint: Option, + ) -> Paint { + // First make sure that the gradient is valid and not degenerate. + if let Err(paint) = validate(self) { + return paint; + } + + let mut may_have_transparency = self.stops.iter().any(|s| s.color.components[3] != 1.0); + + let mut base_transform; + + let mut stops = Cow::Borrowed(&self.stops.0); + + let first_stop = &stops[0]; + let last_stop = &stops[stops.len() - 1]; + + if first_stop.offset != 0.0 || last_stop.offset != 1.0 { + let mut vec = stops.to_smallvec(); + + if first_stop.offset != 0.0 { + let mut first_stop = *first_stop; + first_stop.offset = 0.0; + vec.insert(0, first_stop); + } + + if last_stop.offset != 1.0 { + let mut last_stop = *last_stop; + last_stop.offset = 1.0; + vec.push(last_stop); + } + + stops = Cow::Owned(vec); + } + + let kind = match self.kind { + GradientKind::Linear(LinearGradientPosition { start: p0, end: p1 }) => { + // We update the transform currently in-place, such that the gradient line always + // starts at the point (0, 0) and ends at the point (1, 0). This simplifies the + // calculation for the current position along the gradient line a lot. + base_transform = ts_from_line_to_line(p0, p1, Point::ZERO, Point::new(1.0, 0.0)); + + EncodedKind::Linear(LinearKind) + } + GradientKind::Radial(RadialGradientPosition { + start_center: c0, + start_radius: r0, + end_center: c1, + end_radius: r1, + }) => { + // The implementation of radial gradients is translated from Skia. + // See: + // - + // - + // - + let d_radius = r1 - r0; + + // + let radial_kind = if ((c1 - c0).length() as f32).is_nearly_zero() { + base_transform = Affine::translate((-c1.x, -c1.y)); + base_transform = base_transform.then_scale(1.0 / r0.max(r1) as f64); + + let scale = r1.max(r0) / d_radius; + let bias = -r0 / d_radius; + + RadialKind::Radial { bias, scale } + } else { + base_transform = + ts_from_line_to_line(c0, c1, Point::ZERO, Point::new(1.0, 0.0)); + + if (r1 - r0).is_nearly_zero() { + let scaled_r0 = r1 / (c1 - c0).length() as f32; + RadialKind::Strip { + scaled_r0_squared: scaled_r0 * scaled_r0, + } + } else { + let d_center = (c0 - c1).length() as f32; + + let focal_data = + FocalData::create(r0 / d_center, r1 / d_center, &mut base_transform); + + let fp0 = 1.0 / focal_data.fr1; + let fp1 = focal_data.f_focal_x; + + RadialKind::Focal { + focal_data, + fp0, + fp1, + } + } + }; + + // Even if the gradient has no stops with transparency, we might have to force + // alpha-compositing in case the radial gradient is undefined in certain positions, + // in which case the resulting color will be transparent and thus the gradient overall + // must be treated as non-opaque. + may_have_transparency |= radial_kind.has_undefined(); + + EncodedKind::Radial(radial_kind) + } + GradientKind::Sweep(SweepGradientPosition { + center, + start_angle, + end_angle, + }) => { + // Make sure the center of the gradient falls on the origin (0, 0), to make + // angle calculation easier. + let x_offset = -center.x as f32; + let y_offset = -center.y as f32; + base_transform = Affine::translate((x_offset as f64, y_offset as f64)); + + EncodedKind::Sweep(SweepKind { + start_angle, + // Save the inverse so that we can use a multiplication in the shader instead. + inv_angle_delta: 1.0 / (end_angle - start_angle), + }) + } + }; + + let ranges = encode_stops( + &stops, + self.interpolation_cs, + self.hue_direction, + self.interpolation_alpha_space, + ); + + // This represents the transform that needs to be applied to the starting point of a + // command before starting with the rendering. + // First we need to account for the base transform of the shader, then + // we need to apply the _inverse_ paint transform to the point so that we can account + // for the paint transform of the render context. + let transform = base_transform * transform.inverse(); + + // One possible approach of calculating the positions would be to apply the above + // transform to _each_ pixel that we render in the wide tile. However, a much better + // approach is to apply the transform once for the first pixel in each wide tile, + // and from then on only apply incremental updates to the current x/y position + // that we calculate based on the transform. + // + // Remember that we render wide tiles in column major order (i.e. we first calculate the + // values for a specific x for all Tile::HEIGHT y by incrementing y by 1, and then finally + // we increment the x position by 1 and start from the beginning). If we want to implement + // the above approach of incrementally updating the position, we need to calculate + // how the x/y unit vectors are affected by the transform, and then use this as the + // step delta for a step in the x/y direction. + let (x_advance, y_advance) = x_y_advances(&transform); + + let cache_key = CacheKey(GradientCacheKey { + stops: self.stops.clone(), + interpolation_cs: self.interpolation_cs, + hue_direction: self.hue_direction, + }); + + let has_undefined = kind.has_undefined(); + + let encoded = EncodedGradient { + cache_key, + kind, + has_undefined, + transform, + x_advance, + y_advance, + ranges, + extend: self.extend, + may_have_transparency, + u8_lut: OnceCell::new(), + f32_lut: OnceCell::new(), + }; + + let idx = paints.len(); + paints.push(encoded.into()); + + Paint::Indexed(IndexedPaint::new(idx)) + } +} + +/// Returns a fallback paint in case the gradient is invalid. +/// +/// The paint will be either black or contain the color of the first stop of the gradient. +fn validate(gradient: &Gradient) -> Result<(), Paint> { + let black = Err(BLACK.into()); + + // Gradients need at least two stops. + if gradient.stops.is_empty() { + return black; + } + + let first = Err(gradient.stops[0].color.to_alpha_color::().into()); + + if gradient.stops.len() == 1 { + return first; + } + + for stops in gradient.stops.windows(2) { + let f = stops[0]; + let n = stops[1]; + + // Offsets must be between 0 and 1, and not NaN. + if !(0.0..=1.0).contains(&f.offset) { + return first; + } + + // Stops must be sorted by ascending offset. + if f.offset > n.offset { + return first; + } + } + + // Check the last stop as well. + let last = gradient.stops.last().unwrap(); + if !(0.0..=1.0).contains(&last.offset) { + return first; + } + + let degenerate_point = |p1: &Point, p2: &Point| { + (p1.x - p2.x).abs() as f32 <= DEGENERATE_THRESHOLD + && (p1.y - p2.y).abs() as f32 <= DEGENERATE_THRESHOLD + }; + + let degenerate_val = |v1: f32, v2: f32| (v2 - v1).abs() <= DEGENERATE_THRESHOLD; + + match &gradient.kind { + GradientKind::Linear(LinearGradientPosition { start, end }) => { + // Start and end points must not be too close together. + if degenerate_point(start, end) { + return first; + } + } + GradientKind::Radial(RadialGradientPosition { + start_center, + start_radius, + end_center, + end_radius, + }) => { + // Radii must not be negative. + if *start_radius < 0.0 || *end_radius < 0.0 { + return first; + } + + // Radii and center points must not be close to the same. + if degenerate_point(start_center, end_center) + && degenerate_val(*start_radius, *end_radius) + { + return first; + } + } + GradientKind::Sweep(SweepGradientPosition { + start_angle, + end_angle, + .. + }) => { + // The end angle must be larger than the start angle. + if degenerate_val(*start_angle, *end_angle) { + return first; + } + + if end_angle <= start_angle { + return first; + } + } + } + + Ok(()) +} + +/// Encode all stops into a sequence of ranges. +fn encode_stops( + stops: &[ColorStop], + cs: ColorSpaceTag, + hue_dir: HueDirection, + interpolation_alpha_space: InterpolationAlphaSpace, +) -> Vec { + #[derive(Debug)] + struct EncodedColorStop { + offset: f32, + color: crate::color::AlphaColor, + } + + let create_range = |left_stop: &EncodedColorStop, right_stop: &EncodedColorStop| { + let clamp = |mut color: [f32; 4]| { + // The linear approximation of the gradient can produce values slightly outside of + // [0.0, 1.0], so clamp them. + for c in &mut color { + *c = c.clamp(0.0, 1.0); + } + + color + }; + + let x0 = left_stop.offset; + let x1 = right_stop.offset; + let c0 = if interpolation_alpha_space == InterpolationAlphaSpace::Unpremultiplied { + clamp(left_stop.color.components) + } else { + clamp(left_stop.color.premultiply().components) + }; + let c1 = if interpolation_alpha_space == InterpolationAlphaSpace::Unpremultiplied { + clamp(right_stop.color.components) + } else { + clamp(right_stop.color.premultiply().components) + }; + + // We calculate a bias and scale factor, such that we can simply calculate + // bias + x * scale to get the interpolated color, where x is between x0 and x1, + // to calculate the resulting color. + // Apply a nudge value because we sometimes call `create_range` with the same offset + // to create the padded stops. + let x1_minus_x0 = (x1 - x0).max(NUDGE_VAL); + let mut scale = [0.0; 4]; + let mut bias = c0; + + for i in 0..4 { + scale[i] = (c1[i] - c0[i]) / x1_minus_x0; + bias[i] = c0[i] - x0 * scale[i]; + } + + GradientRange { + x1, + bias, + scale, + interpolation_alpha_space, + } + }; + + // Create additional (SRGB-encoded) stops in-between to approximate the color space we want to + // interpolate in. + if cs != ColorSpaceTag::Srgb { + let interpolated_stops = if interpolation_alpha_space + == InterpolationAlphaSpace::Premultiplied + { + stops + .windows(2) + .flat_map(|s| { + let left_stop = &s[0]; + let right_stop = &s[1]; + + let interpolated = + gradient::(left_stop.color, right_stop.color, cs, hue_dir, 0.01); + + interpolated.map(|st| EncodedColorStop { + offset: left_stop.offset + (right_stop.offset - left_stop.offset) * st.0, + color: st.1.un_premultiply(), + }) + }) + .collect::>() + } else { + stops + .windows(2) + .flat_map(|s| { + let left_stop = &s[0]; + let right_stop = &s[1]; + + let interpolated = gradient_unpremultiplied::( + left_stop.color, + right_stop.color, + cs, + hue_dir, + 0.01, + ); + + interpolated.map(|st| EncodedColorStop { + offset: left_stop.offset + (right_stop.offset - left_stop.offset) * st.0, + color: st.1, + }) + }) + .collect::>() + }; + + interpolated_stops + .windows(2) + .map(|s| { + let left_stop = &s[0]; + let right_stop = &s[1]; + + create_range(left_stop, right_stop) + }) + .collect() + } else { + stops + .windows(2) + .map(|c| { + let c0 = EncodedColorStop { + offset: c[0].offset, + color: c[0].color.to_alpha_color::(), + }; + + let c1 = EncodedColorStop { + offset: c[1].offset, + color: c[1].color.to_alpha_color::(), + }; + + create_range(&c0, &c1) + }) + .collect() + } +} + +pub(crate) fn x_y_advances(transform: &Affine) -> (Vec2, Vec2) { + let scale_skew_transform = { + let c = transform.as_coeffs(); + Affine::new([c[0], c[1], c[2], c[3], 0.0, 0.0]) + }; + + let x_advance = scale_skew_transform * Point::new(1.0, 0.0); + let y_advance = scale_skew_transform * Point::new(0.0, 1.0); + + ( + Vec2::new(x_advance.x, x_advance.y), + Vec2::new(y_advance.x, y_advance.y), + ) +} + +impl private::Sealed for Image {} + +impl EncodeExt for Image { + fn encode_into( + &self, + paints: &mut Vec, + transform: Affine, + tint: Option, + ) -> Paint { + let idx = paints.len(); + + let mut sampler = self.sampler; + + if sampler.alpha != 1.0 { + // If the sampler alpha is not 1.0, we need to force alpha compositing. + unimplemented!("Applying opacity to image commands"); + } + + let c = transform.as_coeffs(); + + // Optimize image quality for integer-only translations. + if (c[0] as f32 - 1.0).is_nearly_zero() + && (c[1] as f32).is_nearly_zero() + && (c[2] as f32).is_nearly_zero() + && (c[3] as f32 - 1.0).is_nearly_zero() + && ((c[4] - c[4].floor()) as f32).is_nearly_zero() + && ((c[5] - c[5].floor()) as f32).is_nearly_zero() + && sampler.quality == ImageQuality::Medium + { + sampler.quality = ImageQuality::Low; + } + + let transform = transform.inverse(); + + let (x_advance, y_advance) = x_y_advances(&transform); + + // If the tint color has alpha < 1.0, the image will have opacities + // even if the source pixels are all opaque. + let tint_has_opacity = tint.as_ref().is_some_and(|t| t.color.components[3] < 1.0); + + let encoded = EncodedImage { + may_have_transparency: self.image.may_have_transparency() || tint_has_opacity, + source: self.image.clone(), + sampler, + transform, + x_advance, + y_advance, + tint, + }; + + paints.push(EncodedPaint::Image(encoded)); + + Paint::Indexed(IndexedPaint::new(idx)) + } +} + +/// An encoded paint. +#[derive(Debug)] +pub enum EncodedPaint { + /// An encoded gradient. + Gradient(EncodedGradient), + /// An encoded image. + Image(EncodedImage), + /// An encoded external texture. + ExternalTexture(EncodedExternalTexture), + /// A blurred, rounded rectangle. + BlurredRoundedRect(EncodedBlurredRoundedRectangle), +} + +impl From for EncodedPaint { + fn from(value: EncodedGradient) -> Self { + Self::Gradient(value) + } +} + +impl From for EncodedPaint { + fn from(value: EncodedBlurredRoundedRectangle) -> Self { + Self::BlurredRoundedRect(value) + } +} + +/// An encoded image. +#[derive(Debug)] +pub struct EncodedImage { + /// The underlying pixmap of the image. + pub source: ImageSource, + /// Sampler + pub sampler: ImageSampler, + /// Whether the image has opacities. + pub may_have_transparency: bool, + /// A transform to apply to the image. + pub transform: Affine, + /// The advance in image coordinates for one step in the x direction. + pub x_advance: Vec2, + /// The advance in image coordinates for one step in the y direction. + pub y_advance: Vec2, + /// Optional tint applied to the image. + pub tint: Option, +} + +/// An encoded external texture. +/// +/// The texture must be bound by the user at render-time in order for us to be able to sample from +/// it; it is not interned into the renderer. +#[derive(Debug)] +pub struct EncodedExternalTexture { + /// External texture handle. + pub texture_id: TextureId, + /// Source region of the texture in texel coordinates. + pub source_region: RectU16, + /// Sampler parameters. + pub sampler: ImageSampler, + /// Whether the sampled content may contain non-opaque pixels. + pub may_have_transparency: bool, + /// Inverse destination transform, mapping scene coordinates to local source-rect space. + pub transform: Affine, + /// Optional tint applied to the sampled color. + pub tint: Option, +} + +/// Computed properties of a linear gradient. +#[derive(Debug, Copy, Clone)] +pub struct LinearKind; + +/// Focal data for a radial gradient. +#[derive(Debug, PartialEq, Copy, Clone)] +pub struct FocalData { + /// The normalized radius of the outer circle in focal space. + pub fr1: f32, + /// The x-coordinate of the focal point in normalized space \[0,1\]. + pub f_focal_x: f32, + /// Whether the focal points have been swapped. + pub f_is_swapped: bool, +} + +impl FocalData { + /// Create a new `FocalData` with the given radii and update the matrix. + pub fn create(mut r0: f32, mut r1: f32, matrix: &mut Affine) -> Self { + let mut swapped = false; + let mut f_focal_x = r0 / (r0 - r1); + + if (f_focal_x - 1.0).is_nearly_zero() { + *matrix = matrix.then_translate(Vec2::new(-1.0, 0.0)); + *matrix = matrix.then_scale_non_uniform(-1.0, 1.0); + core::mem::swap(&mut r0, &mut r1); + f_focal_x = 0.0; + swapped = true; + } + + let focal_matrix = ts_from_line_to_line( + Point::new(f_focal_x as f64, 0.0), + Point::new(1.0, 0.0), + Point::new(0.0, 0.0), + Point::new(1.0, 0.0), + ); + *matrix = focal_matrix * *matrix; + + let fr1 = r1 / (1.0 - f_focal_x).abs(); + + let data = Self { + fr1, + f_focal_x, + f_is_swapped: swapped, + }; + + if data.is_focal_on_circle() { + *matrix = matrix.then_scale(0.5); + } else { + *matrix = matrix.then_scale_non_uniform( + (fr1 / (fr1 * fr1 - 1.0)) as f64, + 1.0 / (fr1 * fr1 - 1.0).abs().sqrt() as f64, + ); + } + + *matrix = matrix.then_scale((1.0 - f_focal_x).abs() as f64); + + data + } + + /// Whether the focal is on the circle. + pub fn is_focal_on_circle(&self) -> bool { + (1.0 - self.fr1).is_nearly_zero() + } + + /// Whether the focal points have been swapped. + pub fn is_swapped(&self) -> bool { + self.f_is_swapped + } + + /// Whether the gradient is well-behaved. + pub fn is_well_behaved(&self) -> bool { + !self.is_focal_on_circle() && self.fr1 > 1.0 + } + + /// Whether the gradient is natively focal. + pub fn is_natively_focal(&self) -> bool { + self.f_focal_x.is_nearly_zero() + } +} + +/// A radial gradient. +#[derive(Debug, PartialEq, Copy, Clone)] +pub enum RadialKind { + /// A radial gradient, i.e. the start and end center points are the same. + Radial { + /// The `bias` value (from the Skia implementation). + /// + /// It is a correction factor that accounts for the fact that the focal center might not + /// lie on the inner circle (if r0 > 0). + bias: f32, + /// The `scale` value (from the Skia implementation). + /// + /// It is a scaling factor that maps from r0 to r1. + scale: f32, + }, + /// A strip gradient, i.e. the start and end radius are the same. + Strip { + /// The squared value of `scaled_r0` (from the Skia implementation). + scaled_r0_squared: f32, + }, + /// A general, two-point conical gradient. + Focal { + /// The focal data (from the Skia implementation). + focal_data: FocalData, + /// The `fp0` value (from the Skia implementation). + fp0: f32, + /// The `fp1` value (from the Skia implementation). + fp1: f32, + }, +} + +impl RadialKind { + /// Whether the gradient is undefined at any location. + pub fn has_undefined(&self) -> bool { + match self { + Self::Radial { .. } => false, + Self::Strip { .. } => true, + Self::Focal { focal_data, .. } => !focal_data.is_well_behaved(), + } + } +} + +/// Computed properties of a sweep gradient. +#[derive(Debug)] +pub struct SweepKind { + /// The start angle of the sweep gradient. + pub start_angle: f32, + /// The inverse delta between start and end angle. + pub inv_angle_delta: f32, +} + +/// A kind of encoded gradient. +#[derive(Debug)] +pub enum EncodedKind { + /// An encoded linear gradient. + Linear(LinearKind), + /// An encoded radial gradient. + Radial(RadialKind), + /// An encoded sweep gradient. + Sweep(SweepKind), +} + +impl EncodedKind { + /// Whether the gradient is undefined at any location. + fn has_undefined(&self) -> bool { + match self { + Self::Radial(radial_kind) => radial_kind.has_undefined(), + _ => false, + } + } +} + +/// An encoded gradient. +#[derive(Debug)] +pub struct EncodedGradient { + /// The cache key for the gradient. + pub cache_key: CacheKey, + /// The underlying kind of gradient. + pub kind: EncodedKind, + /// Whether the gradient can yield undefined `t` values at some locations. + pub has_undefined: bool, + /// A transform that needs to be applied to the position of the first processed pixel. + pub transform: Affine, + /// How much to advance into the x/y direction for one step in the x direction. + pub x_advance: Vec2, + /// How much to advance into the x/y direction for one step in the y direction. + pub y_advance: Vec2, + /// The color ranges of the gradient. + pub ranges: Vec, + /// The extend of the gradient. + pub extend: Extend, + /// Whether the gradient requires `source_over` compositing. + pub may_have_transparency: bool, + u8_lut: OnceCell>, + f32_lut: OnceCell>, +} + +impl EncodedGradient { + /// Get the lookup table for sampling u8-based gradient values. + pub fn u8_lut(&self, simd: S) -> &GradientLut { + self.u8_lut + .get_or_init(|| GradientLut::new(simd, &self.ranges)) + } + + /// Get the lookup table for sampling f32-based gradient values. + pub fn f32_lut(&self, simd: S) -> &GradientLut { + self.f32_lut + .get_or_init(|| GradientLut::new(simd, &self.ranges)) + } +} + +/// Cache key for gradient color ramps based on color-affecting properties. +#[derive(Debug, Clone)] +pub struct GradientCacheKey { + /// The color stops (offsets + colors). + pub stops: ColorStops, + /// Color space used for interpolation. + pub interpolation_cs: ColorSpaceTag, + /// Hue direction used for interpolation. + pub hue_direction: HueDirection, +} + +impl BitHash for GradientCacheKey { + fn bit_hash(&self, state: &mut H) { + self.stops.bit_hash(state); + core::mem::discriminant(&self.interpolation_cs).hash(state); + core::mem::discriminant(&self.hue_direction).hash(state); + } +} + +impl BitEq for GradientCacheKey { + fn bit_eq(&self, other: &Self) -> bool { + self.stops.bit_eq(&other.stops) + && self.interpolation_cs == other.interpolation_cs + && self.hue_direction == other.hue_direction + } +} + +/// An encoded range between two color stops. +#[derive(Debug, Clone)] +pub struct GradientRange { + /// The end value of the range. + pub x1: f32, + /// A bias to apply when interpolating the color (in this case just the values of the start + /// color of the gradient). + pub bias: [f32; 4], + /// The scale factors of the range. By calculating bias + x * factors (where x is + /// between 0.0 and 1.0), we can interpolate between start and end color of the gradient range. + pub scale: [f32; 4], + /// The alpha space in which the interpolation was performed. + pub interpolation_alpha_space: InterpolationAlphaSpace, +} + +/// An encoded blurred, rounded rectangle. +#[derive(Debug)] +pub struct EncodedBlurredRoundedRectangle { + /// An component for computing the blur effect. + pub exponent: f32, + /// An component for computing the blur effect. + pub recip_exponent: f32, + /// An component for computing the blur effect. + pub scale: f32, + /// An component for computing the blur effect. + pub std_dev_inv: f32, + /// An component for computing the blur effect. + pub min_edge: f32, + /// An component for computing the blur effect. + pub w: f32, + /// An component for computing the blur effect. + pub h: f32, + /// An component for computing the blur effect. + pub width: f32, + /// An component for computing the blur effect. + pub height: f32, + /// An component for computing the blur effect. + pub r1: f32, + /// The base color for the blurred rectangle. + pub color: PremulColor, + /// A transform that needs to be applied to the position of the first processed pixel. + pub transform: Affine, + /// How much to advance into the x/y direction for one step in the x direction. + pub x_advance: Vec2, + /// How much to advance into the x/y direction for one step in the y direction. + pub y_advance: Vec2, +} + +impl private::Sealed for BlurredRoundedRectangle {} + +impl EncodeExt for BlurredRoundedRectangle { + fn encode_into( + &self, + paints: &mut Vec, + transform: Affine, + _tint: Option, + ) -> Paint { + let rect = { + // Ensure rectangle has positive width/height. + let mut rect = self.rect; + + if self.rect.x0 > self.rect.x1 { + core::mem::swap(&mut rect.x0, &mut rect.x1); + } + + if self.rect.y0 > self.rect.y1 { + core::mem::swap(&mut rect.y0, &mut rect.y1); + } + + rect + }; + + let transform = Affine::translate((-rect.x0, -rect.y0)) * transform.inverse(); + + let (x_advance, y_advance) = x_y_advances(&transform); + + let width = rect.width() as f32; + let height = rect.height() as f32; + let radius = self.radius.min(0.5 * width.min(height)); + + // To avoid divide by 0; potentially should be a bigger number for antialiasing. + let std_dev = self.std_dev.max(1e-6); + + let min_edge = width.min(height); + let rmax = 0.5 * min_edge; + let r0 = radius.hypot(std_dev * 1.15).min(rmax); + let r1 = radius.hypot(std_dev * 2.0).min(rmax); + + let exponent = 2.0 * r1 / r0; + + let std_dev_inv = std_dev.recip(); + + // Pull in long end (make less eccentric). + let delta = 1.25 + * std_dev + * (exp(-(0.5 * std_dev_inv * width).powi(2)) + - exp(-(0.5 * std_dev_inv * height).powi(2))); + let w = width + delta.min(0.0); + let h = height - delta.max(0.0); + + let recip_exponent = exponent.recip(); + let scale = 0.5 * compute_erf7(std_dev_inv * 0.5 * (w.max(h) - 0.5 * radius)); + + let encoded = EncodedBlurredRoundedRectangle { + exponent, + recip_exponent, + width, + height, + scale, + r1, + std_dev_inv, + min_edge, + color: PremulColor::from_alpha_color(self.color), + w, + h, + transform, + x_advance, + y_advance, + }; + + let idx = paints.len(); + paints.push(encoded.into()); + + Paint::Indexed(IndexedPaint::new(idx)) + } +} + +/// Calculates the transform necessary to map the line spanned by points src1, src2 to +/// the line spanned by dst1, dst2. +/// +/// This creates a transformation that maps any line segment to any other line segment. +/// For gradients, we use this to transform the gradient line to a standard form (0,0) → (1,0). +/// +/// Copied from +fn ts_from_line_to_line(src1: Point, src2: Point, dst1: Point, dst2: Point) -> Affine { + let unit_to_line1 = unit_to_line(src1, src2); + // Calculate the transform necessary to map line1 to the unit vector. + let line1_to_unit = unit_to_line1.inverse(); + // Then map the unit vector to line2. + let unit_to_line2 = unit_to_line(dst1, dst2); + + unit_to_line2 * line1_to_unit +} + +/// Calculate the transform necessary to map the unit vector to the line spanned by the points +/// `p1` and `p2`. +fn unit_to_line(p0: Point, p1: Point) -> Affine { + Affine::new([ + p1.y - p0.y, + p0.x - p1.x, + p1.x - p0.x, + p1.y - p0.y, + p0.x, + p0.y, + ]) +} + +/// A helper trait for converting a premultiplied f32 color to `Self`. +pub trait FromF32Color: Sized + Debug + Copy + Clone { + /// The zero value. + const ZERO: Self; + /// Convert from a premultiplied f32 color to `Self`. + fn from_f32(color: f32x4) -> [Self; 4]; +} + +impl FromF32Color for f32 { + const ZERO: Self = 0.0; + + fn from_f32(color: f32x4) -> [Self; 4] { + color.into() + } +} + +impl FromF32Color for u8 { + const ZERO: Self = 0; + + fn from_f32(mut color: f32x4) -> [Self; 4] { + let simd = color.simd; + color = color.mul_add(f32x4::splat(simd, 255.0), f32x4::splat(simd, 0.5)); + + [ + color[0] as Self, + color[1] as Self, + color[2] as Self, + color[3] as Self, + ] + } +} + +/// A lookup table for sampled gradient values. +#[derive(Debug)] +pub struct GradientLut { + lut: Vec<[T; 4]>, + scale: f32, +} + +impl GradientLut { + /// Create a new lookup table. + fn new(simd: S, ranges: &[GradientRange]) -> Self { + let lut_size = determine_lut_size(ranges); + let mut lut = vec![[T::ZERO; 4]; lut_size]; + + // Calculate how many indices are covered by each range. + let ramps = { + let mut ramps = Vec::with_capacity(ranges.len()); + let mut prev_idx = 0; + + for range in ranges { + let max_idx = (range.x1 * lut_size as f32) as usize; + + ramps.push((prev_idx..max_idx, range)); + prev_idx = max_idx; + } + + ramps + }; + + let scale = lut_size as f32 - 1.0; + + let inv_lut_scale = f32x4::splat(simd, 1.0 / scale); + let add_factor = f32x4::from_slice(simd, &[0.0, 1.0, 2.0, 3.0]) * inv_lut_scale; + + for (ramp_range, range) in ramps { + let biases = f32x16::block_splat(f32x4::from_slice(simd, &range.bias)); + let scales = f32x16::block_splat(f32x4::from_slice(simd, &range.scale)); + + ramp_range.clone().step_by(4).for_each(|idx| { + let t_vals = f32x4::splat(simd, idx as f32).mul_add(inv_lut_scale, add_factor); + + let t_vals = element_wise_splat(simd, t_vals); + + let mut result = scales.mul_add(t_vals, biases); + let alphas = result.splat_4th(); + // Premultiply colors, since we did interpolation in unpremultiplied space. + if range.interpolation_alpha_space == InterpolationAlphaSpace::Unpremultiplied { + result = { + let mask = + mask32x16::block_splat(mask32x4::from_slice(simd, &[-1, -1, -1, 0])); + simd.select_f32x16(mask, result * alphas, alphas) + }; + } + + // Due to floating-point impreciseness, it can happen that + // values either become greater than 1 or the RGB channels + // become greater than the alpha channel. To prevent overflows + // in later parts of the pipeline, we need to take the minimum here. + result = result.min(1.0).min(alphas); + let (im1, im2) = simd.split_f32x16(result); + let (r1, r2) = simd.split_f32x8(im1); + let (r3, r4) = simd.split_f32x8(im2); + let rs = [r1, r2, r3, r4].map(T::from_f32); + + // We always compute 4 samples at a time, but a gradient ramp does not necessarily + // start at a multiple of 4, therefore we might have to truncate. + let lut = &mut lut[idx..(idx + 4).min(lut_size)]; + lut.copy_from_slice(&rs[..lut.len()]); + }); + } + + Self { lut, scale } + } + + /// Get the sample value at a specific index. + #[inline(always)] + pub fn get(&self, idx: usize) -> [T; 4] { + self.lut[idx] + } + + /// Return the raw array of gradient sample values. + #[inline(always)] + pub fn lut(&self) -> &[[T; 4]] { + &self.lut + } + + /// Return the number of entries in the lookup table. + #[inline(always)] + pub fn width(&self) -> usize { + self.lut.len() + } + + /// Get the scale factor by which to scale the parametric value to + /// compute the correct lookup index. + #[inline(always)] + pub fn scale_factor(&self) -> f32 { + self.scale + } +} + +/// The maximum size of the gradient LUT. +// Of course in theory we could still have a stop at 0.0001 in which case this resolution +// wouldn't be enough, but for all intents and purposes this should be more than sufficient +// for most real cases. +pub const MAX_GRADIENT_LUT_SIZE: usize = 4096; + +fn determine_lut_size(ranges: &[GradientRange]) -> usize { + // Inspired by Blend2D. + // By default: + // 256 for 2 stops. + // 512 for 3 stops. + // 1024 for 4 or more stops. + let stop_len = match ranges.len() { + 1 => 256, + 2 => 512, + _ => 1024, + }; + + // In case we have some tricky stops (for example 3 stops with 0.0, 0.001, 1.0), we might + // increase the resolution. + let mut last_x1 = 0.0; + let mut min_size = 0; + + for x1 in ranges.iter().map(|e| e.x1) { + // For example, if the first stop is at 0.001, then we need a resolution of at least 1000 + // so that we can still safely capture the first stop. + let res = ((1.0 / (x1 - last_x1)).ceil() as usize) + .min(MAX_GRADIENT_LUT_SIZE) + .next_power_of_two(); + min_size = min_size.max(res); + last_x1 = x1; + } + + // Take the maximum of both, but don't exceed `MAX_LEN`. + stop_len.max(min_size) +} + +mod private { + #[expect(unnameable_types, reason = "Sealed trait pattern.")] + pub trait Sealed {} + + impl Sealed for super::Gradient {} +} + +#[cfg(test)] +mod tests { + use super::{EncodeExt, Gradient}; + use crate::color::DynamicColor; + use crate::color::palette::css::{BLACK, BLUE, GREEN}; + use crate::kurbo::{Affine, Point}; + use crate::peniko::{ColorStop, ColorStops}; + use alloc::vec; + use peniko::{LinearGradientPosition, RadialGradientPosition}; + use smallvec::smallvec; + + #[test] + fn gradient_missing_stops() { + let mut buf = vec![]; + + let gradient = Gradient { + kind: LinearGradientPosition { + start: Point::new(0.0, 0.0), + end: Point::new(20.0, 0.0), + } + .into(), + ..Default::default() + }; + + assert_eq!( + gradient.encode_into(&mut buf, Affine::IDENTITY, None), + BLACK.into() + ); + } + + #[test] + fn gradient_one_stop() { + let mut buf = vec![]; + + let gradient = Gradient { + kind: LinearGradientPosition { + start: Point::new(0.0, 0.0), + end: Point::new(20.0, 0.0), + } + .into(), + stops: ColorStops(smallvec![ColorStop { + offset: 0.0, + color: DynamicColor::from_alpha_color(GREEN), + }]), + ..Default::default() + }; + + // Should return the color of the first stop. + assert_eq!( + gradient.encode_into(&mut buf, Affine::IDENTITY, None), + GREEN.into() + ); + } + + #[test] + fn gradient_not_sorted_stops() { + let mut buf = vec![]; + + let gradient = Gradient { + kind: LinearGradientPosition { + start: Point::new(0.0, 0.0), + end: Point::new(20.0, 0.0), + } + .into(), + stops: ColorStops(smallvec![ + ColorStop { + offset: 1.0, + color: DynamicColor::from_alpha_color(GREEN), + }, + ColorStop { + offset: 0.0, + color: DynamicColor::from_alpha_color(BLUE), + }, + ]), + ..Default::default() + }; + + assert_eq!( + gradient.encode_into(&mut buf, Affine::IDENTITY, None), + GREEN.into() + ); + } + + #[test] + fn gradient_linear_degenerate() { + let mut buf = vec![]; + + let gradient = Gradient { + kind: LinearGradientPosition { + start: Point::new(0.0, 0.0), + end: Point::new(0.0, 0.0), + } + .into(), + stops: ColorStops(smallvec![ + ColorStop { + offset: 0.0, + color: DynamicColor::from_alpha_color(GREEN), + }, + ColorStop { + offset: 1.0, + color: DynamicColor::from_alpha_color(BLUE), + }, + ]), + ..Default::default() + }; + + assert_eq!( + gradient.encode_into(&mut buf, Affine::IDENTITY, None), + GREEN.into() + ); + } + + #[test] + fn gradient_last_stop_with_infinity_offset() { + let mut buf = vec![]; + + let gradient = Gradient { + kind: LinearGradientPosition { + start: Point::new(0.0, 0.0), + end: Point::new(20.0, 0.0), + } + .into(), + stops: ColorStops(smallvec![ + ColorStop { + offset: 0.0, + color: DynamicColor::from_alpha_color(GREEN), + }, + ColorStop { + offset: f32::INFINITY, + color: DynamicColor::from_alpha_color(BLUE), + }, + ]), + ..Default::default() + }; + + // Invalid gradient, so fall back to first color. + assert_eq!( + gradient.encode_into(&mut buf, Affine::IDENTITY, None), + GREEN.into() + ); + } + + #[test] + fn gradient_stop_with_nan_offset() { + let mut buf = vec![]; + + let gradient = Gradient { + kind: LinearGradientPosition { + start: Point::new(0.0, 0.0), + end: Point::new(20.0, 0.0), + } + .into(), + stops: ColorStops(smallvec![ + ColorStop { + offset: 0.0, + color: DynamicColor::from_alpha_color(GREEN), + }, + ColorStop { + offset: f32::NAN, + color: DynamicColor::from_alpha_color(BLUE), + }, + ]), + ..Default::default() + }; + + // Invalid gradient, so fall back to first color. + assert_eq!( + gradient.encode_into(&mut buf, Affine::IDENTITY, None), + GREEN.into() + ); + } + + #[test] + fn gradient_radial_degenerate() { + let mut buf = vec![]; + + let gradient = Gradient { + kind: RadialGradientPosition { + start_center: Point::new(0.0, 0.0), + start_radius: 20.0, + end_center: Point::new(0.0, 0.0), + end_radius: 20.0, + } + .into(), + stops: ColorStops(smallvec![ + ColorStop { + offset: 0.0, + color: DynamicColor::from_alpha_color(GREEN), + }, + ColorStop { + offset: 1.0, + color: DynamicColor::from_alpha_color(BLUE), + }, + ]), + ..Default::default() + }; + + assert_eq!( + gradient.encode_into(&mut buf, Affine::IDENTITY, None), + GREEN.into() + ); + } +} diff --git a/vendor/vello_common/src/filter/drop_shadow.rs b/vendor/vello_common/src/filter/drop_shadow.rs new file mode 100644 index 000000000..5260225c9 --- /dev/null +++ b/vendor/vello_common/src/filter/drop_shadow.rs @@ -0,0 +1,86 @@ +// Copyright 2026 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! The drop shadow filter. + +use crate::color::{AlphaColor, Srgb}; +use crate::filter::gaussian_blur::{MAX_KERNEL_SIZE, plan_decimated_blur, transform_blur_params}; +use crate::filter::transform_offset_params; +use crate::filter_effects::EdgeMode; +use crate::kurbo::Affine; + +/// A drop shadow filter. +#[derive(Debug)] +pub struct DropShadow { + /// The x-offset of the shadow. + pub dx: f32, + /// The y-offset of the shadow. + pub dy: f32, + /// The color of the shadow. + pub color: AlphaColor, + /// Standard deviation for the blur (for reference/debugging). + pub std_deviation: f32, + /// Edge mode for blur sampling. + pub edge_mode: EdgeMode, + /// Number of 2x2 decimation levels to use (0 means direct convolution). + pub n_decimations: usize, + /// Pre-computed Gaussian kernel weights for the reduced blur. + /// Only the first `kernel_size` elements are valid. + pub kernel: [f32; MAX_KERNEL_SIZE], + /// Actual length of the kernel (kernel is padded to `MAX_KERNEL_SIZE`). + pub kernel_size: u8, +} + +impl DropShadow { + /// Create a new drop shadow filter with the specified parameters. + /// + /// This precomputes the blur decimation plan and kernel for optimal performance. + pub fn new( + dx: f32, + dy: f32, + std_deviation: f32, + edge_mode: EdgeMode, + color: AlphaColor, + ) -> Self { + // Precompute blur plan (same logic as GaussianBlur::new) + let (n_decimations, kernel, kernel_size) = plan_decimated_blur(std_deviation); + + Self { + dx, + dy, + color, + std_deviation, + edge_mode, + n_decimations, + kernel, + kernel_size, + } + } +} + +/// Transform a drop shadow's offset and standard deviation using the affine transformation. +/// +/// Applies the full linear transformation (rotation, scale, and shear) to the offset vector, +/// and scales the blur standard deviation uniformly. +/// +/// # Arguments +/// * `dx` - Horizontal offset in user space +/// * `dy` - Vertical offset in user space +/// * `std_deviation` - Blur standard deviation in user space +/// * `transform` - The transformation matrix to apply +/// +/// # Returns +/// A tuple of (`scaled_dx`, `scaled_dy`, `scaled_std_dev`) in device space +pub(crate) fn transform_shadow_params( + dx: f32, + dy: f32, + std_deviation: f32, + transform: &Affine, +) -> (f32, f32, f32) { + let (scaled_dx, scaled_dy) = transform_offset_params(dx, dy, transform); + + // Scale the blur radius uniformly + let scaled_std_dev = transform_blur_params(std_deviation, transform); + + (scaled_dx, scaled_dy, scaled_std_dev) +} diff --git a/vendor/vello_common/src/filter/flood.rs b/vendor/vello_common/src/filter/flood.rs new file mode 100644 index 000000000..638525a66 --- /dev/null +++ b/vendor/vello_common/src/filter/flood.rs @@ -0,0 +1,20 @@ +// Copyright 2026 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! The flood filter. + +use crate::color::{AlphaColor, Srgb}; + +/// A flood filter. +#[derive(Debug)] +pub struct Flood { + /// The flood color. + pub color: AlphaColor, +} + +impl Flood { + /// Create a new flood filter with the specified color. + pub fn new(color: AlphaColor) -> Self { + Self { color } + } +} diff --git a/vendor/vello_common/src/filter/gaussian_blur.rs b/vendor/vello_common/src/filter/gaussian_blur.rs new file mode 100644 index 000000000..e31bfd509 --- /dev/null +++ b/vendor/vello_common/src/filter/gaussian_blur.rs @@ -0,0 +1,334 @@ +// Copyright 2026 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! The gaussian blur filter. + +use alloc::vec::Vec; + +use crate::filter_effects::EdgeMode; +use crate::kurbo::Affine; +use crate::util::extract_scales; +use core::f32::consts::E; +#[cfg(not(feature = "std"))] +use peniko::kurbo::common::FloatFuncs as _; + +/// Scale a blur's standard deviation uniformly based on the transformation. +/// +/// Extracts the scale factors from the transformation matrix using SVD and +/// averages them to get a uniform scale factor for the blur radius. +/// +/// # Arguments +/// * `std_deviation` - The blur standard deviation in user space +/// * `transform` - The transformation matrix to extract scale from +/// +/// # Returns +/// The scaled standard deviation in device space +pub(crate) fn transform_blur_params(std_deviation: f32, transform: &Affine) -> f32 { + let (scale_x, scale_y) = extract_scales(transform); + let uniform_scale = (scale_x + scale_y) / 2.0; + // TODO: Support separate std_deviation for x and y axes (std_deviation_x, std_deviation_y) + // to properly handle non-uniform scaling. This would eliminate the need for uniform_scale + // and allow blur to scale independently along each axis. + std_deviation * uniform_scale +} + +/// Maximum size of the Gaussian kernel (must be odd and equal to or smaller than [`u8::MAX`]). +/// +/// The multi-scale decimation algorithm guarantees that kernel size never exceeds this value. +/// Decimation stops when remaining variance ≤ 4.0 (σ ≤ 2.0), which produces kernels of size +/// at most 13 (radius = ceil(3σ) = 6, size = 1 + 2×6 = 13). +// Keep in sync with MAX_KERNEL_SIZE in vello_sparse_shaders/shaders/filters.wgsl +pub const MAX_KERNEL_SIZE: usize = 13; + +#[cfg(test)] +const _: () = const { + if MAX_KERNEL_SIZE.is_multiple_of(2) { + panic!("`MAX_KERNEL_SIZE` must be odd"); + } + if MAX_KERNEL_SIZE > u8::MAX as usize { + panic!("`MAX_KERNEL_SIZE` must be less than or equal to `u8::MAX`"); + } +}; + +/// A gaussian blur. +#[derive(Debug)] +pub struct GaussianBlur { + /// The standard deviation. + pub std_deviation: f32, + /// Number of 2× decimation levels to use (0 means no decimation, direct convolution). + pub n_decimations: usize, + /// Pre-computed Gaussian kernel weights for the reduced blur. + /// Only the first `kernel_size` elements are valid. + pub kernel: [f32; MAX_KERNEL_SIZE], + /// Actual length of the kernel (rest is padding up to `MAX_KERNEL_SIZE`). + pub kernel_size: u8, + /// Edge mode for handling out-of-bounds sampling. + pub edge_mode: EdgeMode, +} + +impl GaussianBlur { + /// Create a new Gaussian blur filter with the specified standard deviation. + /// + /// This precomputes the decimation plan, kernel, and radius for optimal performance. + pub fn new(std_deviation: f32, edge_mode: EdgeMode) -> Self { + let (n_decimations, kernel, kernel_size) = plan_decimated_blur(std_deviation); + + Self { + std_deviation, + edge_mode, + n_decimations, + kernel, + kernel_size, + } + } +} + +/// Compute the blur execution plan based on standard deviation. +/// +/// Returns (`n_decimations`, `kernel`, `kernel_size`): +/// - `n_decimations`: Number of 2× downsampling steps to perform (per axis) +/// - `kernel`: Pre-computed Gaussian kernel weights (fixed-size array) +/// - `kernel_size`: Actual length of the kernel (rest is zero-padded) +pub fn plan_decimated_blur(std_deviation: f32) -> (usize, [f32; MAX_KERNEL_SIZE], u8) { + if std_deviation <= 0.0 { + // Invalid standard deviation, return identity kernel (no blur) + let mut kernel = [0.0; MAX_KERNEL_SIZE]; + kernel[0] = 1.0; + return (0, kernel, 1); + } + + // Compute decimation plan using variance analysis. + // Variance (σ²) has the additive property: applying two blurs sequentially + // adds their variances together. We use this to decompose the blur. + // + // Mathematical Foundation: From probability theory, convolving two Gaussians + // G(σ₁) ⊗ G(σ₂) = G(√(σ₁² + σ₂²)). This means variance is additive: σ²_total = σ²_1 + σ²_2. + // Rearranging: σ²_2 = σ²_total - σ²_1, allowing us to decompose the target blur. + let variance = std_deviation * std_deviation; + let mut n_decimations = 0; + let mut remaining_variance = variance; + + // Each decimation step: + // 1. Applies [1,3,3,1] blur which adds variance = 0.75 per axis + // 2. Downsamples by 2× per axis, adding variance = 0.75 × 4 = 3.0 total + // 3. The 2× downsampling scales the remaining variance by 0.25 (= 1/2²) + // Stop when remaining variance ≤ 4.0 (σ ≈ 2), as further decimation isn't beneficial. + while remaining_variance > 4.0 { + remaining_variance = (remaining_variance - 3.0) * 0.25; + n_decimations += 1; + } + // Compute the reduced standard deviation to apply at the decimated resolution + let remaining_sigma = remaining_variance.sqrt(); + // Compute Gaussian kernel for the reduced blur + let (kernel, kernel_size) = compute_gaussian_kernel(remaining_sigma); + + (n_decimations, kernel, kernel_size) +} + +/// Compute 1D Gaussian kernel weights for separable convolution. +/// +/// Returns (`kernel_weights`, `kernel_size`) where `kernel_size = 2×radius + 1`. +/// The kernel is stored in a fixed-size array to avoid heap allocation. +/// Uses the standard Gaussian formula: G(x) = exp(-x² / (2σ²)), normalized to sum to 1. +pub fn compute_gaussian_kernel(std_deviation: f32) -> ([f32; MAX_KERNEL_SIZE], u8) { + // Use radius = 3σ to capture 99.7% of the Gaussian distribution. + // Beyond ±3σ, the Gaussian values are negligible (<0.3%). + let radius = (3.0 * std_deviation).ceil() as usize; + let kernel_size = (1 + radius * 2).min(MAX_KERNEL_SIZE) as u8; + + let mut kernel = [0.0; MAX_KERNEL_SIZE]; + // Compute Gaussian weights using the formula: G(x) = exp(-x² / (2σ²)) + // This creates a symmetric bell curve centered at the middle of the kernel. + let gaussian_denominator = 2.0 * std_deviation * std_deviation; + let mut sum = 0.0; + let kernel_center = (kernel_size / 2) as f32; + for (i, weight) in kernel.iter_mut().enumerate().take(usize::from(kernel_size)) { + // Compute distance from center (0 at center, increases outward) + let x = (i as f32) - kernel_center; + // Apply Gaussian formula: weight decreases exponentially with squared distance + *weight = E.powf(-x * x / gaussian_denominator); + sum += *weight; + } + + // Normalize weights to sum to 1.0, ensuring the blur doesn't change overall brightness. + // Without normalization, blurring a uniform gray area could make it brighter/darker. + let scale = 1.0 / sum; + for weight in kernel.iter_mut().take(usize::from(kernel_size)) { + *weight *= scale; + } + + (kernel, kernel_size) +} + +/// Tracks dimensions through a chain of downscale/upscale operations. +#[derive(Debug, Default)] +pub struct DecimationSizer { + width: u16, + height: u16, + dim_stack: Vec<(u16, u16)>, +} + +impl DecimationSizer { + /// Create a new sizer with the given initial dimensions. + #[inline] + pub fn new(width: u16, height: u16) -> Self { + Self { + width, + height, + dim_stack: Vec::new(), + } + } + + /// Reset the sizer so it can be reused. + #[inline] + pub fn reset(&mut self, width: u16, height: u16) { + self.width = width; + self.height = height; + self.dim_stack.clear(); + } + + /// Returns the current logical dimensions. + #[inline] + pub fn current(&self) -> (u16, u16) { + (self.width, self.height) + } + + /// Apply a new downscale operation. + #[inline] + pub fn downscale(&mut self) -> (u16, u16) { + self.dim_stack.push((self.width, self.height)); + self.width = self.width.div_ceil(2); + self.height = self.height.div_ceil(2); + (self.width, self.height) + } + + /// Apply a new upscale operation. + #[inline] + pub fn upscale(&mut self) -> (u16, u16) { + let (target_w, target_h) = self.dim_stack.pop().unwrap(); + // Clamp because upscale can exceed target on odd dimensions (e.g., 5→3→6 > 5) + self.width = (self.width * 2).min(target_w); + self.height = (self.height * 2).min(target_h); + (self.width, self.height) + } +} + +#[cfg(test)] +mod tests { + use crate::filter::gaussian_blur::{ + DecimationSizer, compute_gaussian_kernel, plan_decimated_blur, + }; + + /// Test Gaussian kernel computation for small σ. + #[test] + fn test_gaussian_kernel_small_sigma() { + let (kernel, size) = compute_gaussian_kernel(1.0); + // For σ=1.0, radius = ceil(3.0) = 3, size = 2*3+1 = 7 + assert_eq!(size, 7); + + // Kernel should be symmetric + for i in 0..size / 2 { + assert!((kernel[usize::from(i)] - kernel[usize::from(size - 1 - i)]).abs() < 1e-6); + } + + // Kernel should sum to 1.0 (normalized) + let sum: f32 = kernel.iter().take(usize::from(size)).sum(); + assert!((sum - 1.0).abs() < 1e-6); + + // Center should be the largest weight + let center_idx = size / 2; + for i in 0..size { + if i != center_idx { + assert!(kernel[usize::from(center_idx)] >= kernel[usize::from(i)]); + } + } + } + + /// Test Gaussian kernel computation for very small σ (near-zero). + #[test] + fn test_gaussian_kernel_very_small_sigma() { + let (kernel, size) = compute_gaussian_kernel(0.1); + // For σ=0.1, radius = ceil(0.3) = 1, size = 3 + assert_eq!(size, 3); + // Should sum to 1.0 + let sum: f32 = kernel.iter().take(usize::from(size)).sum(); + assert!((sum - 1.0).abs() < 1e-6); + // Center weight should be dominant for very small σ + assert!(kernel[1] > 0.9); // Center is highly weighted + } + + /// Test Gaussian kernel for fractional σ. + #[test] + fn test_gaussian_kernel_fractional_sigma() { + let (kernel, size) = compute_gaussian_kernel(0.5); + // For σ=0.5, radius = ceil(1.5) = 2, size = 5 + assert_eq!(size, 5); + + // Should still sum to 1.0 + let sum: f32 = kernel.iter().take(usize::from(size)).sum(); + assert!((sum - 1.0).abs() < 1e-6); + } + + /// Test decimation plan for small blur (no decimation). + #[test] + fn test_plan_no_decimation() { + let (n_decimations, _kernel, _size) = plan_decimated_blur(1.0); + // σ=1.0 → variance=1.0, should not decimate + assert_eq!(n_decimations, 0); + } + + /// Test decimation plan for medium blur (some decimation). + #[test] + fn test_plan_with_decimation() { + let (n_decimations, _kernel, _size) = plan_decimated_blur(5.0); + // σ=5.0 → variance=25.0, should decimate + assert_eq!(n_decimations, 2); + } + + /// Test decimation plan at boundary (σ=2.0). + #[test] + fn test_plan_decimation_boundary() { + let (n_decimations, _kernel, _size) = plan_decimated_blur(2.0); + // σ=2.0 → variance=4.0, right at the boundary + assert_eq!(n_decimations, 0); + } + + /// Test decimation plan for negative σ (invalid, should return identity). + #[test] + fn test_plan_negative_sigma() { + let (n_decimations, kernel, size) = plan_decimated_blur(-1.0); + assert_eq!(n_decimations, 0); + assert_eq!(size, 1); + assert!((kernel[0] - 1.0).abs() < 1e-6); + } + + #[test] + fn test_decimation_sizer_even() { + let mut sizer = DecimationSizer::new(8, 8); + assert_eq!(sizer.current(), (8, 8)); + + assert_eq!(sizer.downscale(), (4, 4)); + assert_eq!(sizer.downscale(), (2, 2)); + + assert_eq!(sizer.upscale(), (4, 4)); + assert_eq!(sizer.upscale(), (8, 8)); + } + + #[test] + fn test_decimation_sizer_odd() { + let mut sizer = DecimationSizer::new(5, 7); + assert_eq!(sizer.downscale(), (3, 4)); + assert_eq!(sizer.downscale(), (2, 2)); + + // Upscale clamps to the pre-downscale target + assert_eq!(sizer.upscale(), (3, 4)); + assert_eq!(sizer.upscale(), (5, 7)); + } + + #[test] + fn test_decimation_sizer_single_level() { + let mut sizer = DecimationSizer::new(100, 50); + assert_eq!(sizer.downscale(), (50, 25)); + assert_eq!(sizer.upscale(), (100, 50)); + } +} diff --git a/vendor/vello_common/src/filter/mod.rs b/vendor/vello_common/src/filter/mod.rs new file mode 100644 index 000000000..4da635d61 --- /dev/null +++ b/vendor/vello_common/src/filter/mod.rs @@ -0,0 +1,94 @@ +// Copyright 2026 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Common filter helper functions. +//! +//! Unlike the filters defines in [`crate::filter_effects`], the filters in this module +//! represent a special representation of each filter to be used as the basis for rendering in +//! `vello_hybrid` and `vello_cpu`. + +use crate::filter::drop_shadow::{DropShadow, transform_shadow_params}; +use crate::filter::flood::Flood; +use crate::filter::gaussian_blur::{GaussianBlur, transform_blur_params}; +use crate::filter::offset::Offset; +use crate::filter_effects::{Filter, FilterPrimitive}; +use crate::kurbo::{Affine, Vec2}; + +pub mod drop_shadow; +pub mod flood; +pub mod gaussian_blur; +pub mod offset; + +/// A filter that has been prepared for rendering. +#[derive(Debug)] +pub enum PreparedFilter { + /// A flood filter. + Flood(Flood), + /// A gaussian blur filter. + GaussianBlur(GaussianBlur), + /// An offset filter. + Offset(Offset), + /// A drop shadow filter. + DropShadow(DropShadow), +} + +impl PreparedFilter { + /// Build a new prepared filter for the given transform. + pub fn new(filter: &Filter, transform: &Affine) -> Self { + // Multi-primitive filter graphs are not yet implemented. + if filter.graph.primitives.len() != 1 { + unimplemented!("Multi-primitive filter graphs are not yet supported"); + } + + match &filter.graph.primitives[0] { + FilterPrimitive::Flood { color } => { + let flood = Flood::new(*color); + Self::Flood(flood) + } + FilterPrimitive::GaussianBlur { + std_deviation, + edge_mode, + } => { + let scaled_std_dev = transform_blur_params(*std_deviation, transform); + let blur = GaussianBlur::new(scaled_std_dev, *edge_mode); + Self::GaussianBlur(blur) + } + FilterPrimitive::DropShadow { + dx, + dy, + std_deviation, + color, + edge_mode, + } => { + let (scaled_dx, scaled_dy, scaled_std_dev) = + transform_shadow_params(*dx, *dy, *std_deviation, transform); + let drop_shadow = + DropShadow::new(scaled_dx, scaled_dy, scaled_std_dev, *edge_mode, *color); + + Self::DropShadow(drop_shadow) + } + FilterPrimitive::Offset { dx, dy } => { + let (scaled_dx, scaled_dy) = transform_offset_params(*dx, *dy, transform); + let offset = Offset::new(scaled_dx, scaled_dy); + + Self::Offset(offset) + } + _ => { + // Other primitives like Blend, ColorMatrix, ComponentTransfer, etc. + // are not yet implemented + unimplemented!("Other filter primitives not yet implemented"); + } + } + } +} + +/// Transform an offset's dx/dy using the affine transformation's linear part. +/// +/// # Returns +/// A tuple of (`scaled_dx`, `scaled_dy`) in device space. +fn transform_offset_params(dx: f32, dy: f32, transform: &Affine) -> (f32, f32) { + let offset = Vec2::new(dx as f64, dy as f64); + let [a, b, c, d, _, _] = transform.as_coeffs(); + let transformed_offset = Vec2::new(a * offset.x + c * offset.y, b * offset.x + d * offset.y); + (transformed_offset.x as f32, transformed_offset.y as f32) +} diff --git a/vendor/vello_common/src/filter/offset.rs b/vendor/vello_common/src/filter/offset.rs new file mode 100644 index 000000000..3ac7d5a27 --- /dev/null +++ b/vendor/vello_common/src/filter/offset.rs @@ -0,0 +1,22 @@ +// Copyright 2026 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! The offset filter. + +/// Translation/shift filter. +/// +/// This shifts the input image by `(dx, dy)` in device pixel space. +#[derive(Clone, Copy, Debug)] +pub struct Offset { + /// The x-offset that should be applied. + pub dx: f32, + /// The y-offset that should be applied. + pub dy: f32, +} + +impl Offset { + /// Create a new offset filter. + pub fn new(dx: f32, dy: f32) -> Self { + Self { dx, dy } + } +} diff --git a/vendor/vello_common/src/filter_effects.rs b/vendor/vello_common/src/filter_effects.rs new file mode 100644 index 000000000..40d7f82b9 --- /dev/null +++ b/vendor/vello_common/src/filter_effects.rs @@ -0,0 +1,1089 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Filter effects API based on the W3C Filter Effects specification. +//! +//! This module provides a comprehensive filter system supporting both high-level +//! CSS filter functions and low-level SVG filter primitives. The API is designed +//! to follow the W3C Filter Effects Module Level 1 specification. +//! +//! See: +//! +//! ## Implementation Status +//! +//! ### ✅ Implemented +//! +//! **Filter Functions:** +//! - `Blur` - Gaussian blur effect +//! +//! **Filter Primitives (Single Use Only):** +//! - `Flood` - Solid color fill +//! - `GaussianBlur` - Gaussian blur filter +//! - `DropShadow` - Drop shadow effect (compound primitive) +//! - `Offset` - Translation/shift (single primitive) +//! +//! **Note:** Currently only single primitive filters are supported. Filter graphs with +//! multiple connected primitives are not yet implemented. +//! +//! ### 🚧 Not Yet Implemented +//! +//! **Core Features:** +//! - `FilterGraph` execution - Chaining multiple filter primitives together +//! - `FilterInputs` - Connecting primitives to create complex effects +//! +//! **Filter Functions:** +//! - `Brightness`, `Contrast`, `Grayscale`, `HueRotate`, `Invert`, +//! `Opacity`, `Saturate`, `Sepia` +//! +//! **Filter Primitives:** +//! - `ColorMatrix` - Matrix-based color transformation +//! - `Composite` - Porter-Duff compositing operations +//! - `Blend` - Blend mode operations +//! - `Morphology` - Dilate/erode operations +//! - `ConvolveMatrix` - Custom convolution kernels +//! - `Turbulence` - Perlin noise generation +//! - `DisplacementMap` - Pixel displacement +//! - `ComponentTransfer` - Per-channel transfer functions +//! - `Image` - External image reference +//! - `Tile` - Tiling operation +//! - `DiffuseLighting`, `SpecularLighting` - Lighting effects + +use crate::color::{AlphaColor, Srgb}; +use crate::kurbo::{Affine, Rect}; +use alloc::sync::Arc; +use alloc::vec::Vec; +use smallvec::SmallVec; + +/// The main filter system. +/// +/// A filter combines a graph of filter primitives with optional spatial bounds. +/// If bounds are specified, the filter only applies within that region. +#[derive(Debug, Clone, PartialEq)] +pub struct Filter { + /// Filter graph defining the effect pipeline. + pub graph: Arc, + // TODO: Add bounds restricting where the filter applies. + // Optional bounds restricting where the filter applies. + // If `None`, the filter applies to the entire filtered element. + // pub bounds: Option, +} + +impl Filter { + /// Create a simple filter system from a filter function. + /// + /// Converts a high-level CSS-style filter function into a filter graph. + /// Use this for simple effects like blur, brightness, etc. + pub fn from_function(function: FilterFunction) -> Self { + // Convert function to primitive + let primitive = match function { + FilterFunction::Blur { radius } => FilterPrimitive::GaussianBlur { + std_deviation: radius, + edge_mode: EdgeMode::default(), + }, + _ => unimplemented!("Filter function {:?} not supported", function), + }; + + Self::from_primitive(primitive) + } + + /// Create a filter system from a filter primitive. + /// + /// Creates a simple filter graph with a single primitive. + /// Use this for direct access to low-level SVG filter operations. + pub fn from_primitive(primitive: FilterPrimitive) -> Self { + let mut graph = FilterGraph::new(); + let filter_id = graph.add(primitive, None); + graph.set_output(filter_id); + + Self { + graph: Arc::new(graph), + } + } + + /// Calculate the bounds expansion for this filter in pixel/device space. + /// + /// Returns a `Rect` representing how many extra pixels are needed around the + /// filtered region to correctly compute the filter effect. For example, a blur + /// filter needs to sample beyond the original bounds to avoid edge artifacts. + /// + /// The expansion accounts for the transform (rotation, scale, and shear) to compute + /// the correct axis-aligned bounding box expansion in device space. + /// + /// The returned rect is centered at origin: + /// - x0: negative left expansion (in pixels) + /// - y0: negative top expansion (in pixels) + /// - x1: positive right expansion (in pixels) + /// - y1: positive bottom expansion (in pixels) + /// + /// # Arguments + /// * `transform` - The transform applied to this filter layer + pub fn bounds_expansion(&self, transform: &Affine) -> Rect { + let [a, b, c, d, _e, _f] = transform.as_coeffs(); + let linear_only = Affine::new([a, b, c, d, 0.0, 0.0]); + + self.graph.bounds_expansion(&linear_only) + } +} + +/// A directed acyclic graph (DAG) of filter operations. +/// +/// The graph represents a pipeline of filter primitives where outputs of some +/// primitives can be used as inputs to others. Each primitive has a unique `FilterId`. +#[derive(Debug, Clone, PartialEq)] +pub struct FilterGraph { + /// All filter primitives in the graph, stored in insertion order. + pub primitives: SmallVec<[FilterPrimitive; 1]>, + /// The final output filter ID whose result is the output of this graph. + pub output: FilterId, + /// Next available filter ID (monotonically increasing counter). + next_id: u16, + /// Accumulated bounds expansion from all primitives in the graph, cached in user space. + /// This is the axis-aligned bounding box of the expansion region (centered at origin), + /// which can be transformed to device space when needed. + expansion_rect: Rect, +} + +impl Default for FilterGraph { + fn default() -> Self { + Self::new() + } +} + +impl FilterGraph { + /// Create a new empty filter graph. + pub fn new() -> Self { + Self { + primitives: SmallVec::new(), + output: FilterId(0), + next_id: 0, + expansion_rect: Rect::ZERO, + } + } + + /// Add a filter primitive with optional inputs. + /// + /// Returns a `FilterId` that can be referenced by other primitives. + /// Automatically updates the accumulated bounds expansion based on the primitive's requirements. + pub fn add(&mut self, primitive: FilterPrimitive, _inputs: Option) -> FilterId { + let id = FilterId(self.next_id); + self.next_id += 1; + + // Update accumulated expansion by taking the union of rects + let primitive_rect = primitive.expansion_rect(); + self.expansion_rect = self.expansion_rect.union(primitive_rect); + + self.primitives.push(primitive); + + id + } + + /// Set the output filter for the graph. + pub fn set_output(&mut self, output: FilterId) { + self.output = output; + } + + /// Get the accumulated bounds expansion for all primitives in this graph. + /// + /// This returns the expansion required by all primitives in the graph, + /// representing the padding needed to render all filter effects correctly. + /// + /// The expansion accounts for the transform (rotation, scale, and shear) to compute + /// the correct axis-aligned bounding box expansion in device space. + /// + /// # Arguments + /// * `transform` - The transform applied to this filter layer + pub fn bounds_expansion(&self, transform: &Affine) -> Rect { + // Transform the cached expansion rect to device space + // transform_rect_bbox computes the axis-aligned bounding box of the transformed rect + transform.transform_rect_bbox(self.expansion_rect) + } +} + +/// All possible filter effects. +/// +/// This enum allows choosing between high-level filter functions (simple CSS-style effects) +/// and low-level filter primitives (complex SVG-style effects with full control). +/// Use `FilterFunction` for common effects like blur, and `FilterPrimitive` for +/// advanced composition and custom filter graphs. +#[derive(Debug, Clone)] +pub enum FilterEffect { + /// Simple, high-level filter functions. + Function(FilterFunction), + /// Low-level filter primitives (granular control). + Primitive(FilterPrimitive), +} + +/// High-level filter functions for common effects (CSS filter functions). +/// +/// These match the CSS Filter Effects specification and provide simple, +/// commonly-used visual effects without needing to construct a filter graph. +/// +/// See: +#[derive(Debug, Clone)] +pub enum FilterFunction { + /// Gaussian blur effect. + /// + /// Applies a Gaussian blur to the input image. Larger radius values + /// produce more blur. The blur is applied equally in all directions. + /// + /// Note: Per the W3C Filter Effects specification, this `radius` parameter + /// represents the standard deviation (σ) of the Gaussian function, not the + /// effective blur range. The effective blur range is approximately 3× this value. + Blur { + /// Standard deviation of the Gaussian blur in pixels. Must be non-negative. + /// A value of 0 means no blur. + /// + /// Despite being called "radius" (to match CSS filter syntax), this is + /// actually the standard deviation. The visible blur effect extends + /// approximately 3 times this value in each direction. + radius: f32, + }, + // + // ============================================================ + // TODO: The following filter functions are not yet implemented + // ============================================================ + // + /// Brightness adjustment. + /// + /// Adjusts the brightness of the input image using a linear multiplier. + Brightness { + /// Brightness amount: 0.0 = completely black, 1.0 = no change, >1.0 = brighter. + /// Must be non-negative. + amount: f32, + }, + /// Contrast adjustment. + /// + /// Adjusts the contrast of the input image. + Contrast { + /// Contrast amount: 0.0 = uniform gray, 1.0 = no change, >1.0 = higher contrast. + /// Must be non-negative. + amount: f32, + }, + /// Grayscale conversion. + /// + /// Converts the input to grayscale. Amount controls the strength of the conversion. + Grayscale { + /// Grayscale amount: 0.0 = original colors, 1.0 = full grayscale. + /// Values should be in range [0.0, 1.0]. + amount: f32, + }, + /// Hue rotation. + /// + /// Rotates the hue of all colors in the input image by the specified angle. + HueRotate { + /// Rotation angle in degrees. Can be negative. + /// 0° = no change, 180° = opposite hue, 360° = back to original. + angle: f32, + }, + /// Color inversion. + /// + /// Inverts the colors of the input image. + Invert { + /// Inversion amount: 0.0 = original colors, 1.0 = fully inverted. + /// Values should be in range [0.0, 1.0]. + amount: f32, + }, + /// Opacity adjustment. + /// + /// Multiplies the alpha channel by the specified amount. + Opacity { + /// Opacity amount: 0.0 = fully transparent, 1.0 = no change. + /// Values should be in range [0.0, 1.0]. + amount: f32, + }, + /// Saturation adjustment. + /// + /// Adjusts the color saturation of the input image. + Saturate { + /// Saturation amount: 0.0 = completely desaturated (grayscale), + /// 1.0 = no change, >1.0 = oversaturated. + /// Must be non-negative. + amount: f32, + }, + /// Sepia tone effect. + /// + /// Applies a sepia tone effect (vintage/old photo appearance). + Sepia { + /// Sepia amount: 0.0 = original colors, 1.0 = full sepia tone. + /// Values should be in range [0.0, 1.0]. + amount: f32, + }, +} + +/// Edge mode for filter operations. +/// +/// Determines how to extend the input image when filter operations require sampling +/// beyond the original image boundaries. This is particularly important for blur and +/// convolution operations near edges. +/// +/// See: +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum EdgeMode { + /// Extend by duplicating edge pixels (clamp to edge). + /// + /// The input image is extended along each border by replicating the color values + /// at the given edge of the input image. This prevents dark halos around edges. + Duplicate, + /// Extend by wrapping to the opposite edge (repeat/tile). + /// + /// The input image is extended by taking color values from the opposite edge, + /// creating a tiling effect. + Wrap, + /// Extend by mirroring across the edge. + /// + /// The input image is extended by taking color values mirrored across the edge. + /// This creates seamless continuation at boundaries. + Mirror, + /// Extend with transparent black (zeros). + /// + /// The input image is extended with pixel values of zero for R, G, B and A. + /// This is the default and most common mode, creating natural fade-to-transparent edges. + #[default] + None, +} + +/// Low-level filter primitives for granular control (SVG filter primitives). +/// +/// These are the building blocks for complex filter effects, corresponding to SVG +/// filter primitives. They can be combined in a `FilterGraph` to create sophisticated +/// visual effects. +/// +/// See: +#[derive(Debug, Clone, PartialEq)] +pub enum FilterPrimitive { + /// Generate a solid color fill. + /// + /// Creates a rectangle filled with the specified color, typically used as + /// input to other filter operations (e.g., for colored shadows). + Flood { + /// Fill color with alpha channel. + color: AlphaColor, + }, + /// Gaussian blur filter. + /// + /// Applies a Gaussian blur using the specified standard deviation (σ). + /// The effective blur range (distance over which pixels are sampled) is + /// approximately 3 × `std_deviation`, as this captures ~99.7% of the + /// Gaussian distribution. + GaussianBlur { + /// Standard deviation for the blur kernel. Larger values create more blur. + /// Must be non-negative. A value of 0 means no blur. + /// + /// This directly corresponds to the σ (sigma) parameter in the Gaussian + /// function. The visible blur effect extends approximately 3σ in each direction. + /// + /// TODO: Per the W3C specification, this should support separate x and y values. + /// The spec allows `stdDeviation` to be either one number (applied to both axes) + /// or two numbers (first for x-axis, second for y-axis). Currently only uniform + /// blur is supported. Consider changing to `(f32, f32)` or a dedicated type. + std_deviation: f32, + /// Edge mode determining how pixels beyond the input bounds are handled. + edge_mode: EdgeMode, + }, + /// Drop shadow effect (compound primitive). + /// + /// Creates a drop shadow by blurring the input's alpha channel, offsetting it, + /// and compositing it with the original. This is a compound operation that + /// combines multiple primitive operations into one. + /// + /// See: + DropShadow { + /// Horizontal offset of the shadow in pixels. Positive values shift right. + dx: f32, + /// Vertical offset of the shadow in pixels. Positive values shift down. + dy: f32, + /// Blur standard deviation for the shadow. Larger values create softer shadows. + std_deviation: f32, + /// Shadow color with alpha channel. Alpha controls shadow opacity. + color: AlphaColor, + /// Edge mode for handling boundaries during blur operation. + /// Default is `EdgeMode::None` per SVG spec. + edge_mode: EdgeMode, + }, + // + // ============================================================ + // TODO: The following filter primitives are not yet implemented + // ============================================================ + // + /// Matrix-based color transformation. + /// + /// Applies a 4x5 matrix transformation to colors, allowing arbitrary + /// color space transformations, hue shifts, and color adjustments. + ColorMatrix { + /// 4x5 color transformation matrix: 4 rows (R,G,B,A) × 5 columns (R,G,B,A,offset). + /// Each output channel is computed as a linear combination of input channels plus offset. + matrix: [f32; 20], + }, + /// Geometric offset/translation. + /// + /// Shifts the input image by the specified offset. Useful for creating + /// shadow effects or positioning elements in a filter graph. + Offset { + /// Horizontal offset in pixels. Positive values shift right. + dx: f32, + /// Vertical offset in pixels. Positive values shift down. + dy: f32, + }, + + /// Composite two inputs using Porter-Duff compositing operations. + /// + /// Combines two input images using standard compositing operators + /// (over, in, out, atop, xor) or custom arithmetic combination. + Composite { + /// Porter-Duff compositing operator to apply. + operator: CompositeOperator, + }, + /// Blend two inputs using blend modes. + /// + /// Combines two input images using Photoshop-style blend modes + /// (multiply, screen, overlay, etc.). + Blend { + /// Blend mode determining how colors are combined. + mode: BlendMode, + }, + /// Morphological operations (dilate/erode). + /// + /// Expands (dilate) or contracts (erode) the shapes in the input image. + /// Useful for creating outline effects or cleaning up edges. + Morphology { + /// Morphological operator determining whether to erode or dilate. + operator: MorphologyOperator, + /// Operation radius in pixels. Larger values create stronger effects. + radius: f32, + }, + /// Custom convolution kernel for image processing. + /// + /// Applies a custom convolution matrix to the input image, enabling + /// effects like sharpening, edge detection, embossing, and custom filters. + ConvolveMatrix { + /// Convolution kernel specification including size, values, and normalization. + kernel: ConvolutionKernel, + }, + /// Generate Perlin noise/turbulence patterns. + /// + /// Creates procedural noise patterns useful for textures, clouds, + /// marble effects, and other organic-looking randomness. + Turbulence { + /// Base frequency for noise generation. Higher values create finer detail. + base_frequency: f32, + /// Number of octaves for fractal noise. More octaves add finer detail. + num_octaves: u32, + /// Random seed for reproducible noise generation. + seed: u32, + /// Type of noise: smooth fractal or more chaotic turbulence. + turbulence_type: TurbulenceType, + }, + /// Displace pixels using a displacement map. + /// + /// Uses the color values from a second input to spatially displace pixels + /// in the primary input, creating warping and distortion effects. + DisplacementMap { + /// Scale factor controlling the displacement intensity. + scale: f32, + /// Color channel from the displacement map used for X-axis displacement. + x_channel: ColorChannel, + /// Color channel from the displacement map used for Y-axis displacement. + y_channel: ColorChannel, + }, + /// Per-channel component transfer using lookup tables or functions. + /// + /// Applies independent transfer functions to each color channel, + /// enabling color corrections, gamma adjustments, and custom mappings. + ComponentTransfer { + /// Transfer function applied to the red channel (None = identity). + red_function: Option, + /// Transfer function applied to the green channel (None = identity). + green_function: Option, + /// Transfer function applied to the blue channel (None = identity). + blue_function: Option, + /// Transfer function applied to the alpha channel (None = identity). + alpha_function: Option, + }, + /// Reference an external image as filter input. + /// + /// Allows using pre-existing images (from an atlas or resource) as + /// input to filter operations, useful for texturing and overlays. + Image { + /// Identifier referencing an image in the resource atlas. + image_id: u32, + /// Optional 2D affine transformation matrix [a, b, c, d, e, f]. + /// Transforms the image before using it as filter input. + transform: Option<[f32; 6]>, + }, + /// Tile the input to fill the filter region. + /// + /// Repeats the input image to fill the entire filter primitive subregion, + /// creating a tiling/repeating pattern. + Tile, + /// Diffuse lighting simulation. + /// + /// Creates a lighting effect by treating the input's alpha channel as a height map + /// and calculating diffuse (matte) reflection from a light source. + DiffuseLighting { + /// Surface scale factor for converting alpha values to heights. + surface_scale: f32, + /// Diffuse reflection constant (kd). Controls lighting intensity. + diffuse_constant: f32, + /// Kernel unit length for gradient calculations in user space. + kernel_unit_length: f32, + /// Configuration of the light source (point, distant, or spot). + light_source: LightSource, + }, + /// Specular lighting simulation. + /// + /// Creates a lighting effect by treating the input's alpha channel as a height map + /// and calculating specular (shiny) reflection highlights from a light source. + SpecularLighting { + /// Surface scale factor for converting alpha values to heights. + surface_scale: f32, + /// Specular reflection constant (ks). Controls highlight intensity. + specular_constant: f32, + /// Specular reflection exponent. Controls highlight sharpness (higher = sharper). + specular_exponent: f32, + /// Kernel unit length for gradient calculations in user space. + kernel_unit_length: f32, + /// Configuration of the light source (point, distant, or spot). + light_source: LightSource, + }, +} + +impl FilterPrimitive { + /// Calculate the bounds expansion as a `Rect` in user space. + /// + /// Returns a rectangle centered at the origin representing how much the filter + /// expands the processing region in each direction. The rect coordinates are: + /// - x0: negative left expansion + /// - y0: negative top expansion + /// - x1: positive right expansion + /// - y1: positive bottom expansion + /// + /// A `Rect::ZERO` means no expansion. This representation allows the expansion + /// to be correctly transformed (including rotation) using standard rect transforms. + /// + /// For example, a blur filter needs additional pixels around the edges (3*sigma). + /// Most filters that don't sample neighboring pixels return `Rect::ZERO`. + pub fn expansion_rect(&self) -> Rect { + match self { + Self::GaussianBlur { std_deviation, .. } => { + // Gaussian blur expands uniformly by 3*sigma (covers 99.7% of distribution) + let radius = (*std_deviation * 3.0) as f64; + Rect::new(-radius, -radius, radius, radius) + } + Self::Offset { dx, dy } => { + // Offset shifts pixels; expand bounds asymmetrically so shifted content isn't cut. + let dx = *dx as f64; + let dy = *dy as f64; + Rect::new(dx.min(0.0), dy.min(0.0), dx.max(0.0), dy.max(0.0)) + } + Self::DropShadow { + std_deviation, + dx, + dy, + .. + } => { + // Drop shadow = blur + offset + composite with original + // The expansion rect encompasses both the blur and the offset + let blur_radius = (*std_deviation * 3.0) as f64; + let dx = *dx as f64; + let dy = *dy as f64; + + Rect::new( + -(blur_radius + (-dx).max(0.0)), + -(blur_radius + (-dy).max(0.0)), + blur_radius + dx.max(0.0), + blur_radius + dy.max(0.0), + ) + } + // Most other filters don't expand bounds + _ => Rect::ZERO, + } + } +} + +#[cfg(test)] +mod offset_expansion_tests { + use super::FilterPrimitive; + use crate::kurbo::Rect; + + #[test] + fn offset_expands_in_direction_of_shift() { + let p = FilterPrimitive::Offset { dx: 2.5, dy: -3.0 }; + assert_eq!( + p.expansion_rect(), + Rect::new(0.0, -3.0, 2.5, 0.0), + "Offset expansion should be asymmetric and include the shift vector" + ); + } +} + +/// Unique identifier for a filter primitive in the graph. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct FilterId(pub u16); + +/// Input connections for a filter primitive. +#[derive(Debug, Clone, PartialEq)] +pub struct FilterInputs { + /// Primary input ("in" attribute in SVG). + pub primary: FilterInput, + /// Secondary input ("in2" attribute in SVG, for composite/blend operations). + pub secondary: Option, +} + +impl FilterInputs { + /// Create filter inputs with a single input. + /// + /// Use this for primitives that operate on a single source (blur, color matrix, etc.). + pub fn single(input: FilterInput) -> Self { + Self { + primary: input, + secondary: None, + } + } + + /// Create filter inputs with two inputs (for composite, blend, etc.). + /// + /// Use this for primitives that combine two sources (composite, blend, displacement map, etc.). + pub fn dual(input1: FilterInput, input2: FilterInput) -> Self { + Self { + primary: input1, + secondary: Some(input2), + } + } +} + +/// A single filter input. +#[derive(Debug, Clone, PartialEq)] +pub enum FilterInput { + /// Input from a source (`SourceGraphic`, `SourceAlpha`, etc.). + Source(FilterSource), + /// Input from another filter's result. + Result(FilterId), +} + +/// Filter input sources. +/// +/// Defines the various built-in sources that can be used as filter inputs, +/// matching the SVG filter primitive input types. These represent implicit +/// inputs available to any filter primitive without requiring previous operations. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FilterSource { + /// The original graphic content being filtered. + /// + /// This is the default input - the rendered result of the element + /// the filter is applied to, including all its fill, stroke, and content. + SourceGraphic, + /// Alpha channel only of the original graphic. + /// + /// Useful for creating effects based on shape/transparency, such as + /// shadows that follow the element's outline. + SourceAlpha, + /// Background image content behind the filtered element. + /// + /// Allows filters to incorporate or blend with content behind the element. + /// Not always available depending on the rendering context. + BackgroundImage, + /// Alpha channel only of the background image. + /// + /// The transparency mask of the background content. + BackgroundAlpha, + /// The fill paint of the element as an image input. + /// + /// For elements with gradient or pattern fills, this provides access + /// to the fill as a filter input. + FillPaint, + /// The stroke paint of the element as an image input. + /// + /// For elements with gradient or pattern strokes, this provides access + /// to the stroke as a filter input. + StrokePaint, +} + +/// Pre-built compound effects for common use cases. +/// +/// These effects combine multiple filter primitives into commonly-used visual effects. +/// They provide a convenient high-level API for complex multi-step filter operations. +/// +/// **Note:** These are planned but not yet implemented. Use `FilterGraph` to manually +/// construct these effects from primitives. +#[derive(Debug, Clone)] +pub enum CompoundFilter { + /// Inner shadow effect (shadow inside the shape). + /// + /// Creates a shadow that appears inside the boundaries of the shape, + /// giving a recessed or inset appearance. This is the opposite of a drop shadow. + InnerShadow { + /// Horizontal offset of the shadow in pixels. Positive values shift right. + dx: f32, + /// Vertical offset of the shadow in pixels. Positive values shift down. + dy: f32, + /// Blur radius for the shadow in pixels. Larger values create softer shadows. + blur: f32, + /// Shadow color with alpha channel. + color: AlphaColor, + }, + /// Glow effect around the shape. + /// + /// Creates a soft glowing halo around the shape by blurring and + /// compositing a colored version with the original. + Glow { + /// Blur radius for the glow in pixels. Larger values create softer glows. + blur: f32, + /// Glow color with alpha channel. + color: AlphaColor, + }, + /// Bevel effect (3D raised/recessed appearance). + /// + /// Creates a 3D beveled edge effect using lighting simulation, + /// making the shape appear raised or recessed from the surface. + Bevel { + /// Light source angle in degrees (0° = right, 90° = up). + angle: f32, + /// Width of the bevel edge in pixels. + distance: f32, + /// Color for the highlight (lit) side of the bevel. + highlight: AlphaColor, + /// Color for the shadow (dark) side of the bevel. + shadow: AlphaColor, + }, + /// Emboss effect for a raised relief appearance. + /// + /// Creates an embossed/stamped appearance by simulating lighting + /// on a raised surface based on the shape's alpha channel. + Emboss { + /// Light angle in degrees determining emboss direction. + angle: f32, + /// Depth of the emboss effect. + depth: f32, + /// Overall strength/intensity of the effect (0.0 = none, 1.0 = full). + amount: f32, + }, +} + +/// Composite operators for combining filter inputs. +/// +/// These are the Porter-Duff compositing operators used to combine two images. +/// Each operator defines how the source (input 1) and destination (input 2) +/// are combined based on their color and alpha values. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum CompositeOperator { + /// Source over destination (standard alpha blending). + /// + /// The source is composited over the destination. This is the most common + /// blending mode where source alpha determines visibility. + Over, + /// Source in destination (intersection). + /// + /// The source is only visible where the destination is opaque. + /// Result alpha = `source_alpha` × `dest_alpha`. + In, + /// Source out destination (subtract). + /// + /// The source is only visible where the destination is transparent. + /// Useful for masking/cutting out regions. + Out, + /// Source atop destination. + /// + /// Source is composited over destination, but only where destination is opaque. + Atop, + /// Source XOR destination (exclusive or). + /// + /// Shows source where destination is transparent and vice versa, + /// but not where both are opaque. + Xor, + /// Arithmetic combination with custom coefficients. + /// + /// Custom linear combination: result = k1*src*dst + k2*src + k3*dst + k4. + /// Allows creating custom compositing operations beyond the standard Porter-Duff set. + Arithmetic { + /// Coefficient k1 for the (source * destination) term. + k1: f32, + /// Coefficient k2 for the source term. + k2: f32, + /// Coefficient k3 for the destination term. + k3: f32, + /// Constant offset k4 added to the result. + k4: f32, + }, +} + +/// Blend modes for combining colors. +/// +/// These are blend modes that define how to combine the colors +/// of two layers. Unlike compositing operators which deal with alpha, blend modes +/// focus on color mixing while preserving the compositing behavior. +/// +/// See: +pub type BlendMode = peniko::Mix; + +/// Morphological operators for dilate/erode operations. +/// +/// These operators modify the shape of objects by expanding or contracting them. +/// They work by examining neighborhoods of pixels and applying min/max operations. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MorphologyOperator { + /// Erode operation (shrink/thin shapes). + /// + /// Makes objects smaller by removing pixels at the edges. Takes the minimum + /// value in the neighborhood. Useful for removing noise or separating touching objects. + Erode, + /// Dilate operation (expand/thicken shapes). + /// + /// Makes objects larger by adding pixels at the edges. Takes the maximum + /// value in the neighborhood. Useful for filling holes or connecting nearby objects. + Dilate, +} + +/// Convolution kernel for custom filtering operations. +/// +/// Defines a square matrix of weights used for convolution-based image processing. +/// The kernel is applied to each pixel by multiplying surrounding pixels by the weights, +/// summing the results, dividing by the divisor, and adding the bias. +#[derive(Debug, Clone, PartialEq)] +pub struct ConvolutionKernel { + /// Kernel size (e.g., 3 for a 3×3 kernel, 5 for 5×5). + /// The kernel must be square, so this defines both width and height. + pub size: u32, + /// Kernel weight values in row-major order. + /// Length must equal size × size. Center of kernel is typically at (size/2, size/2). + pub values: Vec, + /// Normalization divisor applied to the convolution result. + /// Common practice is to use the sum of all weights for averaging, or 1.0 otherwise. + pub divisor: f32, + /// Bias value added to the result after normalization. + /// Useful for edge detection or emboss effects to shift the result range. + pub bias: f32, + /// Whether to preserve the alpha channel unchanged. + /// If true, convolution only applies to RGB; if false, it applies to RGBA. + pub preserve_alpha: bool, +} + +/// Types of turbulence noise generation. +/// +/// Determines the algorithm used for generating procedural noise patterns. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TurbulenceType { + /// Fractal noise (smooth, natural-looking Perlin noise). + /// + /// Creates smooth, continuous patterns suitable for natural textures + /// like clouds, marble, wood grain, or terrain. + FractalNoise, + /// Turbulence noise (more chaotic and energetic). + /// + /// Creates more chaotic patterns with sharper transitions, + /// suitable for fire, smoke, or turbulent effects. + Turbulence, +} + +/// Color channels for displacement mapping and channel selection. +/// +/// Specifies which color channel to use for operations that need to +/// extract or reference individual channels from an image. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ColorChannel { + /// Red color channel (R component). + Red, + /// Green color channel (G component). + Green, + /// Blue color channel (B component). + Blue, + /// Alpha channel (transparency/opacity). + Alpha, +} + +/// Transfer functions for component transfer operations. +/// +/// These functions map input color channel values to output values, +/// enabling gamma correction, color grading, and custom color curves. +/// Input and output values are typically in the range [0, 1]. +#[derive(Debug, Clone, PartialEq)] +pub enum TransferFunction { + /// Identity function (output = input, no change). + Identity, + /// Table lookup with linear interpolation. + /// + /// Maps input values using a lookup table with linear interpolation between entries. + /// Input 0.0 maps to values\[0\], 1.0 maps to values\[n-1\], intermediate values interpolate. + Table { + /// Lookup table values defining the transfer curve. + /// More values provide smoother curves. Minimum 2 values required. + values: Vec, + }, + /// Discrete step function (posterization). + /// + /// Maps input to discrete output values without interpolation, creating step/banding effects. + /// Each segment gets a constant output value from the table. + Discrete { + /// Step values for each discrete output level. + /// Input range is divided into len(values) segments, each mapping to one value. + values: Vec, + }, + /// Linear function: output = slope × input + intercept. + /// + /// Simple linear transformation of the input value. + Linear { + /// Slope coefficient (rate of change). + slope: f32, + /// Intercept offset (constant added to result). + intercept: f32, + }, + /// Gamma correction: output = amplitude × input^exponent + offset. + /// + /// Applies power-law transformation, commonly used for gamma correction and + /// adjusting midtone brightness without affecting blacks or whites. + Gamma { + /// Amplitude multiplier applied to the result. + amplitude: f32, + /// Gamma exponent (< 1 brightens, > 1 darkens midtones). + exponent: f32, + /// Offset added to the final result. + offset: f32, + }, +} + +/// Light source configurations for lighting effects. +/// +/// Defines different types of light sources used in diffuse and specular lighting +/// filter primitives. Each type has different characteristics and use cases. +#[derive(Debug, Clone, PartialEq)] +pub enum LightSource { + /// Distant light source (infinitely far away, like the sun). + /// + /// All rays are parallel, creating uniform lighting across the surface. + /// Direction is specified using spherical coordinates (azimuth and elevation). + Distant { + /// Azimuth angle in degrees (0° = pointing right, 90° = pointing up). + /// Defines the horizontal direction of the light. + azimuth: f32, + /// Elevation angle in degrees (0° = horizon, 90° = directly overhead). + /// Defines the vertical angle of the light source. + elevation: f32, + }, + /// Point light source at a specific 3D position. + /// + /// Light radiates uniformly in all directions from a single point. + /// Intensity decreases with distance. Like a light bulb. + Point { + /// Light source X coordinate in user space. + x: f32, + /// Light source Y coordinate in user space. + y: f32, + /// Light source Z coordinate (height above the surface). + /// Larger values create softer lighting across larger areas. + z: f32, + }, + /// Spot light with position, direction, and cone angle. + /// + /// Light emanates from a point in a specific direction with limited spread. + /// Like a flashlight or stage spotlight with adjustable focus. + Spot { + /// Light source X coordinate in user space. + x: f32, + /// Light source Y coordinate in user space. + y: f32, + /// Light source Z coordinate (height above the surface). + z: f32, + /// X coordinate the spotlight is aimed at. + points_at_x: f32, + /// Y coordinate the spotlight is aimed at. + points_at_y: f32, + /// Z coordinate the spotlight is aimed at. + points_at_z: f32, + /// Specular exponent controlling the focus/sharpness of the spotlight beam. + /// Higher values create tighter, more focused beams. + specular_exponent: f32, + /// Optional cone angle in degrees limiting the spotlight spread. + /// If None, the light spreads based only on the specular exponent. + limiting_cone_angle: Option, + }, +} + +/// Common color transformation matrices. +/// +/// These 4x5 matrices are used with the `ColorMatrix` filter primitive. +/// Each row transforms a color channel: [R, G, B, A, offset]. +pub mod matrices { + /// Identity matrix (no change). + pub const IDENTITY: [f32; 20] = [ + 1.0, 0.0, 0.0, 0.0, 0.0, // Red + 0.0, 1.0, 0.0, 0.0, 0.0, // Green + 0.0, 0.0, 1.0, 0.0, 0.0, // Blue + 0.0, 0.0, 0.0, 1.0, 0.0, // Alpha + ]; + + /// Extract alpha channel to RGB (for shadow effects). + pub const ALPHA_TO_BLACK: [f32; 20] = [ + 0.0, 0.0, 0.0, 1.0, 0.0, // Red = Alpha + 0.0, 0.0, 0.0, 1.0, 0.0, // Green = Alpha + 0.0, 0.0, 0.0, 1.0, 0.0, // Blue = Alpha + 0.0, 0.0, 0.0, 1.0, 0.0, // Alpha = Alpha + ]; + + /// Grayscale conversion matrix using luminosity weights. + pub const GRAYSCALE: [f32; 20] = [ + 0.2126, 0.7152, 0.0722, 0.0, 0.0, // Red + 0.2126, 0.7152, 0.0722, 0.0, 0.0, // Green + 0.2126, 0.7152, 0.0722, 0.0, 0.0, // Blue + 0.0, 0.0, 0.0, 1.0, 0.0, // Alpha + ]; + + /// Sepia tone matrix for vintage photo effect. + pub const SEPIA: [f32; 20] = [ + 0.393, 0.769, 0.189, 0.0, 0.0, // Red + 0.349, 0.686, 0.168, 0.0, 0.0, // Green + 0.272, 0.534, 0.131, 0.0, 0.0, // Blue + 0.0, 0.0, 0.0, 1.0, 0.0, // Alpha + ]; +} + +/// Common convolution kernels. +/// +/// These kernels are used with the `ConvolveMatrix` filter primitive +/// for various image processing effects. All provided kernels are 3x3. +pub mod kernels { + use super::ConvolutionKernel; + use alloc::vec; + + /// 3x3 Gaussian blur kernel for basic smoothing. + pub fn gaussian_3x3() -> ConvolutionKernel { + ConvolutionKernel { + size: 3, + values: vec![1.0, 2.0, 1.0, 2.0, 4.0, 2.0, 1.0, 2.0, 1.0], + divisor: 16.0, + bias: 0.0, + preserve_alpha: false, + } + } + + /// 3x3 Sharpen kernel to enhance edges and details. + pub fn sharpen_3x3() -> ConvolutionKernel { + ConvolutionKernel { + size: 3, + values: vec![0.0, -1.0, 0.0, -1.0, 5.0, -1.0, 0.0, -1.0, 0.0], + divisor: 1.0, + bias: 0.0, + preserve_alpha: true, + } + } + + /// 3x3 Edge detection kernel (Laplacian operator). + pub fn edge_detect_3x3() -> ConvolutionKernel { + ConvolutionKernel { + size: 3, + values: vec![-1.0, -1.0, -1.0, -1.0, 8.0, -1.0, -1.0, -1.0, -1.0], + divisor: 1.0, + bias: 0.0, + preserve_alpha: true, + } + } + + /// 3x3 Emboss kernel for creating a raised/beveled appearance. + pub fn emboss_3x3() -> ConvolutionKernel { + ConvolutionKernel { + size: 3, + values: vec![-2.0, -1.0, 0.0, -1.0, 1.0, 1.0, 0.0, 1.0, 2.0], + divisor: 1.0, + bias: 0.5, + preserve_alpha: true, + } + } +} diff --git a/vendor/vello_common/src/flatten.rs b/vendor/vello_common/src/flatten.rs new file mode 100644 index 000000000..be219c6c3 --- /dev/null +++ b/vendor/vello_common/src/flatten.rs @@ -0,0 +1,286 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Flattening filled and stroked paths. + +use crate::flatten_simd::{Callback, LinePathEl}; +use crate::geometry::RectU16; +use crate::kurbo::{self, Affine, PathEl, Stroke, StrokeCtx, StrokeOpts}; +use alloc::vec::Vec; +use fearless_simd::{Level, Simd, dispatch}; +use log::warn; + +pub use crate::flatten_simd::FlattenCtx; + +// The current tolerance is set to 0.25. Since `sqrt` doesn't work in const contexts, we instead +// hardcode the squared tolerance and derive the others from that. +pub(crate) const SQRT_TOL: f64 = 0.5; +pub(crate) const TOL: f64 = SQRT_TOL * SQRT_TOL; +pub(crate) const TOL_2: f64 = TOL * TOL; + +/// A point. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Point { + /// The x coordinate of the point. + pub x: f32, + /// The y coordinate of the point. + pub y: f32, +} + +impl Point { + /// The point `(0, 0)`. + pub const ZERO: Self = Self::new(0., 0.); + + /// Create a new point. + pub const fn new(x: f32, y: f32) -> Self { + Self { x, y } + } +} + +impl From for Point { + #[inline(always)] + fn from(value: kurbo::Point) -> Self { + Self { + x: value.x as f32, + y: value.y as f32, + } + } +} + +impl core::ops::Add for Point { + type Output = Self; + + fn add(self, rhs: Self) -> Self { + Self::new(self.x + rhs.x, self.y + rhs.y) + } +} + +impl core::ops::Sub for Point { + type Output = Self; + + fn sub(self, rhs: Self) -> Self { + Self::new(self.x - rhs.x, self.y - rhs.y) + } +} + +impl core::ops::Mul for Point { + type Output = Self; + + fn mul(self, rhs: f32) -> Self { + Self::new(self.x * rhs, self.y * rhs) + } +} + +/// A line. +#[derive(Clone, Copy, Debug)] +pub struct Line { + /// The start point of the line. + pub p0: Point, + /// The end point of the line. + pub p1: Point, +} + +impl Line { + /// Create a new line. + pub fn new(p0: Point, p1: Point) -> Self { + Self { p0, p1 } + } +} + +/// Flatten a filled Bézier path into line segments. +/// +/// # Open subpaths and culling +/// +/// Open subpaths in the input path get closed by connecting the last endpoint in the subpath to +/// the starting point. The output lines in `line_buf` describe the flattened path, but these lines +/// may describe open subpaths, as some path elements may have been culled. +/// +/// For example, consider the following, where the box describes the viewport, a path is marked by +/// `*`, and the region to be filled in the viewport is shaded. For ease of drawing the ASCII art, +/// the path elements are all lines ([`PathEl::LineTo`]), but the same also holds for Bézier path +/// elements. +/// +/// ```text +/// ---> winding scan direction +/// +/// * * * * * +/// * * +/// ---------- * ----- * +/// | *░░░░░░░| * +/// | *░░░░░░░░░| * +/// | *░░░░░░░░░░░| * +/// | *░░░░░░░░░░░░░| * +/// |*░░░░░░░░░░░░░░░| * +/// *|░░░░░░░░░░░░░░░░| * +/// * |░░░░░░░░░░░░░░░░| * +/// * |░░░░░░░░░░░░░░░░| * +/// * ------------------ * +/// * * +/// * * +/// * * +/// * * +/// * * +/// * * +/// * * +/// * * * * * * * * * * * * * * +/// ``` +/// +/// Because the winding scan direction is from left to right, only the left-of-viewport and +/// diagonal lines matter in later stages of rendering for the winding number and pixel coverage. +/// The other three lines can be culled. +/// +/// ```text +/// * +/// * +/// ---------- * ----- +/// | *░░░░░░░| +/// | *░░░░░░░░░| +/// | *░░░░░░░░░░░| +/// | *░░░░░░░░░░░░░| +/// |*░░░░░░░░░░░░░░░| +/// *|░░░░░░░░░░░░░░░░| +/// * |░░░░░░░░░░░░░░░░| +/// * |░░░░░░░░░░░░░░░░| +/// * ------------------ +/// * +/// * +/// * +/// * +/// * +/// * +/// * +/// ``` +/// +/// It is important to keep these flattened subpaths open after culling, as closing the subpaths +/// might yield different geometry like the following. +/// +/// ```text +/// * +/// ** +/// ---------- * *---- +/// | *░░* | +/// | *░░░* | +/// | *░░░░* | +/// | *░░░░░* | +/// |*░░░░░░* | +/// *|░░░░░░* | +/// * |░░░░░* | +/// * |░░░░* | +/// * --- * ------------ +/// * * +/// * * +/// * * +/// * * +/// * * +/// ** +/// * +/// ``` +pub fn fill( + level: Level, + path: impl IntoIterator, + affine: Affine, + line_buf: &mut Vec, + ctx: &mut FlattenCtx, + cull_bbox: RectU16, +) { + dispatch!(level, simd => fill_impl(simd, path, affine, line_buf, ctx, cull_bbox)); +} + +/// Flatten a filled bezier path into line segments. +/// +/// See the note about open subpaths and culling on [`fill`]. +#[inline(always)] +pub fn fill_impl( + simd: S, + path: impl IntoIterator, + affine: Affine, + line_buf: &mut Vec, + flatten_ctx: &mut FlattenCtx, + cull_bbox: RectU16, +) { + line_buf.clear(); + let mut lb = FlattenerCallback { + line_buf, + start: Point::ZERO, + p0: Point::ZERO, + is_nan: false, + }; + + crate::flatten_simd::flatten(simd, path, affine, &mut lb, flatten_ctx, cull_bbox); + + // A path that contains NaN is ill-defined, so ignore it. + if lb.is_nan { + warn!("A path contains NaN, ignoring it."); + + line_buf.clear(); + } +} +/// Flatten a stroked Bézier path into line segments. +/// +/// See the note about open subpaths and culling on [`fill`]. +pub fn stroke( + level: Level, + path: impl IntoIterator, + style: &Stroke, + affine: Affine, + line_buf: &mut Vec, + flatten_ctx: &mut FlattenCtx, + stroke_ctx: &mut StrokeCtx, + cull_bbox: RectU16, +) { + // TODO: Temporary hack to ensure that strokes are scaled properly by the transform. + let tolerance = TOL + / affine.as_coeffs()[0] + .abs() + .max(affine.as_coeffs()[3].abs()) + .max(1.); + + expand_stroke(path, style, tolerance, stroke_ctx); + fill( + level, + stroke_ctx.output(), + affine, + line_buf, + flatten_ctx, + cull_bbox, + ); +} + +/// Expand a stroked path to a filled path. +pub fn expand_stroke( + path: impl IntoIterator, + style: &Stroke, + tolerance: f64, + stroke_ctx: &mut StrokeCtx, +) { + kurbo::stroke_with(path, style, &StrokeOpts::default(), tolerance, stroke_ctx); +} + +struct FlattenerCallback<'a> { + line_buf: &'a mut Vec, + start: Point, + p0: Point, + is_nan: bool, +} + +impl Callback for FlattenerCallback<'_> { + #[inline(always)] + fn callback(&mut self, el: LinePathEl) { + match el { + LinePathEl::MoveTo(p) => { + self.is_nan |= p.is_nan(); + + let p = p.into(); + self.start = p; + self.p0 = p; + } + LinePathEl::LineTo(p) => { + self.is_nan |= p.is_nan(); + + let p = p.into(); + self.line_buf.push(Line::new(self.p0, p)); + self.p0 = p; + } + } + } +} diff --git a/vendor/vello_common/src/flatten_simd.rs b/vendor/vello_common/src/flatten_simd.rs new file mode 100644 index 000000000..e71df65ca --- /dev/null +++ b/vendor/vello_common/src/flatten_simd.rs @@ -0,0 +1,687 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! This is a temporary module that contains a SIMD version of flattening of cubic curves, as +//! well as some code that was copied from kurbo, which is needed to reimplement the +//! full `flatten` method. + +#[cfg(not(feature = "std"))] +use crate::kurbo::common::FloatFuncs as _; +use crate::kurbo::{CubicBez, Line, ParamCurve, ParamCurveNearest, PathEl, Point, QuadBez}; +use crate::{ + flatten::{SQRT_TOL, TOL, TOL_2}, + geometry::RectU16, + kurbo::Affine, + tile::Tile, +}; +use alloc::vec::Vec; +use bytemuck::{Pod, Zeroable}; +use fearless_simd::*; + +/// The element of a path made of lines. +/// +/// Each subpath must start with a `MoveTo`. Closing of subpaths is not supported, and subpaths are +/// not closed implicitly when a new subpath (with `MoveTo`) is started. It is expected that closed +/// subpaths are watertight in the sense that the last `LineTo` matches exactly with the first +/// `MoveTo`. +/// +/// This intentionally allows for non-watertight subpaths, as, e.g., lines that are fully outside +/// of the viewport do not need to be drawn. +/// +/// See [`PathEl`] for a more general-purpose path element type. +pub(crate) enum LinePathEl { + MoveTo(Point), + LineTo(Point), +} + +// Unlike kurbo, which takes a closure with a callback for outputting the lines, we use a trait +// instead. The reason is that this way the callback can be inlined, which is not possible with +// a closure and turned out to have a noticeable overhead. +pub(crate) trait Callback { + fn callback(&mut self, el: LinePathEl); +} + +/// See the docs for the kurbo implementation of flattening: +/// +/// +/// This version works using a similar approach but using f32x4/f32x8 SIMD instead. +#[inline(always)] +pub(crate) fn flatten( + simd: S, + path: impl IntoIterator, + // Note: we explicitly pass the `Affine` here instead of using `map` on + // the iterator because it seems like the `map` function isn't always inlined + // properly, even when annotating the closure with `#[inline(always)]`. + // See https://github.com/linebender/vello/pull/1600. + affine: Affine, + callback: &mut impl Callback, + flatten_ctx: &mut FlattenCtx, + cull_bbox: RectU16, +) { + flatten_ctx.flattened_cubics.clear(); + + // For the culling performed here to be correct, the top y coordinate of the cull bbox must be + // aligned to strip row boundaries. Consider the alternative: for example, a strip row starting + // at y=8, a cull bbox starting at y=10, and (nearly) horizontal geometry at y=9 that does not + // cross into the cull bbox and does not extend above y=8 (and note this is all in a y-down + // coordinate space). + // + // The culling performed here would remove that geometry. However, as it does not extend above + // the strip row, it does not add coarse winding to the strip, and does not produce a sparse + // fill. Yet, if this is the top part of, say, a rectangle that is partially visible, the + // geometry is necessary for tiling to emit intermediate tiles that are necessary for the + // bottom part of the strip to get filled. + // + // Therefore, we align `top` to strip row boundaries, such that all intermediate tiles are + // produced. + // + // This is not necessary for the bottom. Consider a path where a segment extends just below the + // cull bbox, but remains in the same strip row. The path is closed, so if anything is to be + // rendered at all, there will be other geometry above that edge of the cull bbox. If that + // geometry extends above the row, there will be coarse winding for a sparse fill. If not, it + // there will be geometry to generate the intermediate tiles. + let left = cull_bbox.x0 as f64; + let top = ((cull_bbox.y0 / Tile::HEIGHT) * Tile::HEIGHT) as f64; + let right = cull_bbox.x1 as f64; + let bottom = cull_bbox.y1 as f64; + + let mut path = path.into_iter(); + let Some(first_el) = path.next() else { + return; + }; + let first_el = affine * first_el; + let PathEl::MoveTo(start_pt) = first_el else { + debug_assert!( + matches!(first_el, PathEl::MoveTo(_)), + "Non-empty paths must begin with `PathEl::MoveTo`, got {first_el:?}" + ); + return; + }; + + let mut start_pt = start_pt; + let mut last_pt = start_pt; + callback.callback(LinePathEl::MoveTo(start_pt)); + + for el in path { + match affine * el { + PathEl::MoveTo(p) => { + if last_pt != start_pt { + callback.callback(LinePathEl::LineTo(start_pt)); + } + last_pt = p; + start_pt = p; + callback.callback(LinePathEl::MoveTo(p)); + } + PathEl::LineTo(p) => { + last_pt = p; + callback.callback(LinePathEl::LineTo(p)); + } + PathEl::QuadTo(p1, p2) => { + let p0 = last_pt; + let line = Line::new(p0, p2); + // If the quadratic Bézier is fully to the right, top, or bottom of the culling + // bbox, it does not impact pixel coverage or winding. We can ignore it. The + // following checks that conservatively by checking whether the bounding box of the + // Bézier's control points is fully outside the culling bbox. + if [p0, p1, p2].into_iter().all(|p| p.x > right) + || [p0, p1, p2].into_iter().all(|p| p.y < top) + || [p0, p1, p2].into_iter().all(|p| p.y > bottom) + { + callback.callback(LinePathEl::MoveTo(p2)); + } + // The following checks two things. First, if the quadratic Bézier is fully to the + // left of the culling bbox, it may affect pixel coverage and winding, but its + // exact shape does not matter. It can be emitted as a line segment [p0, p2]. + // + // Second, an upper bound on the shortest distance of any point on the quadratic + // Bézier curve to the line segment [p0, p2] is 1/2 of the control-point-to-line-segment + // distance. + // + // The derivation is similar to that for the cubic Bézier (see below). In + // short: + // + // q(t) = B0(t) p0 + B1(t) p1 + B2(t) p2 + // dist(q(t), [p0, p1]) <= B1(t) dist(p1, [p0, p1]) + // = 2 (1-t)t dist(p1, [p0, p1]). + // + // The maximum occurs at t=1/2, hence + // max(dist(q(t), [p0, p1] <= 1/2 dist(p1, [p0, p1])). + // + // The following takes the square to elide the square root of the Euclidean + // distance. + else if [p0, p1, p2].into_iter().all(|p| p.x < left) + || line.nearest(p1, 0.).distance_sq <= 4. * TOL_2 + { + callback.callback(LinePathEl::LineTo(p2)); + } else { + let q = QuadBez::new(p0, p1, p2); + let params = q.estimate_subdiv(SQRT_TOL); + let n = ((0.5 / SQRT_TOL * params.val).ceil() as usize).max(1); + let step = 1.0 / (n as f64); + for i in 1..n { + let u = (i as f64) * step; + let t = q.determine_subdiv_t(¶ms, u); + let p = q.eval(t); + callback.callback(LinePathEl::LineTo(p)); + } + callback.callback(LinePathEl::LineTo(p2)); + } + last_pt = p2; + } + PathEl::CurveTo(p1, p2, p3) => { + let p0 = last_pt; + let line = Line::new(p0, p3); + // If the cubic Bézier is fully to the right, top, or bottom of the culling bbox, + // it does not impact pixel coverage or winding. We can ignore it. The following + // checks that conservatively by checking whether the bounding box of the Bézier's + // control points is fully outside the culling bbox. + if [p0, p1, p2, p3].into_iter().all(|p| p.x > right) + || [p0, p1, p2, p3].into_iter().all(|p| p.y < top) + || [p0, p1, p2, p3].into_iter().all(|p| p.y > bottom) + { + callback.callback(LinePathEl::MoveTo(p3)); + } + // The following checks two things. First, if the cubic Bézier is fully to the left + // of the culling bbox, it may affect pixel coverage and winding, but its exact + // shape does not matter. It can be emitted as a line segment [p0, p3]. + // + // Second, an upper bound on the shortest distance of any point on the cubic Bézier + // curve to the line segment [p0, p3] is 3/4 of the maximum of the + // control-point-to-line-segment distances. + // + // With Bernstein weights Bi(t), we have + // c(t) = B0(t) p0 + B1(t) p1 + B2(t) p2 + B3(t) p3 + // with t from 0 to 1 (inclusive). + // + // Through convexivity of the Euclidean distance function and the line segment, + // we have + // dist(c(t), [p0, p3]) <= B1(t) dist(p1, [p0, p3]) + B2(t) dist(p2, [p0, p3]) + // <= (B1(t) + B2(t)) max(dist(p1, [p0, p3]), dist(p2, [p0, p3])) + // = 3 ((1-t)t^2 + (1-t)^2t) max(dist(p1, [p0, p3]), dist(p2, [p0, p3])). + // + // The inner polynomial has its maximum of 1/4 at t=1/2, hence + // max(dist(c(t), [p0, p3])) <= 3/4 max(dist(p1, [p0, p3]), dist(p2, [p0, p3])). + // + // The following takes the square to elide the square root of the Euclidean + // distance. + else if [p0, p1, p2, p3].into_iter().all(|p| p.x < left) + || f64::max( + line.nearest(p1, 0.).distance_sq, + line.nearest(p2, 0.).distance_sq, + ) <= 16. / 9. * TOL_2 + { + callback.callback(LinePathEl::LineTo(p3)); + } else { + let c = CubicBez::new(p0, p1, p2, p3); + let max = flatten_cubic_simd(simd, c, flatten_ctx); + + for p in &flatten_ctx.flattened_cubics[1..max] { + callback.callback(LinePathEl::LineTo(Point::new(p.x as f64, p.y as f64))); + } + } + last_pt = p3; + } + PathEl::ClosePath => { + if last_pt != start_pt { + callback.callback(LinePathEl::LineTo(start_pt)); + + // Kurbo says: "If `quad_to` [or another drawing op] is called immediately + // after `close_path` then the current subpath starts at the initial point of + // the previous subpath." + // + // Hence, we set `last_pt` back to the just-closed subpath's `start_pt`. + last_pt = start_pt; + } + } + } + } + + if last_pt != start_pt { + callback.callback(LinePathEl::LineTo(start_pt)); + } +} + +// The below methods are copied from kurbo and needed to implement flattening of normal quad curves. + +/// An approximation to $\int (1 + 4x^2) ^ -0.25 dx$ +/// +/// This is used for flattening curves. +fn approx_parabola_integral(x: f64) -> f64 { + const D: f64 = 0.67; + x / (1.0 - D + (D.powi(4) + 0.25 * x * x).sqrt().sqrt()) +} + +/// An approximation to the inverse parabola integral. +fn approx_parabola_inv_integral(x: f64) -> f64 { + const B: f64 = 0.39; + x * (1.0 - B + (B * B + 0.25 * x * x).sqrt()) +} + +impl FlattenParamsExt for QuadBez { + #[inline(always)] + fn estimate_subdiv(&self, sqrt_tol: f64) -> FlattenParams { + // Determine transformation to $y = x^2$ parabola. + let d01 = self.p1 - self.p0; + let d12 = self.p2 - self.p1; + let dd = d01 - d12; + let cross = (self.p2 - self.p0).cross(dd); + let x0 = d01.dot(dd) * cross.recip(); + let x2 = d12.dot(dd) * cross.recip(); + let scale = (cross / (dd.hypot() * (x2 - x0))).abs(); + + // Compute number of subdivisions needed. + let a0 = approx_parabola_integral(x0); + let a2 = approx_parabola_integral(x2); + let val = if scale.is_finite() { + let da = (a2 - a0).abs(); + let sqrt_scale = scale.sqrt(); + if x0.signum() == x2.signum() { + da * sqrt_scale + } else { + // Handle cusp case (segment contains curvature maximum) + let xmin = sqrt_tol / sqrt_scale; + sqrt_tol * da / approx_parabola_integral(xmin) + } + } else { + 0.0 + }; + let u0 = approx_parabola_inv_integral(a0); + let u2 = approx_parabola_inv_integral(a2); + let uscale = (u2 - u0).recip(); + FlattenParams { + a0, + a2, + u0, + uscale, + val, + } + } + + #[inline(always)] + fn determine_subdiv_t(&self, params: &FlattenParams, x: f64) -> f64 { + let a = params.a0 + (params.a2 - params.a0) * x; + let u = approx_parabola_inv_integral(a); + (u - params.u0) * params.uscale + } +} + +trait FlattenParamsExt { + fn estimate_subdiv(&self, sqrt_tol: f64) -> FlattenParams; + fn determine_subdiv_t(&self, params: &FlattenParams, x: f64) -> f64; +} + +// Everything below is a SIMD implementation of flattening of cubic curves. +// It's a combination of https://gist.github.com/raphlinus/5f4e9feb85fd79bafc72da744571ec0e +// and https://gist.github.com/raphlinus/44e114fef2fd33b889383a60ced0129b. + +// TODO(laurenz): Perhaps we should get rid of this in the future and work directly with f32, +// as it's the only reason we have to pull in proc_macros via the `derive` feature of bytemuck. +#[derive(Clone, Copy, Debug, Default, Zeroable, Pod)] +#[repr(C)] +struct Point32 { + x: f32, + y: f32, +} + +struct FlattenParams { + a0: f64, + a2: f64, + u0: f64, + uscale: f64, + /// The number of `subdivisions * 2 * sqrt_tol`. + val: f64, +} + +/// This limit was chosen based on the pre-existing GitHub gist. +/// This limit should not be hit in normal operation, but _might_ be hit for very large +/// transforms. +const MAX_QUADS: usize = 16; + +/// The context needed for flattening curves. +#[derive(Default, Debug)] +pub struct FlattenCtx { + // The +4 is to encourage alignment; might be better to be explicit + even_pts: [Point32; MAX_QUADS + 4], + odd_pts: [Point32; MAX_QUADS], + a0: [f32; MAX_QUADS], + da: [f32; MAX_QUADS], + u0: [f32; MAX_QUADS], + uscale: [f32; MAX_QUADS], + val: [f32; MAX_QUADS], + n_quads: usize, + /// Reusable buffer for flattened cubic points. + flattened_cubics: Vec, +} + +#[inline(always)] +fn is_finite_simd(x: f32x4) -> mask32x4 { + let simd = x.simd; + + let x_abs = x.abs(); + let reinterpreted = u32x4::from_bytes(x_abs.to_bytes()); + simd.simd_lt_u32x4(reinterpreted, u32x4::splat(simd, 0x7f80_0000)) +} + +/// An approximation to $\int (1 + 4x^2) ^ -0.25 dx$ +/// +/// This is used for flattening curves. +/// +/// SIMD version of [`approx_parabola_integral`]. +#[inline(always)] +fn approx_parabola_integral_simd>(x: F) -> F { + let simd = x.witness(); + + const D: f32 = 0.67; + const D_POWI_4: f32 = 0.201_511_2; + + let temp = F::splat(x.witness(), 0.25) + .mul_add(x * x, F::splat(simd, D_POWI_4)) + .sqrt() + .sqrt(); + let denom = temp + (1. - D); + x / denom +} + +/// An approximation to the inverse parabola integral. +/// +/// SIMD version of [`approx_parabola_inv_integral`]. +#[inline(always)] +fn approx_parabola_inv_integral_simd(x: f32x8) -> f32x8 { + let simd = x.simd; + + const B: f32 = 0.39; + const ONE_MINUS_B: f32 = 1.0 - B; + + let temp = f32x8::splat(simd, 0.25).mul_add(x * x, B * B).sqrt(); + let factor = f32x8::splat(simd, ONE_MINUS_B) + temp; + x * factor +} + +#[inline(always)] +fn pt_splat_simd(simd: S, pt: Point32) -> f32x8 { + let p_f64: f64 = bytemuck::cast(pt); + simd.reinterpret_f32_f64x4(f64x4::splat(simd, p_f64)) +} + +#[inline(always)] +fn eval_cubics_simd(simd: S, c: &CubicBez, n: usize, result: &mut FlattenCtx) { + result.n_quads = n; + let dt = 0.5 / n as f32; + + // TODO(laurenz): Perhaps we can SIMDify this better + let p0p1 = f32x4::from_slice( + simd, + &[c.p0.x as f32, c.p0.y as f32, c.p1.x as f32, c.p1.y as f32], + ); + let p2p3 = f32x4::from_slice( + simd, + &[c.p2.x as f32, c.p2.y as f32, c.p3.x as f32, c.p3.y as f32], + ); + + let split_single = |input: f32x4| { + let t1 = simd.reinterpret_f64_f32x4(input); + let p0 = simd.zip_low_f64x2(t1, t1); + let p1 = simd.zip_high_f64x2(t1, t1); + + let p0 = simd.reinterpret_f32_f64x2(p0); + let p1 = simd.reinterpret_f32_f64x2(p1); + + (f32x8::block_splat(p0), f32x8::block_splat(p1)) + }; + + let (p0_128, p1_128) = split_single(p0p1); + let (p2_128, p3_128) = split_single(p2p3); + + // Use Horner's method to evaluate p(t) as ((a*t + b)*t + c)*t + d. This allows hoisting + // coefficients out of the loop, and then evaluating as three sequential FMAs. Estrin's method + // would expose more ILP, but the increase in work on such small polynomials (cubic here and + // quad below) makes it a net slowdown. + let coeff_a = (p1_128 - p2_128).mul_add(3.0, p3_128 - p0_128); + let coeff_b = p1_128.mul_add(-2.0, p0_128 + p2_128) * 3.0; + let coeff_c = (p1_128 - p0_128) * 3.0; + let coeff_d = p0_128; + + let iota = f32x8::from_slice(simd, &[0.0, 0.0, 2.0, 2.0, 1.0, 1.0, 3.0, 3.0]); + let step = iota * dt; + let mut t = step; + let t_inc = f32x8::splat(simd, 4.0 * dt); + + let even_pts: &mut [f32] = bytemuck::cast_slice_mut(&mut result.even_pts); + let odd_pts: &mut [f32] = bytemuck::cast_slice_mut(&mut result.odd_pts); + + for i in 0..n.div_ceil(2) { + let evaluated = (coeff_a.mul_add(t, coeff_b)) + .mul_add(t, coeff_c) + .mul_add(t, coeff_d); + + let (low, high) = simd.split_f32x8(evaluated); + + low.store_slice(&mut even_pts[i * 4..][..4]); + high.store_slice(&mut odd_pts[i * 4..][..4]); + + t += t_inc; + } + + p3_128.store_slice(&mut even_pts[n * 2..][..8]); +} + +#[inline(always)] +fn estimate_subdiv_simd(simd: S, sqrt_tol: f32, ctx: &mut FlattenCtx) { + let n = ctx.n_quads; + + let even_pts: &mut [f32] = bytemuck::cast_slice_mut(&mut ctx.even_pts); + let odd_pts: &mut [f32] = bytemuck::cast_slice_mut(&mut ctx.odd_pts); + + for i in 0..n.div_ceil(4) { + let p0 = f32x8::from_slice(simd, &even_pts[i * 8..][..8]); + let p_onehalf = f32x8::from_slice(simd, &odd_pts[i * 8..][..8]); + let p2 = f32x8::from_slice(simd, &even_pts[(i * 8 + 2)..][..8]); + let x = p0 * -0.5; + let x1 = p_onehalf.mul_add(2.0, x); + let p1 = p2.mul_add(-0.5, x1); + + p1.store_slice(&mut odd_pts[(i * 8)..][..8]); + + let d01 = p1 - p0; + let d12 = p2 - p1; + let d01x = simd.unzip_low_f32x8(d01, d01); + let d01y = simd.unzip_high_f32x8(d01, d01); + let d12x = simd.unzip_low_f32x8(d12, d12); + let d12y = simd.unzip_high_f32x8(d12, d12); + let ddx = d01x - d12x; + let ddy = d01y - d12y; + let d02x = d01x + d12x; + let d02y = d01y + d12y; + // (d02x * ddy) - (d02y * ddx) + let cross = ddx.mul_add(-d02y, d02x * ddy); + + let x0_x2_a = { + let (d01x_low, _) = simd.split_f32x8(d01x); + let (d12x_low, _) = simd.split_f32x8(d12x); + + simd.combine_f32x4(d12x_low, d01x_low) * ddx + }; + let temp1 = { + let (d12y_low, _) = simd.split_f32x8(d12y); + let (d01y_low, _) = simd.split_f32x8(d01y); + + simd.combine_f32x4(d12y_low, d01y_low) + }; + let x0_x2_num = temp1.mul_add(ddy, x0_x2_a); + let x0_x2 = x0_x2_num / cross; + let (ddx_low, _) = simd.split_f32x8(ddx); + let (ddy_low, _) = simd.split_f32x8(ddy); + let dd_hypot = ddy_low.mul_add(ddy_low, ddx_low * ddx_low).sqrt(); + let (x0, x2) = simd.split_f32x8(x0_x2); + let scale_denom = dd_hypot * (x2 - x0); + let (temp2, _) = simd.split_f32x8(cross); + let scale = (temp2 / scale_denom).abs(); + let a0_a2 = approx_parabola_integral_simd(x0_x2); + let (a0, a2) = simd.split_f32x8(a0_a2); + let da = a2 - a0; + let da_abs = da.abs(); + let sqrt_scale = scale.sqrt(); + let temp3 = simd.or_i32x4(x0.bitcast(), x2.bitcast()); + let mask = simd.simd_ge_i32x4(temp3, i32x4::splat(simd, 0)); + let noncusp = da_abs * sqrt_scale; + // TODO: should we skip this if neither is a cusp? Maybe not worth branch prediction cost + let xmin = sqrt_tol / sqrt_scale; + let approxint = approx_parabola_integral_simd(xmin); + let cusp = (sqrt_tol * da_abs) / approxint; + let val_raw = simd.select_f32x4(mask, noncusp, cusp); + let finite_mask = is_finite_simd(val_raw); + let val = simd.select_f32x4(finite_mask, val_raw, f32x4::splat(simd, 0.0)); + let u0_u2 = approx_parabola_inv_integral_simd(a0_a2); + let (u0, u2) = simd.split_f32x8(u0_u2); + let uscale_a = u2 - u0; + let uscale = 1.0 / uscale_a; + + a0.store_slice(&mut ctx.a0[i * 4..][..4]); + da.store_slice(&mut ctx.da[i * 4..][..4]); + u0.store_slice(&mut ctx.u0[i * 4..][..4]); + uscale.store_slice(&mut ctx.uscale[i * 4..][..4]); + val.store_slice(&mut ctx.val[i * 4..][..4]); + } +} + +#[inline(always)] +fn output_lines_simd( + simd: S, + ctx: &mut FlattenCtx, + i: usize, + x0: f32, + dx: f32, + n: usize, + start_idx: usize, +) { + let p0 = pt_splat_simd(simd, ctx.even_pts[i]); + let p1 = pt_splat_simd(simd, ctx.odd_pts[i]); + let p2 = pt_splat_simd(simd, ctx.even_pts[i + 1]); + + const IOTA2: [f32; 8] = [0., 0., 1., 1., 2., 2., 3., 3.]; + let iota2 = f32x8::from_slice(simd, IOTA2.as_ref()); + let x = iota2.mul_add(dx, f32x8::splat(simd, x0)); + let da = f32x8::splat(simd, ctx.da[i]); + let mut a = da.mul_add(x, f32x8::splat(simd, ctx.a0[i])); + let a_inc = 4.0 * dx * da; + let uscale = f32x8::splat(simd, ctx.uscale[i]); + let u0 = f32x8::splat(simd, ctx.u0[i]); + + // See Horner comment above + let coeff_a = p1.mul_add(-2.0, p0) + p2; + let coeff_b = (p1 - p0) * 2.0; + let coeff_c = p0; + + let out: &mut [f32] = bytemuck::cast_slice_mut(&mut ctx.flattened_cubics[start_idx..]); + + for j in 0..n.div_ceil(4) { + let u = approx_parabola_inv_integral_simd(a); + let t = (u - u0) * uscale; + let p = coeff_a.mul_add(t, coeff_b).mul_add(t, coeff_c); + p.store_slice(&mut out[j * 8..][..8]); + a += a_inc; + } +} + +#[inline(always)] +fn flatten_cubic_simd(simd: S, c: CubicBez, ctx: &mut FlattenCtx) -> usize { + let n_quads = estimate_num_quads(c, TOL as f32); + eval_cubics_simd(simd, &c, n_quads, ctx); + let tol = (TOL as f32) * (1.0 - TO_QUAD_TOL); + let sqrt_tol = tol.sqrt(); + estimate_subdiv_simd(simd, sqrt_tol, ctx); + let sum: f32 = ctx.val[..n_quads].iter().sum(); + let n = ((0.5 * sum / sqrt_tol).ceil() as usize).max(1); + let target_len = n + 4; + if target_len > ctx.flattened_cubics.len() { + ctx.flattened_cubics.resize(target_len, Point32::default()); + } + + let step = sum / (n as f32); + let step_recip = 1.0 / step; + let mut val_sum = 0.0; + let mut last_n = 0; + let mut x0base = 0.0; + + for i in 0..n_quads { + let val = ctx.val[i]; + val_sum += val; + let this_n = val_sum * step_recip; + let this_n_next = 1.0 + this_n.floor(); + let dn = this_n_next as usize - last_n; + if dn > 0 { + let dx = step / val; + let x0 = x0base * dx; + output_lines_simd(simd, ctx, i, x0, dx, dn, last_n); + } + x0base = this_n_next - this_n; + last_n = this_n_next as usize; + } + + ctx.flattened_cubics[n] = ctx.even_pts[n_quads]; + + n + 1 +} + +#[inline(always)] +fn estimate_num_quads(c: CubicBez, accuracy: f32) -> usize { + let q_accuracy = (accuracy * TO_QUAD_TOL) as f64; + let max_hypot2 = 432.0 * q_accuracy * q_accuracy; + let p1x2 = c.p1.to_vec2() * 3.0 - c.p0.to_vec2(); + let p2x2 = c.p2.to_vec2() * 3.0 - c.p3.to_vec2(); + let err = (p2x2 - p1x2).hypot2(); + let err_div = err / max_hypot2; + + estimate(err_div) +} + +const TO_QUAD_TOL: f32 = 0.1; + +#[inline(always)] +fn estimate(err_div: f64) -> usize { + // The original version of this method was: + // let n_quads = (err_div.powf(1. / 6.0).ceil() as usize).max(1); + // n_quads.min(MAX_QUADS) + // + // Note how we always round up and clamp to the range [1, max_quads]. Since we don't + // care about the actual fractional value resulting from the powf call we can simply + // compute this using a precomputed lookup table evaluating 1^6, 2^6, 3^6, etc. and simply + // comparing if the value is less than or equal to each threshold. + + const LUT: [f64; MAX_QUADS] = [ + 1.0, 64.0, 729.0, 4096.0, 15625.0, 46656.0, 117649.0, 262144.0, 531441.0, 1000000.0, + 1771561.0, 2985984.0, 4826809.0, 7529536.0, 11390625.0, 16777216.0, + ]; + + #[expect(clippy::needless_range_loop, reason = "better clarity")] + for i in 0..MAX_QUADS { + if err_div <= LUT[i] { + return i + 1; + } + } + + MAX_QUADS +} + +#[cfg(test)] +mod tests { + use crate::flatten_simd::{MAX_QUADS, estimate}; + + fn old_estimate(err_div: f64) -> usize { + let n_quads = (err_div.powf(1. / 6.0).ceil() as usize).max(1); + n_quads.min(MAX_QUADS) + } + + // Test is disabled by default since it takes 10-20 seconds to run, even in release mode. + #[test] + #[ignore] + fn accuracy() { + for i in 0..u32::MAX { + let num = f32::from_bits(i); + + if num.is_finite() { + assert_eq!(old_estimate(num as f64), estimate(num as f64), "{num}"); + } + } + } +} diff --git a/vendor/vello_common/src/geometry.rs b/vendor/vello_common/src/geometry.rs new file mode 100644 index 000000000..4337550eb --- /dev/null +++ b/vendor/vello_common/src/geometry.rs @@ -0,0 +1,108 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Geometry utilities. + +/// An axis-aligned rectangle with `u16` coordinates, stored as two corners `(x0, y0)` and +/// `(x1, y1)`. +/// +/// `(x0, y0)` is the top-left (minimum) corner and `(x1, y1)` is the bottom-right (maximum) corner. +/// The rectangle is considered to be empty when `x0 >= x1` or `y0 >= y1`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct RectU16 { + /// The minimum x coordinate (left edge). + pub x0: u16, + /// The minimum y coordinate (top edge). + pub y0: u16, + /// The maximum x coordinate (right edge, exclusive). + pub x1: u16, + /// The maximum y coordinate (bottom edge, exclusive). + pub y1: u16, +} + +impl RectU16 { + /// A rectangle with all coordinates set to zero. + pub const ZERO: Self = Self { + x0: 0, + y0: 0, + x1: 0, + y1: 0, + }; + + /// An empty, maximally inverted rectangle, useful as a starting value for incremental union + /// operations. + /// + /// Has `(x0, y0) = (u16::MAX, u16::MAX)` and `(x1, y1) = (0, 0)`. + pub const INVERTED: Self = Self { + x0: u16::MAX, + y0: u16::MAX, + x1: 0, + y1: 0, + }; + + /// Create a new rectangle from its corner coordinates. + #[inline(always)] + pub const fn new(x0: u16, y0: u16, x1: u16, y1: u16) -> Self { + Self { x0, y0, x1, y1 } + } + + /// The width of the rectangle (`x1 - x0`), saturating at zero. + #[inline(always)] + pub const fn width(self) -> u16 { + self.x1.saturating_sub(self.x0) + } + + /// The height of the rectangle (`y1 - y0`), saturating at zero. + #[inline(always)] + pub const fn height(self) -> u16 { + self.y1.saturating_sub(self.y0) + } + + /// Returns `true` if the rectangle has zero area (`x0 >= x1` or `y0 >= y1`). + #[inline(always)] + pub const fn is_empty(self) -> bool { + self.x0 >= self.x1 || self.y0 >= self.y1 + } + + /// Check if a point `(x, y)` is contained within this rectangle. + /// + /// Returns `true` if `x0 <= x < x1` and `y0 <= y < y1`. + #[inline(always)] + pub const fn contains(self, x: u16, y: u16) -> bool { + (x >= self.x0) & (x < self.x1) & (y >= self.y0) & (y < self.y1) + } + + /// Compute the intersection of two rectangles. + /// + /// The result may be empty if the rectangles do not overlap. + #[inline(always)] + pub const fn intersect(self, other: Self) -> Self { + Self { + x0: const_max(self.x0, other.x0), + y0: const_max(self.y0, other.y0), + x1: const_min(self.x1, other.x1), + y1: const_min(self.y1, other.y1), + } + } + + /// Expand this rectangle to also cover `other` (union in place). + /// + /// The union of `self` with a [`Self::INVERTED`] returns `self`. + #[inline(always)] + pub const fn union(&mut self, other: Self) { + self.x0 = const_min(self.x0, other.x0); + self.y0 = const_min(self.y0, other.y0); + self.x1 = const_max(self.x1, other.x1); + self.y1 = const_max(self.y1, other.y1); + } +} + +#[inline(always)] +const fn const_max(a: u16, b: u16) -> u16 { + if a > b { a } else { b } +} + +#[inline(always)] +const fn const_min(a: u16, b: u16) -> u16 { + if a < b { a } else { b } +} diff --git a/vendor/vello_common/src/image_cache.rs b/vendor/vello_common/src/image_cache.rs new file mode 100644 index 000000000..f19c42f7a --- /dev/null +++ b/vendor/vello_common/src/image_cache.rs @@ -0,0 +1,342 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Image resource caching with multi-atlas allocation. +//! +//! This module provides an [`ImageCache`] that manages image resources across multiple texture +//! atlases, supporting allocation, deallocation, and slot reuse. + +use crate::multi_atlas::{ + AllocId, AllocationStrategy, AtlasConfig, AtlasError, AtlasId, MultiAtlasManager, +}; +use crate::paint::ImageId; +use alloc::vec::Vec; + +/// Represents an image resource for rendering. +#[derive(Debug)] +pub struct ImageResource { + /// The width of the image. + pub width: u16, + /// The height of the image. + pub height: u16, + /// The Id of the atlas containing this image. + pub atlas_id: AtlasId, + /// The offset of the image within its atlas (does not include padding, i.e. it points to the + /// position of the first actual top-left pixel). + pub offset: [u16; 2], + /// The number of transparent padding pixels around the image in the atlas. + pub padding: u16, + /// The atlas allocation ID for deallocation. + atlas_alloc_id: AllocId, +} + +impl ImageResource { + /// Returns the offset as `[u32; 2]`. + pub fn offsets(&self) -> [u32; 2] { + [self.offset[0] as u32, self.offset[1] as u32] + } + + /// Returns the size as `[u32; 2]`. + pub fn size(&self) -> [u32; 2] { + [self.width as u32, self.height as u32] + } +} + +/// Manages image resources for the renderer. +pub struct ImageCache { + /// Multi-atlas manager for handling multiple texture atlases. + atlas_manager: MultiAtlasManager, + /// Vector of optional image resources (None = free slot). + slots: Vec>, + /// Stack of free indices. + free_idxs: Vec, +} + +impl core::fmt::Debug for ImageCache { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let atlas_stats = self.atlas_manager.atlas_stats(); + + f.debug_struct("ImageCache") + .field("slots", &self.slots) + .field("free_idxs", &self.free_idxs) + .field("atlas_count", &self.atlas_manager.atlas_count()) + .field("atlas_stats", &atlas_stats) + .finish() + } +} + +impl ImageCache { + /// Create a new image cache with custom atlas configuration. + pub fn new_with_config(config: AtlasConfig) -> Self { + Self { + atlas_manager: MultiAtlasManager::new(config), + slots: Vec::new(), + free_idxs: Vec::new(), + } + } + + /// Create a new dummy image atlas that is supposed to act as a stub. + pub fn new_dummy() -> Self { + Self::new_with_config(AtlasConfig { + initial_atlas_count: 1, + max_atlases: 1, + atlas_size: (1, 1), + auto_grow: false, + allocation_strategy: AllocationStrategy::FirstFit, + }) + } + + /// Get an image resource by its Id. + pub fn get(&self, id: ImageId) -> Option<&ImageResource> { + self.slots.get(id.as_u32() as usize)?.as_ref() + } + + /// Allocate an image in the cache, with optional transparent padding. + pub fn allocate( + &mut self, + width: u32, + height: u32, + padding: u16, + ) -> Result { + self.allocate_excluding(width, height, padding, None) + } + + /// Allocate an image in the cache, with optional transparency padding + /// and optionally excluding a specific atlas. + #[expect( + clippy::cast_possible_truncation, + reason = "u16 is enough for the offset and width/height" + )] + pub fn allocate_excluding( + &mut self, + width: u32, + height: u32, + padding: u16, + exclude_atlas_id: Option, + ) -> Result { + let padded_width = width + u32::from(padding) * 2; + let padded_height = height + u32::from(padding) * 2; + let atlas_alloc = self.atlas_manager.try_allocate_excluding( + padded_width, + padded_height, + exclude_atlas_id, + )?; + + let slot_idx = self.free_idxs.pop().unwrap_or_else(|| { + // No free slots, append to vector + let index = self.slots.len(); + // Placeholder, will be replaced + self.slots.push(None); + index + }); + + let image_id = ImageId::new(slot_idx as u32); + let image_resource = ImageResource { + width: width as u16, + height: height as u16, + atlas_id: atlas_alloc.atlas_id, + offset: [ + atlas_alloc.allocation.x as u16 + padding, + atlas_alloc.allocation.y as u16 + padding, + ], + padding, + atlas_alloc_id: atlas_alloc.allocation.id, + }; + self.slots[slot_idx] = Some(image_resource); + + Ok(image_id) + } + + /// Deallocate an image from the cache, returning the image resource if it existed. + pub fn deallocate(&mut self, id: ImageId) -> Option { + let index = id.as_u32() as usize; + if let Some(image_resource) = self.slots.get_mut(index).and_then(Option::take) { + // Deallocate from the appropriate atlas + let padded_width = image_resource.width as u32 + u32::from(image_resource.padding) * 2; + let padded_height = + image_resource.height as u32 + u32::from(image_resource.padding) * 2; + self.atlas_manager + .deallocate( + image_resource.atlas_id, + image_resource.atlas_alloc_id, + padded_width, + padded_height, + ) + .unwrap(); + self.free_idxs.push(index); + Some(image_resource) + } else { + None + } + } + + /// Get access to the atlas manager. + pub fn atlas_manager(&self) -> &MultiAtlasManager { + &self.atlas_manager + } + + /// Get the number of atlases. + pub fn atlas_count(&self) -> usize { + self.atlas_manager.atlas_count() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ATLAS_SIZE: u32 = 1024; + + #[test] + fn test_insert_single_image() { + let mut cache = ImageCache::new_with_config(AtlasConfig { + atlas_size: (ATLAS_SIZE, ATLAS_SIZE), + ..Default::default() + }); + + let id = cache.allocate(100, 100, 0).unwrap(); + + assert_eq!(id.as_u32(), 0); + let resource = cache.get(id).unwrap(); + assert_eq!(resource.width, 100); + assert_eq!(resource.height, 100); + // First image should be at origin + assert_eq!(resource.offset, [0, 0]); + } + + #[test] + fn test_insert_single_image_with_padding() { + let mut cache = ImageCache::new_with_config(AtlasConfig { + atlas_size: (ATLAS_SIZE, ATLAS_SIZE), + ..Default::default() + }); + + let id = cache.allocate(100, 100, 4).unwrap(); + + assert_eq!(id.as_u32(), 0); + let resource = cache.get(id).unwrap(); + assert_eq!(resource.width, 100); + assert_eq!(resource.height, 100); + assert_eq!(resource.padding, 4); + // Offset should be shifted inward by padding. + assert_eq!(resource.offset, [4, 4]); + } + + #[test] + fn test_insert_multiple_images() { + let mut cache = ImageCache::new_with_config(AtlasConfig { + atlas_size: (ATLAS_SIZE, ATLAS_SIZE), + ..Default::default() + }); + + let id1 = cache.allocate(50, 50, 0).unwrap(); + let id2 = cache.allocate(75, 75, 0).unwrap(); + + assert_eq!(id1.as_u32(), 0); + assert_eq!(id2.as_u32(), 1); + + let resource1 = cache.get(id1).unwrap(); + let resource2 = cache.get(id2).unwrap(); + + assert_eq!(resource1.width, 50); + assert_eq!(resource2.width, 75); + + // Second image should be placed adjacent to first + assert_ne!(resource1.offset, resource2.offset); + } + + #[test] + fn test_get_nonexistent_image() { + let cache: ImageCache = ImageCache::new_with_config(AtlasConfig { + atlas_size: (ATLAS_SIZE, ATLAS_SIZE), + ..Default::default() + }); + + assert!(cache.get(ImageId::new(0)).is_none()); + assert!(cache.get(ImageId::new(999)).is_none()); + } + + #[test] + fn test_remove_image() { + let mut cache = ImageCache::new_with_config(AtlasConfig { + atlas_size: (ATLAS_SIZE, ATLAS_SIZE), + ..Default::default() + }); + + let id = cache.allocate(100, 100, 0).unwrap(); + assert!(cache.get(id).is_some()); + + cache.deallocate(id); + assert!(cache.get(id).is_none()); + } + + #[test] + fn test_remove_nonexistent_image() { + let mut cache: ImageCache = ImageCache::new_with_config(AtlasConfig { + atlas_size: (ATLAS_SIZE, ATLAS_SIZE), + ..Default::default() + }); + + // Should not panic when unregistering non-existent image + cache.deallocate(ImageId::new(0)); + cache.deallocate(ImageId::new(999)); + } + + #[test] + fn test_slot_reuse_after_remove() { + let mut cache = ImageCache::new_with_config(AtlasConfig { + atlas_size: (ATLAS_SIZE, ATLAS_SIZE), + ..Default::default() + }); + + // Register three images + let id1 = cache.allocate(50, 50, 0).unwrap(); + let id2 = cache.allocate(60, 60, 0).unwrap(); + let id3 = cache.allocate(70, 70, 0).unwrap(); + + assert_eq!(id1.as_u32(), 0); + assert_eq!(id2.as_u32(), 1); + assert_eq!(id3.as_u32(), 2); + + // Unregister the middle one + cache.deallocate(id2); + assert!(cache.get(id2).is_none()); + + // Register a new image - should reuse slot 1 + let id4 = cache.allocate(80, 80, 0).unwrap(); + // Reused slot 1 + assert_eq!(id4.as_u32(), 1); + + // Verify other images are still there + assert!(cache.get(id1).is_some()); + assert!(cache.get(id3).is_some()); + assert!(cache.get(id4).is_some()); + assert_eq!(cache.get(id4).unwrap().width, 80); + } + + #[test] + fn test_multiple_remove_and_reuse() { + let mut cache = ImageCache::new_with_config(AtlasConfig { + atlas_size: (ATLAS_SIZE, ATLAS_SIZE), + ..Default::default() + }); + + // Register several images + let ids: Vec<_> = (0..5) + .map(|i| cache.allocate(100 + i * 10, 100 + i * 10, 0).unwrap()) + .collect(); + + // Unregister some in the middle + cache.deallocate(ids[1]); + cache.deallocate(ids[3]); + + // Register new images - should reuse the freed slots + let new_id1 = cache.allocate(200, 200, 0).unwrap(); + let new_id2 = cache.allocate(300, 300, 0).unwrap(); + + // Should have reused slots 3 and 1 (in reverse order due to stack behavior) + assert_eq!(new_id1.as_u32(), 3); + assert_eq!(new_id2.as_u32(), 1); + assert_ne!(new_id1.as_u32(), new_id2.as_u32()); + } +} diff --git a/vendor/vello_common/src/lib.rs b/vendor/vello_common/src/lib.rs new file mode 100644 index 000000000..6862ea431 --- /dev/null +++ b/vendor/vello_common/src/lib.rs @@ -0,0 +1,106 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +// After you edit the crate's doc comment, run this command, then check README.md for any missing links +// cargo rdme --workspace-project=vello_common + +//! This crate includes common geometry representations, tiling logic, and other fundamental components used by both [Vello CPU][vello_cpu] and Vello Hybrid. +//! +//! # Usage +//! +//! This crate should not be used on its own, and you should instead use one of the renderers which use it. +//! At the moment, only [Vello CPU][vello_cpu] is published, and you probably want to use that. +//! +//! We also develop [Vello](https://crates.io/crates/vello), which makes use of the GPU for 2D rendering and has higher performance than Vello CPU. +//! Vello CPU is being developed as part of work to address shortcomings in Vello. +//! Vello does not use this crate. +//! +//! # Features +//! +//! - `std` (enabled by default): Get floating point functions from the standard library +//! (likely using your target's libc). +//! - `libm`: Use floating point implementations from [libm][]. +//! - `png` (enabled by default): Allow loading [`Pixmap`][crate::pixmap::Pixmap]s from PNG images. +//! Also required for rendering glyphs with an embedded PNG. +//! Implies `std`. +//! +//! At least one of `std` and `libm` is required; `std` overrides `libm`. +//! +//! # Contents +//! +//! - Shared data structures for paths, tiles, and strips +//! - Geometry processing utilities +//! - Common logic for rendering stages +//! +//! This crate acts as a foundation for `vello_cpu` and `vello_hybrid`, providing essential components to minimize duplication. +//! +//! [vello_cpu]: https://crates.io/crates/vello_cpu +#![cfg_attr(feature = "libm", doc = "[libm]: libm")] +#![cfg_attr(not(feature = "libm"), doc = "[libm]: https://crates.io/crates/libm")] +// LINEBENDER LINT SET - lib.rs - v3 +// See https://linebender.org/wiki/canonical-lints/ +// These lints shouldn't apply to examples or tests. +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +// These lints shouldn't apply to examples. +#![warn(clippy::print_stdout, clippy::print_stderr)] +// Targeting e.g. 32-bit means structs containing usize can give false positives for 64-bit. +#![cfg_attr(target_pointer_width = "64", warn(clippy::trivially_copy_pass_by_ref))] +// END LINEBENDER LINT SET +#![cfg_attr(docsrs, feature(doc_cfg))] +#![forbid(unsafe_code)] +#![expect( + clippy::cast_possible_truncation, + reason = "We temporarily ignore those because the casts\ +only break in edge cases, and some of them are also only related to conversions from f64 to f32." +)] +#![no_std] + +// Suppress the unused_crate_dependencies lint when both std and libm are specified. +#[cfg(all(feature = "std", feature = "libm"))] +use libm as _; + +extern crate alloc; +#[cfg(feature = "std")] +extern crate std; + +pub mod blurred_rounded_rect; +pub mod clip; +pub mod coarse; +pub mod encode; +pub mod filter; +pub mod filter_effects; +pub mod flatten; +pub(crate) mod flatten_simd; +pub mod geometry; +pub mod image_cache; +pub mod mask; +pub mod math; +pub mod multi_atlas; +pub mod paint; +#[doc(hidden)] +#[cfg(feature = "pico_svg")] +pub mod pico_svg; +pub mod pixmap; +#[doc(hidden)] +#[cfg(feature = "probe")] +pub mod probe; +pub mod rect; +pub mod render_graph; +pub mod render_state; +pub mod simd; +pub mod strip; +pub mod strip_generator; +pub mod tile; +pub mod util; + +pub use fearless_simd; +pub use peniko; +pub use peniko::color; +pub use peniko::kurbo; + +/// A handle to an external, user-provided texture. +/// +/// This is resolved at render time by passing in a mapping of handles to textures, but is +/// otherwise opaque to the renderer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct TextureId(pub u64); diff --git a/vendor/vello_common/src/mask.rs b/vendor/vello_common/src/mask.rs new file mode 100644 index 000000000..8047be828 --- /dev/null +++ b/vendor/vello_common/src/mask.rs @@ -0,0 +1,116 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Alpha and luminance masks. + +use crate::pixmap::Pixmap; +use alloc::sync::Arc; +use alloc::vec::Vec; + +#[derive(Debug, PartialEq, Eq)] +struct MaskRepr { + data: Vec, + width: u16, + height: u16, +} + +// Note that we are on purpose storing width and height inside the `Arc` +// to reduce the memory footprint of the struct. +/// A mask. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Mask(Arc); + +impl Mask { + /// Create a new alpha mask from the pixmap. + pub fn new_alpha(pixmap: &Pixmap) -> Self { + Self::new_with(pixmap, true) + } + + /// Create a new luminance mask from the pixmap. + pub fn new_luminance(pixmap: &Pixmap) -> Self { + Self::new_with(pixmap, false) + } + + /// Create a new mask from the given alpha data. + /// + /// The `data` vector must be of length `width * height` exactly. + /// + /// The pixels are in row-major order. + /// + /// # Panics + /// + /// Panics if the `data` vector is not of length `width * height`. + pub fn from_parts(data: Vec, width: u16, height: u16) -> Self { + assert_eq!( + data.len(), + usize::from(width) * usize::from(height), + "`data` should have `width * height` length" + ); + + Self(Arc::new(MaskRepr { + data, + width, + height, + })) + } + + fn new_with(pixmap: &Pixmap, alpha_mask: bool) -> Self { + let data = pixmap + .data() + .iter() + .map(|pixel| { + if alpha_mask { + pixel.a + } else { + let r = f32::from(pixel.r) / 255.; + let g = f32::from(pixel.g) / 255.; + let b = f32::from(pixel.b) / 255.; + + // See CSS Masking Module Level 1 § 7.10.1 + // + // and Filter Effects Module Level 1 § 9.6 + // . + // Note r, g and b are premultiplied by alpha. + let luma = r * 0.2126 + g * 0.7152 + b * 0.0722; + #[expect(clippy::cast_possible_truncation, reason = "This cannot overflow")] + { + (luma * 255.0 + 0.5) as u8 + } + } + }) + .collect::>(); + + Self(Arc::new(MaskRepr { + data, + width: pixmap.width(), + height: pixmap.height(), + })) + } + + /// Return the width of the mask. + #[inline(always)] + pub fn width(&self) -> u16 { + self.0.width + } + + /// Return the height of the mask. + #[inline(always)] + pub fn height(&self) -> u16 { + self.0.height + } + + /// Sample the value at a specific location. + /// + /// This function might panic or yield a wrong result if the location + /// is out-of-bounds. + #[inline(always)] + pub fn sample(&self, x: u16, y: u16) -> u8 { + let repr = &*self.0; + debug_assert!( + x < repr.width && y < repr.height, + "cannot sample mask outside of its range" + ); + + repr.data[y as usize * repr.width as usize + x as usize] + } +} diff --git a/vendor/vello_common/src/math.rs b/vendor/vello_common/src/math.rs new file mode 100644 index 000000000..36efbacae --- /dev/null +++ b/vendor/vello_common/src/math.rs @@ -0,0 +1,60 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Mathematical helper functions. + +use core::ops::Sub; +#[cfg(not(feature = "std"))] +use peniko::kurbo::common::FloatFuncs as _; + +// See https://raphlinus.github.io/audio/2018/09/05/sigmoid.html for a little +// explanation of this approximation to the erf function. +/// Approximate the erf function. +pub fn compute_erf7(x: f32) -> f32 { + // Clamp `x`, because for large `x` the terms here become `inf`, causing the result to be 0 or + // `NaN`. This clamping doesn't lose any information, because `erf(±10) ≈ 1` well within `f64` + // machine precision, let alone `f32`. + let x = x.clamp(-10., 10.); + let x = x * core::f32::consts::FRAC_2_SQRT_PI; + let xx = x * x; + let x = x + (0.24295 + (0.03395 + 0.0104 * xx) * xx) * (x * xx); + x / (1.0 + x * x).sqrt() +} + +// From +// Note: If this value changes, also update NEARLY_ZERO_TOLERANCE in render_strips.wgsl +// @see {@link https://github.com/linebender/vello/blob/58b80d660e2fc5aef3bd32b24af3f95e973aab95/sparse_strips/vello_sparse_shaders/shaders/render_strips.wgsl#L63} +const SCALAR_NEARLY_ZERO: f32 = 1.0 / (1 << 12) as f32; + +/// A number of useful methods for floating-point numbers. +pub trait FloatExt: Sized + Sub { + /// Whether the number is approximately 0. + fn is_nearly_zero(&self) -> bool; + + /// Whether the number is approximately 0, with a given tolerance. + fn is_nearly_zero_within_tolerance(&self, tolerance: f32) -> bool; +} + +impl FloatExt for f32 { + fn is_nearly_zero(&self) -> bool { + self.is_nearly_zero_within_tolerance(SCALAR_NEARLY_ZERO) + } + + fn is_nearly_zero_within_tolerance(&self, tolerance: f32) -> bool { + debug_assert!(tolerance >= 0.0, "tolerance must be positive"); + + self.abs() <= tolerance + } +} + +impl FloatExt for f64 { + fn is_nearly_zero(&self) -> bool { + self.is_nearly_zero_within_tolerance(SCALAR_NEARLY_ZERO) + } + + fn is_nearly_zero_within_tolerance(&self, tolerance: f32) -> bool { + debug_assert!(tolerance >= 0.0, "tolerance must be positive"); + + self.abs() <= tolerance as Self + } +} diff --git a/vendor/vello_common/src/multi_atlas.rs b/vendor/vello_common/src/multi_atlas.rs new file mode 100644 index 000000000..4e42918e2 --- /dev/null +++ b/vendor/vello_common/src/multi_atlas.rs @@ -0,0 +1,760 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Multi-atlas management for texture atlases. +//! +//! This module provides support for managing multiple texture atlases, allowing for handling of +//! large numbers of images. +//! +//! The allocator backend is [guillotiere](https://github.com/nical/guillotiere)'s tree-based +//! guillotine algorithm, providing O(1) neighbor lookup during deallocation and automatic +//! free-rect coalescing. + +use alloc::vec::Vec; +pub use guillotiere::AllocId; +use guillotiere::AtlasAllocator; +use thiserror::Error; + +/// The result of a successful rectangle allocation within a single atlas. +#[derive(Debug)] +pub struct Allocation { + /// Opaque handle used for deallocation. + pub id: AllocId, + /// X coordinate of the top-left corner of the allocated rectangle. + pub x: u32, + /// Y coordinate of the top-left corner of the allocated rectangle. + pub y: u32, +} + +// --------------------------------------------------------------------------- +// Unified Atlas type +// --------------------------------------------------------------------------- + +/// Represents a single atlas in the multi-atlas system. +pub struct Atlas { + /// Unique identifier for this atlas. + pub id: AtlasId, + /// Rectangle allocator backend. + allocator: AtlasAllocator, + /// Current usage statistics. + stats: AtlasUsageStats, + /// Allocation counter. + allocation_counter: u32, +} + +impl Atlas { + /// Create a new atlas with the given ID and size. + pub fn new(id: AtlasId, width: u32, height: u32) -> Self { + Self { + id, + allocator: AtlasAllocator::new(guillotiere::size2(width as i32, height as i32)), + stats: AtlasUsageStats { + allocated_area: 0, + total_area: width * height, + allocated_count: 0, + }, + allocation_counter: 0, + } + } + + /// Try to allocate an image in this atlas. + #[expect( + clippy::cast_sign_loss, + reason = "coordinates are always non-negative for valid allocations" + )] + pub fn allocate(&mut self, width: u32, height: u32) -> Option { + let alloc = self + .allocator + .allocate(guillotiere::size2(width as i32, height as i32))?; + self.stats.allocated_area += width * height; + self.stats.allocated_count += 1; + self.allocation_counter += 1; + Some(Allocation { + id: alloc.id, + x: alloc.rectangle.min.x as u32, + y: alloc.rectangle.min.y as u32, + }) + } + + /// Deallocate an image from this atlas. + pub fn deallocate(&mut self, alloc_id: AllocId, width: u32, height: u32) { + self.allocator.deallocate(alloc_id); + self.stats.allocated_area = self.stats.allocated_area.saturating_sub(width * height); + self.stats.allocated_count = self.stats.allocated_count.saturating_sub(1); + } + + /// Get current usage statistics. + pub fn stats(&self) -> &AtlasUsageStats { + &self.stats + } +} + +impl core::fmt::Debug for Atlas { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("Atlas") + .field("id", &self.id) + .field("stats", &self.stats) + .field("allocation_counter", &self.allocation_counter) + .finish_non_exhaustive() + } +} + +// --------------------------------------------------------------------------- +// MultiAtlasManager +// --------------------------------------------------------------------------- + +/// Manages multiple texture atlases. +pub struct MultiAtlasManager { + /// All atlases managed by this instance. + atlases: Vec, + /// Configuration for atlas management. + config: AtlasConfig, + /// Round-robin counter for allocation strategy. + round_robin_counter: usize, +} + +impl MultiAtlasManager { + /// Create a new multi-atlas manager with the given configuration. + pub fn new(config: AtlasConfig) -> Self { + let mut manager = Self { + atlases: Vec::new(), + config, + round_robin_counter: 0, + }; + + for _ in 0..config.initial_atlas_count { + manager + .create_atlas() + .expect("Failed to create initial atlas"); + } + + manager + } + + /// Get the current configuration. + pub fn config(&self) -> &AtlasConfig { + &self.config + } + + /// Create a new atlas and return its ID. + pub fn create_atlas(&mut self) -> Result { + if self.atlases.len() >= self.config.max_atlases { + return Err(AtlasError::AtlasLimitReached); + } + + let atlas_id = AtlasId::new(self.next_atlas_id()); + + let atlas = Atlas::new(atlas_id, self.config.atlas_size.0, self.config.atlas_size.1); + self.atlases.push(atlas); + + Ok(atlas_id) + } + + /// Get the next available atlas ID. + pub fn next_atlas_id(&self) -> u32 { + u32::try_from(self.atlases.len()).unwrap() + } + + /// Try to allocate space for an image with the given dimensions. + pub fn try_allocate(&mut self, width: u32, height: u32) -> Result { + self.try_allocate_excluding(width, height, None) + } + + /// Try to allocate space for an image with the given dimensions, + /// optionally excluding a specific atlas. + pub fn try_allocate_excluding( + &mut self, + width: u32, + height: u32, + exclude_atlas_id: Option, + ) -> Result { + // Check if the image is too large for any atlas + if width > self.config.atlas_size.0 || height > self.config.atlas_size.1 { + return Err(AtlasError::TextureTooLarge { width, height }); + } + + // Try allocation based on strategy + match self.config.allocation_strategy { + AllocationStrategy::FirstFit => { + self.allocate_first_fit(width, height, exclude_atlas_id) + } + AllocationStrategy::BestFit => self.allocate_best_fit(width, height, exclude_atlas_id), + AllocationStrategy::LeastUsed => { + self.allocate_least_used(width, height, exclude_atlas_id) + } + AllocationStrategy::RoundRobin => { + self.allocate_round_robin(width, height, exclude_atlas_id) + } + } + } + + /// Allocate using first-fit strategy: try atlases in order until one has space. + fn allocate_first_fit( + &mut self, + width: u32, + height: u32, + exclude_atlas_id: Option, + ) -> Result { + for atlas in &mut self.atlases { + if Some(atlas.id) == exclude_atlas_id { + continue; + } + + if let Some(allocation) = atlas.allocate(width, height) { + return Ok(AtlasAllocation { + atlas_id: atlas.id, + allocation, + }); + } + } + + // Try creating a new atlas if auto-grow is enabled + if self.config.auto_grow { + let atlas_id = self.create_atlas()?; + let atlas = self.atlases.last_mut().unwrap(); + if let Some(allocation) = atlas.allocate(width, height) { + return Ok(AtlasAllocation { + atlas_id, + allocation, + }); + } + } + + Err(AtlasError::NoSpaceAvailable) + } + + /// Allocate using best-fit strategy: choose the atlas with the smallest remaining space that + /// can fit the image. + fn allocate_best_fit( + &mut self, + width: u32, + height: u32, + exclude_atlas_id: Option, + ) -> Result { + let mut best_atlas_idx = None; + let mut best_remaining_space = u32::MAX; + + // Find the atlas with the least remaining space that can fit the image + for (idx, atlas) in self.atlases.iter().enumerate() { + if Some(atlas.id) == exclude_atlas_id { + continue; + } + + let stats = atlas.stats(); + let remaining_space = stats.total_area - stats.allocated_area; + + if remaining_space >= width * height && remaining_space < best_remaining_space { + best_remaining_space = remaining_space; + best_atlas_idx = Some(idx); + } + } + + if let Some(idx) = best_atlas_idx { + let atlas = &mut self.atlases[idx]; + if let Some(allocation) = atlas.allocate(width, height) { + return Ok(AtlasAllocation { + atlas_id: atlas.id, + allocation, + }); + } + } + + // Fallback to first-fit if best-fit didn't work + self.allocate_first_fit(width, height, exclude_atlas_id) + } + + /// Allocate using least-used strategy: prefer the atlas with the lowest usage percentage. + fn allocate_least_used( + &mut self, + width: u32, + height: u32, + exclude_atlas_id: Option, + ) -> Result { + let mut best_atlas_idx = None; + let mut lowest_usage = f32::MAX; + + // Find the atlas with the lowest usage percentage + for (idx, atlas) in self.atlases.iter().enumerate() { + if Some(atlas.id) == exclude_atlas_id { + continue; + } + + let usage = atlas.stats().usage_percentage(); + if usage < lowest_usage { + lowest_usage = usage; + best_atlas_idx = Some(idx); + } + } + + if let Some(idx) = best_atlas_idx + && let Some(allocation) = self.atlases[idx].allocate(width, height) + { + let atlas_id = self.atlases[idx].id; + return Ok(AtlasAllocation { + atlas_id, + allocation, + }); + } + + // Fallback to first-fit if least-used didn't work + self.allocate_first_fit(width, height, exclude_atlas_id) + } + + /// Allocate using round-robin strategy: cycle through atlases using a round-robin counter. + fn allocate_round_robin( + &mut self, + width: u32, + height: u32, + exclude_atlas_id: Option, + ) -> Result { + if self.atlases.is_empty() { + return self.allocate_first_fit(width, height, exclude_atlas_id); + } + + let start_idx = self.round_robin_counter % self.atlases.len(); + + // Try starting from the round-robin position + for i in 0..self.atlases.len() { + let idx = (start_idx + i) % self.atlases.len(); + + if Some(self.atlases[idx].id) == exclude_atlas_id { + continue; + } + + if let Some(allocation) = self.atlases[idx].allocate(width, height) { + let atlas_id = self.atlases[idx].id; + self.round_robin_counter = (idx + 1) % self.atlases.len(); + return Ok(AtlasAllocation { + atlas_id, + allocation, + }); + } + } + + // Try creating a new atlas if auto-grow is enabled + if self.config.auto_grow { + let atlas_id = self.create_atlas()?; + let atlas = self.atlases.last_mut().unwrap(); + if let Some(allocation) = atlas.allocate(width, height) { + self.round_robin_counter = self.atlases.len() - 1; + return Ok(AtlasAllocation { + atlas_id, + allocation, + }); + } + } + + Err(AtlasError::NoSpaceAvailable) + } + + /// Deallocate space in the specified atlas. + pub fn deallocate( + &mut self, + atlas_id: AtlasId, + alloc_id: AllocId, + width: u32, + height: u32, + ) -> Result<(), AtlasError> { + // Since atlases only grow (never deallocate) and id is the index into the atlases vec, + // we can do a lookup instead of a linear search + let atlas = self + .atlases + .get_mut(atlas_id.0 as usize) + .ok_or(AtlasError::AtlasNotFound(atlas_id))?; + atlas.deallocate(alloc_id, width, height); + Ok(()) + } + + /// Get statistics for all atlases. + pub fn atlas_stats(&self) -> Vec<(AtlasId, &AtlasUsageStats)> { + self.atlases + .iter() + .map(|atlas| (atlas.id, atlas.stats())) + .collect() + } + + /// Get the number of atlases. + pub fn atlas_count(&self) -> usize { + self.atlases.len() + } +} + +impl core::fmt::Debug for MultiAtlasManager { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("MultiAtlasManager") + .field("atlas_count", &self.atlases.len()) + .field("config", &self.config) + .field("next_atlas_id", &self.next_atlas_id()) + .field("round_robin_counter", &self.round_robin_counter) + .field("atlases", &self.atlases) + .finish() + } +} + +/// Errors that can occur during atlas operations. +#[derive(Debug, Clone, Error)] +pub enum AtlasError { + /// No space available in any atlas. + #[error("No space available in any atlas")] + NoSpaceAvailable, + /// Maximum number of atlases reached. + #[error("Maximum number of atlases reached")] + AtlasLimitReached, + /// The requested texture size is too large for any atlas. + #[error("Texture too large ({width}x{height}) for atlas")] + TextureTooLarge { + /// The width of the requested texture. + width: u32, + /// The height of the requested texture. + height: u32, + }, + /// The specified atlas was not found. + #[error("Atlas with Id {0:?} not found")] + AtlasNotFound(AtlasId), +} + +/// Unique identifier for an atlas. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct AtlasId(pub u32); + +impl AtlasId { + /// Create a new atlas ID. + pub fn new(id: u32) -> Self { + Self(id) + } + + /// Get the raw ID value. + pub fn as_u32(self) -> u32 { + self.0 + } +} + +/// Usage statistics for an atlas. +#[derive(Debug, Clone)] +pub struct AtlasUsageStats { + /// Total allocated area in pixels. + pub allocated_area: u32, + /// Total available area in pixels. + pub total_area: u32, + /// Number of allocated images. + pub allocated_count: u32, +} + +impl AtlasUsageStats { + /// Calculate usage percentage (0.0 to 1.0). + pub fn usage_percentage(&self) -> f32 { + if self.total_area == 0 { + 0.0 + } else { + self.allocated_area as f32 / self.total_area as f32 + } + } +} + +/// Result of an atlas allocation attempt. +#[derive(Debug)] +pub struct AtlasAllocation { + /// The atlas where the allocation was made. + pub atlas_id: AtlasId, + /// The allocation details. + pub allocation: Allocation, +} + +/// Configuration for multiple atlas support. +/// +/// Note that any values provided here are recommendations and might not be fully +/// honored depending on the capabilities of the backend. For example, if you define +/// the atlas size to be 8192x8192 but the device only supports texture sizes up to 4096x4096, +/// the backend will likely decide to instead use the value that is compatible with the device. +#[derive(Debug, Clone, Copy)] +pub struct AtlasConfig { + /// Initial number of atlases to create. + pub initial_atlas_count: usize, + /// Maximum number of atlases to create. + pub max_atlases: usize, + // TODO: Make those u16 instead? + /// Size of each atlas texture. + pub atlas_size: (u32, u32), + /// Whether to automatically create new atlases when needed. + pub auto_grow: bool, + /// Strategy for allocating images across atlases. + pub allocation_strategy: AllocationStrategy, +} + +impl Default for AtlasConfig { + fn default() -> Self { + Self { + initial_atlas_count: 1, + max_atlases: 8, + atlas_size: (4096, 4096), + auto_grow: true, + allocation_strategy: AllocationStrategy::FirstFit, + } + } +} + +/// Strategy for allocating images across multiple atlases. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AllocationStrategy { + /// Try atlases in order until one has space. + #[default] + FirstFit, + /// Choose the atlas with the smallest remaining space that can fit the image. + BestFit, + /// Prefer the atlas with the lowest usage percentage. + LeastUsed, + /// Cycle through atlases in round-robin fashion. + RoundRobin, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_atlas_creation() { + let mut manager = MultiAtlasManager::new(AtlasConfig { + initial_atlas_count: 0, + ..Default::default() + }); + + let atlas_id = manager.create_atlas().unwrap(); + assert_eq!(atlas_id.as_u32(), 0); + assert_eq!(manager.atlas_count(), 1); + } + + #[test] + fn test_allocation_strategies() { + let mut manager = MultiAtlasManager::new(AtlasConfig { + initial_atlas_count: 1, + max_atlases: 3, + atlas_size: (256, 256), + allocation_strategy: AllocationStrategy::FirstFit, + auto_grow: true, + }); + + // Should create atlas automatically + let allocation = manager.try_allocate(100, 100).unwrap(); + assert_eq!(allocation.atlas_id.as_u32(), 0); + } + + #[test] + fn test_atlas_limit() { + let mut manager = MultiAtlasManager::new(AtlasConfig { + initial_atlas_count: 1, + max_atlases: 1, + atlas_size: (256, 256), + allocation_strategy: AllocationStrategy::FirstFit, + auto_grow: false, + }); + + assert!(manager.create_atlas().is_err()); + } + + #[test] + fn test_texture_too_large() { + let mut manager = MultiAtlasManager::new(AtlasConfig { + atlas_size: (256, 256), + ..Default::default() + }); + + let result = manager.try_allocate(300, 300); + assert!(matches!(result, Err(AtlasError::TextureTooLarge { .. }))); + } + + #[test] + fn test_first_fit_allocation_strategy() { + let mut manager = MultiAtlasManager::new(AtlasConfig { + initial_atlas_count: 3, + max_atlases: 3, + atlas_size: (256, 256), + allocation_strategy: AllocationStrategy::FirstFit, + auto_grow: false, + }); + + // First allocation should go to atlas 0 + let allocation0 = manager.try_allocate(100, 100).unwrap(); + assert_eq!(allocation0.atlas_id.as_u32(), 0); + + // Second allocation should also go to atlas 0 (first fit) + let allocation1 = manager.try_allocate(50, 50).unwrap(); + assert_eq!(allocation1.atlas_id.as_u32(), 0); + + // Third allocation should still go to atlas 0 (first fit continues to use same atlas) + let allocation2 = manager.try_allocate(80, 80).unwrap(); + assert_eq!(allocation2.atlas_id.as_u32(), 0); + + // Try to allocate something very large that definitely won't fit in atlas 0's remaining space + // This should force it to go to atlas 1 + let allocation3 = manager.try_allocate(200, 200).unwrap(); + assert_eq!(allocation3.atlas_id.as_u32(), 1); + + // Next small allocation should go back to atlas 0 (first fit tries atlas 0 first) + let allocation4 = manager.try_allocate(20, 20).unwrap(); + assert_eq!(allocation4.atlas_id.as_u32(), 0); + } + + #[test] + fn test_best_fit_allocation_strategy() { + let mut manager = MultiAtlasManager::new(AtlasConfig { + initial_atlas_count: 3, + max_atlases: 3, + atlas_size: (256, 256), + allocation_strategy: AllocationStrategy::BestFit, + auto_grow: false, + }); + + // All atlases start empty, so first allocation goes to atlas 0 (first available) + let allocation0 = manager.try_allocate(150, 150).unwrap(); + assert_eq!(allocation0.atlas_id.as_u32(), 0); + + // Second allocation should also go to atlas 0 since it still has the least remaining space + // that can fit the image (all atlases have same remaining space, so it picks the first) + let allocation1 = manager.try_allocate(100, 100).unwrap(); + assert_eq!(allocation1.atlas_id.as_u32(), 0); + + // Now atlas 0 has less remaining space than atlases 1 and 2 + // For a small allocation, it should still go to atlas 0 (best fit - least remaining space) + let allocation2 = manager.try_allocate(100, 100).unwrap(); + assert_eq!(allocation2.atlas_id.as_u32(), 0); + + // Now try to allocate something very large that won't fit in atlas 0's remaining space + // This should force it to go to atlas 1 (which has the most remaining space) + let allocation3 = manager.try_allocate(200, 200).unwrap(); + assert_eq!(allocation3.atlas_id.as_u32(), 1); + + // Now atlas 1 has less remaining space + // A small allocation should go to atlas 0 as it can + let allocation4 = manager.try_allocate(80, 80).unwrap(); + assert_eq!(allocation4.atlas_id.as_u32(), 0); + + // Now atlas 1 has less remaining space but it can't fit the allocation + // It should go to atlas 2 (best fit - least remaining space) + let allocation5 = manager.try_allocate(80, 80).unwrap(); + assert_eq!(allocation5.atlas_id.as_u32(), 2); + } + + #[test] + fn test_least_used_allocation_strategy() { + let mut manager = MultiAtlasManager::new(AtlasConfig { + initial_atlas_count: 3, + max_atlases: 3, + atlas_size: (256, 256), + allocation_strategy: AllocationStrategy::LeastUsed, + auto_grow: false, + }); + + // First allocation goes to atlas 0 (all atlases have 0% usage, picks first) + let allocation0 = manager.try_allocate(100, 100).unwrap(); + assert_eq!(allocation0.atlas_id.as_u32(), 0); + + // Second allocation should go to atlas 1 (least used among remaining) + let allocation1 = manager.try_allocate(50, 50).unwrap(); + assert_eq!(allocation1.atlas_id.as_u32(), 1); + + // Third allocation should go to atlas 2 (least used) + let allocation2 = manager.try_allocate(30, 30).unwrap(); + assert_eq!(allocation2.atlas_id.as_u32(), 2); + + // Fourth allocation should go to atlas 2 again (still least used) + let allocation3 = manager.try_allocate(30, 30).unwrap(); + assert_eq!(allocation3.atlas_id.as_u32(), 2); + } + + #[test] + fn test_round_robin_allocation_strategy() { + let mut manager = MultiAtlasManager::new(AtlasConfig { + initial_atlas_count: 3, + max_atlases: 3, + atlas_size: (256, 256), + allocation_strategy: AllocationStrategy::RoundRobin, + auto_grow: false, + }); + + // Allocations should cycle through atlases in order + let allocation0 = manager.try_allocate(50, 50).unwrap(); + assert_eq!(allocation0.atlas_id.as_u32(), 0); + + let allocation1 = manager.try_allocate(50, 50).unwrap(); + assert_eq!(allocation1.atlas_id.as_u32(), 1); + + let allocation2 = manager.try_allocate(50, 50).unwrap(); + assert_eq!(allocation2.atlas_id.as_u32(), 2); + + // Should wrap back to atlas 0 + let allocation3 = manager.try_allocate(50, 50).unwrap(); + assert_eq!(allocation3.atlas_id.as_u32(), 0); + + // Continue the cycle + let allocation4 = manager.try_allocate(50, 50).unwrap(); + assert_eq!(allocation4.atlas_id.as_u32(), 1); + } + + #[test] + fn test_auto_grow() { + let mut manager = MultiAtlasManager::new(AtlasConfig { + initial_atlas_count: 1, + max_atlases: 3, + atlas_size: (256, 256), + allocation_strategy: AllocationStrategy::FirstFit, + auto_grow: true, + }); + + let allocation0 = manager.try_allocate(256, 256).unwrap(); + assert_eq!(allocation0.atlas_id.as_u32(), 0); + + let allocation1 = manager.try_allocate(256, 256).unwrap(); + assert_eq!(allocation1.atlas_id.as_u32(), 1); + + let allocation2 = manager.try_allocate(256, 256).unwrap(); + assert_eq!(allocation2.atlas_id.as_u32(), 2); + } + + fn test_allocate_excluding_with_strategy(strategy: AllocationStrategy) { + let mut manager = MultiAtlasManager::new(AtlasConfig { + initial_atlas_count: 3, + max_atlases: 3, + atlas_size: (256, 256), + allocation_strategy: strategy, + auto_grow: false, + }); + + let allocation0 = manager.try_allocate(100, 100).unwrap(); + let first_atlas = allocation0.atlas_id; + let allocation1 = manager + .try_allocate_excluding(256, 256, Some(first_atlas)) + .unwrap(); + assert_ne!(allocation1.atlas_id, first_atlas); + + let second_atlas = allocation1.atlas_id; + let allocation2 = manager + .try_allocate_excluding(100, 100, Some(second_atlas)) + .unwrap(); + assert_ne!(allocation2.atlas_id, second_atlas); + + let allocation3 = manager + .try_allocate_excluding(100, 100, Some(first_atlas)) + .unwrap(); + assert_ne!(allocation3.atlas_id, first_atlas); + } + + #[test] + fn test_allocate_excluding_first_fit() { + test_allocate_excluding_with_strategy(AllocationStrategy::FirstFit); + } + + #[test] + fn test_allocate_excluding_best_fit() { + test_allocate_excluding_with_strategy(AllocationStrategy::BestFit); + } + + #[test] + fn test_allocate_excluding_least_used() { + test_allocate_excluding_with_strategy(AllocationStrategy::LeastUsed); + } + + #[test] + fn test_allocate_excluding_round_robin() { + test_allocate_excluding_with_strategy(AllocationStrategy::RoundRobin); + } +} diff --git a/vendor/vello_common/src/paint.rs b/vendor/vello_common/src/paint.rs new file mode 100644 index 000000000..b032bc31b --- /dev/null +++ b/vendor/vello_common/src/paint.rs @@ -0,0 +1,276 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Types for paints. + +use crate::pixmap::Pixmap; +use alloc::sync::Arc; +pub use peniko::Color; +use peniko::{ + Gradient, + color::{AlphaColor, PremulRgba8, Srgb}, +}; + +/// A paint that needs to be resolved via its index. +// In the future, we might add additional flags, that's why we have +// this thin wrapper around u32, so we can change the underlying +// representation without breaking the API. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IndexedPaint(u32); + +impl IndexedPaint { + /// Create a new indexed paint from an index. + pub fn new(index: usize) -> Self { + Self(u32::try_from(index).expect("exceeded the maximum number of paints")) + } + + /// Return the index of the paint. + pub fn index(&self) -> usize { + usize::try_from(self.0).unwrap() + } +} + +/// A paint that is used internally by a rendering frontend to store how a wide tile command +/// should be painted. There are only two types of paint: +/// +/// 1) Simple solid colors, which are stored in premultiplied representation so that +/// each wide tile doesn't have to recompute it. +/// 2) Indexed paints, which can represent any arbitrary, more complex paint that is +/// determined by the frontend. The intended way of using this is to store a vector +/// of paints and store its index inside `IndexedPaint`. +#[derive(Debug, Clone, PartialEq)] +pub enum Paint { + /// A premultiplied RGBA8 color. + Solid(PremulColor), + /// A paint that needs to be resolved via an index. + Indexed(IndexedPaint), +} + +impl From> for Paint { + fn from(value: AlphaColor) -> Self { + Self::Solid(PremulColor::from_alpha_color(value)) + } +} + +/// Opaque image handle +#[derive(Clone, Copy, Hash, PartialEq, Eq, Debug)] +pub struct ImageId(u32); + +impl ImageId { + // TODO: make this private in future + /// Create a new image id from a u32. + pub fn new(value: u32) -> Self { + Self(value) + } + + /// Return the image id as a u32. + pub fn as_u32(&self) -> u32 { + self.0 + } +} + +/// Bitmap source used by `Image`. +#[derive(Debug, Clone)] +pub enum ImageSource { + /// Pixmap pixels travel with the scene packet. + Pixmap(Arc), + /// Pixmap pixels were registered earlier; this is just a handle. + OpaqueId { + /// The image handle. + id: ImageId, + /// Whether the image may contain non-opaque pixels. + may_have_transparency: bool, + }, +} + +impl ImageSource { + /// Create an [`ImageSource`] from a pre-registered image handle. + /// + /// Conservatively assumes the image may have non-opaque pixels. + /// Use [`Self::opaque_id_with_transparency_hint`] when you know the image is fully opaque. + pub fn opaque_id(id: ImageId) -> Self { + Self::OpaqueId { + id, + may_have_transparency: true, + } + } + + /// Create an [`ImageSource`] from a pre-registered image handle, + /// with an explicit hint about whether the image may have non-opaque pixels. + pub fn opaque_id_with_transparency_hint(id: ImageId, may_have_transparency: bool) -> Self { + Self::OpaqueId { + id, + may_have_transparency, + } + } + + /// Returns whether this image source may contain non-opaque pixels. + pub fn may_have_transparency(&self) -> bool { + match self { + Self::Pixmap(p) => p.may_have_transparency(), + Self::OpaqueId { + may_have_transparency, + .. + } => *may_have_transparency, + } + } + + /// Convert a [`peniko::ImageData`] to an [`ImageSource`]. + /// + /// This is a somewhat lossy conversion, as the image data data is transformed to + /// [premultiplied RGBA8](`PremulRgba8`). + /// + /// # Panics + /// + /// This panics if `image` has a `width` or `height` greater than `u16::MAX`. + pub fn from_peniko_image_data(image: &peniko::ImageData) -> Self { + // TODO: how do we deal with `peniko::ImageFormat` growing? See also + // . + let do_alpha_multiply = image.alpha_type != peniko::ImageAlphaType::AlphaPremultiplied; + + assert!( + image.width <= u16::MAX as u32 && image.height <= u16::MAX as u32, + "The image is too big. Its width and height can be no larger than {} pixels.", + u16::MAX, + ); + let width = image.width.try_into().unwrap(); + let height = image.height.try_into().unwrap(); + + // TODO: SIMD + #[expect(clippy::cast_possible_truncation, reason = "This cannot overflow.")] + let pixels = image + .data + .data() + .chunks_exact(4) + .map(|pixel| { + let rgba: [u8; 4] = match image.format { + peniko::ImageFormat::Rgba8 => pixel.try_into().unwrap(), + peniko::ImageFormat::Bgra8 => [pixel[2], pixel[1], pixel[0], pixel[3]], + format => unimplemented!("Unsupported image format: {format:?}"), + }; + let alpha = u16::from(rgba[3]); + let multiply = |component| ((alpha * u16::from(component)) / 255) as u8; + if do_alpha_multiply { + PremulRgba8 { + r: multiply(rgba[0]), + g: multiply(rgba[1]), + b: multiply(rgba[2]), + a: rgba[3], + } + } else { + PremulRgba8 { + r: rgba[0], + g: rgba[1], + b: rgba[2], + a: rgba[3], + } + } + }) + .collect(); + let pixmap = Pixmap::from_parts(pixels, width, height); + + Self::Pixmap(Arc::new(pixmap)) + } +} + +/// An image. +pub type Image = peniko::ImageBrush; + +/// Trait for resolving opaque image IDs to pixmaps at rasterization time. +/// +/// This allows delaying the resolution of `ImageSource::OpaqueId` until the +/// image is actually needed during rasterization, enabling patterns like +/// dynamic sprite atlases where the image data may be updated between +/// encoding and rendering. +pub trait ImageResolver: Send + Sync { + /// Resolve an `ImageId` to its pixmap data. + /// + /// This method may be called repeatedly (dozens or even hundreds of times + /// per frame) and should therefore be very fast. + /// + /// Returns `None` if the image ID is not found in the registry. + fn resolve(&self, id: ImageId) -> Option>; +} + +/// A no-op image resolver that always returns `None`. +#[derive(Debug, Clone, Copy, Default)] +pub struct NoOpImageResolver; + +impl ImageResolver for NoOpImageResolver { + fn resolve(&self, _id: ImageId) -> Option> { + None + } +} + +/// A premultiplied color. +#[derive(Debug, Clone, PartialEq, Copy)] +pub struct PremulColor { + premul_u8: PremulRgba8, + premul_f32: peniko::color::PremulColor, +} + +impl PremulColor { + /// Create a new premultiplied color. + pub fn from_alpha_color(color: AlphaColor) -> Self { + Self::from_premul_color(color.premultiply()) + } + + /// Create a new premultiplied color from `peniko::PremulColor`. + pub fn from_premul_color(color: peniko::color::PremulColor) -> Self { + Self { + premul_u8: color.to_rgba8(), + premul_f32: color, + } + } + + /// Return the color as a premultiplied RGBA8 color. + pub fn as_premul_rgba8(&self) -> PremulRgba8 { + self.premul_u8 + } + + /// Return the color as a premultiplied RGBAF32 color. + pub fn as_premul_f32(&self) -> peniko::color::PremulColor { + self.premul_f32 + } + + /// Return whether the color is opaque (i.e. doesn't have transparency). + pub fn is_opaque(&self) -> bool { + self.premul_f32.components[3] == 1.0 + } +} + +/// How tint color is applied to an image. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum TintMode { + /// Alpha-mask tinting: `tint_premul * source.alpha`. + /// + /// The source image's alpha channel is used as a coverage mask, + /// and the result is filled with the premultiplied tint color. + /// This is the standard approach for glyph / monochrome image tinting. + AlphaMask = 0, + /// Component-wise multiply: `source * tint`. + /// + /// Each channel of the source pixel is multiplied by the corresponding + /// channel of the tint color. This works well for full-color images. + Multiply = 1, +} + +impl TintMode { + /// Return the discriminant as a `u32`. + pub fn as_u32(self) -> u32 { + self as u32 + } +} + +/// A tint applied to image paints. +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct Tint { + /// The tint color. + pub color: Color, + /// How the tint is applied. + pub mode: TintMode, +} + +/// A kind of paint that can be used for filling and stroking shapes. +pub type PaintType = peniko::Brush; diff --git a/vendor/vello_common/src/pico_svg.rs b/vendor/vello_common/src/pico_svg.rs new file mode 100644 index 000000000..c9f3fdc31 --- /dev/null +++ b/vendor/vello_common/src/pico_svg.rs @@ -0,0 +1,356 @@ +// Copyright 2024 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +// Below is copied, lightly adapted, from Vello. + +//! A minimal SVG parser for rendering examples +//! +//! This module provides a simple SVG parser to load and render SVG files +//! for demonstration purposes. It supports basic SVG features like paths, +//! fill, stroke, and grouping. + +#![expect( + clippy::print_stderr, + reason = "We don't have a better way to handle these errors, plus this code is not public API" +)] + +extern crate std; + +use crate::color::{AlphaColor, DynamicColor, Srgb, palette}; +use crate::kurbo::{Affine, BezPath, Point, Size, Vec2}; +use alloc::boxed::Box; +use alloc::vec; +use alloc::vec::Vec; +use core::str::FromStr; +use roxmltree::{Document, Node}; +use std::eprintln; + +/// A simplified representation of an SVG document +#[derive(Debug)] +pub struct PicoSvg { + /// The items (shapes, groups) contained in the SVG + pub items: Vec, + /// The size of the SVG document + pub size: Size, +} + +/// Represents a single item in an SVG document +#[derive(Debug)] +pub enum Item { + /// A filled shape + Fill(FillItem), + /// A stroked shape + Stroke(StrokeItem), + /// A group of items + Group(GroupItem), +} + +/// A stroke item with styling information +#[derive(Debug)] +pub struct StrokeItem { + /// The width of the stroke + pub width: f64, + /// The color of the stroke + pub color: AlphaColor, + /// The path to be stroked + pub path: BezPath, +} + +/// A fill item with styling information +#[derive(Debug)] +pub struct FillItem { + /// The color to fill with + pub color: AlphaColor, + /// The path to be filled + pub path: BezPath, +} + +/// A group of items that can be transformed together +#[derive(Debug)] +pub struct GroupItem { + /// The affine transformation to apply to all children + pub affine: Affine, + /// The child items in this group + pub children: Vec, +} + +struct Parser { + scale: f64, +} + +impl PicoSvg { + /// Load an SVG document from a string + pub fn load(xml_string: &str, scale: f64) -> Result> { + let doc = Document::parse(xml_string)?; + let root = doc.root_element(); + let mut parser = Parser::new(scale); + let root_width = root.attribute("width").and_then(|s| f64::from_str(s).ok()); + let root_height = root.attribute("height").and_then(|s| f64::from_str(s).ok()); + let (origin, viewbox_size) = root + .attribute("viewBox") + .and_then(|vb_attr| { + let vs: Vec = vb_attr + .split(' ') + .map(|s| f64::from_str(s).unwrap()) + .collect(); + if let &[x, y, width, height] = vs.as_slice() { + Some((Point { x, y }, Size { width, height })) + } else { + None + } + }) + .unzip(); + + let mut transform = if let Some(origin) = origin { + Affine::translate(origin.to_vec2() * -1.0) + } else { + Affine::IDENTITY + }; + + transform *= match (root_width, root_height, viewbox_size) { + (None, None, Some(_)) => Affine::IDENTITY, + (Some(w), Some(h), Some(s)) => { + Affine::scale_non_uniform(1.0 / s.width * w, 1.0 / s.height * h) + } + (Some(w), None, Some(s)) => Affine::scale(1.0 / s.width * w), + (None, Some(h), Some(s)) => Affine::scale(1.0 / s.height * h), + _ => Affine::IDENTITY, + }; + + let size = match (root_width, root_height, viewbox_size) { + (None, None, Some(s)) => s, + (mw, mh, None) => Size { + width: mw.unwrap_or(300_f64), + height: mh.unwrap_or(150_f64), + }, + (Some(w), None, Some(s)) => Size { + width: w, + height: 1.0 / w * s.width * s.height, + }, + (None, Some(h), Some(s)) => Size { + width: 1.0 / h * s.height * s.width, + height: h, + }, + (Some(width), Some(height), Some(_)) => Size { width, height }, + }; + + transform *= if scale >= 0.0 { + Affine::scale(scale) + } else { + Affine::new([-scale, 0.0, 0.0, scale, 0.0, 0.0]) + }; + let props = RecursiveProperties { + fill: Some(palette::css::BLACK), + }; + // The root element is the svg document element, which we don't care about + let mut items = Vec::new(); + for node in root.children() { + parser.rec_parse(node, &props, &mut items)?; + } + let root_group = Item::Group(GroupItem { + affine: transform, + children: items, + }); + Ok(Self { + items: vec![root_group], + size, + }) + } +} + +#[derive(Clone)] +struct RecursiveProperties { + fill: Option>, +} + +impl Parser { + fn new(scale: f64) -> Self { + Self { scale } + } + + fn rec_parse( + &mut self, + node: Node<'_, '_>, + properties: &RecursiveProperties, + items: &mut Vec, + ) -> Result<(), Box> { + if node.is_element() { + let mut properties = properties.clone(); + if let Some(fill_color) = node.attribute("fill") { + if fill_color == "none" { + properties.fill = None; + } else { + let color = parse_color(fill_color); + let color = modify_opacity(color, "fill-opacity", node); + // TODO: Handle recursive opacity properly + let color = modify_opacity(color, "opacity", node); + properties.fill = Some(color); + } + } + match node.tag_name().name() { + "g" => { + let mut children = Vec::new(); + let mut affine = Affine::default(); + if let Some(transform) = node.attribute("transform") { + affine = parse_transform(transform); + } + for child in node.children() { + self.rec_parse(child, &properties, &mut children)?; + } + items.push(Item::Group(GroupItem { affine, children })); + } + "path" => { + let d = node.attribute("d").ok_or("missing 'd' attribute")?; + let bp = BezPath::from_svg(d)?; + let path = bp; + if let Some(color) = properties.fill { + items.push(Item::Fill(FillItem { + color, + path: path.clone(), + })); + } + if let Some(stroke_color) = node.attribute("stroke") + && stroke_color != "none" + { + let width = node + .attribute("stroke-width") + .map(|a| f64::from_str(a).unwrap_or(1.0)) + .unwrap_or(1.0) + * self.scale.abs(); + let color = parse_color(stroke_color); + let color = modify_opacity(color, "stroke-opacity", node); + // TODO: Handle recursive opacity properly + let color = modify_opacity(color, "opacity", node); + items.push(Item::Stroke(StrokeItem { width, color, path })); + } + } + other => eprintln!("Unhandled node type {other}"), + } + } + Ok(()) + } +} + +fn parse_transform(transform: &str) -> Affine { + let mut nt = Affine::IDENTITY; + for ts in transform.split(')').map(str::trim) { + nt *= if let Some(s) = ts.strip_prefix("matrix(") { + let vals = s + .split([',', ' ']) + .map(str::parse) + .collect::, _>>() + .expect("Could parse all values of 'matrix' as floats"); + Affine::new( + vals.try_into() + .expect("Should be six arguments to `matrix`"), + ) + } else if let Some(s) = ts.strip_prefix("translate(") { + if let Ok(vals) = s + .split([',', ' ']) + .map(str::trim) + .map(str::parse) + .collect::, _>>() + { + match vals.as_slice() { + &[x, y] => Affine::translate(Vec2 { x, y }), + _ => Affine::IDENTITY, + } + } else { + Affine::IDENTITY + } + } else if let Some(s) = ts.strip_prefix("scale(") { + if let Ok(vals) = s + .split([',', ' ']) + .map(str::trim) + .map(str::parse) + .collect::, _>>() + { + match *vals.as_slice() { + [x, y] => Affine::scale_non_uniform(x, y), + [x] => Affine::scale(x), + _ => Affine::IDENTITY, + } + } else { + Affine::IDENTITY + } + } else if let Some(s) = ts.strip_prefix("scaleX(") { + s.trim() + .parse() + .ok() + .map(|x| Affine::scale_non_uniform(x, 1.0)) + .unwrap_or(Affine::IDENTITY) + } else if let Some(s) = ts.strip_prefix("scaleY(") { + s.trim() + .parse() + .ok() + .map(|y| Affine::scale_non_uniform(1.0, y)) + .unwrap_or(Affine::IDENTITY) + } else { + if !ts.is_empty() { + eprintln!("Did not understand transform attribute {ts:?})"); + } + Affine::IDENTITY + }; + } + nt +} + +fn parse_color(color: &str) -> AlphaColor { + let color = color.trim(); + crate::color::parse_color(color.trim()) + .map(DynamicColor::to_alpha_color) + .unwrap_or(palette::css::FUCHSIA.with_alpha(0.5)) +} + +fn modify_opacity( + color: AlphaColor, + attr_name: &str, + node: Node<'_, '_>, +) -> AlphaColor { + if let Some(opacity) = node.attribute(attr_name) { + let alpha: f32 = if let Some(o) = opacity.strip_suffix('%') { + let pctg = o.parse().unwrap_or(100.0); + pctg * 0.01 + } else { + opacity.parse().unwrap_or(1.0) + }; + color.with_alpha(alpha.clamp(0., 1.)) + } else { + color + } +} + +#[cfg(test)] +mod tests { + use super::parse_color; + use crate::color::{AlphaColor, Srgb, palette}; + + fn assert_close_color(c1: AlphaColor, c2: AlphaColor) { + const EPSILON: f32 = 1e-4; + assert_eq!(c1.cs, c2.cs); + for i in 0..4 { + assert!((c1.components[i] - c2.components[i]).abs() < EPSILON); + } + } + + #[test] + fn parse_colors() { + let lime = palette::css::LIME; + let lime_a = lime.with_alpha(0.4); + + let named = parse_color("lime"); + assert_close_color(lime, named); + + let hex = parse_color("#00ff00"); + assert_close_color(lime, hex); + + let rgb = parse_color("rgb(0, 255, 0)"); + assert_close_color(lime, rgb); + + let modern = parse_color("color(srgb 0 1 0)"); + assert_close_color(lime, modern); + + let modern_a = parse_color("color(srgb 0 1 0 / 0.4)"); + assert_close_color(lime_a, modern_a); + } +} diff --git a/vendor/vello_common/src/pixmap.rs b/vendor/vello_common/src/pixmap.rs new file mode 100644 index 000000000..83055dd51 --- /dev/null +++ b/vendor/vello_common/src/pixmap.rs @@ -0,0 +1,378 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! A simple pixmap type. + +use alloc::vec; +use alloc::vec::Vec; +#[cfg(feature = "png")] +use std::io::{BufRead, Seek}; + +use crate::peniko::color::{PremulRgba8, Rgba8}; + +#[cfg(feature = "png")] +extern crate std; + +/// A pixmap of premultiplied RGBA8 values backed by [`u8`][core::u8]. +#[derive(Debug, Clone)] +pub struct Pixmap { + /// Width of the pixmap in pixels. + width: u16, + /// Height of the pixmap in pixels. + height: u16, + /// Buffer of the pixmap in RGBA8 format. + buf: Vec, + /// Whether the pixmap may have non-opaque pixels. + /// + /// Note: This may become stale if pixels are modified via [`data_mut()`](Self::data_mut), + /// [`data_as_u8_slice_mut()`](Self::data_as_u8_slice_mut), or [`set_pixel()`](Self::set_pixel). + may_have_transparency: bool, +} + +impl Pixmap { + /// Create a new pixmap with the given width and height in pixels. + /// + /// All pixels are initialized to transparent black. + pub fn new(width: u16, height: u16) -> Self { + let buf = vec![PremulRgba8::from_u32(0); width as usize * height as usize]; + Self { + width, + height, + buf, + may_have_transparency: true, + } + } + + /// Create a new pixmap with the given premultiplied RGBA8 data. + /// + /// The `data` vector must be of length `width * height` exactly. + /// + /// The pixels are in row-major order. + /// + /// This assumes the image may have transparent pixels. Use + /// [`from_parts_with_opacity`](Self::from_parts_with_opacity) if you already + /// know the opacity status to enable optimizations. + /// + /// # Panics + /// + /// Panics if the `data` vector is not of length `width * height`. + pub fn from_parts(data: Vec, width: u16, height: u16) -> Self { + Self::from_parts_with_opacity(data, width, height, true) + } + + /// Create a new pixmap with the given premultiplied RGBA8 data and precomputed opacity flag. + /// + /// The `data` vector must be of length `width * height` exactly. + /// + /// The pixels are in row-major order. + /// + /// Use this when you've already determined whether the data contains + /// non-opaque pixels to avoid redundant scanning. + /// + /// # Panics + /// + /// Panics if the `data` vector is not of length `width * height`. + pub fn from_parts_with_opacity( + data: Vec, + width: u16, + height: u16, + may_have_transparency: bool, + ) -> Self { + assert_eq!( + data.len(), + usize::from(width) * usize::from(height), + "Expected `data` to have length of exactly `width * height`" + ); + Self { + width, + height, + buf: data, + may_have_transparency, + } + } + + /// Resizes the pixmap container to the given width and height; this does not resize the + /// contained image. + /// + /// If the pixmap buffer has to grow to fit the new size, those pixels are set to transparent + /// black. If the pixmap buffer is larger than required, the buffer is truncated and its + /// reserved capacity is unchanged. + pub fn resize(&mut self, width: u16, height: u16) { + let new_len = usize::from(width) * usize::from(height); + // If we're growing, new pixels are transparent black + if new_len > self.buf.len() { + self.may_have_transparency = true; + } + self.width = width; + self.height = height; + self.buf.resize(new_len, PremulRgba8::from_u32(0)); + } + + /// Shrink the capacity of the pixmap buffer to fit the pixmap's current size. + pub fn shrink_to_fit(&mut self) { + self.buf.shrink_to_fit(); + } + + /// The reserved capacity (in pixels) of this pixmap. + /// + /// When calling [`Pixmap::resize`] with a `width * height` smaller than this value, the pixmap + /// does not need to reallocate. + pub fn capacity(&self) -> usize { + self.buf.capacity() + } + + /// Return the width of the pixmap. + pub fn width(&self) -> u16 { + self.width + } + + /// Return the height of the pixmap. + pub fn height(&self) -> u16 { + self.height + } + + /// Returns whether the pixmap may have non-opaque pixels. + /// + /// This value is computed at construction time. It may become stale if pixels are + /// modified directly via [`data_mut()`](Self::data_mut), + /// [`data_as_u8_slice_mut()`](Self::data_as_u8_slice_mut), or [`set_pixel()`](Self::set_pixel). + /// + /// Use [`set_may_have_transparency()`](Self::set_may_have_transparency) to manually update the flag, + /// or [`recompute_may_have_transparency()`](Self::recompute_may_have_transparency) to recalculate it + /// by scanning all pixels. + pub fn may_have_transparency(&self) -> bool { + self.may_have_transparency + } + + /// Manually set the `may_have_transparency` flag. + /// + /// Use this after modifying pixels via [`data_mut()`](Self::data_mut) or + /// [`set_pixel()`](Self::set_pixel) when you know whether the image has + /// non-opaque pixels. + pub fn set_may_have_transparency(&mut self, may_have_transparency: bool) { + self.may_have_transparency = may_have_transparency; + } + + /// Recalculate `may_have_transparency` by scanning all pixels. + /// + /// Use this after modifying pixels via [`data_mut()`](Self::data_mut) or + /// [`set_pixel()`](Self::set_pixel) when you need accurate opacity information. + pub fn recompute_may_have_transparency(&mut self) { + self.may_have_transparency = self.buf.iter().any(|pixel| pixel.a != 255); + } + + /// Apply an alpha value to the whole pixmap. + pub fn multiply_alpha(&mut self, alpha: u8) { + #[expect( + clippy::cast_possible_truncation, + reason = "cannot overflow in this case" + )] + let multiply = |component| ((u16::from(alpha) * u16::from(component)) / 255) as u8; + + for pixel in self.data_mut() { + *pixel = PremulRgba8 { + r: multiply(pixel.r), + g: multiply(pixel.g), + b: multiply(pixel.b), + a: multiply(pixel.a), + }; + } + + // If we applied a non-opaque alpha, the image now has transparency + if alpha != 255 { + self.may_have_transparency = true; + } + } + + /// Create a pixmap from a PNG file. + #[cfg(feature = "png")] + pub fn from_png(data: impl BufRead + Seek) -> Result { + let mut decoder = png::Decoder::new(data); + decoder.set_transformations( + png::Transformations::normalize_to_color8() | png::Transformations::ALPHA, + ); + + let mut reader = decoder.read_info()?; + let mut pixmap = { + let info = reader.info(); + let width: u16 = info + .width + .try_into() + .map_err(|_| png::DecodingError::LimitsExceeded)?; + let height: u16 = info + .height + .try_into() + .map_err(|_| png::DecodingError::LimitsExceeded)?; + Self::new(width, height) + }; + + // Note `reader.info()` returns the pre-transformation color type output, whereas + // `reader.output_color_type()` takes the transformation into account. + let (color_type, bit_depth) = reader.output_color_type(); + debug_assert_eq!( + bit_depth, + png::BitDepth::Eight, + "normalize_to_color8 means the bit depth is always 8." + ); + + match color_type { + png::ColorType::Rgb | png::ColorType::Grayscale => { + unreachable!("We set a transformation to always convert to alpha") + } + png::ColorType::Indexed => { + unreachable!("Transformation should have expanded indexed images") + } + png::ColorType::Rgba => { + debug_assert_eq!( + Some(pixmap.data_as_u8_slice().len()), + reader.output_buffer_size(), + "The pixmap buffer should have the same number of bytes as the image." + ); + reader.next_frame(pixmap.data_as_u8_slice_mut())?; + } + png::ColorType::GrayscaleAlpha => { + debug_assert_eq!( + Some(pixmap.data().len() * 2), + reader.output_buffer_size(), + "The pixmap buffer should have twice the number of bytes of the grayscale image." + ); + let mut grayscale_data = vec![0; reader.output_buffer_size().unwrap_or_default()]; + reader.next_frame(&mut grayscale_data)?; + + for (grayscale_pixel, pixmap_pixel) in + grayscale_data.chunks_exact(2).zip(pixmap.data_mut()) + { + let [gray, alpha] = grayscale_pixel.try_into().unwrap(); + *pixmap_pixel = PremulRgba8 { + r: gray, + g: gray, + b: gray, + a: alpha, + }; + } + } + }; + + let mut may_have_transparency = false; + for pixel in pixmap.data_mut() { + let alpha = pixel.a; + if alpha != 255 { + may_have_transparency = true; + } + let alpha_u16 = u16::from(alpha); + #[expect( + clippy::cast_possible_truncation, + reason = "Overflow should be impossible." + )] + let premultiply = |e: u8| ((u16::from(e) * alpha_u16) / 255) as u8; + pixel.r = premultiply(pixel.r); + pixel.g = premultiply(pixel.g); + pixel.b = premultiply(pixel.b); + } + pixmap.may_have_transparency = may_have_transparency; + + Ok(pixmap) + } + + /// Return the current content of the pixmap as a PNG. + #[cfg(feature = "png")] + pub fn into_png(self) -> Result, png::EncodingError> { + let mut data = Vec::new(); + let mut encoder = png::Encoder::new(&mut data, self.width as u32, self.height as u32); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header()?; + writer.write_image_data(bytemuck::cast_slice(&self.take_unpremultiplied()))?; + writer.finish().map(|_| data) + } + + /// Returns a reference to the underlying data as premultiplied RGBA8. + /// + /// The pixels are in row-major order. + pub fn data(&self) -> &[PremulRgba8] { + &self.buf + } + + /// Returns a mutable reference to the underlying data as premultiplied RGBA8. + /// + /// The pixels are in row-major order. + pub fn data_mut(&mut self) -> &mut [PremulRgba8] { + &mut self.buf + } + + /// Returns a reference to the underlying data as premultiplied RGBA8. + /// + /// The pixels are in row-major order. Each pixel consists of four bytes in the order + /// `[r, g, b, a]`. + pub fn data_as_u8_slice(&self) -> &[u8] { + bytemuck::cast_slice(&self.buf) + } + + /// Returns a mutable reference to the underlying data as premultiplied RGBA8. + /// + /// The pixels are in row-major order. Each pixel consists of four bytes in the order + /// `[r, g, b, a]`. + pub fn data_as_u8_slice_mut(&mut self) -> &mut [u8] { + bytemuck::cast_slice_mut(&mut self.buf) + } + + /// Sample a pixel from the pixmap. + /// + /// The pixel data is [premultiplied RGBA8][PremulRgba8]. + #[inline(always)] + pub fn sample(&self, x: u16, y: u16) -> PremulRgba8 { + let idx = self.width as usize * y as usize + x as usize; + self.buf[idx] + } + + /// Sample a pixel from a custom-calculated index. This index should be calculated assuming that + /// the data is stored in row-major order. + #[inline(always)] + pub fn sample_idx(&self, idx: u32) -> PremulRgba8 { + self.buf[idx as usize] + } + + /// Set a pixel in the pixmap at the given coordinates. + /// + /// The pixel data should be [premultiplied RGBA8][PremulRgba8]. The coordinate system has + /// its origin at the top-left corner, with `x` increasing to the right and `y` increasing + /// downward. + #[inline(always)] + pub fn set_pixel(&mut self, x: u16, y: u16, pixel: PremulRgba8) { + let idx = self.width as usize * y as usize + x as usize; + self.buf[idx] = pixel; + } + + /// Consume the pixmap, returning the data as the underlying [`Vec`] of premultiplied RGBA8. + /// + /// The pixels are in row-major order. + pub fn take(self) -> Vec { + self.buf + } + + /// Consume the pixmap, returning the data as (unpremultiplied) RGBA8. + /// + /// Not fast, but useful for saving to PNG etc. + /// + /// The pixels are in row-major order. + pub fn take_unpremultiplied(self) -> Vec { + self.buf + .into_iter() + .map(|PremulRgba8 { r, g, b, a }| { + let alpha = 255.0 / f32::from(a); + if a != 0 { + #[expect(clippy::cast_possible_truncation, reason = "deliberate quantization")] + let unpremultiply = |component| (f32::from(component) * alpha + 0.5) as u8; + Rgba8 { + r: unpremultiply(r), + g: unpremultiply(g), + b: unpremultiply(b), + a, + } + } else { + Rgba8 { r, g, b, a } + } + }) + .collect() + } +} diff --git a/vendor/vello_common/src/probe.rs b/vendor/vello_common/src/probe.rs new file mode 100644 index 000000000..5e48f1243 --- /dev/null +++ b/vendor/vello_common/src/probe.rs @@ -0,0 +1,419 @@ +// Copyright 2026 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Helpers for performing probing to verify the basic capabilities of the device we are +//! running on. + +use crate::color::{AlphaColor, palette::css}; +use crate::filter_effects::{EdgeMode, Filter, FilterPrimitive}; +use crate::kurbo::{Affine, BezPath, Circle, Point, Rect, Shape}; +use crate::paint::{Image, ImageSource, PaintType}; +use crate::peniko::{ + BlendMode, ColorStop, ColorStops, Compose, Extend, Gradient, ImageQuality, ImageSampler, + LinearGradientPosition, Mix, +}; +use crate::pixmap::Pixmap; +use alloc::vec::Vec; + +const REFERENCE_RGBA: &[u8] = include_bytes!("../assets/probe.rgba"); + +const ELEMENTS_PER_ROW: usize = 3; +const ELEMENT_MARGIN: f64 = 1.0; + +const RECT_SIZE: f64 = 10.0; +const CIRCLE_RADIUS: f64 = 5.0; +const CIRCLE_CENTER_OFFSET_X: f64 = 1.5; +const IMAGE_SOURCE_SIZE: f64 = 5.0; +const PATH_TOLERANCE: f64 = 0.1; + +const ELEMENTS: [ProbeElement; 9] = [ + ProbeElement::SolidRect, + ProbeElement::AlphaBlending, + ProbeElement::Gradient, + ProbeElement::ImageNearest, + ProbeElement::Filter, + ProbeElement::ImageBilinear, + ProbeElement::OpacityLayer, + ProbeElement::Blending, + ProbeElement::Transformed, +]; +/// Per-channel absolute tolerance used when comparing probe pixels. +const CHANNEL_TOLERANCE: u8 = 3; + +/// Result of running the renderer probe. +#[derive(Debug, Clone)] +pub enum Probe { + /// The probe matched the bundled reference image. + Success, + /// The probe did not match the bundled reference image. + Error(ProbeResult), + /// Rendering the probe scene produces an error. + RenderError(E), +} + +/// Probe failure output. +#[derive(Debug, Clone)] +pub struct ProbeResult { + /// The expected probe image. + pub expected: ProbeImage, + /// The actual probe image. + pub actual: ProbeImage, +} + +/// A probe image stored as RGBA8 bytes. +#[derive(Debug, Clone)] +pub struct ProbeImage { + /// Width of the image in pixels. + pub width: u16, + /// Height of the image in pixels. + pub height: u16, + /// The image data as RGBA8 bytes. + pub data: Vec, +} + +impl Probe { + /// Returns `true` when the probe matched the bundled reference image. + pub fn is_success(&self) -> bool { + matches!(self, Self::Success) + } + + /// Construct a new probe result by inspecting the provided pixmap and comparing it + /// against the reference output. + pub fn from_actual(actual: Pixmap) -> Self { + let (width, height) = canvas_size(); + let expected = ProbeImage { + width, + height, + data: REFERENCE_RGBA.to_vec(), + }; + let actual = ProbeImage::from_pixmap(actual); + let matches_reference = expected.width == actual.width + && expected.height == actual.height + && expected.data.len() == actual.data.len() + && expected + .data + .chunks_exact(4) + .zip(actual.data.chunks_exact(4)) + .all(|(expected, actual)| { + pixels_within_tolerance(expected, actual, CHANNEL_TOLERANCE) + }); + + if matches_reference { + Self::Success + } else { + Self::Error(ProbeResult { expected, actual }) + } + } +} + +impl ProbeImage { + fn from_pixmap(pixmap: Pixmap) -> Self { + Self { + width: pixmap.width(), + height: pixmap.height(), + data: bytemuck::cast_slice(&pixmap.take_unpremultiplied()).to_vec(), + } + } +} + +/// API necessary to draw the probe scene. +pub trait ProbeRenderer { + fn set_transform(&mut self, transform: Affine); + fn set_paint(&mut self, paint: PaintType); + fn fill_path(&mut self, path: &BezPath); + fn fill_rect(&mut self, rect: &Rect); + fn push_layer(&mut self, blend_mode: Option, opacity: Option); + fn push_filter_layer(&mut self, filter: Filter); + fn pop_layer(&mut self); + fn set_paint_transform(&mut self, paint_transform: Affine); + fn reset_paint_transform(&mut self); +} + +#[derive(Clone, Copy, Debug)] +enum ProbeElement { + SolidRect, + Transformed, + AlphaBlending, + Gradient, + ImageNearest, + Filter, + ImageBilinear, + OpacityLayer, + Blending, +} + +#[derive(Clone, Copy, Debug)] +struct GridLayout { + columns: usize, + rows: usize, + cell_width: f64, + cell_height: f64, +} + +impl GridLayout { + fn from_elements(elements: &[ProbeElement]) -> Self { + let columns = ELEMENTS_PER_ROW.min(elements.len()); + let rows = elements.len().div_ceil(columns); + let (cell_width, cell_height) = elements + .iter() + .copied() + .map(ProbeElement::bounds) + .fold((0.0_f64, 0.0_f64), |(max_w, max_h), (w, h)| { + (max_w.max(w), max_h.max(h)) + }); + + Self { + columns, + rows, + cell_width, + cell_height, + } + } + + fn canvas_size(self) -> (u16, u16) { + let width = self.columns as f64 * self.cell_width + + self.columns.saturating_sub(1) as f64 * ELEMENT_MARGIN; + let height = self.rows as f64 * self.cell_height + + self.rows.saturating_sub(1) as f64 * ELEMENT_MARGIN; + (width.ceil() as u16, height.ceil() as u16) + } + + fn canvas_rect(self) -> Rect { + let (width, height) = self.canvas_size(); + Rect::new(0.0, 0.0, f64::from(width), f64::from(height)) + } + + fn cell_rect(self, index: usize) -> Rect { + let column = index % self.columns; + let row = index / self.columns; + let x0 = column as f64 * (self.cell_width + ELEMENT_MARGIN); + let y0 = row as f64 * (self.cell_height + ELEMENT_MARGIN); + Rect::new(x0, y0, x0 + self.cell_width, y0 + self.cell_height) + } +} + +impl ProbeElement { + fn bounds(self) -> (f64, f64) { + let (width, height) = match self { + Self::SolidRect + | Self::Gradient + | Self::ImageNearest + | Self::ImageBilinear + | Self::Filter + | Self::OpacityLayer => (RECT_SIZE, RECT_SIZE), + Self::Transformed => ( + RECT_SIZE * core::f64::consts::SQRT_2, + RECT_SIZE * core::f64::consts::SQRT_2, + ), + Self::AlphaBlending | Self::Blending => ( + CIRCLE_RADIUS * 2.0 + CIRCLE_CENTER_OFFSET_X * 2.0, + CIRCLE_RADIUS * 2.0, + ), + }; + (width + ELEMENT_MARGIN * 2.0, height + ELEMENT_MARGIN * 2.0) + } +} + +/// Return the canvas size of the shared probe scene. +pub fn canvas_size() -> (u16, u16) { + GridLayout::from_elements(&ELEMENTS).canvas_size() +} + +/// Return the pixmap that is referenced when drawing images in the scene. +pub fn probe_image_pixmap() -> Pixmap { + let mut pixmap = Pixmap::new(IMAGE_SOURCE_SIZE as u16, IMAGE_SOURCE_SIZE as u16); + for y in 0..pixmap.height() { + for x in 0..pixmap.width() { + pixmap.set_pixel( + x, + y, + AlphaColor::from_rgba8(255, 0, 0, 255) + .premultiply() + .to_rgba8(), + ); + } + } + pixmap.set_may_have_transparency(false); + pixmap +} + +fn image_paint(image: ImageSource, quality: ImageQuality) -> PaintType { + Image { + image, + sampler: ImageSampler { + x_extend: Extend::Pad, + y_extend: Extend::Pad, + quality, + alpha: 1.0, + }, + } + .into() +} + +/// Draw the full shared probe scene into a rendering context. +pub fn draw_scene(ctx: &mut T, image: ImageSource) { + let layout = GridLayout::from_elements(&ELEMENTS); + let image_nearest = image_paint(image.clone(), ImageQuality::Low); + let image_bilinear = image_paint(image, ImageQuality::Medium); + ctx.set_transform(Affine::IDENTITY); + ctx.set_paint(css::WHITE.into()); + ctx.fill_rect(&layout.canvas_rect()); + + for (index, element) in ELEMENTS.iter().copied().enumerate() { + draw_probe_element( + ctx, + layout.cell_rect(index), + element, + &image_nearest, + &image_bilinear, + ); + } +} + +fn pixels_within_tolerance(expected: &[u8], actual: &[u8], channel_tolerance: u8) -> bool { + if expected[3] == 0 && actual[3] == 0 { + return true; + } + + expected + .iter() + .zip(actual) + .all(|(expected, actual)| expected.abs_diff(*actual) <= channel_tolerance) +} + +fn draw_probe_element( + ctx: &mut impl ProbeRenderer, + cell: Rect, + element: ProbeElement, + image_nearest: &PaintType, + image_bilinear: &PaintType, +) { + match element { + ProbeElement::SolidRect => { + ctx.set_paint(css::BLUE.into()); + ctx.fill_rect(¢ered_rect(cell, RECT_SIZE, RECT_SIZE)); + } + ProbeElement::Transformed => { + draw_transformed_rect(ctx, centered_rect(cell, RECT_SIZE, RECT_SIZE)); + } + ProbeElement::AlphaBlending => { + let center = cell.center(); + ctx.set_paint(css::YELLOW.with_alpha(0.5).into()); + ctx.fill_path( + &Circle::new((center.x - CIRCLE_CENTER_OFFSET_X, center.y), CIRCLE_RADIUS) + .to_path(PATH_TOLERANCE), + ); + ctx.set_paint(css::GREEN.with_alpha(0.5).into()); + ctx.fill_path( + &Circle::new((center.x + CIRCLE_CENTER_OFFSET_X, center.y), CIRCLE_RADIUS) + .to_path(PATH_TOLERANCE), + ); + } + ProbeElement::Gradient => { + let rect = centered_rect(cell, RECT_SIZE, RECT_SIZE); + ctx.set_paint(linear_gradient(&rect).into()); + ctx.fill_rect(&rect); + } + ProbeElement::ImageNearest => draw_centered_padded_image(ctx, cell, image_nearest), + ProbeElement::Filter => draw_blurred_rect(ctx, centered_rect(cell, RECT_SIZE, RECT_SIZE)), + ProbeElement::ImageBilinear => draw_centered_padded_image(ctx, cell, image_bilinear), + ProbeElement::OpacityLayer => { + draw_opacity_layer_rect(ctx, centered_rect(cell, RECT_SIZE, RECT_SIZE)); + } + ProbeElement::Blending => draw_layered_difference_circles(ctx, cell), + } +} + +fn centered_rect(cell: Rect, width: f64, height: f64) -> Rect { + let center = cell.center(); + Rect::new( + center.x - width * 0.5, + center.y - height * 0.5, + center.x + width * 0.5, + center.y + height * 0.5, + ) +} + +fn draw_centered_padded_image(ctx: &mut impl ProbeRenderer, cell: Rect, image_paint: &PaintType) { + let dst_rect = centered_rect(cell, RECT_SIZE, RECT_SIZE); + let image_origin = ( + dst_rect.x0 + (RECT_SIZE - IMAGE_SOURCE_SIZE) * 0.5, + dst_rect.y0 + (RECT_SIZE - IMAGE_SOURCE_SIZE) * 0.5, + ); + ctx.set_paint(image_paint.clone()); + ctx.set_paint_transform(Affine::translate(image_origin)); + ctx.fill_rect(&dst_rect); + ctx.reset_paint_transform(); +} + +fn draw_transformed_rect(ctx: &mut impl ProbeRenderer, rect: Rect) { + let center = rect.center(); + ctx.set_transform( + Affine::translate((center.x, center.y)) + * Affine::rotate(core::f64::consts::FRAC_PI_4) + * Affine::translate((-center.x, -center.y)), + ); + ctx.set_paint(css::BLUE.into()); + ctx.fill_rect(&rect); + ctx.set_transform(Affine::IDENTITY); +} + +fn draw_blurred_rect(ctx: &mut impl ProbeRenderer, rect: Rect) { + let blur = Filter::from_primitive(FilterPrimitive::GaussianBlur { + std_deviation: 0.5, + edge_mode: EdgeMode::None, + }); + ctx.push_filter_layer(blur); + ctx.set_paint(css::REBECCA_PURPLE.into()); + ctx.fill_rect(&rect); + ctx.pop_layer(); +} + +fn draw_opacity_layer_rect(ctx: &mut impl ProbeRenderer, rect: Rect) { + ctx.push_layer(None, Some(0.5)); + ctx.set_paint(css::ORANGE_RED.into()); + ctx.fill_rect(&rect); + ctx.pop_layer(); +} + +fn draw_layered_difference_circles(ctx: &mut impl ProbeRenderer, cell: Rect) { + let center = cell.center(); + + ctx.push_layer(None, None); + ctx.set_paint(css::YELLOW.with_alpha(0.5).into()); + ctx.fill_path( + &Circle::new((center.x - CIRCLE_CENTER_OFFSET_X, center.y), CIRCLE_RADIUS) + .to_path(PATH_TOLERANCE), + ); + + ctx.push_layer( + Some(BlendMode::new(Mix::Difference, Compose::SrcOver)), + None, + ); + ctx.set_paint(css::GREEN.with_alpha(0.5).into()); + ctx.fill_path( + &Circle::new((center.x + CIRCLE_CENTER_OFFSET_X, center.y), CIRCLE_RADIUS) + .to_path(PATH_TOLERANCE), + ); + ctx.pop_layer(); + ctx.pop_layer(); +} + +fn linear_gradient(rect: &Rect) -> Gradient { + Gradient { + kind: LinearGradientPosition { + start: Point::new(rect.x0, rect.y0), + end: Point::new(rect.x1, rect.y0), + } + .into(), + stops: ColorStops::from( + [ + ColorStop::from((0.0, css::BLUE)), + ColorStop::from((1.0, css::RED)), + ] + .as_slice(), + ), + extend: Extend::Pad, + ..Default::default() + } +} diff --git a/vendor/vello_common/src/rect.rs b/vendor/vello_common/src/rect.rs new file mode 100644 index 000000000..55f98b716 --- /dev/null +++ b/vendor/vello_common/src/rect.rs @@ -0,0 +1,180 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Fast pixel-aligned rectangle rendering directly into strips. + +use crate::kurbo::Rect; +#[cfg(not(feature = "std"))] +use crate::kurbo::common::FloatFuncs as _; +use crate::strip::Strip; +use crate::tile::Tile; +use alloc::vec::Vec; +use fearless_simd::*; + +/// Render a pixel-aligned rectangle directly into strips. +/// +/// This bypasses the full path processing pipeline (flatten → tiles → strips) +/// by directly creating strip coverage data for the rectangle. +/// +/// The rect bounds should already be clamped to the viewport. +pub fn render(level: Level, rect: Rect, strip_buf: &mut Vec, alpha_buf: &mut Vec) { + dispatch!(level, simd => render_impl(simd, rect, strip_buf, alpha_buf)); +} + +/// Generates strip data for an axis-aligned rectangle. +/// +/// # Strip layout strategy +/// +/// Tile rows are classified into two kinds: +/// +/// - **Edge rows** (top/bottom of rect): the rect boundary crosses partway +/// through the tile vertically, so individual pixels need per-cell alpha. +/// We emit a *single wide strip* spanning all tile columns, with alpha = +/// `x_alpha` * `y_alpha` (so the intersection of the alpha mask in each direction). +/// +/// - **Interior rows**: every pixel in the tile has full vertical coverage, +/// so we only need to handle the left and right partial-column edges. +/// We emit a **left edge strip** (with its x-alpha mask) and, when the rect +/// spans more than one tile column, a **right edge strip** with `fill_gap = +/// true` so the renderer fills solid 0xFF between them. +/// +/// The x-alpha masks for the left/right edge tiles are y-independent, so they +/// are precomputed once and reused across all interior rows. +fn render_impl(s: S, rect: Rect, strip_buf: &mut Vec, alpha_buf: &mut Vec) { + if rect.is_zero_area() { + return; + } + + let rect_x0 = rect.x0 as f32; + let rect_y0 = rect.y0 as f32; + let rect_x1 = rect.x1 as f32; + let rect_y1 = rect.y1 as f32; + + // Integer pixel bounds. + let px_x0 = rect_x0.floor() as u16; + let px_y0 = rect_y0.floor() as u16; + let px_y1 = rect_y1.ceil() as u16; + + let left_tile_x = (px_x0 / Tile::WIDTH) * Tile::WIDTH; + // Inclusive, so don't use `ceil` here but just `rect_x1` directly. + let right_tile_x = (rect_x1 as u16 / Tile::WIDTH) * Tile::WIDTH; + + let y0 = (px_y0 / Tile::HEIGHT) * Tile::HEIGHT; + // Note: y1 is exclusive, but it's gonna break for the very last tile if we have a height of u16::MAX. + let y1 = (px_y1.saturating_add(Tile::HEIGHT - 1) / Tile::HEIGHT) * Tile::HEIGHT; + // Include one tile past the right edge so the right-edge tile column is + // covered by the edge-row wide-strip loop. + let x_end = right_tile_x.saturating_add(Tile::WIDTH); + + if x_end <= left_tile_x || y1 <= y0 { + return; + } + + let tile_start_y = y0 / Tile::HEIGHT; + let tile_end_y = y1 / Tile::HEIGHT; + + // A right strip is only needed when the rect spans more than one tile column. + let needs_right_strip = right_tile_x > left_tile_x; + + let left_x_cov = coverage(left_tile_x, rect_x0, rect_x1); + let right_x_cov = coverage(right_tile_x, rect_x0, rect_x1); + let left_x_mask = alpha_mask_from_x_coverage(s, &left_x_cov); + let right_x_mask = alpha_mask_from_x_coverage(s, &right_x_cov); + + for tile_y in tile_start_y..tile_end_y { + let strip_y = tile_y * Tile::HEIGHT; + let strip_y_f = strip_y as f32; + let strip_y_end_f = strip_y as f32 + Tile::HEIGHT as f32; + + // A row is an "edge" if the rect's top or bottom boundary falls + // *inside* it (i.e. partial vertical coverage). + let is_top_edge = strip_y_f < rect_y0 && rect_y0 < strip_y_end_f; + let is_bottom_edge = strip_y_f < rect_y1 && rect_y1 < strip_y_end_f; + + if is_top_edge || is_bottom_edge { + let alpha_start = alpha_buf.len() as u32; + + let y_cov = coverage(strip_y, rect_y0, rect_y1); + let mut col = left_tile_x; + // TODO: Can this result in an infinite loop in case x_end == u16::MAX? + while col + Tile::WIDTH <= x_end { + // TODO: We could optimize this so this is only computed for the left-most and right-most + // tile of the edge, all intermediate tiles have full horizontal coverage. + let x_cov = coverage(col, rect_x0, rect_x1); + let combined = combined_tile_alpha(s, &x_cov, &y_cov); + alpha_buf.extend_from_slice(combined.as_slice()); + col += Tile::WIDTH; + } + + strip_buf.push(Strip::new(left_tile_x, strip_y, alpha_start, false)); + } else { + let alpha_start = alpha_buf.len() as u32; + alpha_buf.extend_from_slice(left_x_mask.as_slice()); + strip_buf.push(Strip::new(left_tile_x, strip_y, alpha_start, false)); + + if needs_right_strip { + // `fill_gap = true` tells the renderer to fill solid 0xFF + // between the previous strip's end and this strip's start. + let alpha_start = alpha_buf.len() as u32; + alpha_buf.extend_from_slice(right_x_mask.as_slice()); + strip_buf.push(Strip::new(right_tile_x, strip_y, alpha_start, true)); + } + } + } + + // Sentinel strip: marks the end of the strip list for this shape. + let last_strip_y = (tile_end_y - 1) * Tile::HEIGHT; + strip_buf.push(Strip::new( + u16::MAX, + last_strip_y, + alpha_buf.len() as u32, + false, + )); +} + +/// Compute fractional pixel coverage for `N` consecutive pixels starting at `start`. +#[inline(always)] +fn coverage(start: u16, rect_lo: f32, rect_hi: f32) -> [f32; N] { + let mut cov = [0.0_f32; N]; + + #[allow(clippy::needless_range_loop, reason = "better clarity")] + for i in 0..N { + let px = (start as usize + i) as f32; + cov[i] = (rect_hi.min(px + 1.0) - rect_lo.max(px)).clamp(0.0, 1.0); + } + cov +} + +/// Build an alpha mask for the 4x4 tile from the given horizontal coverages, +/// splatting them across the other dimension. +#[inline(always)] +fn alpha_mask_from_x_coverage(s: S, cov: &[f32; Tile::WIDTH as usize]) -> u8x16 { + let mut buf = [0_u8; 16]; + + #[allow(clippy::needless_range_loop, reason = "better clarity")] + for col in 0..Tile::WIDTH as usize { + let alpha = (cov[col] * 255.0 + 0.5) as u8; + let base = col * Tile::HEIGHT as usize; + buf[base..base + Tile::HEIGHT as usize].fill(alpha); + } + + u8x16::from_slice(s, &buf) +} + +/// Compute the alphas for a single 4x4 tile, taking horizontal as well as vertical coverage +/// of the rectangle into account. +#[inline(always)] +fn combined_tile_alpha( + s: S, + x_cov: &[f32; Tile::WIDTH as usize], + y_cov: &[f32; Tile::HEIGHT as usize], +) -> u8x16 { + let mut buf = [0_u8; 16]; + for (col, xc) in x_cov.iter().copied().enumerate() { + for (row, yc) in y_cov.iter().copied().enumerate() { + buf[col * Tile::HEIGHT as usize + row] = (xc * yc * 255.0 + 0.5) as u8; + } + } + + u8x16::from_slice(s, &buf) +} diff --git a/vendor/vello_common/src/render_graph.rs b/vendor/vello_common/src/render_graph.rs new file mode 100644 index 000000000..4ddb871ec --- /dev/null +++ b/vendor/vello_common/src/render_graph.rs @@ -0,0 +1,616 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Render graph for multi-pass rendering with filter effects. +//! +//! This module provides a directed acyclic graph (DAG) representation of the rendering pipeline, +//! enabling efficient multi-pass rendering when layers have filter effects applied. +//! +//! # Overview +//! +//! The render graph tracks dependencies between rendering operations (nodes) and ensures they +//! execute in the correct order. Each node represents either: +//! - The root (backdrop) layer that forms the base for compositing +//! - A filtered layer that renders geometry, applies effects, and blends the result +//! +//! # Key Features +//! +//! - **Incremental execution order**: The graph builds its execution order as layers are popped +//! during scene encoding, avoiding the need for a full topological sort at render time. +//! - **Data dependencies**: Edges represent dependencies where one layer's output is consumed +//! as input to another operation (e.g., a filter reading from a previous layer's buffer). +//! - **Efficient traversal**: The pre-computed execution order allows O(1) iteration over nodes +//! in dependency order, with children always processed before their parents. +//! +//! # Usage Pattern +//! +//! 1. During scene encoding, nodes are added via [`RenderGraph::add_node`] as layers are created +//! 2. When layers with dependencies are popped, edges are added via [`RenderGraph::add_edge`] +//! 3. As each layer completes, it's recorded in execution order via [`RenderGraph::record_node_for_execution`] +//! 4. At render time, iterate nodes in order using [`RenderGraph::execution_order`] +//! +//! # Example Structure +//! +//! ```text +//! RootLayer (id=0) ← backdrop +//! │ +//! └─→ FilterLayer (id=1, blur filter) +//! │ +//! └─→ FilterLayer (id=2, drop shadow filter) +//! +//! Execution order: [2, 1, 0] (deepest children first, then parents) +//! ``` +//! +//! In this example, `FilterLayer 2` (drop shadow) is the most deeply nested child, so it +//! executes first. Then `FilterLayer 1` (blur) executes and can reference `FilterLayer 2`'s output +//! if needed. Finally, the `RootLayer` composites all filtered results into the final image. +//! +//! # Future Improvements +//! +//! TODO: Consider using `render_graph` for `FilterGraph` execution for filters. The render graph +//! infrastructure could potentially be extended to handle the internal DAG structure within +//! individual filter effects, not just layer-to-layer dependencies. + +use crate::coarse::WideTilesBbox; +use crate::filter_effects::Filter; +use crate::kurbo::Affine; +use alloc::vec; +use alloc::vec::Vec; +use hashbrown::HashMap; + +/// A render graph containing nodes and edges representing the rendering pipeline. +/// +/// This graph tracks layers with filter effects and their dependencies, enabling +/// multi-pass rendering where filter outputs can be used as inputs to subsequent operations. +#[derive(Debug, Clone)] +pub struct RenderGraph { + /// All render operations (layers) in the graph. + pub nodes: Vec, + /// Dependencies between nodes, defining execution order. + pub edges: Vec, + /// Counter for generating unique node IDs. + next_node_id: NodeId, + /// Pre-computed execution order collected during `pop_layer` calls. + /// This is built incrementally as layers are popped (children before parents), + /// avoiding the need for topological sort at render time. + node_execution_order: Vec, + /// The root layer node ID, tracked separately since it's never popped. + /// This is appended to the execution order when iterating. + root_node: Option, + /// Flag indicating whether the graph contains any filter layers. + /// Set to true when a `FilterLayer` node is added. + has_filters: bool, +} + +/// The type of dependency between nodes. +#[derive(Debug, Clone)] +pub enum DependencyKind { + /// Sequential execution: the `from` node must execute before the `to` node. + /// + /// The `to` node consumes output from the `from` node via the specified layer. + /// For example, a parent filter reads from a child layer buffer produced by a previous operation. + Sequential { + /// The layer that connects the two nodes, whose output flows from `from` to `to`. + layer_id: LayerId, + }, +} + +impl Default for RenderGraph { + fn default() -> Self { + Self::new() + } +} + +impl RenderGraph { + /// Creates a new empty render graph. + /// + /// The graph starts with no nodes or edges. Nodes are added via [`add_node`](Self::add_node) + /// as layers are created during scene encoding. + pub fn new() -> Self { + Self { + nodes: Vec::new(), + edges: Vec::new(), + next_node_id: 0, + node_execution_order: Vec::new(), + root_node: None, + has_filters: false, + } + } + + /// Clears the render graph state while preserving allocated capacity. + /// + /// This resets the graph to an empty state (equivalent to [`new`](Self::new)) + /// but reuses the existing memory allocations, avoiding the need to reallocate + /// when building a new scene. + /// + /// After calling `clear()`, the graph will have no nodes or edges, and counters + /// will be reset to their initial state. + pub fn clear(&mut self) { + self.nodes.clear(); + self.edges.clear(); + self.next_node_id = 0; + self.node_execution_order.clear(); + self.root_node = None; + self.has_filters = false; + } + + /// Add a node to the graph and return its ID. + /// + /// This is called during scene encoding when a new layer is created. The returned ID + /// should be stored with the layer to establish edges later when dependencies are known. + pub fn add_node(&mut self, kind: RenderNodeKind) -> NodeId { + let id = self.next_node_id; + self.next_node_id += 1; + + // Track root node separately since it's never popped and executes last + if matches!(kind, RenderNodeKind::RootLayer { .. }) { + self.root_node = Some(id); + } + + // Track if we have any filters to avoid scanning nodes later + if matches!(kind, RenderNodeKind::FilterLayer { .. }) { + self.has_filters = true; + } + + self.nodes.push(RenderNode { id, kind }); + id + } + + /// Add an edge between two nodes, establishing a dependency relationship. + /// + /// This is called when a layer with dependencies is popped during scene encoding. + /// The edge ensures that `from` node executes before `to` node, typically because + /// `to` needs to read data produced by `from` (e.g., a filter reading from a layer buffer). + pub fn add_edge(&mut self, from: NodeId, to: NodeId, kind: DependencyKind) { + self.edges.push(RenderEdge { from, to, kind }); + } + + /// Record a node as ready for execution in topological order. + /// + /// This is called from `pop_layer` to incrementally build the execution order + /// as layers are popped. Since layers are popped in a depth-first manner (children + /// before parents), this naturally builds the correct dependency order without + /// requiring a separate topological sort at render time. + /// + /// # Example + /// + /// For nested layers A → B → C (where C is the deepest child): + /// - C is popped first → recorded first + /// - B is popped second → recorded second + /// - A is popped last → recorded last + /// + /// This gives execution order [C, B, A], which respects dependencies. + pub fn record_node_for_execution(&mut self, node_id: NodeId) { + self.node_execution_order.push(node_id); + } + + /// Check if the graph contains any filter passes. + /// + /// Returns `true` if any layers have filter effects that need processing. + /// This is useful for determining whether to use the multi-pass rendering + /// pipeline (with filter support) or a simpler single-pass approach. + /// + /// This is set when filter nodes are added. + pub fn has_filters(&self) -> bool { + self.has_filters + } + + /// Get an iterator over nodes in execution order. + /// + /// Returns filter layers followed by the root layer, in the order they should be executed. + /// This order is built incrementally during `pop_layer` calls (children before parents), + /// avoiding the need for topological sort at render time. Returns an iterator to avoid allocation. + pub fn execution_order(&self) -> impl Iterator + '_ { + self.node_execution_order + .iter() + .copied() + .chain(self.root_node) + } + + /// Topologically sort nodes based on their dependencies using Kahn's algorithm. + /// + /// Returns nodes in execution order, ensuring all dependencies are satisfied before + /// each node is processed. This implements a standard topological sort with cycle detection. + /// + /// # Performance + /// + /// This is primarily used for validation and debugging. For normal rendering, + /// prefer [`execution_order`](Self::execution_order) which is pre-computed during + /// scene encoding and avoids the O(V+E) sorting overhead. + /// + /// # Panics + /// + /// Panics if the graph contains a cycle, which should never happen in a valid render graph. + #[allow( + dead_code, + reason = "we don't use currently topological sort but will in the future" + )] + pub fn topological_sort(&self) -> Vec { + // Track how many incoming edges each node has (dependencies it's waiting for) + let mut in_degree = vec![0; self.nodes.len()]; + + // Map each node to its dependents (nodes that depend on it) + let mut adj_list: HashMap> = HashMap::new(); + + // Build adjacency list and count incoming edges for each node + for edge in &self.edges { + adj_list.entry(edge.from).or_default().push(edge.to); + in_degree[edge.to] += 1; + } + + // Start with nodes that have no dependencies (in-degree = 0) + // These are safe to execute immediately + let mut queue: Vec = (0..self.nodes.len()) + .filter(|&i| in_degree[i] == 0) + .collect(); + + let mut result = Vec::new(); + + // Process nodes in dependency order (Kahn's algorithm) + while let Some(node_id) = queue.pop() { + result.push(node_id); + + // For each node that depends on this one, mark this dependency as satisfied + if let Some(neighbors) = adj_list.get(&node_id) { + for &neighbor in neighbors { + in_degree[neighbor] -= 1; + // If all dependencies are now satisfied, this node is ready to execute + if in_degree[neighbor] == 0 { + queue.push(neighbor); + } + } + } + } + + // If we didn't process all nodes, there's a cycle (which should never happen) + assert_eq!( + result.len(), + self.nodes.len(), + "Cycle detected in render graph" + ); + result + } +} + +/// A node in the render graph representing a single render operation. +#[derive(Debug, Clone)] +pub struct RenderNode { + /// Unique identifier for this node. + pub id: NodeId, + /// The type of render operation this node performs. + pub kind: RenderNodeKind, +} + +impl RenderNode { + /// Returns `true` if this node has an empty (zero-area) bounding box. + pub fn is_empty(&self) -> bool { + match &self.kind { + RenderNodeKind::RootLayer { wtile_bbox, .. } => wtile_bbox.is_empty(), + RenderNodeKind::FilterLayer { wtile_bbox, .. } => wtile_bbox.is_empty(), + } + } +} + +/// An edge representing a dependency between two nodes. +#[derive(Debug, Clone)] +pub struct RenderEdge { + /// The source node that must complete first. + pub from: NodeId, + /// The destination node that depends on the source. + pub to: NodeId, + /// The type of dependency relationship. + pub kind: DependencyKind, +} + +/// The type of operation a render node performs. +/// +/// Each variant represents a different kind of rendering pass in the pipeline. +/// Nodes are executed in dependency order to ensure correct compositing and filtering. +#[derive(Debug, Clone)] +pub enum RenderNodeKind { + /// The root (backdrop) layer that serves as the base for compositing. + /// + /// This layer is always present and is rendered last, after all filter layers have + /// been processed. It contains all non-filtered geometry and serves as the final + /// compositing target. + RootLayer { + /// ID of the root layer. + layer_id: LayerId, + /// Bounding box in wide tile coordinates covered by this layer. + wtile_bbox: WideTilesBbox, + }, + /// A layer with filter effects applied. + /// + /// This combines multiple passes: render geometry → apply filter → blend result. + /// Layers are added to the render graph only if they have filters. The filter + /// output may be consumed by other filter layers or composited into the root layer. + FilterLayer { + /// ID of this filtered layer. + layer_id: LayerId, + /// The filter effect to apply. + filter: Filter, + /// Bounding box in wide tile coordinates containing geometry for this layer. + wtile_bbox: WideTilesBbox, + /// Transform that was active when the layer was created. + /// Used to scale filter parameters based on the current scale/zoom level. + transform: Affine, + }, +} + +/// Unique identifier for a layer in the rendering system. +/// +/// Layer IDs are assigned by the encoding system and used to track layer buffers +/// across multiple render passes. A layer may or may not have a corresponding node +/// in the render graph (only layers with filters create graph nodes). +pub type LayerId = u32; + +/// Unique identifier for a node in the render graph. +/// +/// Node IDs are assigned sequentially as nodes are added during scene encoding. +/// They are used to reference nodes when adding edges and during execution order traversal. +pub type NodeId = usize; + +#[cfg(test)] +mod tests { + use super::*; + use crate::color::AlphaColor; + + #[test] + fn new_creates_empty_graph() { + let graph = RenderGraph::new(); + assert_eq!(graph.nodes.len(), 0); + assert_eq!(graph.edges.len(), 0); + assert!(!graph.has_filters()); + assert!(graph.root_node.is_none()); + } + + #[test] + fn clear_resets_to_empty_state() { + let mut graph = RenderGraph::new(); + graph.add_node(RenderNodeKind::RootLayer { + layer_id: 0, + wtile_bbox: dummy_bbox(), + }); + graph.add_node(RenderNodeKind::FilterLayer { + layer_id: 1, + filter: dummy_filter(), + wtile_bbox: dummy_bbox(), + transform: Affine::IDENTITY, + }); + graph.record_node_for_execution(1); + + graph.clear(); + + assert_eq!(graph.nodes.len(), 0); + assert_eq!(graph.edges.len(), 0); + assert_eq!(graph.next_node_id, 0); + assert!(graph.root_node.is_none()); + assert!(!graph.has_filters()); + assert_eq!(graph.execution_order().count(), 0); + } + + #[test] + fn clear_preserves_capacity() { + let mut graph = RenderGraph::new(); + let from = graph.add_node(RenderNodeKind::RootLayer { + layer_id: 0, + wtile_bbox: dummy_bbox(), + }); + let to = graph.add_node(RenderNodeKind::RootLayer { + layer_id: 1, + wtile_bbox: dummy_bbox(), + }); + graph.add_edge(from, to, DependencyKind::Sequential { layer_id: 1 }); + + let nodes_capacity = graph.nodes.capacity(); + let edges_capacity = graph.edges.capacity(); + + graph.clear(); + + assert!(graph.nodes.capacity() >= nodes_capacity); + assert!(graph.edges.capacity() >= edges_capacity); + } + + #[test] + fn add_node_returns_sequential_ids() { + let mut graph = RenderGraph::new(); + let id0 = graph.add_node(RenderNodeKind::RootLayer { + layer_id: 0, + wtile_bbox: dummy_bbox(), + }); + let id1 = graph.add_node(RenderNodeKind::FilterLayer { + layer_id: 1, + filter: dummy_filter(), + wtile_bbox: dummy_bbox(), + transform: Affine::IDENTITY, + }); + + assert_eq!(id0, 0); + assert_eq!(id1, 1); + } + + #[test] + fn add_node_tracks_root() { + let mut graph = RenderGraph::new(); + let root_id = graph.add_node(RenderNodeKind::RootLayer { + layer_id: 0, + wtile_bbox: dummy_bbox(), + }); + + assert_eq!(graph.root_node, Some(root_id)); + } + + #[test] + fn add_node_filter_sets_has_filters() { + let mut graph = RenderGraph::new(); + + graph.add_node(RenderNodeKind::FilterLayer { + layer_id: 1, + filter: dummy_filter(), + wtile_bbox: dummy_bbox(), + transform: Affine::IDENTITY, + }); + + assert!(graph.has_filters()); + } + + #[test] + fn add_node_root_does_not_set_has_filters() { + let mut graph = RenderGraph::new(); + + graph.add_node(RenderNodeKind::RootLayer { + layer_id: 0, + wtile_bbox: dummy_bbox(), + }); + + assert!(!graph.has_filters()); + } + + #[test] + fn add_edge_creates_dependency() { + let mut graph = RenderGraph::new(); + let from = graph.add_node(RenderNodeKind::FilterLayer { + layer_id: 1, + filter: dummy_filter(), + wtile_bbox: dummy_bbox(), + transform: Affine::IDENTITY, + }); + let to = graph.add_node(RenderNodeKind::RootLayer { + layer_id: 0, + wtile_bbox: dummy_bbox(), + }); + + graph.add_edge(from, to, DependencyKind::Sequential { layer_id: 1 }); + + assert_eq!(graph.edges.len(), 1); + assert_eq!(graph.edges[0].from, from); + assert_eq!(graph.edges[0].to, to); + } + + #[test] + fn add_edge_multiple_from_same_node() { + let mut graph = RenderGraph::new(); + let from = graph.add_node(RenderNodeKind::FilterLayer { + layer_id: 1, + filter: dummy_filter(), + wtile_bbox: dummy_bbox(), + transform: Affine::IDENTITY, + }); + let to1 = graph.add_node(RenderNodeKind::FilterLayer { + layer_id: 2, + filter: dummy_filter(), + wtile_bbox: dummy_bbox(), + transform: Affine::IDENTITY, + }); + let to2 = graph.add_node(RenderNodeKind::RootLayer { + layer_id: 0, + wtile_bbox: dummy_bbox(), + }); + + graph.add_edge(from, to1, DependencyKind::Sequential { layer_id: 1 }); + graph.add_edge(from, to2, DependencyKind::Sequential { layer_id: 1 }); + + assert_eq!(graph.edges.len(), 2); + } + + #[test] + fn execution_order_includes_recorded_nodes() { + let mut graph = RenderGraph::new(); + let filter = graph.add_node(RenderNodeKind::FilterLayer { + layer_id: 1, + filter: dummy_filter(), + wtile_bbox: dummy_bbox(), + transform: Affine::IDENTITY, + }); + + graph.record_node_for_execution(filter); + + let order: Vec<_> = graph.execution_order().collect(); + assert_eq!(order, vec![filter]); + } + + #[test] + fn execution_order_includes_only_root_when_present() { + let mut graph = RenderGraph::new(); + let root = graph.add_node(RenderNodeKind::RootLayer { + layer_id: 0, + wtile_bbox: dummy_bbox(), + }); + + let order: Vec<_> = graph.execution_order().collect(); + assert_eq!(order, vec![root]); + } + + #[test] + fn execution_order_root_comes_last() { + let mut graph = RenderGraph::new(); + let filter1 = graph.add_node(RenderNodeKind::FilterLayer { + layer_id: 1, + filter: dummy_filter(), + wtile_bbox: dummy_bbox(), + transform: Affine::IDENTITY, + }); + let filter2 = graph.add_node(RenderNodeKind::FilterLayer { + layer_id: 2, + filter: dummy_filter(), + wtile_bbox: dummy_bbox(), + transform: Affine::IDENTITY, + }); + let root = graph.add_node(RenderNodeKind::RootLayer { + layer_id: 0, + wtile_bbox: dummy_bbox(), + }); + + graph.record_node_for_execution(filter2); + graph.record_node_for_execution(filter1); + + let order: Vec<_> = graph.execution_order().collect(); + assert_eq!(order, vec![filter2, filter1, root]); + } + + #[test] + fn topological_sort_diamond_dependency() { + let mut graph = RenderGraph::new(); + let bottom = graph.add_node(RenderNodeKind::FilterLayer { + layer_id: 3, + filter: dummy_filter(), + wtile_bbox: dummy_bbox(), + transform: Affine::IDENTITY, + }); + let left = graph.add_node(RenderNodeKind::FilterLayer { + layer_id: 1, + filter: dummy_filter(), + wtile_bbox: dummy_bbox(), + transform: Affine::IDENTITY, + }); + let right = graph.add_node(RenderNodeKind::FilterLayer { + layer_id: 2, + filter: dummy_filter(), + wtile_bbox: dummy_bbox(), + transform: Affine::IDENTITY, + }); + let top = graph.add_node(RenderNodeKind::RootLayer { + layer_id: 0, + wtile_bbox: dummy_bbox(), + }); + + // Diamond: bottom -> left -> top, bottom -> right -> top + graph.add_edge(bottom, left, DependencyKind::Sequential { layer_id: 3 }); + graph.add_edge(bottom, right, DependencyKind::Sequential { layer_id: 3 }); + graph.add_edge(left, top, DependencyKind::Sequential { layer_id: 1 }); + graph.add_edge(right, top, DependencyKind::Sequential { layer_id: 2 }); + + let sorted = graph.topological_sort(); + assert_eq!(sorted, vec![bottom, right, left, top]); + } + + fn dummy_filter() -> Filter { + Filter::from_primitive(crate::filter_effects::FilterPrimitive::Flood { + color: AlphaColor::from_rgba8(0, 0, 0, 255), + }) + } + + fn dummy_bbox() -> WideTilesBbox { + WideTilesBbox::ZERO + } +} diff --git a/vendor/vello_common/src/render_state.rs b/vendor/vello_common/src/render_state.rs new file mode 100644 index 000000000..11e9056db --- /dev/null +++ b/vendor/vello_common/src/render_state.rs @@ -0,0 +1,56 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Shared render state. + +use crate::kurbo::{Affine, Cap, Join, Stroke}; +use crate::paint::{PaintType, Tint}; +use crate::peniko::color::palette::css::BLACK; +use crate::peniko::{BlendMode, Compose, Fill, Mix}; + +/// A render state which contains the style properties for path rendering and +/// the current transform. +#[derive(Debug, Clone)] +pub struct RenderState { + /// The paint type (solid color, gradient, or image). + pub paint: PaintType, + /// Transform applied to the paint coordinates. + pub paint_transform: Affine, + /// Stroke style for path stroking operations. + pub stroke: Stroke, + /// Transform applied to geometry. + pub transform: Affine, + /// Fill rule for path filling operations. + pub fill_rule: Fill, + /// Blend mode for compositing. + pub blend_mode: BlendMode, + /// The tint for image painting. + pub tint: Option, +} + +impl Default for RenderState { + fn default() -> Self { + Self { + paint: BLACK.into(), + paint_transform: Affine::IDENTITY, + stroke: Stroke { + width: 1.0, + join: Join::Bevel, + start_cap: Cap::Butt, + end_cap: Cap::Butt, + ..Default::default() + }, + transform: Affine::IDENTITY, + fill_rule: Fill::NonZero, + blend_mode: BlendMode::new(Mix::Normal, Compose::SrcOver), + tint: None, + } + } +} + +impl RenderState { + /// Reset to default state. + pub fn reset(&mut self) { + *self = Self::default(); + } +} diff --git a/vendor/vello_common/src/simd.rs b/vendor/vello_common/src/simd.rs new file mode 100644 index 000000000..d267fc33e --- /dev/null +++ b/vendor/vello_common/src/simd.rs @@ -0,0 +1,76 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! A number of SIMD extension traits. + +use fearless_simd::*; + +/// Splatting every 4th element in the vector, used for splatting the alpha value of +/// a color to all lanes. +pub trait Splat4thExt { + /// Splat every 4th element of the vector. + fn splat_4th(self) -> Self; +} + +impl Splat4thExt for f32x4 { + #[inline(always)] + fn splat_4th(self) -> Self { + // TODO: Explore whether it's just faster to manually access the 4th element and splat it. + let zip1 = self.zip_high(self); + zip1.zip_high(zip1) + } +} + +impl Splat4thExt for f32x8 { + #[inline(always)] + fn splat_4th(self) -> Self { + let (mut p1, mut p2) = self.simd.split_f32x8(self); + p1 = p1.splat_4th(); + p2 = p2.splat_4th(); + + self.simd.combine_f32x4(p1, p2) + } +} + +impl Splat4thExt for f32x16 { + #[inline(always)] + fn splat_4th(self) -> Self { + let (mut p1, mut p2) = self.simd.split_f32x16(self); + p1 = p1.splat_4th(); + p2 = p2.splat_4th(); + + self.simd.combine_f32x8(p1, p2) + } +} + +impl Splat4thExt for u8x16 { + #[inline(always)] + fn splat_4th(self) -> Self { + // TODO: SIMDify + [ + self[3], self[3], self[3], self[3], self[7], self[7], self[7], self[7], self[11], + self[11], self[11], self[11], self[15], self[15], self[15], self[15], + ] + .simd_into(self.simd) + } +} + +impl Splat4thExt for u8x32 { + #[inline(always)] + fn splat_4th(self) -> Self { + let (mut p1, mut p2) = self.simd.split_u8x32(self); + p1 = p1.splat_4th(); + p2 = p2.splat_4th(); + + self.simd.combine_u8x16(p1, p2) + } +} + +/// Splat each single element in the vector to 4 lanes. +#[inline(always)] +pub fn element_wise_splat(simd: S, input: f32x4) -> f32x16 { + simd.combine_f32x8( + simd.combine_f32x4(f32x4::splat(simd, input[0]), f32x4::splat(simd, input[1])), + simd.combine_f32x4(f32x4::splat(simd, input[2]), f32x4::splat(simd, input[3])), + ) +} diff --git a/vendor/vello_common/src/strip.rs b/vendor/vello_common/src/strip.rs new file mode 100644 index 000000000..315f1972e --- /dev/null +++ b/vendor/vello_common/src/strip.rs @@ -0,0 +1,535 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Rendering strips. + +use crate::flatten::Line; +use crate::peniko::Fill; +use crate::tile::{Tile, Tiles}; +use crate::util::f32_to_u8; +use alloc::vec::Vec; +use fearless_simd::*; + +/// A strip. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Strip { + /// The x coordinate of the strip, in user coordinates. + pub x: u16, + /// The y coordinate of the strip, in user coordinates. + pub y: u16, + /// Packed alpha index and fill gap flag. + /// + /// Bit layout (u32): + /// - bit 31: `fill_gap` (See `Strip::fill_gap()`). + /// - bits 0..=30: `alpha_idx` (See `Strip::alpha_idx()`). + packed_alpha_idx_fill_gap: u32, +} + +impl Strip { + /// The bit mask for `fill_gap` packed into `packed_alpha_idx_fill_gap`. + const FILL_GAP_MASK: u32 = 1 << 31; + + /// Creates a new strip. + pub fn new(x: u16, y: u16, alpha_idx: u32, fill_gap: bool) -> Self { + // Ensure `alpha_idx` does not collide with the fill flag bit. + assert!( + alpha_idx & Self::FILL_GAP_MASK == 0, + "`alpha_idx` too large" + ); + let fill_gap = u32::from(fill_gap) << 31; + Self { + x, + y, + packed_alpha_idx_fill_gap: alpha_idx | fill_gap, + } + } + + /// Return whether the strip is a sentinel strip. + pub fn is_sentinel(&self) -> bool { + self.x == u16::MAX + } + + /// Return the y coordinate of the strip, in strip units. + pub fn strip_y(&self) -> u16 { + self.y / Tile::HEIGHT + } + + /// Returns the alpha index. + #[inline(always)] + pub fn alpha_idx(&self) -> u32 { + self.packed_alpha_idx_fill_gap & !Self::FILL_GAP_MASK + } + + /// Sets the alpha index. + /// + /// Note that the largest value that can be stored in the alpha index is `u32::MAX << 1`, as the + /// highest bit is reserved for `fill_gap`. + #[inline(always)] + pub fn set_alpha_idx(&mut self, alpha_idx: u32) { + // Ensure `alpha_idx` does not collide with the fill flag bit. + assert!( + alpha_idx & Self::FILL_GAP_MASK == 0, + "`alpha_idx` too large" + ); + let fill_gap = self.packed_alpha_idx_fill_gap & Self::FILL_GAP_MASK; + self.packed_alpha_idx_fill_gap = alpha_idx | fill_gap; + } + + /// Returns whether the gap that lies between this strip and the previous in the same row should be filled. + #[inline(always)] + pub fn fill_gap(&self) -> bool { + (self.packed_alpha_idx_fill_gap & Self::FILL_GAP_MASK) != 0 + } + + /// Sets whether the gap that lies between this strip and the previous in the same row should be filled. + #[inline(always)] + pub fn set_fill_gap(&mut self, fill: bool) { + let fill = u32::from(fill) << 31; + self.packed_alpha_idx_fill_gap = + (self.packed_alpha_idx_fill_gap & !Self::FILL_GAP_MASK) | fill; + } + + /// When early culling is active, geometry fully to the left of the viewport creates no tiles. + /// However, if that geometry has a non-zero winding (e.g. a large shape surrounding the + /// viewport), then we must output strips for those fills. + /// + /// We reconstruct this "background" fill using `row_windings` (the winding at x=0) to emit solid + /// strips for: + /// 1. All rows vertically above the first visible tile. + /// 2. 'Captive' rows between two tile-containing rows. + /// 3. All rows vertically below the last visible tile. + #[inline(always)] + fn emit_culled_background( + start: u16, + end: u16, + strips: &mut Vec, + alphas: &mut Vec, + windings: &crate::tile::CulledWindings, + mut should_fill: F, + ) where + F: FnMut(i32) -> bool, + { + windings.for_active_rows_in_range(start as usize, end as usize, |row| { + if should_fill(windings.coarse[row] as i32) { + let y_pos = row as u16 * Tile::HEIGHT; + strips.push(Self::new(0, y_pos, alphas.len() as u32, false)); + alphas.extend([255_u8; Tile::HEIGHT as usize * Tile::WIDTH as usize]); + // TODO: Clamp to the scene width instead of u16::MAX; in the future there might + // not be clamping on the x. + strips.push(Self::new(u16::MAX, y_pos, alphas.len() as u32, true)); + } + }); + } +} + +/// Render the tiles stored in `tiles` into the strip and alpha buffer. +pub fn render( + level: Level, + tiles: &Tiles, + strip_buf: &mut Vec, + alpha_buf: &mut Vec, + fill_rule: Fill, + aliasing_threshold: Option, + lines: &[Line], +) { + dispatch!(level, simd => render_impl(simd, + tiles, + strip_buf, + alpha_buf, + fill_rule, + aliasing_threshold, + lines)); +} + +#[inline(always)] +fn render_impl( + s: S, + tiles: &Tiles, + strip_buf: &mut Vec, + alpha_buf: &mut Vec, + fill_rule: Fill, + aliasing_threshold: Option, + lines: &[Line], +) { + let row_windings = &tiles.windings.coarse; + let has_culled_tiles = tiles.has_culled_tiles(); + + // If no tiles were culled and the tile buffer is empty, we can simply exit. If tiles were + // culled, the tile buffer may be empty but there may be winding produced by culled geometry + // left of the viewport that must be checked for filling. + if !has_culled_tiles && tiles.is_empty() { + return; + } + + let should_fill = |winding: i32| match fill_rule { + Fill::NonZero => winding != 0, + Fill::EvenOdd => winding % 2 != 0, + }; + + // Helper to handle "captive strips". When a row has tiles, but the first tile + // is not at the left edge of the viewport (x != 0), we must emit a solid strip + // from x=0 to that tile if the coarse winding dictates a fill. + let emit_captive_strip = + |y: u16, is_left_viewport: bool, strips: &mut Vec, alphas: &mut Vec| { + let coarse_wd = tiles.windings.coarse[y as usize] as i32; + + if should_fill(coarse_wd) && !is_left_viewport { + strips.push(Strip::new(0, y * Tile::HEIGHT, alphas.len() as u32, false)); + alphas.extend([255_u8; Tile::HEIGHT as usize * Tile::WIDTH as usize]); + } + + let mut acc = f32x4::splat(s, coarse_wd as f32); + if is_left_viewport { + let fine_winding: f32x4<_> = tiles.windings.partial[y as usize].simd_into(s); + acc += fine_winding; + } + + (coarse_wd, acc) + }; + + // The accumulated tile winding delta. A line that crosses the top edge of a tile + // increments the delta if the line is directed upwards, and decrements it if goes + // downwards. Horizontal lines leave it unchanged. + let mut winding_delta: i32 = 0; + + // The previous tile visited. + let mut prev_tile = if has_culled_tiles && tiles.is_empty() { + Tile::SENTINEL + } else { + *tiles.get(0) + }; + + // The accumulated (fractional) winding of the tile-sized location we're currently at. + // Note multiple tiles can be at the same location. + // Note that we are also implicitly assuming here that the tile height exactly fits into a + // SIMD vector (i.e. 128 bits). + let mut location_winding = [f32x4::splat(s, 0.0); Tile::WIDTH as usize]; + // The accumulated (fractional) windings at this location's right edge. When we move to the + // next location, this is splatted to that location's starting winding. + let mut accumulated_winding = f32x4::splat(s, 0.0); + + let left_viewport = prev_tile.x == 0; + if has_culled_tiles { + let row_max = prev_tile.y.min(row_windings.len() as u16); + Strip::emit_culled_background( + 0, + row_max, + strip_buf, + alpha_buf, + &tiles.windings, + should_fill, + ); + if tiles.is_empty() { + return; + } + let (wd, acc) = emit_captive_strip(prev_tile.y, left_viewport, strip_buf, alpha_buf); + winding_delta = wd; + accumulated_winding = acc; + location_winding = [accumulated_winding; Tile::WIDTH as usize]; + } + + // The strip we're building. + let mut strip = Strip::new( + prev_tile.x * Tile::WIDTH, + prev_tile.y * Tile::HEIGHT, + alpha_buf.len() as u32, + should_fill(winding_delta) && !left_viewport, + ); + + for (tile_idx, tile) in tiles.iter().copied().chain([Tile::SENTINEL]).enumerate() { + let line = lines[tile.line_idx() as usize]; + let tile_left_x = f32::from(tile.x) * f32::from(Tile::WIDTH); + let tile_top_y = f32::from(tile.y) * f32::from(Tile::HEIGHT); + let p0_x = line.p0.x - tile_left_x; + let p0_y = line.p0.y - tile_top_y; + let p1_x = line.p1.x - tile_left_x; + let p1_y = line.p1.y - tile_top_y; + + // Push out the winding as an alpha mask when we move to the next location (i.e., a tile + // without the same location). + if !prev_tile.same_loc(&tile) { + match fill_rule { + Fill::NonZero => { + let p1 = f32x4::splat(s, 0.5); + let p2 = f32x4::splat(s, 255.0); + + #[expect(clippy::needless_range_loop, reason = "dimension clarity")] + for x in 0..Tile::WIDTH as usize { + let area = location_winding[x]; + let coverage = area.abs(); + let mulled = coverage.mul_add(p2, p1); + // Note that we are not storing the location winding here but the actual + // alpha value as f32, so we reuse the variable as a temporary storage. + // Also note that we need the `min` here because the winding can be > 1 + // and thus the calculated alpha value need to be clamped to 255. + location_winding[x] = mulled.min(p2); + } + } + Fill::EvenOdd => { + let p1 = f32x4::splat(s, 0.5); + let p2 = f32x4::splat(s, -2.0); + let p3 = f32x4::splat(s, 255.0); + + #[expect(clippy::needless_range_loop, reason = "dimension clarity")] + for x in 0..Tile::WIDTH as usize { + let area = location_winding[x]; + let im1 = area.mul_add(p1, p1).floor(); + let coverage = p2.mul_add(im1, area).abs(); + let mulled = p3.mul_add(coverage, p1); + // TODO: It is possible that, unlike for `NonZero`, we don't need the `min` + // here. + location_winding[x] = mulled.min(p3); + } + } + }; + + let p1 = s.combine_f32x4(location_winding[0], location_winding[1]); + let p2 = s.combine_f32x4(location_winding[2], location_winding[3]); + + let mut u8_vals = f32_to_u8(s.combine_f32x8(p1, p2)); + + if let Some(aliasing_threshold) = aliasing_threshold { + u8_vals = s.select_u8x16( + u8_vals.simd_ge(u8x16::splat(s, aliasing_threshold)), + u8x16::splat(s, 255), + u8x16::splat(s, 0), + ); + } + + alpha_buf.extend_from_slice(u8_vals.as_slice()); + + #[expect(clippy::needless_range_loop, reason = "dimension clarity")] + for x in 0..Tile::WIDTH as usize { + location_winding[x] = accumulated_winding; + } + } + + // Push out the strip if we're moving to a next strip. + if !prev_tile.same_loc(&tile) && !prev_tile.prev_loc(&tile) { + debug_assert_eq!( + (prev_tile.x as u32 + 1) * Tile::WIDTH as u32 - strip.x as u32, + ((alpha_buf.len() - strip.alpha_idx() as usize) / usize::from(Tile::HEIGHT)) as u32, + "The number of columns written to the alpha buffer should equal the number of columns spanned by this strip." + ); + strip_buf.push(strip); + + let is_sentinel = tile_idx == tiles.len() as usize; + let left_viewport = tile.x == 0; + if !prev_tile.same_row(&tile) { + // Emit a final strip in the row if there is non-zero winding for the sparse fill, + // or unconditionally if we've reached the sentinel tile to end the path (the + // `alpha_idx` field is used for width calculations). + if winding_delta != 0 || is_sentinel { + strip_buf.push(Strip::new( + u16::MAX, + prev_tile.y * Tile::HEIGHT, + alpha_buf.len() as u32, + should_fill(winding_delta), + )); + } + + // Logic identical to the start (see above): fill any vertical gaps (empty rows) + // between the previous and current tile using the row windings. + if has_culled_tiles && !is_sentinel { + Strip::emit_culled_background( + prev_tile.y + 1, + tile.y, + strip_buf, + alpha_buf, + &tiles.windings, + should_fill, + ); + + let (wd, acc) = emit_captive_strip(tile.y, left_viewport, strip_buf, alpha_buf); + winding_delta = wd; + accumulated_winding = acc; + } else { + winding_delta = 0; + accumulated_winding = f32x4::splat(s, 0.0); + }; + + #[expect(clippy::needless_range_loop, reason = "dimension clarity")] + for x in 0..Tile::WIDTH as usize { + location_winding[x] = accumulated_winding; + } + } else { + // Note: this fill is mathematically not necessary. It provides a way to reduce + // accumulation of float rounding errors. + accumulated_winding = f32x4::splat(s, winding_delta as f32); + } + + if is_sentinel { + break; + } + + strip = Strip::new( + tile.x * Tile::WIDTH, + tile.y * Tile::HEIGHT, + alpha_buf.len() as u32, + should_fill(winding_delta) && !left_viewport, + ); + } + prev_tile = tile; + + // TODO: horizontal geometry has no impact on winding. This branch will be removed when + // horizontal geometry is culled at the tile-generation stage. + if p0_y == p1_y { + continue; + } + + // Lines moving upwards (in a y-down coordinate system) add to winding; lines moving + // downwards subtract from winding. + let sign = (p0_y - p1_y).signum(); + + // Calculate winding / pixel area coverage. + // + // Conceptually, horizontal rays are shot from left to right. Every time the ray crosses a + // line that is directed upwards (decreasing `y`), the winding is incremented. Every time + // the ray crosses a line moving downwards (increasing `y`), the winding is decremented. + // The fractional area coverage of a pixel is the integral of the winding within it. + // + // Practically, to calculate this, each pixel is considered individually, and we determine + // whether the line moves through this pixel. The line's y-delta within this pixel is + // accumulated and added to the area coverage of pixels to the right. Within the pixel + // itself, the area to the right of the line segment forms a trapezoid (or a triangle in + // the degenerate case). The area of this trapezoid is added to the pixel's area coverage. + // + // For example, consider the following pixel square, with a line indicated by asterisks + // starting inside the pixel and crossing its bottom edge. The area covered is the + // trapezoid on the bottom-right enclosed by the line and the pixel square. The area is + // positive if the line moves down, and negative otherwise. + // + // __________________ + // | | + // | *------| + // | * | + // | * | + // | * | + // | * | + // | * | + // |___*____________| + // * + // * + + let (line_top_y, line_top_x, line_bottom_y, line_bottom_x) = if p0_y < p1_y { + (p0_y, p0_x, p1_y, p1_x) + } else { + (p1_y, p1_x, p0_y, p0_x) + }; + + let y_slope = (line_bottom_y - line_top_y) / (line_bottom_x - line_top_x); + let x_slope = 1. / y_slope; + + winding_delta += sign as i32 * i32::from(tile.winding()); + + let line_top_y = f32x4::splat(s, line_top_y); + let line_bottom_y = f32x4::splat(s, line_bottom_y); + + // See the explanation of this term on the `line_px_left_yx` and `line_px_right_yx` + // variables below. + let line_px_base_yx = line_top_y.mul_add(-x_slope, line_top_x); + + let px_top_y = f32x4::simd_from(s, [0., 1., 2., 3.]); + let px_bottom_y = 1. + px_top_y; + + let ymin = line_top_y.max(px_top_y); + let ymax = line_bottom_y.min(px_bottom_y); + + let mut acc = f32x4::splat(s, 0.0); + + for x_idx in 0..Tile::WIDTH { + let x_idx_s = f32x4::splat(s, x_idx as f32); + let px_left_x = x_idx_s; + let px_right_x = 1.0 + x_idx_s; + + // The y-coordinate of the intersections between the line and the pixel's left and + // right edges respectively. + // + // There is some subtlety going on here: `y_slope` will usually be finite, but will + // be `inf` for purely vertical lines (`p0_x == p1_x`). + // + // In the case of `inf`, the resulting slope calculation will be `-inf` or `inf` + // depending on whether the pixel edge is left or right of the line, respectively + // (from the viewport's coordinate system perspective). The `min` and `max` + // y-clamping logic generalizes nicely, as a pixel edge to the left of the line is + // clamped to `ymin`, and a pixel edge to the right is clamped to `ymax`. + // + // In the special case where a vertical line and pixel edge are at the exact same + // x-position (collinear), the line belongs to the pixel on whose _left_ edge it is + // situated. The resulting slope calculation for the edge the line is situated on + // will be NaN, as `0 * inf` results in NaN. This is true for both the left and + // right edge. + // + // We know `ymin` and `ymax` are finite. We require the `max` operation to pick `ymin` + // if its first operand is NaN. On x86, that maps to the semantics of `_mm_max_ps`, + // which `f32x4::max` emits: that instruction takes element-wise + // `if first > second { first } else { second }`. For AArch64, we do require the + // `f32x4::max_precise` semantics (as `vmax_f32` returns NaN if either operand is NaN); + // however, for AArch64 the precise version should be comparatively less expensive than + // on x86. For `min`, we then know both operands are finite, so we can unambiguously + // use the relaxed version. If this ever breaks, tests should fail loudly, because NaNs + // happen a lot here! + trait F32x4MaxExt { + fn max_if_first_nan_take_second(self, rhs: Self) -> Self; + } + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] + impl F32x4MaxExt for f32x4 { + #[inline(always)] + fn max_if_first_nan_take_second(self, rhs: Self) -> Self { + self.max(rhs) + } + } + #[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))] + impl F32x4MaxExt for f32x4 { + #[inline(always)] + fn max_if_first_nan_take_second(self, rhs: Self) -> Self { + self.max_precise(rhs) + } + } + let line_px_left_y = (px_left_x - line_top_x) + .mul_add(y_slope, line_top_y) + .max_if_first_nan_take_second(ymin) + .min(ymax); + let line_px_right_y = (px_right_x - line_top_x) + .mul_add(y_slope, line_top_y) + .max_if_first_nan_take_second(ymin) + .min(ymax); + + // For each pixel we calculate the x-coordinates of the left- and rightmost points on + // the line segment within that pixel. We do this based on the y-offsets of those two + // points from the top of the line. This can be calculated as, e.g., + // `(line_px_left_y - line_top_y) * x_slope + line_top_x`. + // + // Rather than calculating that y-offset twice for each pixel within the loop through + // subtracting from the points' y-coordinates, we get rid of that subtraction by baking + // it into the algebraic "base" x-coordinate `line_px_base_yx` calculated above the + // loop. When adding that term to `y * x_slope` it gives the x-coordinate of the point + // along the line. + // + // Note `x_slope` is always finite, as horizontal geometry is elided. + let line_px_left_yx = line_px_left_y.mul_add(x_slope, line_px_base_yx); + let line_px_right_yx = line_px_right_y.mul_add(x_slope, line_px_base_yx); + let h = (line_px_right_y - line_px_left_y).abs(); + + // The trapezoidal area enclosed between the line and the right edge of the pixel + // square. More straightforwardly written as follows, but the `madd` is faster. + // 0.5 * h * (2. * px_right_x - line_px_right_yx - line_px_left_yx). + let area = h * (line_px_right_yx + line_px_left_yx).mul_add(-0.5, px_right_x); + location_winding[x_idx as usize] += area.mul_add(sign, acc); + acc = h.mul_add(sign, acc); + } + + accumulated_winding += acc; + } + + if has_culled_tiles { + Strip::emit_culled_background( + (prev_tile.y + 1).min(row_windings.len() as u16), + row_windings.len() as u16, + strip_buf, + alpha_buf, + &tiles.windings, + should_fill, + ); + } +} diff --git a/vendor/vello_common/src/strip_generator.rs b/vendor/vello_common/src/strip_generator.rs new file mode 100644 index 000000000..9a8bfc237 --- /dev/null +++ b/vendor/vello_common/src/strip_generator.rs @@ -0,0 +1,440 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Abstraction for generating strips from paths. + +use crate::clip::{PathDataRef, intersect}; +use crate::fearless_simd::Level; +use crate::flatten::{FlattenCtx, Line}; +use crate::geometry::RectU16; +use crate::kurbo::{Affine, PathEl, Rect, Stroke}; +use crate::peniko::Fill; +use crate::strip::Strip; +use crate::tile::Tiles; +use crate::{flatten, rect, strip}; +use alloc::vec::Vec; +use peniko::kurbo::StrokeCtx; + +/// A storage for storing strip-related data. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct StripStorage { + /// The strips in the storage. + pub strips: Vec, + /// The alphas in the storage. + pub alphas: Vec, + generation_mode: GenerationMode, +} + +/// The generation mode of the strip storage. +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq)] +pub enum GenerationMode { + #[default] + /// Clear strips before generating the new ones. + Replace, + /// Don't clear strips, append to the existing buffer. + Append, + /// Truncate strips to the given index before generating new ones, + /// preserving strips in `[0..n]`. + ReplaceAfter(usize), +} + +impl StripStorage { + /// Create a new strip storage with the given generation mode. + pub fn new(generation_mode: GenerationMode) -> Self { + Self { + strips: Vec::new(), + alphas: Vec::new(), + generation_mode, + } + } + + /// Reset the storage. + pub fn clear(&mut self) { + self.strips.clear(); + self.alphas.clear(); + } + + /// Get the current generation mode. + pub fn generation_mode(&self) -> GenerationMode { + self.generation_mode + } + + /// Set the generation mode of the storage. + pub fn set_generation_mode(&mut self, mode: GenerationMode) { + self.generation_mode = mode; + } + + /// Whether the strip storage is empty. + pub fn is_empty(&self) -> bool { + self.strips.is_empty() && self.alphas.is_empty() + } + + /// Extend the current strip storage with the data from another storage. + pub fn extend(&mut self, other: &Self) { + self.strips.extend(&other.strips); + self.alphas.extend(&other.alphas); + } +} + +/// An object for easily generating strips for a filled/stroked path. +#[derive(Debug)] +pub struct StripGenerator { + pub(crate) level: Level, + line_buf: Vec, + flatten_ctx: FlattenCtx, + stroke_ctx: StrokeCtx, + temp_storage: StripStorage, + tiles: Tiles, + width: u16, + height: u16, +} + +impl StripGenerator { + /// Create a new strip generator. + pub fn new(width: u16, height: u16, level: Level) -> Self { + Self { + level, + line_buf: Vec::new(), + tiles: Tiles::new(level, height), + flatten_ctx: FlattenCtx::default(), + stroke_ctx: StrokeCtx::default(), + temp_storage: StripStorage::default(), + width, + height, + } + } + + /// Get this strip generator's viewport width. + #[inline(always)] + pub fn width(&self) -> u16 { + self.width + } + + /// Get this strip generator's viewport height. + #[inline(always)] + pub fn height(&self) -> u16 { + self.height + } + + /// Generate the strips for a filled path. + pub fn generate_filled_path( + &mut self, + path: impl IntoIterator, + fill_rule: Fill, + transform: Affine, + aliasing_threshold: Option, + strip_storage: &mut StripStorage, + clip_path: Option>, + ) { + let cull_bbox = clip_path + .map(|clip_path| clip_path.bbox) + .unwrap_or(RectU16::new(0, 0, self.width, self.height)); + flatten::fill( + self.level, + path, + transform, + &mut self.line_buf, + &mut self.flatten_ctx, + cull_bbox, + ); + + self.generate_with_clip(aliasing_threshold, strip_storage, fill_rule, clip_path); + } + + /// Generate the strips for a stroked path. + pub fn generate_stroked_path( + &mut self, + path: impl IntoIterator, + stroke: &Stroke, + transform: Affine, + aliasing_threshold: Option, + strip_storage: &mut StripStorage, + clip_path: Option>, + ) { + let cull_bbox = clip_path + .map(|clip_path| clip_path.bbox) + .unwrap_or(RectU16::new(0, 0, self.width, self.height)); + flatten::stroke( + self.level, + path, + stroke, + transform, + &mut self.line_buf, + &mut self.flatten_ctx, + &mut self.stroke_ctx, + cull_bbox, + ); + self.generate_with_clip(aliasing_threshold, strip_storage, Fill::NonZero, clip_path); + } + + fn generate_with_clip( + &mut self, + aliasing_threshold: Option, + strip_storage: &mut StripStorage, + fill_rule: Fill, + clip_path: Option>, + ) { + self.tiles + .make_tiles_analytic_aa(self.level, &self.line_buf, self.width, self.height); + + self.tiles.sort_tiles(); + + let level = self.level; + let tiles = &self.tiles; + let line_buf = &self.line_buf; + render_with_clip( + level, + &mut self.temp_storage, + strip_storage, + clip_path, + |strips, alphas| { + strip::render( + level, + tiles, + strips, + alphas, + fill_rule, + aliasing_threshold, + line_buf, + ); + }, + ); + } + + /// Generate strips directly for a pixel-aligned rectangle. + /// + /// This bypasses the full path processing pipeline (flatten -> tiles -> strips) + /// by directly creating strip coverage data for the rectangle. + pub fn generate_filled_rect_fast( + &mut self, + rect: &Rect, + strip_storage: &mut StripStorage, + clip_path: Option>, + ) { + let viewport = Rect::new(0.0, 0.0, self.width as f64, self.height as f64); + let clip_bbox = clip_path + .map(|clip| { + // Clip bbox is always guaranteed to be within viewport bounds, so no need to + // intersect again. + Rect::new( + f64::from(clip.bbox.x0), + f64::from(clip.bbox.y0), + f64::from(clip.bbox.x1), + f64::from(clip.bbox.y1), + ) + }) + .unwrap_or(viewport); + let clamped = rect.abs().intersect(clip_bbox); + + let level = self.level; + render_with_clip( + level, + &mut self.temp_storage, + strip_storage, + clip_path, + |strips, alphas| { + rect::render(level, clamped, strips, alphas); + }, + ); + } + + /// Reset the strip generator. + pub fn reset(&mut self) { + self.line_buf.clear(); + self.tiles.reset(); + self.temp_storage.clear(); + } +} + +/// Render strips via `render_fn` with optional clip intersection. +/// +/// When `clip_path` is `Some`, strips are rendered into `temp_storage` first, then +/// intersected with the clip mask into `strip_storage`. Otherwise strips are rendered +/// directly into `strip_storage`. +fn render_with_clip( + level: Level, + temp_storage: &mut StripStorage, + strip_storage: &mut StripStorage, + clip_path: Option>, + render_fn: impl FnOnce(&mut Vec, &mut Vec), +) { + match strip_storage.generation_mode { + GenerationMode::Replace => strip_storage.strips.clear(), + GenerationMode::Append => {} + GenerationMode::ReplaceAfter(n) => strip_storage.strips.truncate(n), + } + + if let Some(clip_path) = clip_path { + temp_storage.clear(); + + render_fn(&mut temp_storage.strips, &mut temp_storage.alphas); + + let path_data = PathDataRef { + strips: &temp_storage.strips, + alphas: &temp_storage.alphas, + bbox: RectU16::new(0, 0, u16::MAX, u16::MAX), + }; + intersect(level, clip_path, path_data, strip_storage); + } else { + render_fn(&mut strip_storage.strips, &mut strip_storage.alphas); + } +} + +#[cfg(test)] +mod tests { + use alloc::format; + + use crate::fearless_simd::Level; + use crate::kurbo::{Affine, Rect, Shape}; + use crate::peniko::Fill; + use crate::strip_generator::{StripGenerator, StripStorage}; + + #[test] + fn reset() { + let mut generator = StripGenerator::new(100, 100, Level::baseline()); + let mut storage = StripStorage::default(); + let rect = Rect::new(0.0, 0.0, 100.0, 100.0); + + generator.generate_filled_path( + rect.to_path(0.1), + Fill::NonZero, + Affine::IDENTITY, + None, + &mut storage, + None, + ); + + assert!(!generator.line_buf.is_empty()); + assert!(!storage.is_empty()); + + generator.reset(); + storage.clear(); + + assert!(generator.line_buf.is_empty()); + assert!(storage.is_empty()); + } + + /// Assert that `generate_filled_rect_fast` produces the same strips as the + /// path-based pipeline for the given rectangle. + fn assert_rect_fast_eq_path(rect: Rect, test_name: &str) { + let mut generator = StripGenerator::new(100, 100, Level::baseline()); + let mut storage_path = StripStorage::default(); + let mut storage_rect = StripStorage::default(); + + generator.generate_filled_path( + rect.to_path(0.1), + Fill::NonZero, + Affine::IDENTITY, + None, + &mut storage_path, + None, + ); + generator.reset(); + + generator.generate_filled_rect_fast(&rect, &mut storage_rect, None); + + assert_eq!( + storage_path.strips, storage_rect.strips, + "{test_name}: strips mismatch", + ); + assert_eq!( + storage_path.alphas, storage_rect.alphas, + "{test_name}: alphas mismatch", + ); + } + + #[test] + fn rect_small_single_tile() { + assert_rect_fast_eq_path(Rect::new(1.0, 1.0, 3.0, 3.0), "small_single_tile"); + } + + #[test] + fn rect_spanning_multiple_tiles_horizontally() { + assert_rect_fast_eq_path(Rect::new(2.0, 1.0, 14.0, 3.0), "spanning_horizontal"); + } + + #[test] + fn rect_spanning_multiple_tiles_vertically() { + assert_rect_fast_eq_path(Rect::new(1.0, 2.0, 3.0, 14.0), "spanning_vertical"); + } + + #[test] + fn rect_spanning_multiple_tiles_both_directions() { + assert_rect_fast_eq_path(Rect::new(2.0, 2.0, 18.0, 18.0), "spanning_both"); + } + + #[test] + fn rect_tile_aligned() { + assert_rect_fast_eq_path(Rect::new(0.0, 0.0, 8.0, 8.0), "tile_aligned"); + } + + #[test] + fn rect_one_pixel_wide() { + assert_rect_fast_eq_path(Rect::new(5.0, 2.0, 6.0, 12.0), "one_pixel_wide"); + } + + #[test] + fn rect_one_pixel_tall() { + assert_rect_fast_eq_path(Rect::new(2.0, 5.0, 12.0, 6.0), "one_pixel_tall"); + } + + #[test] + fn rect_fractional_within_single_tile() { + let cases: &[(f64, f64, f64, f64)] = &[ + (0.25, 0.75, 2.5, 3.5), + (1.2, 1.3, 1.8, 1.7), + (0.1, 0.1, 3.9, 3.9), + (2.5, 2.5, 2.6, 2.6), + (0.01, 0.99, 3.99, 3.01), + ]; + for (i, &(x0, y0, x1, y1)) in cases.iter().enumerate() { + assert_rect_fast_eq_path(Rect::new(x0, y0, x1, y1), &format!("single_tile_{i}")); + } + } + + #[test] + fn rect_fractional_multi_tile() { + let cases: &[(f64, f64, f64, f64)] = &[ + (1.5, 2.3, 10.7, 8.9), + (0.5, 0.5, 8.5, 8.5), + (2.3, 5.1, 15.7, 5.9), + (5.1, 2.3, 5.9, 15.7), + (0.25, 0.25, 12.75, 12.75), + (1.0 / 3.0, 2.0 / 3.0, 10.33, 8.67), + (1.99, 2.01, 9.01, 7.99), + (3.9, 3.9, 8.1, 8.1), + (3.2, 6.3, 14.8, 6.7), + (6.3, 3.2, 6.7, 14.8), + (0.1, 0.9, 49.9, 49.1), + (4.0, 2.7, 12.0, 9.3), + (2.7, 4.0, 9.3, 12.0), + (1.5, 1.2, 10.5, 2.8), + (1.5, 2.5, 14.5, 18.5), + (0.7, 0.3, 30.2, 25.8), + (7.9, 7.9, 8.1, 8.1), + (3.5, 0.5, 4.5, 0.9), + (0.01, 0.01, 99.99, 99.99), + (10.0, 10.0, 10.1, 10.1), + ]; + for (i, &(x0, y0, x1, y1)) in cases.iter().enumerate() { + assert_rect_fast_eq_path(Rect::new(x0, y0, x1, y1), &format!("multi_tile_{i}")); + } + } + + #[test] + fn rect_fractional_exhaustive() { + for xi in 0..100_u32 { + for yi in 0..100_u32 { + let dx = xi as f64 * 0.01; + let dy = yi as f64 * 0.01; + let rect = Rect::new(dx, dy, 50.0 + dx, 50.0 + dy); + assert_rect_fast_eq_path(rect, &format!("exhaustive_{dx}_{dy}")); + } + } + } + + #[test] + fn rect_inverted_both_axes() { + assert_rect_fast_eq_path(Rect::new(18.0, 18.0, 2.0, 2.0), "inverted_both_axes"); + } +} diff --git a/vendor/vello_common/src/tile.rs b/vendor/vello_common/src/tile.rs new file mode 100644 index 000000000..92e3da418 --- /dev/null +++ b/vendor/vello_common/src/tile.rs @@ -0,0 +1,2426 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Primitives for creating tiles. + +use crate::flatten::Line; +use alloc::vec; +use alloc::vec::Vec; +use fearless_simd::*; +#[cfg(not(feature = "std"))] +use peniko::kurbo::common::FloatFuncs as _; + +/// T-op bit +const T: u32 = 0b00001; +/// B-ottom bit +const B: u32 = 0b00010; +/// L-eft bit +const L: u32 = 0b00100; +/// R-ight bit +const R: u32 = 0b01000; +/// W-inding bit +const W: u32 = 0b10000; + +/// Shift amount corresponding to the bottom bit. +const BOT_SHIFT: u32 = B.trailing_zeros(); +/// Shift amount corresponding to the left bit. +const LEFT_SHIFT: u32 = L.trailing_zeros(); +/// Shift amount corresponding to the right bit. +const RIGHT_SHIFT: u32 = R.trailing_zeros(); +/// Shift amount corresponding to the winding bit. +const WINDING_SHIFT: u32 = W.trailing_zeros(); + +/// Mask for all intersection and winding bits (Bits 0-4). +const INTERSECTION_MASK: u32 = W | R | L | B | T; +/// Shift amount corresponding to the intersection bits. +const INT_MASK_SHIFT: u32 = INTERSECTION_MASK.count_ones(); + +/// The max number of lines per path. +/// +/// Trying to render a path with more lines than this may result in visual artifacts. +pub const MAX_LINES_PER_PATH: u32 = 1 << (32 - INT_MASK_SHIFT); + +/// A logical grouping of arrays used for culled tile processing, +#[derive(Debug, Clone, Default)] +pub struct CulledWindings { + /// Fractional winding coverage for each individual scanline in a row. + pub partial: Vec<[f32; Tile::HEIGHT as usize]>, + // Note that this will cause issues if we have windings greater/less than i8, + // but this should only occur in pathological cases. + /// Accumulated integer winding deltas for each tile row. + pub coarse: Vec, + /// Bitmask tracking which rows contain active geometry or winding data. + pub active: Vec, + /// Flag indicating if any geometry was early-culled outside the viewport. + pub culled: bool, +} + +impl CulledWindings { + /// Number of bits in a single active mask word. + const WORD_BITS: usize = 32; + /// Bit shift equivalent to dividing by `WORD_BITS` (2^5 = 32). + const WORD_SHIFT: usize = 5; + /// Bitmask equivalent to modulo `WORD_BITS` (32 - 1 = 31). + const WORD_MASK: usize = 31; + + /// Constructor chained to `Tiles`' constructor and matches its lifetime. Since `Tiles` itself + /// matches the lifetime of `StripGenerator`, we know that the viewport dimensions will never + /// change, and thus the backing vecs never need to be resized. (For now). + pub fn new(height: u16) -> Self { + let height_usize = height as usize; + let tile_height = Tile::HEIGHT as usize; + let num_rows = height_usize.div_ceil(tile_height); + let num_bits = num_rows.div_ceil(Self::WORD_BITS); + + Self { + partial: vec![[0.0; Tile::HEIGHT as usize]; num_rows], + coarse: vec![0; num_rows], + active: vec![0; num_bits], + culled: false, + } + } + + /// Clears but does not resize + pub fn reset(&mut self) { + // TODO: Maybe consider tracking touched regions and only resetting those + // instead of always the full array? + if self.culled { + self.partial.fill([0.0; Tile::HEIGHT as usize]); + self.coarse.fill(0); + self.active.fill(0); + self.culled = false; + } + } + + /// Marks if a row was culled early for faster traversal in strip generation. + #[inline(always)] + pub fn mark_row_active(&mut self, row_idx: usize) { + self.active[row_idx >> Self::WORD_SHIFT] |= 1 << (row_idx & Self::WORD_MASK); + } + + /// Bulk marks a range of rows as active [`start_row`, `end_row`). + #[inline(always)] + pub fn mark_row_range_active(&mut self, start_row: usize, end_row: usize) { + if start_row >= end_row { + return; + } + + let start_word = start_row >> Self::WORD_SHIFT; + let end_word = (end_row - 1) >> Self::WORD_SHIFT; + + if start_word == end_word { + // All bits fall within the same u32 word + let shift = start_row & Self::WORD_MASK; + let count = end_row - start_row; + let mask = if count == Self::WORD_BITS { + u32::MAX + } else { + ((1_u32 << count) - 1) << shift + }; + self.active[start_word] |= mask; + } else { + // Bits span multiple words: handle start, full middle words, and end + self.active[start_word] |= u32::MAX << (start_row & Self::WORD_MASK); + + self.active[(start_word + 1)..end_word].fill(u32::MAX); + + let end_shift = ((end_row - 1) & Self::WORD_MASK) + 1; + let mask = if end_shift == Self::WORD_BITS { + u32::MAX + } else { + (1_u32 << end_shift) - 1 + }; + self.active[end_word] |= mask; + } + } + + /// Calls `f` on active rows in the range [`start`, `end`). + #[inline(always)] + pub fn for_active_rows_in_range(&self, start: usize, end: usize, mut f: F) + where + F: FnMut(usize), + { + if start >= end { + return; + } + + let start_word = start >> Self::WORD_SHIFT; + let end_word = (end - 1) >> Self::WORD_SHIFT; + + let mut process_word = |mut word: u32, word_idx: usize| { + while word != 0 { + let bit = word.trailing_zeros(); + word &= !(1_u32 << bit); + f((word_idx << Self::WORD_SHIFT) + bit as usize); + } + }; + + let end_limit = ((end - 1) & Self::WORD_MASK) + 1; + let end_mask = if end_limit == Self::WORD_BITS { + u32::MAX + } else { + (1_u32 << end_limit) - 1 + }; + + // Start Word + { + let mut word = self.active[start_word]; + let start_bit = start & Self::WORD_MASK; + word &= !((1_u32 << start_bit) - 1); + + if start_word == end_word { + word &= end_mask; + } + + process_word(word, start_word); + } + + // Middle Words & End Word + if start_word < end_word { + for (word_idx, &word_val) in self + .active + .iter() + .enumerate() + .take(end_word) + .skip(start_word + 1) + { + process_word(word_val, word_idx); + } + + // End Word + process_word(self.active[end_word] & end_mask, end_word); + } + } +} + +/// A tile represents an aligned area on the pixmap, used to subdivide the viewport into sub-areas +/// (currently 4x4) and analyze line intersections inside each such area. +/// +/// Keep in mind that it is possible to have multiple tiles with the same index, +/// namely if we have multiple lines crossing the same 4x4 area! +/// +/// # Note +/// +/// This struct is `#[repr(C)]`, but the byte order of its fields is dependent on the endianness of +/// the compilation target. +#[derive(Debug, Clone, Copy)] +#[repr(C)] +pub struct Tile { + // The field ordering is important. + // + // The given ordering (variant over little and big endian compilation targets), ensures that + // `Tile::to_bits` doesn't do any actual work, as the ordering of the fields is such that the + // numeric value of a `Tile` in memory is identical as returned by that method. This allows + // for, e.g., comparison and sorting. + #[cfg(target_endian = "big")] + /// The index of the tile in the y direction. + pub y: u16, + + #[cfg(target_endian = "big")] + /// The index of the tile in the x direction. + pub x: u16, + + /// The index of the line this tile belongs to into the line buffer, intersection data, + /// and winding data packed together. + /// + /// The layout is: + /// - **Bits 0-4 (5 bits):** Intersection and Winding Mask (`W | R | L | B | T`). + /// - Bit 0 (mask `0b00001`): Intersects top edge (T) + /// - Bit 1 (mask `0b00010`): Intersects bottom edge (B) + /// - Bit 2 (mask `0b00100`): Intersects left edge (L) + /// - Bit 3 (mask `0b01000`): Intersects right edge (R) + /// - Bit 4 (mask `0b10000`): Winding (W) - 1 if crosses top edge. + /// - **Bits 5-31 (27 bits):** The line index (`line_idx`). + /// + /// **Sorting Note:** The `line_idx` occupies the higher bits to ensure that when sorting + /// tiles with the same (x, y) coordinates, they are sorted by their line index first, + /// and then by their intersection mask. + pub packed_winding_line_idx: u32, + + #[cfg(target_endian = "little")] + /// The index of the tile in the x direction. + pub x: u16, + + #[cfg(target_endian = "little")] + /// The index of the tile in the y direction. + pub y: u16, +} + +impl Tile { + /// The width of a tile in pixels. + pub const WIDTH: u16 = 4; + + /// The height of a tile in pixels. + pub const HEIGHT: u16 = 4; + + /// A special tile used to signal the end of a tile stream during rendering. + pub const SENTINEL: Self = Self::new(u16::MAX, u16::MAX, 0, 0); + + /// Create a new tile. + /// `x` and `y` will be clamped to the largest possible coordinate if they are too large. + /// + /// `line_idx` must be smaller than [`MAX_LINES_PER_PATH`]. + #[inline] + pub fn new_clamped(x: u16, y: u16, line_idx: u32, intersection_mask: u32) -> Self { + Self::new( + // Make sure that x and y stay in range when multiplying + // with the tile width and height during strips generation. + x.min(u16::MAX / Self::WIDTH), + y.min(u16::MAX / Self::HEIGHT), + line_idx, + intersection_mask, + ) + } + + /// The base tile constructor + /// + /// Unlike [`Self::new_clamped`], this constructor stores `x` and `y` exactly as provided. + /// Callers must ensure these coordinates do not exceed the limits required by downstream + /// processing (typically `u16::MAX / WIDTH` and `u16::MAX / HEIGHT`). + #[inline] + pub const fn new(x: u16, y: u16, line_idx: u32, intersection_mask: u32) -> Self { + #[cfg(debug_assertions)] + if line_idx >= MAX_LINES_PER_PATH { + panic!("Max. number of lines per path exceeded."); + } + // The intersection_mask is expected to contain bits 0-4 (T, B, L, R, W). + // We pack line_idx into the high bits (5-31) and intersection_mask into low bits (0-4). + Self { + x, + y, + packed_winding_line_idx: (line_idx << INT_MASK_SHIFT) | intersection_mask, + } + } + + /// Check whether two tiles are at the same location. + #[inline] + pub const fn same_loc(&self, other: &Self) -> bool { + self.same_row(other) && self.x == other.x + } + + /// Check whether `self` is adjacent to the left of `other`. + #[inline] + pub const fn prev_loc(&self, other: &Self) -> bool { + self.same_row(other) && self.x + 1 == other.x + } + + /// Check whether two tiles are on the same row. + #[inline] + pub const fn same_row(&self, other: &Self) -> bool { + self.y == other.y + } + + /// The index of the line this tile belongs to into the line buffer. + /// + /// Returns the high 27 bits. + #[inline] + pub const fn line_idx(&self) -> u32 { + self.packed_winding_line_idx >> INT_MASK_SHIFT + } + + /// Whether the line crosses the top edge of the tile. + /// + /// Lines making this crossing increment or decrement the coarse tile winding, depending on the + /// line direction. + /// + /// Checks Bit 4 (Winding). + #[inline] + pub const fn winding(&self) -> bool { + (self.packed_winding_line_idx & W) != 0 + } + + /// The 5 bits of intersection and winding data. + #[inline] + pub const fn intersection_mask(&self) -> u32 { + self.packed_winding_line_idx & INTERSECTION_MASK + } + + /// Whether the line intersects the top edge of the tile. + #[inline] + pub const fn intersects_top(&self) -> bool { + (self.intersection_mask() & T) != 0 + } + + /// Whether the line intersects the bottom edge of the tile. + #[inline] + pub const fn intersects_bottom(&self) -> bool { + (self.intersection_mask() & B) != 0 + } + + /// Whether the line intersects the left edge of the tile. + #[inline] + pub const fn intersects_left(&self) -> bool { + (self.intersection_mask() & L) != 0 + } + + /// Whether the line intersects the right edge of the tile. + #[inline] + pub const fn intersects_right(&self) -> bool { + (self.intersection_mask() & R) != 0 + } + + /// Return the `u64` representation of this tile. + /// + /// This is the u64 interpretation of `(y, x, packed_winding_line_idx)` where `y` is the + /// most-significant part of the number and `packed_winding_line_idx` the least significant. + #[inline(always)] + const fn to_bits(self) -> u64 { + // Note that for correct rendering, tiles only need to be sorted on `(y, x)`. Sorting on + // the line index in addition to the coordinate improves data locality in strip rendering. + // This is trading off increased sorting time for decreased strip rendering time. How the + // trade-off falls is scene-dependent. + // + // This operation compiles to a no-op: `Tile`'s field order is such that this is exactly + // the in-memory representation. + ((self.y as u64) << 48) | ((self.x as u64) << 32) | self.packed_winding_line_idx as u64 + } + + /// Whether a tile is a sentinel tile + // + // A tile produced organically by a make_tiles call can never have this coordinate because of + // the division by tile size on creation, so checking on x is sufficient to identify it. + #[inline(always)] + pub const fn is_sentinel(&self) -> bool { + self.x == u16::MAX + } +} + +impl PartialEq for Tile { + #[inline(always)] + fn eq(&self, other: &Self) -> bool { + self.to_bits() == other.to_bits() + } +} + +impl Ord for Tile { + #[inline(always)] + fn cmp(&self, other: &Self) -> core::cmp::Ordering { + self.to_bits().cmp(&other.to_bits()) + } +} + +impl PartialOrd for Tile { + #[inline(always)] + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Eq for Tile {} + +/// Handles the tiling of paths. +#[derive(Clone, Debug)] +pub struct Tiles { + tile_buf: Vec, + level: Level, + sorted: bool, + /// Auxiliary data tracking row windings and active rows for early culling. + pub windings: CulledWindings, +} + +impl Tiles { + /// Create a new tiles container. + pub fn new(level: Level, height: u16) -> Self { + Self { + tile_buf: vec![], + level, + sorted: false, + windings: CulledWindings::new(height), + } + } + + /// Get the number of tiles in the container. + pub fn len(&self) -> u32 { + self.tile_buf.len() as u32 + } + + /// Returns `true` if the container has no tiles. + pub fn is_empty(&self) -> bool { + self.tile_buf.is_empty() + } + + /// Returns `true` if any geometry was early-culled outside the viewport. + pub fn has_culled_tiles(&self) -> bool { + self.windings.culled + } + + /// Reset the tiles' container. + pub fn reset(&mut self) { + self.windings.reset(); + self.tile_buf.clear(); + self.sorted = false; + } + + /// Sort the tiles in the container. + pub fn sort_tiles(&mut self) { + self.sorted = true; + // To enable auto-vectorization. + self.level.dispatch(|_| self.tile_buf.sort_unstable()); + } + + /// Get the tile at a certain index. + /// + /// Panics if the container hasn't been sorted before. + #[inline] + pub fn get(&self, index: u32) -> &Tile { + assert!( + self.sorted, + "attempted to call `get` before sorting the tile container." + ); + + &self.tile_buf[index as usize] + } + + /// Iterate over the tiles in sorted order. + /// + /// Panics if the container hasn't been sorted before. + #[inline] + pub fn iter(&self) -> impl Iterator { + assert!( + self.sorted, + "attempted to call `iter` before sorting the tile container." + ); + + self.tile_buf.iter() + } + + /// Generates tile commands for Analytic Anti-Aliasing rasterization. Unlike the MSAA path, this + /// function performs "coarse binning" to simply identify every tile a line segment traverses. + /// It encodes the line index and winding direction, delegating the precise calculation of pixel + /// coverage to `strip::render`. + pub fn make_tiles_analytic_aa( + &mut self, + level: Level, + lines: &[Line], + width: u16, + height: u16, + ) -> bool { + dispatch!(level, simd => self.make_tiles_analytic_aa_impl::<_>( + simd, + lines, + width, + height, + )) + } + + fn make_tiles_analytic_aa_impl( + &mut self, + s: S, + lines: &[Line], + width: u16, + height: u16, + ) -> bool { + self.reset(); + + if width == 0 || height == 0 { + return self.windings.culled; + } + + debug_assert!( + lines.len() <= MAX_LINES_PER_PATH as usize, + "Max. number of lines per path exceeded. Max is {}, got {}.", + MAX_LINES_PER_PATH, + lines.len() + ); + + let tile_columns = width.div_ceil(Tile::WIDTH); + let tile_rows = height.div_ceil(Tile::HEIGHT); + + let px_top = f32x4::from_slice(s, &[0.0, 1.0, 2.0, 3.0]); + let px_bottom = px_top + f32x4::splat(s, 1.0); + let simd_zero = f32x4::splat(s, 0.0); + let tile_height_f32 = Tile::HEIGHT as f32; + + for (line_idx, line) in lines.iter().take(MAX_LINES_PER_PATH as usize).enumerate() { + let line_idx = line_idx as u32; + + let p0_x = line.p0.x / f32::from(Tile::WIDTH); + let p0_y = line.p0.y / f32::from(Tile::HEIGHT); + let p1_x = line.p1.x / f32::from(Tile::WIDTH); + let p1_y = line.p1.y / f32::from(Tile::HEIGHT); + + let (line_left_x, line_right_x) = if p0_x < p1_x { + (p0_x, p1_x) + } else { + (p1_x, p0_x) + }; + + // Lines whose left-most endpoint exceed the right edge of the viewport are culled + if line_left_x > tile_columns as f32 { + continue; + } + + let (line_top_y, line_top_x, line_bottom_y, line_bottom_x) = if p0_y < p1_y { + (p0_y, p0_x, p1_y, p1_x) + } else { + (p1_y, p1_x, p0_y, p0_x) + }; + + // The `as u16` casts here intentionally clamp negative coordinates to 0. + let y_top_tiles = (line_top_y as u16).min(tile_rows); + let line_bottom_y_ceil = line_bottom_y.ceil(); + let y_bottom_tiles = (line_bottom_y_ceil as u16).min(tile_rows); + + // If y_top_tiles == y_bottom_tiles, then the line is either completely above or below + // the viewport OR it is perfectly horizontal and aligned to the tile grid, contributing + // no winding. In either case, it should be culled. + if y_top_tiles >= y_bottom_tiles { + // Technically, the `>` part of the `>=` is unnecessary due to clamping, but this + // gives stronger signal + continue; + } + + let dir = if p0_y >= p1_y { 1 } else { -1 }; + let f_dir = dir as f32; + let f_dir_v = f32x4::splat(s, f_dir); + + macro_rules! calc_fractional_coverage { + ($y_idx:expr, $segment_top_y:expr, $segment_bottom_y:expr) => {{ + let y_idx_f32 = f32::from($y_idx); + let local_y_start = ($segment_top_y - y_idx_f32) * tile_height_f32; + let local_y_end = ($segment_bottom_y - y_idx_f32) * tile_height_f32; + + let start_v = f32x4::splat(s, local_y_start); + let end_v = f32x4::splat(s, local_y_end); + + (px_bottom.min(end_v) - px_top.max(start_v)).max(simd_zero) + }}; + } + + // Lines fully to the left of the viewport are not visible but still produce winding + // which we record here and forward to the rendering stage. + if line_right_x < 0.0 { + let is_start_culled = line_top_y < 0.0; + + // This branch is for handling the "start" of the line. In case + // the line reaches above the viewport, we are already in the + // middle so we can skip that part. + if !is_start_culled { + self.windings.mark_row_active(y_top_tiles as usize); + + // Note: In theory, == should be enough, but just as + // additional safety against numerical precision errors we + // use <=. + let at_top_of_tile = line_top_y <= f32::from(y_top_tiles); + if at_top_of_tile { + self.windings.coarse[y_top_tiles as usize] += dir; + } + + let fractional_coverage = + calc_fractional_coverage!(y_top_tiles, line_top_y, line_bottom_y); + let target_row = &mut self.windings.partial[y_top_tiles as usize]; + let current = f32x4::from_slice(s, target_row); + + // See comment below on the double counting risk! + let double_count = if at_top_of_tile { + f_dir_v + } else { + f32x4::splat(s, 0.0) + }; + let next = fractional_coverage.mul_add(f_dir_v, current - double_count); + next.store_slice(target_row); + } + + let y_start_middle = if is_start_culled { + y_top_tiles + } else { + y_top_tiles + 1 + }; + let line_bottom_floor = line_bottom_y.floor(); + let y_end_middle = (line_bottom_floor as u16).min(tile_rows); + + for y_idx in y_start_middle..y_end_middle { + self.windings.coarse[y_idx as usize] += dir; + } + self.windings + .mark_row_range_active(y_start_middle as usize, y_end_middle as usize); + + if line_bottom_y != line_bottom_floor + && y_end_middle < tile_rows + // Prevent double-processing, unless the start was off-screen and hasn't been + // handled yet. + && (is_start_culled || y_end_middle != y_top_tiles) + { + self.windings.mark_row_active(y_end_middle as usize); + // Ends implicitly cross the top. + self.windings.coarse[y_end_middle as usize] += dir; + let fractional_coverage = + calc_fractional_coverage!(y_end_middle, line_top_y, line_bottom_y); + let target_row = &mut self.windings.partial[y_end_middle as usize]; + let current = f32x4::from_slice(s, target_row); + // Subtract the inverse direction to avoid double counting with the coarse winding. + let next = fractional_coverage.mul_add(f_dir_v, current - f_dir_v); + next.store_slice(target_row); + } + + self.windings.culled = true; + continue; + } + + // Get tile coordinates for start/end points, use i32 to preserve negative coordinates. + let p0_tile_x = line_top_x.floor() as i32; + let p0_tile_y = line_top_y.floor() as i32; + let p1_tile_x = line_bottom_x.floor() as i32; + let p1_tile_y = line_bottom_y.floor() as i32; + + // Special-case out lines which are fully contained within a tile. + let not_same_tile = p0_tile_y != p1_tile_y || p0_tile_x != p1_tile_x; + if not_same_tile { + // Case vertical lines: By definition, these cannot be horizontally crossing, and + // thus require no additional left-edge culling handling. + if line_left_x == line_right_x { + let x = (line_left_x as u16).min(tile_columns.saturating_sub(1)); + + // Row Start, not culled. + let is_start_culled = line_top_y < 0.0; + if !is_start_culled { + let winding = + ((f32::from(y_top_tiles) >= line_top_y) as u32) << WINDING_SHIFT; + let tile = Tile::new_clamped(x, y_top_tiles, line_idx, winding); + self.tile_buf.push(tile); + } + + // Middle + // If the start was culled, the first tile inside the viewport is a middle. + let y_start = if is_start_culled { + y_top_tiles + } else { + y_top_tiles + 1 + }; + + for y_idx in y_start..y_bottom_tiles { + let tile = Tile::new_clamped(x, y_idx, line_idx, W); + self.tile_buf.push(tile); + } + } else { + // General case, any line which crosses more than one tile and is not vertical. + let dx = p1_x - p0_x; + let dy = p1_y - p0_y; + let x_slope = dx / dy; + let dx_dir = (line_bottom_x >= line_top_x) as u32; + let not_dx_dir = dx_dir ^ 1; + + let w_start_base = dx_dir << WINDING_SHIFT; + let w_end_base = not_dx_dir << WINDING_SHIFT; + + let mut push_row = { + #[inline(always)] + |y_idx: u16, + row_top_y: f32, + row_bottom_y: f32, + w_start: u32, + w_end: u32, + w_single: u32| { + let row_top_x = p0_x + (row_top_y - p0_y) * x_slope; + let row_bottom_x = p0_x + (row_bottom_y - p0_y) * x_slope; + + let row_left_x = f32::min(row_top_x, row_bottom_x).max(line_left_x); + let row_right_x = f32::max(row_top_x, row_bottom_x).min(line_right_x); + + if row_left_x < 0.0 { + self.windings.culled = true; + + if row_right_x < 0.0 { + // Although the line may cross the left edge, the rightmost point in + // this row may still be fully left of the viewport. In this case, + // record the winding and emit no tiles. + self.windings.mark_row_active(y_idx as usize); + + let crosses_top = (w_single & W) != 0; + if crosses_top { + self.windings.coarse[y_idx as usize] += dir; + } + + let fractional_coverage = + calc_fractional_coverage!(y_idx, row_top_y, row_bottom_y); + let target_row = &mut self.windings.partial[y_idx as usize]; + let current = f32x4::from_slice(s, target_row); + + let double_count = if crosses_top { + f_dir_v + } else { + f32x4::splat(s, 0.0) + }; + let next = fractional_coverage + .mul_add(f_dir_v, current - double_count); + next.store_slice(target_row); + + return; + } else { + // The line crosses into the viewport in this row. Record only the + // fractional portion of the winding, as the coarse winding will + // naturally get included by the clamped tile logic! + let y_slope = dy / dx; + let y_intersect = row_top_y - (row_top_x * y_slope); + + let (off_screen_top_y, off_screen_bottom_y) = if row_top_x < 0.0 + { + (row_top_y, f32::min(row_bottom_y, y_intersect)) + } else { + (f32::max(row_top_y, y_intersect), row_bottom_y) + }; + + if off_screen_top_y < off_screen_bottom_y { + self.windings.mark_row_active(y_idx as usize); + let fractional_coverage = calc_fractional_coverage!( + y_idx, + off_screen_top_y, + off_screen_bottom_y + ); + let target_row = &mut self.windings.partial[y_idx as usize]; + let current = f32x4::from_slice(s, target_row); + let next = fractional_coverage.mul_add(f_dir_v, current); + next.store_slice(target_row); + } + } + } + + let x_start = row_left_x as u16; + let x_end = (row_right_x as u16).min(tile_columns.saturating_sub(1)); + + if x_start <= x_end { + let winding = if x_start == x_end { w_single } else { w_start }; + + self.tile_buf + .push(Tile::new(x_start, y_idx, line_idx, winding)); + + for x_idx in x_start.saturating_add(1)..x_end { + self.tile_buf.push(Tile::new(x_idx, y_idx, line_idx, 0)); + } + + if x_start < x_end { + self.tile_buf.push(Tile::new(x_end, y_idx, line_idx, w_end)); + } + } + } + }; + + let is_start_culled = line_top_y < 0.0; + if !is_start_culled { + let y = f32::from(y_top_tiles); + let row_bottom_y = (y + 1.0).min(line_bottom_y); + let mask = ((y >= line_top_y) as u32) << WINDING_SHIFT; + push_row( + y_top_tiles, + line_top_y, + row_bottom_y, + w_start_base & mask, + w_end_base & mask, + W & mask, + ); + } + + let y_start = if is_start_culled { + y_top_tiles + } else { + y_top_tiles + 1 + }; + + for y_idx in y_start..y_bottom_tiles { + let y = f32::from(y_idx); + let row_bottom_y = (y + 1.0).min(line_bottom_y); + push_row(y_idx, y, row_bottom_y, w_start_base, w_end_base, W); + } + } + } else { + // Case line is fully contained within a single tile: These also cannot cross edges! + let tile = Tile::new_clamped( + (line_left_x as u16).min(tile_columns + 1), + y_top_tiles, + line_idx, + ((f32::from(y_top_tiles) >= line_top_y) as u32) << WINDING_SHIFT, + ); + self.tile_buf.push(tile); + } + } + + self.windings.culled + } + + /// Generates tile commands for MSAA (Multisample Anti-Aliasing) rasterization. + /// + /// [ Architecture & Watertightness ] + /// The primary goal of this function is to establish a source of "ground truth" for line-tile + /// intersections. Because the downstream rasterization (MSAA) occurs in parallel, it is + /// critical that intersections are "watertight." If Thread A handles Tile (0,0) and Thread B + /// handles Tile (1,0), they must agree exactly on whether a line crosses the shared edge. + /// + /// While calculating exact intersection coordinates here is feasible, it is computationally + /// expensive. Instead, we defer the heavy math to the GPU/rasterizer and produce a lightweight + /// Intersection Bitmask. This mask unambiguously defines which edges of a tile a line segment + /// touches or crosses. + /// + /// [ The Intersection Bitmask (5 bits) ] + /// The bitmask encodes winding information and edge intersections. A line is said to + /// "intersect" an edge if it touches that edge AND continues into the neighboring tile. + /// + /// Bit representation: + /// Bit: 4 | 3 | 2 | 1 | 0 + /// Val: W | R | L | B | T + /// + /// - W (Winding): Tracks whether the line touched the top edge of the tile. + /// - R/L/B/T: Right, Left, Bottom, and Top edge intersections. + pub fn make_tiles_msaa(&mut self, lines: &[Line], width: u16, height: u16) { + self.reset(); + + if width == 0 || height == 0 { + return; + } + + debug_assert!( + lines.len() <= MAX_LINES_PER_PATH as usize, + "Max. number of lines per path exceeded. Max is {}, got {}.", + MAX_LINES_PER_PATH, + lines.len() + ); + + let tile_columns = width.div_ceil(Tile::WIDTH); + let tile_rows = height.div_ceil(Tile::HEIGHT); + + for (line_idx, line) in lines.iter().take(MAX_LINES_PER_PATH as usize).enumerate() { + let line_idx = line_idx as u32; + + let p0_x = line.p0.x / f32::from(Tile::WIDTH); + let p0_y = line.p0.y / f32::from(Tile::HEIGHT); + let p1_x = line.p1.x / f32::from(Tile::WIDTH); + let p1_y = line.p1.y / f32::from(Tile::HEIGHT); + + let (line_left_x, line_right_x) = if p0_x < p1_x { + (p0_x, p1_x) + } else { + (p1_x, p0_x) + }; + + // Lines whose left-most endpoint exceed the right edge of the viewport are culled + if line_left_x > tile_columns as f32 { + continue; + } + + let (line_top_y, line_top_x, line_bottom_y, line_bottom_x) = if p0_y < p1_y { + (p0_y, p0_x, p1_y, p1_x) + } else { + (p1_y, p1_x, p0_y, p0_x) + }; + + // The `as u16` casts here intentionally clamp negative coordinates to 0. + let y_top_tiles = (line_top_y as u16).min(tile_rows); + let line_bottom_y_ceil = line_bottom_y.ceil(); + let y_bottom_tiles = (line_bottom_y_ceil as u16).min(tile_rows); + + // If y_top_tiles == y_bottom_tiles, then the line is either completely above or below + // the viewport OR it is perfectly horizontal and aligned to the tile grid, contributing + // no winding. In either case, it should be culled. + if y_top_tiles >= y_bottom_tiles { + continue; + } + + // Get tile coordinates for start/end points, use i32 to preserve negative coordinates + let p0_tile_x = line_top_x.floor() as i32; + let p0_tile_y = line_top_y.floor() as i32; + let p1_tile_x = line_bottom_x.floor() as i32; + + let p1_tile_y = if line_bottom_y == line_bottom_y_ceil { + line_bottom_y as i32 - 1 + } else { + line_bottom_y.floor() as i32 + }; + + // Special-case out lines which are fully contained within a tile. + let not_same_tile = p0_tile_y != p1_tile_y || p0_tile_x != p1_tile_x; + if not_same_tile { + // For ease of logic, special-case purely vertical tiles. + if line_left_x == line_right_x { + let x = (line_left_x as u16).min(tile_columns.saturating_sub(1)); + + // Row Start, not culled. + let is_start_culled = line_top_y < 0.0; + if !is_start_culled { + let winding = + ((f32::from(y_top_tiles) >= line_top_y) as u32) << WINDING_SHIFT; + let intersection_mask = B | winding; + let tile = Tile::new_clamped(x, y_top_tiles, line_idx, intersection_mask); + self.tile_buf.push(tile); + } + + // Middle + // If the start was culled, the first tile inside the viewport is a middle. + let y_start = if is_start_culled { + y_top_tiles + } else { + y_top_tiles + 1 + }; + let line_bottom_floor = line_bottom_y.floor(); + let y_end_idx = (line_bottom_floor as u16).min(tile_rows); + + if y_start < y_end_idx { + let y_last = y_end_idx - 1; + for y_idx in y_start..y_last { + let intersection_mask = W | B | T; + let tile = Tile::new_clamped(x, y_idx, line_idx, intersection_mask); + self.tile_buf.push(tile); + } + + // Perfect touching B case. + { + let is_end_tile = ((y_last as i32) == p1_tile_y) as u32; + let intersection_mask = W | T | ((1 ^ is_end_tile) << BOT_SHIFT); + let tile = Tile::new_clamped(x, y_last, line_idx, intersection_mask); + self.tile_buf.push(tile); + } + } + + // Row End, handle the final tile (y_end_idx), but *only* if the line does + // not perfectly end on the top edge of the tile. In the case that it does, + // it gets handled by the middle logic above. + if line_bottom_y != line_bottom_floor && y_end_idx < tile_rows { + let intersection_mask = W | T; + let tile = Tile::new_clamped(x, y_end_idx, line_idx, intersection_mask); + self.tile_buf.push(tile); + } + } else { + let dx = p1_x - p0_x; + let dy = p1_y - p0_y; + let x_slope = dx / dy; + let dx_dir = (line_bottom_x >= line_top_x) as u32; + let not_dx_dir = dx_dir ^ 1; + + let w_start_base = dx_dir << WINDING_SHIFT; + let w_end_base = not_dx_dir << WINDING_SHIFT; + + // Check if the line is fully within the horizontal viewport bounds. If it is, + // we can skip the min/max clamping per row. + let min_x = p0_x.min(p1_x); + let max_x = p0_x.max(p1_x); + // Note: We use >= on the right edge to ensure strictly safe integer truncation + let needs_clamping = min_x < line_left_x || max_x >= line_right_x; + + // Handles the bitmask logic for start/end tiles. Invariant to clamping. + macro_rules! push_edge { + ($x:expr, $y:expr, $row_top_x:expr, $row_bottom_x:expr, + $canonical_start:expr, $canonical_end:expr, $winding_input:expr, + $check_s:tt, $check_e:expr) => {{ + let x_idx = $x; + + let unc_row_start = (x_idx as i32 == $canonical_start) as u32; + let unc_row_end = (x_idx == $canonical_end) as u32; + + let canonical_row_start = + (dx_dir & unc_row_start) | (not_dx_dir & unc_row_end); + let canonical_row_end = + (not_dx_dir & unc_row_start) | (dx_dir & unc_row_end); + + let start_tile = if $check_s { + ((x_idx as i32 == p0_tile_x) && ($y as i32 == p0_tile_y)) as u32 + } else { + 0 + }; + + let end_tile = if $check_e { + ((x_idx as i32 == p1_tile_x) && ($y as i32 == p1_tile_y)) as u32 + } else { + 0 + }; + + let mut mask = $winding_input; + + // Entrant/Exit + mask |= canonical_row_start & (1 ^ start_tile); + mask |= (1 ^ canonical_row_start) << not_dx_dir << LEFT_SHIFT; + mask |= (canonical_row_end & (1 ^ end_tile)) << BOT_SHIFT; + mask |= (1 ^ canonical_row_end) << dx_dir << LEFT_SHIFT; + + // Corner + let x_left_f = x_idx as f32; + let x_right_f = (x_idx + 1) as f32; + let trc = (($row_top_x == x_right_f) as u32) & (1 ^ start_tile); + let tlc = (($row_top_x == x_left_f) as u32) & (1 ^ start_tile); + let brc = (($row_bottom_x == x_right_f) as u32) & (1 ^ end_tile); + let blc = (($row_bottom_x == x_left_f) as u32) & (1 ^ end_tile); + // Top left is handled specially + let tie_break = tlc & (canonical_row_start ^ 1); + + mask |= (tie_break | blc) << LEFT_SHIFT; + mask |= (trc | brc) << RIGHT_SHIFT; + mask &= !(tie_break | trc); + mask &= !((blc | brc) << BOT_SHIFT); + + self.tile_buf.push(Tile::new(x_idx, $y, line_idx, mask)); + }}; + } + + // Handles row geometry and clamping logic. + macro_rules! process_row { + ($y_idx:expr, $row_top_y:expr, $row_bottom_y:expr, $w_mask:expr, + $check_s:tt, $check_e:tt, $clamped:tt) => {{ + let row_top_x = p0_x + ($row_top_y - p0_y) * x_slope; + let row_bottom_x = p0_x + ($row_bottom_y - p0_y) * x_slope; + + let (row_left_x, row_right_x, x_end) = if $clamped { + let lx = f32::min(row_top_x, row_bottom_x).max(line_left_x); + let rx = f32::max(row_top_x, row_bottom_x).min(line_right_x); + let xe = (rx as u16).min(tile_columns.saturating_sub(1)); + (lx, rx, xe) + } else { + let lx = f32::min(row_top_x, row_bottom_x); + let rx = f32::max(row_top_x, row_bottom_x); + let xe = rx as u16; // Safe because we checked bounds earlier + (lx, rx, xe) + }; + + let canonical_x_start = row_left_x.floor() as i32; + let canonical_x_end = row_right_x as u16; + let x_start = row_left_x as u16; + + if x_start <= x_end { + let is_single = (x_start == x_end) as u32; + let w_left = (w_start_base | (is_single << 4)) & $w_mask; + + push_edge!( + x_start, + $y_idx, + row_top_x, + row_bottom_x, + canonical_x_start, + canonical_x_end, + w_left, + $check_s, + $check_e + ); + + for x_idx in x_start.saturating_add(1)..x_end { + self.tile_buf + .push(Tile::new(x_idx, $y_idx, line_idx, R | L)); + } + + if x_start < x_end { + let w_right = w_end_base & $w_mask; + push_edge!( + x_end, + $y_idx, + row_top_x, + row_bottom_x, + canonical_x_start, + canonical_x_end, + w_right, + $check_s, + $check_e + ); + } + } + }}; + } + + // Central macro + macro_rules! run_loops { + ($clamped:tt) => {{ + // Top Row + let is_start_culled = line_top_y < 0.0; + if !is_start_culled { + let y = f32::from(y_top_tiles); + let row_bottom_y = (y + 1.0).min(line_bottom_y); + let mask = ((y >= line_top_y) as u32) << WINDING_SHIFT; + process_row!( + y_top_tiles, + line_top_y, + row_bottom_y, + mask, + true, + true, + $clamped + ); + } + + let y_start_middle = if is_start_culled { + y_top_tiles + } else { + y_top_tiles + 1 + }; + let line_bottom_floor = line_bottom_y.floor(); + let y_end_middle = (line_bottom_floor as u16).min(tile_rows); + let has_separate_bottom_row = line_bottom_y != line_bottom_floor + && y_end_middle < tile_rows + && (is_start_culled || y_end_middle != y_top_tiles); + + if y_start_middle < y_end_middle { + for y_idx in y_start_middle..y_end_middle { + let y = f32::from(y_idx); + let row_bottom_y = (y + 1.0).min(line_bottom_y); + let is_last_middle = y_idx == y_end_middle - 1; + let check_end = is_last_middle && !has_separate_bottom_row; + + process_row!( + y_idx, + y, + row_bottom_y, + u32::MAX, + false, + check_end, + $clamped + ); + } + } + + // Bottom Row + if has_separate_bottom_row { + let y_idx = y_end_middle; + let y = f32::from(y_idx); + process_row!( + y_idx, + y, + line_bottom_y, + u32::MAX, + false, + true, + $clamped + ); + } + }}; + } + + if needs_clamping { + run_loops!(true); + } else { + run_loops!(false); + } + } + } else { + // Case: Line is fully contained within a single tile. + let tile = Tile::new_clamped( + (line_left_x as u16).min(tile_columns + 1), + y_top_tiles, + line_idx, + ((f32::from(y_top_tiles) >= line_top_y) as u32) << WINDING_SHIFT, + ); + self.tile_buf.push(tile); + } + } + } +} + +#[cfg(test)] +mod tests { + use crate::flatten::{FlattenCtx, Line, Point, fill}; + use crate::geometry::RectU16; + use crate::kurbo::{Affine, BezPath}; + use crate::tile::CulledWindings; + use crate::tile::{B, L, R, T, Tile, Tiles, W}; + use fearless_simd::Level; + use std::vec::Vec; + + const VIEW_DIM: u16 = 100; + const F_V_DIM: f32 = VIEW_DIM as f32; + + impl Tiles { + fn assert_tiles_match( + &mut self, + lines: &[Line], + width: u16, + height: u16, + expected: &[Tile], + ) { + self.make_tiles_msaa(lines, width, height); + assert_eq!(self.tile_buf, expected, "MSAA: Tile buffer mismatch"); + + self.make_tiles_analytic_aa(Level::baseline(), lines, width, height); + check_analytic_aa_matches(&self.tile_buf, expected); + } + } + + fn check_analytic_aa_matches(actual: &[Tile], expected: &[Tile]) { + assert_eq!( + actual.len(), + expected.len(), + "Analytic AA: Tile count mismatch." + ); + + for (i, (got, want)) in actual.iter().zip(expected.iter()).enumerate() { + assert_eq!(got.x, want.x, "Analytic AA: Tile[{}] X mismatch", i); + assert_eq!(got.y, want.y, "Analytic AA: Tile[{}] Y mismatch", i); + assert_eq!( + got.line_idx(), + want.line_idx(), + "Analytic AA: Tile[{}] Line Index mismatch", + i + ); + + let got_winding = got.packed_winding_line_idx & W; + let want_winding = want.packed_winding_line_idx & W; + assert_eq!( + got_winding, want_winding, + "Analytic AA: Tile[{}] Winding mismatch", + i + ); + } + } + + //============================================================================================== + // Culled Lines + //============================================================================================== + #[test] + fn cull_sloped_outside_lines() { + let lines = [ + Line { + p0: Point { x: 1.0, y: -7.0 }, + p1: Point { x: 3.0, y: -1.0 }, + }, + Line { + p0: Point { x: 1.0, y: -11.0 }, + p1: Point { x: 3.0, y: -1.0 }, + }, + Line { + p0: Point { + x: F_V_DIM + 1.0, + y: 50.0, + }, + p1: Point { + x: F_V_DIM + 3.0, + y: 70.0, + }, + }, + Line { + p0: Point { + x: 1.0, + y: F_V_DIM + 1.0, + }, + p1: Point { + x: 3.0, + y: F_V_DIM + 7.0, + }, + }, + Line { + p0: Point { + x: 1.0, + y: F_V_DIM + 1.0, + }, + p1: Point { + x: 3.0, + y: F_V_DIM + 13.0, + }, + }, + ]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &[]); + } + + #[test] + fn sloped_line_crossing_top() { + let lines = [ + Line { + p0: Point { x: -2.0, y: -3.0 }, + p1: Point { x: 2.0, y: 1.0 }, + }, + Line { + p0: Point { x: 6.0, y: -1.0 }, + p1: Point { x: 5.0, y: 2.0 }, + }, + Line { + p0: Point { x: 9.0, y: -10.0 }, + p1: Point { x: 10.0, y: 3.0 }, + }, + Line { + p0: Point { x: 2.0, y: 1.0 }, + p1: Point { x: -2.0, y: -3.0 }, + }, + ]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, W | T), + Tile::new(1, 0, 1, W | T), + Tile::new(2, 0, 2, W | T), + Tile::new(0, 0, 3, W | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn sloped_line_crossing_bot() { + let lines = [ + Line { + p0: Point { + x: 5.0, + y: F_V_DIM + 3.0, + }, + p1: Point { + x: 6.0, + y: F_V_DIM - 2.0, + }, + }, + Line { + p0: Point { + x: 10.0, + y: F_V_DIM + 1.0, + }, + p1: Point { + x: 9.0, + y: F_V_DIM - 1.0, + }, + }, + Line { + p0: Point { + x: 2.0, + y: F_V_DIM - 2.0, + }, + p1: Point { + x: 3.0, + y: F_V_DIM + 3.0, + }, + }, + ]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(1, 24, 0, B), + Tile::new(2, 24, 1, B), + Tile::new(0, 24, 2, B), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn sloped_line_crossing_top_multi_tile() { + let lines = [ + Line { + p0: Point { x: 1.0, y: -5.0 }, + p1: Point { x: 6.0, y: 7.0 }, + }, + Line { + p0: Point { x: 2.5, y: -10.0 }, + p1: Point { x: 3.5, y: 6.0 }, + }, + ]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, W | T | R), + Tile::new(1, 0, 0, L | B), + Tile::new(1, 1, 0, W | T), + Tile::new(0, 0, 1, W | T | B), + Tile::new(0, 1, 1, W | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn sloped_line_crossing_bot_multi_tile() { + let lines = [ + Line { + p0: Point { + x: 12.0, + y: F_V_DIM + 10.0, + }, + p1: Point { x: 2.0, y: 94.0 }, + }, + Line { + p0: Point { + x: 1.5, + y: F_V_DIM + 5.0, + }, + p1: Point { x: 3.5, y: 94.0 }, + }, + ]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 23, 0, B), + Tile::new(0, 24, 0, W | T | R), + Tile::new(1, 24, 0, B | L), + Tile::new(0, 23, 1, B), + Tile::new(0, 24, 1, W | T | B), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn sloped_line_crossing_right() { + let lines = [ + Line { + p0: Point { x: 97.0, y: 1.0 }, + p1: Point { + x: F_V_DIM + 1.0, + y: 2.0, + }, + }, + Line { + p0: Point { x: 93.0, y: 1.0 }, + p1: Point { + x: F_V_DIM + 5.0, + y: 2.0, + }, + }, + ]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(24, 0, 0, R), + Tile::new(23, 0, 1, R), + Tile::new(24, 0, 1, R | L), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn sloped_line_crossing_left() { + let lines = [ + Line { + p0: Point { x: -5.0, y: 1.0 }, + p1: Point { x: 1.0, y: 2.0 }, + }, + Line { + p0: Point { x: -5.0, y: 1.0 }, + p1: Point { x: 5.0, y: 2.0 }, + }, + Line { + p0: Point { x: -5.0, y: 1.0 }, + p1: Point { x: 13.0, y: 9.0 }, + }, + ]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, L), + Tile::new(0, 0, 1, L | R), + Tile::new(1, 0, 1, L), + Tile::new(0, 0, 2, L | B), + Tile::new(0, 1, 2, W | R | T), + Tile::new(1, 1, 2, R | L), + Tile::new(2, 1, 2, L | B), + Tile::new(2, 2, 2, W | R | T), + Tile::new(3, 2, 2, L), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn horizontal_line_above_viewport() { + let lines = [Line { + p0: Point { x: 10.0, y: -5.0 }, + p1: Point { x: 90.0, y: -5.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &[]); + } + + #[test] + fn horizontal_line_below_viewport() { + let lines = [Line { + p0: Point { + x: 10.0, + y: F_V_DIM + 5.0, + }, + p1: Point { + x: 90.0, + y: F_V_DIM + 5.0, + }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &[]); + } + + #[test] + fn horizontal_line_crossing_left_viewport() { + let lines = [Line { + p0: Point { x: -10.0, y: 10.0 }, + p1: Point { x: 10.0, y: 10.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 2, 0, L | R), + Tile::new(1, 2, 0, L | R), + Tile::new(2, 2, 0, L), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn horizontal_line_crossing_right_viewport() { + let lines = [Line { + p0: Point { + x: F_V_DIM - 5.0, + y: 10.0, + }, + p1: Point { + x: F_V_DIM + 5.0, + y: 10.0, + }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [Tile::new(23, 2, 0, R), Tile::new(24, 2, 0, L | R)]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn vertical_lines_outside_viewport() { + let lines = [ + Line { + p0: Point { x: 1.0, y: -5.0 }, + p1: Point { x: 1.0, y: -1.0 }, + }, + Line { + p0: Point { + x: 1.0, + y: F_V_DIM + 1.0, + }, + p1: Point { + x: 1.0, + y: F_V_DIM + 5.0, + }, + }, + ]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &[]); + } + + #[test] + fn vertical_path_on_the_right_of_viewport() { + const VIEWPORT_WIDTH: u16 = 10; + const VIEWPORT_HEIGHT: u16 = 10; + + let path = BezPath::from_svg("M261,0 L78848,0 L78848,4 L261,4 Z").unwrap(); + let mut line_buf: Vec = Vec::new(); + fill( + Level::try_detect().unwrap_or(Level::baseline()), + &path, + Affine::IDENTITY, + &mut line_buf, + &mut FlattenCtx::default(), + RectU16::new(0, 0, VIEWPORT_WIDTH, VIEWPORT_HEIGHT), + ); + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + tiles.assert_tiles_match(&line_buf, VIEWPORT_WIDTH, VIEWPORT_HEIGHT, &[]); + } + + #[test] + fn vertical_line_crossing_top_viewport() { + let lines = [ + Line { + p0: Point { x: 1.0, y: -7.0 }, + p1: Point { x: 1.0, y: 3.0 }, + }, + Line { + p0: Point { x: 1.0, y: -7.0 }, + p1: Point { x: 1.0, y: 7.0 }, + }, + Line { + p0: Point { x: 1.0, y: -7.0 }, + p1: Point { x: 1.0, y: 8.0 }, + }, + ]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, W | T), + Tile::new(0, 0, 1, W | B | T), + Tile::new(0, 1, 1, W | T), + Tile::new(0, 0, 2, W | B | T), + Tile::new(0, 1, 2, W | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn vertical_line_crossing_bot_viewport() { + let lines = [ + Line { + p0: Point { + x: 1.0, + y: F_V_DIM - 1.0, + }, + p1: Point { + x: 1.0, + y: F_V_DIM + 5.0, + }, + }, + Line { + p0: Point { + x: 1.0, + y: F_V_DIM - 5.0, + }, + p1: Point { + x: 1.0, + y: F_V_DIM + 5.0, + }, + }, + ]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 24, 0, B), + Tile::new(0, 23, 1, B), + Tile::new(0, 24, 1, W | T | B), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn clip_top_left_corner() { + let lines = [Line { + p0: Point { x: -1.0, y: 2.0 }, + p1: Point { x: 2.0, y: -1.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [Tile::new(0, 0, 0, W | L | T)]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn clip_bottom_right_corner() { + let lines = [Line { + p0: Point { + x: F_V_DIM + 1.0, + y: F_V_DIM - 2.0, + }, + p1: Point { + x: F_V_DIM - 2.0, + y: F_V_DIM + 1.0, + }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [Tile::new(24, 24, 0, R | B)]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + //============================================================================================== + // Axis-aligned lines + //============================================================================================== + #[test] + fn horizontal_line_left_to_right_three_tile() { + let lines = [Line { + p0: Point { x: 1.5, y: 1.0 }, + p1: Point { x: 8.5, y: 1.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, R), + Tile::new(1, 0, 0, R | L), + Tile::new(2, 0, 0, L), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn horizontal_line_right_to_left_three_tile() { + let lines = [Line { + p0: Point { x: 8.5, y: 1.0 }, + p1: Point { x: 1.5, y: 1.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, R), + Tile::new(1, 0, 0, R | L), + Tile::new(2, 0, 0, L), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn horizontal_line_multi_tile() { + let lines = [Line { + p0: Point { x: 1.5, y: 1.0 }, + p1: Point { x: 12.5, y: 1.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, R), + Tile::new(1, 0, 0, R | L), + Tile::new(2, 0, 0, R | L), + Tile::new(3, 0, 0, L), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn vertical_line_down_three_tile() { + let lines = [Line { + p0: Point { x: 1.0, y: 1.5 }, + p1: Point { x: 1.0, y: 8.5 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, B), + Tile::new(0, 1, 0, W | T | B), + Tile::new(0, 2, 0, W | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn vertical_line_down_multi_tile() { + let lines = [Line { + p0: Point { x: 1.0, y: 1.0 }, + p1: Point { x: 1.0, y: 13.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, B), + Tile::new(0, 1, 0, W | T | B), + Tile::new(0, 2, 0, W | T | B), + Tile::new(0, 3, 0, W | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn vertical_line_up_three_tile() { + let lines = [Line { + p0: Point { x: 1.0, y: 13.0 }, + p1: Point { x: 1.0, y: 1.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, B), + Tile::new(0, 1, 0, W | T | B), + Tile::new(0, 2, 0, W | T | B), + Tile::new(0, 3, 0, W | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn vertical_line_up_multi_tile() { + let lines = [Line { + p0: Point { x: 1.0, y: 8.5 }, + p1: Point { x: 1.0, y: 1.5 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, B), + Tile::new(0, 1, 0, W | T | B), + Tile::new(0, 2, 0, W | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + // Exclusive to the bottom edge, no P required. + #[test] + fn vertical_line_touching_bot() { + let lines = [Line { + p0: Point { x: 1.0, y: 1.0 }, + p1: Point { x: 1.0, y: 8.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [Tile::new(0, 0, 0, B), Tile::new(0, 1, 0, W | T)]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn vertical_line_touching_top() { + let lines = [Line { + p0: Point { x: 1.0, y: 0.0 }, + p1: Point { x: 1.0, y: 7.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [Tile::new(0, 0, 0, W | B), Tile::new(0, 1, 0, W | T)]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + //============================================================================================== + // Sloped Lines + //============================================================================================== + #[test] + fn top_left_to_bottom_right() { + let lines = [Line { + p0: Point { x: 1.0, y: 1.0 }, + p1: Point { x: 11.0, y: 9.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, R), + Tile::new(1, 0, 0, L | B), + Tile::new(1, 1, 0, W | R | T), + Tile::new(2, 1, 0, L | B), + Tile::new(2, 2, 0, W | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn bottom_right_to_top_left() { + let lines = [Line { + p0: Point { x: 11.0, y: 9.0 }, + p1: Point { x: 1.0, y: 1.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, R), + Tile::new(1, 0, 0, L | B), + Tile::new(1, 1, 0, W | R | T), + Tile::new(2, 1, 0, L | B), + Tile::new(2, 2, 0, W | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn bottom_left_to_top_right() { + let lines = [Line { + p0: Point { x: 2.0, y: 11.0 }, + p1: Point { x: 14.0, y: 6.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(2, 1, 0, R | B), + Tile::new(3, 1, 0, L), + Tile::new(0, 2, 0, R), + Tile::new(1, 2, 0, R | L), + Tile::new(2, 2, 0, W | L | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn top_right_to_bottom_left() { + let lines = [Line { + p0: Point { x: 14.0, y: 6.0 }, + p1: Point { x: 2.0, y: 11.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(2, 1, 0, R | B), + Tile::new(3, 1, 0, L), + Tile::new(0, 2, 0, R), + Tile::new(1, 2, 0, R | L), + Tile::new(2, 2, 0, W | L | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn two_lines_in_single_tile() { + let lines = [ + Line { + p0: Point { x: 1.0, y: 3.0 }, + p1: Point { x: 3.0, y: 3.0 }, + }, + Line { + p0: Point { x: 3.0, y: 3.0 }, + p1: Point { x: 0.0, y: 1.0 }, + }, + ]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [Tile::new(0, 0, 0, 0), Tile::new(0, 0, 1, 0)]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn diagonal_cross_corner() { + let lines = [Line { + p0: Point { x: 3.0, y: 5.0 }, + p1: Point { x: 5.0, y: 3.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(1, 0, 0, L), + Tile::new(0, 1, 0, R), + Tile::new(1, 1, 0, W | L | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn diagonal_cross_corner_two() { + let lines = [Line { + p0: Point { x: 7.9, y: 7.9 }, + p1: Point { x: 0.1, y: 0.1 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, R), + Tile::new(1, 0, 0, L), + Tile::new(1, 1, 0, W | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn diagonal_down_slope_tiles() { + let lines = [Line { + p0: Point { x: 5.0, y: 5.0 }, + p1: Point { x: 9.0, y: 9.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(1, 1, 0, R), + Tile::new(2, 1, 0, L), + Tile::new(2, 2, 0, W | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn diagonal_up_slope_tiles() { + let lines = [Line { + p0: Point { x: 5.0, y: 9.0 }, + p1: Point { x: 9.0, y: 5.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(1, 1, 0, R | B), + Tile::new(2, 1, 0, L), + Tile::new(1, 2, 0, W | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn diagonal_down_one_tile() { + let lines = [Line { + p0: Point { x: 0.0, y: 0.0 }, + p1: Point { x: 4.0, y: 4.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [Tile::new(0, 0, 0, W | R), Tile::new(1, 0, 0, L)]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn diagonal_up_one_tile() { + let lines = [Line { + p0: Point { x: 0.0, y: 4.0 }, + p1: Point { x: 4.0, y: 0.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [Tile::new(0, 0, 0, R), Tile::new(1, 0, 0, W | L)]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn diagonal_down_two_tile() { + let lines = [Line { + p0: Point { x: 0.0, y: 0.0 }, + p1: Point { x: 8.0, y: 8.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, W | R), + Tile::new(1, 0, 0, L), + Tile::new(1, 1, 0, W | R | T), + Tile::new(2, 1, 0, L), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn diagonal_up_two_tile() { + let lines = [Line { + p0: Point { x: 0.0, y: 8.0 }, + p1: Point { x: 8.0, y: 0.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(1, 0, 0, R | L), + Tile::new(2, 0, 0, W | L), + Tile::new(0, 1, 0, R), + Tile::new(1, 1, 0, W | L | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn sloped_ending_right() { + let lines = [Line { + p0: Point { x: 1.0, y: 1.0 }, + p1: Point { x: 8.0, y: 2.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, R), + Tile::new(1, 0, 0, R | L), + Tile::new(2, 0, 0, L), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn sloped_touching_top() { + let lines = [Line { + p0: Point { x: 0.0, y: 8.0 }, + p1: Point { x: 4.0, y: 0.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, R | B), + Tile::new(1, 0, 0, W | L), + Tile::new(0, 1, 0, W | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn sloped_touching_bot() { + let lines = [Line { + p0: Point { x: 0.0, y: 0.0 }, + p1: Point { x: 4.0, y: 8.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, W | B), + Tile::new(0, 1, 0, W | R | T), + Tile::new(1, 1, 0, L), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + //============================================================================================== + // Same Tile Cases + //============================================================================================== + #[test] + fn same_tile() { + let lines = [Line { + p0: Point { x: 1.0, y: 1.0 }, + p1: Point { x: 3.0, y: 3.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [Tile::new(0, 0, 0, 0)]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn same_tile_left() { + let lines = [Line { + p0: Point { x: 0.0, y: 1.0 }, + p1: Point { x: 3.0, y: 1.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [Tile::new(0, 0, 0, 0)]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn same_tile_top() { + let lines = [Line { + p0: Point { x: 1.0, y: 0.0 }, + p1: Point { x: 1.0, y: 3.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [Tile::new(0, 0, 0, W)]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn same_tile_right() { + let lines = [Line { + p0: Point { x: 1.0, y: 1.0 }, + p1: Point { x: 4.0, y: 1.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [Tile::new(0, 0, 0, R), Tile::new(1, 0, 0, L)]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn same_tile_bottom() { + let lines = [ + Line { + p0: Point { x: 1.0, y: 1.0 }, + p1: Point { x: 1.0, y: 4.0 }, + }, + Line { + p0: Point { x: 1.0, y: 1.0 }, + p1: Point { x: 2.0, y: 4.0 }, + }, + ]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [Tile::new(0, 0, 0, 0), Tile::new(0, 0, 1, 0)]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn same_tile_top_left() { + let lines = [ + Line { + p0: Point { x: 0.0, y: 1.0 }, + p1: Point { x: 1.0, y: 0.0 }, + }, + Line { + p0: Point { x: 0.0, y: 0.0001 }, + p1: Point { x: 0.0001, y: 0.0 }, + }, + ]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [Tile::new(0, 0, 0, W), Tile::new(0, 0, 1, W)]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + //============================================================================================== + // CulledWindings & Row Marking Logic + //============================================================================================== + #[test] + fn test_culled_windings_new_and_reset() { + let mut windings = CulledWindings::new(100); + assert_eq!(windings.partial.len(), 25); + assert_eq!(windings.coarse.len(), 25); + assert_eq!(windings.active.len(), 1); + + windings.coarse[0] = 1; + windings.active[0] = 0xFF; + windings.culled = true; + + windings.reset(); + assert_eq!(windings.coarse[0], 0); + assert_eq!(windings.active[0], 0); + + windings.coarse[0] = 1; + windings.active[0] = 0xFF; + windings.culled = false; + + windings.reset(); + assert_eq!(windings.coarse[0], 1); + assert_eq!(windings.active[0], 0xFF); + } + + #[test] + fn test_mark_row_active() { + let mut windings = CulledWindings::new(200); + windings.mark_row_active(0); + windings.mark_row_active(5); + windings.mark_row_active(31); + windings.mark_row_active(32); + assert_eq!(windings.active[0], (1 << 0) | (1 << 5) | (1 << 31)); + assert_eq!(windings.active[1], 1 << 0); + } + + #[test] + fn test_mark_row_range_single_word() { + let mut windings = CulledWindings::new(200); + windings.mark_row_range_active(5, 10); + let expected_mask = ((1_u32 << 5) - 1) << 5; + assert_eq!(windings.active[0], expected_mask); + assert_eq!(windings.active[1], 0); + } + + #[test] + fn test_mark_row_range_full_word() { + let mut windings = CulledWindings::new(200); + windings.mark_row_range_active(0, 32); + assert_eq!(windings.active[0], u32::MAX); + assert_eq!(windings.active[1], 0); + } + + #[test] + fn test_mark_row_range_spanning_two_words() { + let mut windings = CulledWindings::new(200); + windings.mark_row_range_active(30, 35); + assert_eq!(windings.active[0], (1 << 30) | (1 << 31)); + assert_eq!(windings.active[1], (1 << 0) | (1 << 1) | (1 << 2)); + } + + #[test] + fn test_mark_row_range_spanning_multiple_words() { + let mut windings = CulledWindings::new(500); + windings.mark_row_range_active(10, 80); + assert_eq!(windings.active[0], u32::MAX << 10); + assert_eq!(windings.active[1], u32::MAX); + assert_eq!(windings.active[2], (1 << 16) - 1); + assert_eq!(windings.active[3], 0); + } + + #[test] + fn test_mark_row_range_empty_or_invalid() { + let mut windings = CulledWindings::new(200); + windings.mark_row_range_active(10, 10); + windings.mark_row_range_active(15, 10); + assert_eq!(windings.active[0], 0); + assert_eq!(windings.active[1], 0); + } + + //============================================================================================== + // Miscellaneous Cases + //============================================================================================== + #[test] + // See https://github.com/LaurenzV/cpu-sparse-experiments/issues/46. + fn infinite_loop() { + let line = Line { + p0: Point { x: 22.0, y: 552.0 }, + p1: Point { x: 224.0, y: 388.0 }, + }; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + tiles.make_tiles_msaa(&[line], 600, 600); + tiles.make_tiles_analytic_aa(Level::baseline(), &[line], 600, 600); + } + + #[test] + // See https://github.com/linebender/vello/issues/1321 + fn overflow() { + let line = Line { + p0: Point { + x: 59.60001, + y: 40.78, + }, + p1: Point { + x: 520599.6, + y: 100.18, + }, + }; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + tiles.make_tiles_analytic_aa(Level::baseline(), &[line], 200, 100); + tiles.make_tiles_msaa(&[line], 200, 100); + } + + #[test] + fn sort_test() { + let mut lines: Vec = Vec::new(); + let mut tiles = Tiles::new(Level::baseline(), VIEW_DIM); + + let step = 4.0; + let mut y = F_V_DIM - 10.0; + while y > 10.0 { + lines.push(Line { + p0: Point { + x: F_V_DIM - 10.0, + y, + }, + p1: Point { x: 10.0, y }, + }); + + lines.push(Line { + p0: Point { + x: F_V_DIM - 12.0, + y, + }, + p1: Point { x: 12.0, y }, + }); + + y -= step; + } + + tiles.make_tiles_msaa(&lines, VIEW_DIM, VIEW_DIM); + assert!(tiles.tile_buf.first().unwrap().y > tiles.tile_buf.last().unwrap().y); + tiles.sort_tiles(); + check_sorted(&tiles.tile_buf); + + tiles.make_tiles_analytic_aa(Level::baseline(), &lines, VIEW_DIM, VIEW_DIM); + assert!(tiles.tile_buf.first().unwrap().y > tiles.tile_buf.last().unwrap().y); + tiles.sort_tiles(); + check_sorted(&tiles.tile_buf); + } + + fn check_sorted(buf: &[Tile]) { + for i in 0..buf.len() - 1 { + let current = buf[i]; + let next = buf[i + 1]; + + if current.y > next.y { + panic!( + "Sort Failure [Y]: Tile[{}] (y={}) > Tile[{}] (y={})", + i, + current.y, + i + 1, + next.y + ); + } + + if current.y == next.y { + if current.x > next.x { + panic!( + "Sort Failure [X]: at Row y={}, Tile[{}] (x={}) > Tile[{}] (x={})", + current.y, + i, + current.x, + i + 1, + next.x + ); + } + + if current.x == next.x + && current.packed_winding_line_idx > next.packed_winding_line_idx + { + panic!( + "Sort Failure [Payload]: at {}x{}, Tile[{}] (val={}) > Tile[{}] (val={})", + current.x, + current.y, + i, + current.packed_winding_line_idx, + i + 1, + next.packed_winding_line_idx + ); + } + } + } + } +} diff --git a/vendor/vello_common/src/util.rs b/vendor/vello_common/src/util.rs new file mode 100644 index 000000000..a2dce6ea5 --- /dev/null +++ b/vendor/vello_common/src/util.rs @@ -0,0 +1,126 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Utility functions. + +use crate::math::FloatExt; +use fearless_simd::{ + Bytes, Simd, SimdBase, SimdFloat, f32x16, u8x16, u8x32, u16x16, u16x32, u32x16, +}; +use peniko::kurbo::Affine; +#[cfg(not(feature = "std"))] +use peniko::kurbo::common::FloatFuncs as _; + +/// Convert f32x16 to u8x16. +/// +/// **Important note: The values need to be between 0.0 and 1.0, otherwise you might +/// get inconsistent results across different platforms.** +// We can't guarantee correctness for values < 0.0 due to a restriction in fearless_simd: +// https://github.com/linebender/fearless_simd/blob/3f4489389940b7c3c6ee1847a2d007a22494eeff/fearless_simd/src/generated/simd_types.rs#L1623 +#[inline(always)] +pub fn f32_to_u8(val: f32x16) -> u8x16 { + let simd = val.simd; + let converted = val.to_int::>().to_bytes(); + + let (x8_1, x8_2) = simd.split_u8x64(converted); + let (p1, p2) = simd.split_u8x32(x8_1); + let (p3, p4) = simd.split_u8x32(x8_2); + + let uzp1 = simd.unzip_low_u8x16(p1, p2); + let uzp2 = simd.unzip_low_u8x16(p3, p4); + simd.unzip_low_u8x16(uzp1, uzp2) +} + +/// A trait for implementing a fast approximal division by 255 for integers. +pub trait Div255Ext { + /// Divide by 255. + fn div_255(self) -> Self; +} + +impl Div255Ext for u16x32 { + #[inline(always)] + fn div_255(self) -> Self { + let p1 = Self::splat(self.simd, 255); + let p2 = self + p1; + p2 >> 8 + } +} + +impl Div255Ext for u16x16 { + #[inline(always)] + fn div_255(self) -> Self { + let p1 = Self::splat(self.simd, 255); + let p2 = self + p1; + p2 >> 8 + } +} + +/// Perform a normalized multiplication for u8x32. +#[inline(always)] +pub fn normalized_mul_u8x32(a: u8x32, b: u8x32) -> u16x32 { + (S::widen_u8x32(a.simd, a) * S::widen_u8x32(b.simd, b)).div_255() +} + +/// Perform a normalized multiplication for u8x16. +#[inline(always)] +pub fn normalized_mul_u8x16(a: u8x16, b: u8x16) -> u16x16 { + (S::widen_u8x16(a.simd, a) * S::widen_u8x16(b.simd, b)).div_255() +} + +/// Check if an affine transform is a pure integer translation. +/// +/// Returns true if the transform only contains integer translation (no rotation, +/// skew, or scaling), meaning rectangles will remain pixel-aligned after transformation. +#[inline] +pub fn is_integer_translation(transform: &Affine) -> bool { + let [a, b, c, d, e, f] = transform.as_coeffs(); + (a - 1.0).is_nearly_zero() + && b.is_nearly_zero() + && c.is_nearly_zero() + && (d - 1.0).is_nearly_zero() + && (e - e.round()).is_nearly_zero() + && (f - f.round()).is_nearly_zero() +} +/// Check if an affine transform has no skewing (i.e. preserves axis alignment). +#[inline] +pub fn is_axis_aligned(transform: &Affine) -> bool { + let [_, b, c, ..] = transform.as_coeffs(); + b.is_nearly_zero() && c.is_nearly_zero() +} + +/// Extract scale factors from an affine transform using singular value decomposition. +/// +/// Returns a tuple of (`scale_x`, `scale_y`) representing the scale along each axis. +/// This uses the same algorithm as kurbo's internal `svd()` method. +/// +/// # Arguments +/// * `transform` - The affine transformation to extract scales from. +/// +/// # Returns +/// A tuple `(scale_x, scale_y)` with minimum values clamped to 1e-6 to avoid division by zero. +/// +/// # Note +/// TODO: Consider making `Affine::svd()` public in kurbo to avoid duplicating this code. +/// This implementation mirrors kurbo's internal SVD calculation for extracting scale factors +/// from arbitrary affine transformations. +#[inline] +pub fn extract_scales(transform: &Affine) -> (f32, f32) { + let [a, b, c, d, _, _] = transform.as_coeffs(); + let a = a as f32; + let b = b as f32; + let c = c as f32; + let d = d as f32; + + // Compute singular values using the same formula as kurbo's svd() + let a2 = a * a; + let b2 = b * b; + let c2 = c * c; + let d2 = d * d; + let s1 = a2 + b2 + c2 + d2; + let s2 = ((a2 - b2 + c2 - d2).powi(2) + 4.0 * (a * b + c * d).powi(2)).sqrt(); + + let scale_x = (0.5 * (s1 + s2)).sqrt(); + let scale_y = (0.5 * (s1 - s2)).sqrt(); + + (scale_x.max(1e-6), scale_y.max(1e-6)) +} diff --git a/vendor/winit/src/platform_impl/linux/wayland/seat/pointer/mod.rs b/vendor/winit/src/platform_impl/linux/wayland/seat/pointer/mod.rs index 3dcb00b26..8a789a869 100644 --- a/vendor/winit/src/platform_impl/linux/wayland/seat/pointer/mod.rs +++ b/vendor/winit/src/platform_impl/linux/wayland/seat/pointer/mod.rs @@ -34,6 +34,7 @@ use crate::platform_impl::wayland::state::WinitState; use crate::platform_impl::wayland::{self, DeviceId, WindowId}; pub mod relative_pointer; +mod scroll; impl PointerHandler for WinitState { fn pointer_frame( @@ -169,26 +170,20 @@ impl PointerHandler for WinitState { window_id, ); }, - PointerEventKind::Axis { horizontal, vertical, .. } => { + PointerEventKind::Axis { horizontal, vertical, source, .. } => { // Get the current phase. let mut pointer_data = pointer.winit_data().inner.lock().unwrap(); let has_discrete_scroll = horizontal.discrete != 0 || vertical.discrete != 0; - // Figure out what to do about start/ended phases here. - // - // Figure out how to deal with `Started`. Also the `Ended` is not guaranteed - // to be sent for mouse wheels. - let phase = if horizontal.stop || vertical.stop { - TouchPhase::Ended - } else { - match pointer_data.phase { - // Discrete scroll only results in moved events. - _ if has_discrete_scroll => TouchPhase::Moved, - TouchPhase::Started | TouchPhase::Moved => TouchPhase::Moved, - _ => TouchPhase::Started, - } - }; + // PdfCraft patch (13, #991): use the source as well as discrete steps; + // pixel-only wheels do not promise an Ended event either. + let phase = scroll::phase( + pointer_data.phase, + source, + has_discrete_scroll, + horizontal.stop || vertical.stop, + ); // Update the phase. pointer_data.phase = phase; diff --git a/vendor/winit/src/platform_impl/linux/wayland/seat/pointer/scroll.rs b/vendor/winit/src/platform_impl/linux/wayland/seat/pointer/scroll.rs new file mode 100644 index 000000000..791637132 --- /dev/null +++ b/vendor/winit/src/platform_impl/linux/wayland/seat/pointer/scroll.rs @@ -0,0 +1,21 @@ +//! PdfCraft patch (13): Wayland scroll-phase policy, shared with the desktop regression tests. + +use wayland_client::protocol::wl_pointer::AxisSource; + +use crate::event::TouchPhase; + +pub(crate) fn phase(previous: TouchPhase, source: Option, has_discrete_scroll: bool, stopped: bool) -> TouchPhase { + // Wayland does not guarantee axis_stop for these sources. A pixel-only wheel must + // not start a finger gesture: consumers would retain its modifiers indefinitely. + // https://wayland.freedesktop.org/docs/html/apa.html#protocol-spec-wl_pointer-event-axis_source + if stopped { + TouchPhase::Ended + } else if has_discrete_scroll || matches!(source, Some(AxisSource::Wheel | AxisSource::WheelTilt | AxisSource::Continuous)) { + TouchPhase::Moved + } else { + match previous { + TouchPhase::Started | TouchPhase::Moved => TouchPhase::Moved, + _ => TouchPhase::Started, + } + } +} diff --git a/vendor/winit/src/platform_impl/macos/view.rs b/vendor/winit/src/platform_impl/macos/view.rs index 13cc718f6..2423d5724 100644 --- a/vendor/winit/src/platform_impl/macos/view.rs +++ b/vendor/winit/src/platform_impl/macos/view.rs @@ -144,6 +144,8 @@ pub struct ViewState { marked_text: RefCell>, accepts_first_mouse: bool, + /// PdfCraft patch: answers `mouseDownCanMoveWindow` (see there). + movable_by_window_background: bool, // Weak reference because the window keeps a strong reference to the view _ns_window: WeakId, @@ -882,6 +884,17 @@ declare_class!( trace_scope!("acceptsFirstMouse:"); self.ivars().accepts_first_mouse } + + // PdfCraft patch: a press on this view never moves the window unless the window asked to + // be movable by its background. NSView answers YES when it isn't opaque, so with a + // transparent title bar and a full-size content view AppKit moved the window for any drag + // that started in the title-bar band, before the app saw it: tabs drawn there could not + // be dragged. Apps that draw their own title bar move the window with `drag_window`. + #[method(mouseDownCanMoveWindow)] + fn mouse_down_can_move_window(&self) -> bool { + trace_scope!("mouseDownCanMoveWindow"); + self.ivars().movable_by_window_background + } } ); @@ -890,6 +903,7 @@ impl WinitView { app_delegate: &ApplicationDelegate, window: &WinitWindow, accepts_first_mouse: bool, + movable_by_window_background: bool, option_as_alt: OptionAsAlt, ) -> Retained { let mtm = MainThreadMarker::from(window); @@ -911,6 +925,7 @@ impl WinitView { committed_in_key_down: Default::default(), marked_text: Default::default(), accepts_first_mouse, + movable_by_window_background, _ns_window: WeakId::new(&window.retain()), option_as_alt: Cell::new(option_as_alt), }); diff --git a/vendor/winit/src/platform_impl/macos/window_delegate.rs b/vendor/winit/src/platform_impl/macos/window_delegate.rs index e293505eb..86587bdcf 100644 --- a/vendor/winit/src/platform_impl/macos/window_delegate.rs +++ b/vendor/winit/src/platform_impl/macos/window_delegate.rs @@ -634,6 +634,7 @@ fn new_window( app_delegate, &window, attrs.platform_specific.accepts_first_mouse, + attrs.platform_specific.movable_by_window_background, attrs.platform_specific.option_as_alt, ); diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index def0fe755..7c6ca7aed 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -6,6 +6,8 @@ publish = false license.workspace = true [dependencies] +pdfcraft-oracle = { workspace = true } +pdfcraft-cos = { workspace = true } anyhow = "1" lopdf = "0.45" png = "0.18" diff --git a/xtask/src/gates.rs b/xtask/src/gates.rs index 4406a957c..6591233e9 100644 --- a/xtask/src/gates.rs +++ b/xtask/src/gates.rs @@ -200,9 +200,10 @@ pub fn check(args: &[String]) -> anyhow::Result<()> { /// median. Plan target (M2): median ≥ 0.97. pub fn text_oracle(args: &[String]) -> anyhow::Result<()> { let limit: usize = args.iter().position(|a| a == "--limit").and_then(|i| args.get(i + 1)).and_then(|v| v.parse().ok()).unwrap_or(usize::MAX); - if Command::new("pdftotext").arg("-v").output().is_err() { - bail!("pdftotext (poppler) not found; install it to run the text oracle (brew install poppler / apt install poppler-utils)"); - } + let pdftotext = match pdfcraft_oracle::pdftotext() { + Ok(o) => o, + Err(absent) => bail!("{absent}"), + }; run_args(&["build", "--release", "-p", "pdfcraft-cli"])?; let corpus = root().join("corpus/pdfjs/test/pdfs"); let mut files: Vec = @@ -211,9 +212,18 @@ pub fn text_oracle(args: &[String]) -> anyhow::Result<()> { let cli = release_cli(); let mut scores = Vec::new(); let mut worst: Vec<(f64, String)> = Vec::new(); + let mut not_permitted = 0usize; for f in files.iter().take(limit) { + // A document may forbid copying its text, and we honour that: `pdfcraft-cli text` refuses + // and says how to override it with the owner password. pdftotext ignores the flag and + // extracts anyway, so scoring us against it there measures obedience, not quality -- + // bug1815476.pdf scored 0.000 for behaving correctly on a page we render perfectly. + if !copying_allowed(f) { + not_permitted = not_permitted.saturating_add(1); + continue; + } let ours = run_capture(Command::new(&cli).arg("text").arg(f), 20); - let theirs = run_capture(Command::new("pdftotext").arg("-q").arg(f).arg("-"), 20); + let theirs = run_capture(pdftotext.command().arg("-q").arg(f).arg("-"), 20); let (Some(ours), Some(theirs)) = (ours, theirs) else { continue }; let (a, b) = (words(&ours), words(&theirs)); if b.len() < 20 { @@ -231,6 +241,9 @@ pub fn text_oracle(args: &[String]) -> anyhow::Result<()> { let median = scores[scores.len() / 2]; let mean = scores.iter().sum::() / scores.len() as f64; println!("text oracle: {} files with text; median F1 {:.3}, mean {:.3}, p10 {:.3}", scores.len(), median, mean, scores[scores.len() / 10]); + if not_permitted > 0 { + println!(" {not_permitted} skipped: the document forbids copying its text, which we honour and pdftotext does not"); + } println!("lowest:"); for (s, n) in worst.iter().take(12) { println!(" {s:.3} {n}"); @@ -238,15 +251,153 @@ pub fn text_oracle(args: &[String]) -> anyhow::Result<()> { Ok(()) } +/// Compare our rendering against Poppler's, page 1 of every PDF in a directory. +/// +/// This is the measured half of the fidelity gap (`docs/gaps.md` gap 12): until it exists, every +/// claim about rendering quality is a judgement. It reports rather than gates, because the corpus +/// is not fetched on CI and because a first reading of a borrowed renderer is a baseline to +/// improve on, not a bar to clear. +/// +/// The score is the share of pixels with no near match in the other renderer's 3x3 neighbourhood +/// (`pdfcraft_oracle::pixels`), which forgives antialiasing and subpixel placement but not +/// missing or wrong content. **Lower is better; it is a disagreement rate, not a quality score.** +pub fn render_oracle(args: &[String]) -> anyhow::Result<()> { + let limit: usize = flag_num(args, "--limit").unwrap_or(usize::MAX); + let dpi: u32 = flag_num(args, "--dpi").unwrap_or(72); + let tolerance: u8 = flag_num(args, "--tolerance").unwrap_or(8); + // Annotations are shown by default, which is not the obvious choice and was settled by + // measurement rather than argument. Poppler synthesises an appearance for an annotation that + // has none, where we draw only what the document carries (`view.render-annotation-appearances` + // records link borders without `/AP` as not covered), so showing them scores us for a known + // gap: bug1552113.pdf is 84% purely because Poppler paints a 112-unit link border over the + // text that fixture exists to show -- and our rendering, not Poppler's, is the readable one. + // + // Hiding them is worse, though, because it scores Poppler for every appearance we *do* draw. + // Over the same 250 files: shown gives mean 2.16%, p90 5.35%, 25 files within 0.1%; hidden + // gives 2.18%, 5.55% and 22, with annotation-tx3.pdf jumping to 45.64%. The two biases very + // nearly cancel, so the default stays with the fuller picture and this flag exists to tell + // the two apart when a single file's score needs explaining. + let hide_annotations = args.iter().any(|a| a == "--hide-annotations"); + let dir = + args.iter().position(|a| a == "--dir").and_then(|i| args.get(i + 1)).map_or_else(|| root().join("corpus/pdfjs/test/pdfs"), PathBuf::from); + + let pdftoppm = match pdfcraft_oracle::pdftoppm() { + Ok(tool) => tool, + Err(absent) => bail!("{absent}"), + }; + run_args(&["build", "--release", "-p", "pdfcraft-cli"])?; + let cli = release_cli(); + + let mut files: Vec = std::fs::read_dir(&dir) + .with_context(|| format!("{}: no such directory (cargo xtask corpus, or pass --dir)", dir.display()))? + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("pdf"))) + .collect(); + files.sort(); + + let out_pam = std::env::temp_dir().join(format!("pdfcraft-render-oracle-{}.pam", std::process::id())); + let (mut scores, mut sizes_differed, mut ours_failed, mut theirs_failed) = (Vec::new(), 0usize, 0usize, 0usize); + let mut worst: Vec<(f64, String)> = Vec::new(); + for file in files.iter().take(limit) { + let name = file.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let ours = Command::new(&cli) + .args(["render", "--page", "1", "--dpi"]) + .arg(dpi.to_string()) + .arg("--out") + .arg(&out_pam) + .arg(file) + .status() + .ok() + .filter(std::process::ExitStatus::success) + .and_then(|_| std::fs::read(&out_pam).ok()) + .and_then(|b| pdfcraft_oracle::pixels::decode_pam_gray(&b)); + let mut theirs_cmd = pdftoppm.command(); + theirs_cmd.args(["-singlefile", "-gray", "-f", "1", "-l", "1", "-r"]).arg(dpi.to_string()); + if hide_annotations { + theirs_cmd.arg("-hide-annotations"); + } + theirs_cmd.arg(file); + let theirs = run_capture_bytes(&mut theirs_cmd, 30).and_then(|b| pdfcraft_oracle::pixels::decode_pgm(&b)); + // An oracle that cannot read a file says nothing about our fidelity, and a file we cannot + // read is `xtask check`'s business, not this one's. Count each side and move on. + if ours.is_none() { + ours_failed = ours_failed.saturating_add(1); + } + if theirs.is_none() { + theirs_failed = theirs_failed.saturating_add(1); + } + let (Some((aw, ah, a)), Some((bw, bh, b))) = (ours, theirs) else { continue }; + let (Some(a), Some(b)) = (pixels_of(aw, ah, &a), pixels_of(bw, bh, &b)) else { continue }; + match pdfcraft_oracle::pixels::compare(a, b, tolerance) { + Ok(diff) => { + scores.push(diff.percent()); + worst.push((diff.percent(), name)); + } + // Different rounding of the page box at this scale: not a fidelity signal. + Err(_) => sizes_differed = sizes_differed.saturating_add(1), + } + } + let _ = std::fs::remove_file(&out_pam); + + if scores.is_empty() { + bail!( + "no comparable files in {} ({sizes_differed} size mismatches, {ours_failed} we could not render, {theirs_failed} Poppler could not)", + dir.display() + ); + } + scores.sort_by(f64::total_cmp); + worst.sort_by(|a, b| b.0.total_cmp(&a.0)); + let median = scores.get(scores.len() / 2).copied().unwrap_or(0.0); + let mean = scores.iter().sum::() / scores.len() as f64; + let p90 = scores.get(scores.len().saturating_mul(9) / 10).copied().unwrap_or(0.0); + let clean = scores.iter().filter(|s| **s < 0.1).count(); + println!("render oracle vs Poppler at {dpi} dpi, tolerance {tolerance} (disagreement rate, lower is better)"); + println!(" {} compared: median {median:.2}%, mean {mean:.2}%, p90 {p90:.2}%", scores.len()); + println!(" {clean} within 0.1%; {sizes_differed} size mismatches, {ours_failed} we could not render, {theirs_failed} Poppler could not"); + println!("worst:"); + for (score, name) in worst.iter().take(12) { + println!(" {score:6.2}% {name}"); + } + Ok(()) +} + +/// Whether the document lets its text be copied. +/// +/// `true` when it is unencrypted, when the flag is set, and when the file cannot be opened at all +/// — a file we cannot read is a different problem, and silently dropping it from the comparison +/// would hide it. +fn copying_allowed(path: &Path) -> bool { + let Ok(bytes) = std::fs::read(path) else { return true }; + let Ok(doc) = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)) else { return true }; + doc.permissions().is_none_or(|p| p.copy()) +} + +/// The value after `name` on the command line, parsed, or `None` when it is absent or malformed. +fn flag_num(args: &[String], name: &str) -> Option { + args.iter().position(|a| a == name).and_then(|i| args.get(i.checked_add(1)?)).and_then(|v| v.parse().ok()) +} + +fn pixels_of(w: usize, h: usize, buf: &[u8]) -> Option> { + pdfcraft_oracle::pixels::Gray::new(w, h, buf) +} + fn run_capture(cmd: &mut Command, timeout_s: u64) -> Option { + // `read_to_string` left the buffer empty when the output was not UTF-8; `from_utf8` keeps + // exactly that behaviour now that the capture itself is byte-oriented. + Some(String::from_utf8(run_capture_bytes(cmd, timeout_s)?).unwrap_or_default()) +} + +/// [`run_capture`] for output that is not text — an oracle's image on stdout, for instance. +fn run_capture_bytes(cmd: &mut Command, timeout_s: u64) -> Option> { let mut child = cmd.stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null()).spawn().ok()?; let start = std::time::Instant::now(); - let mut out = String::new(); + let mut out = Vec::new(); let mut stdout = child.stdout.take()?; let reader = std::thread::spawn(move || { use std::io::Read; - let mut s = String::new(); - let _ = stdout.read_to_string(&mut s); + let mut s = Vec::new(); + let _ = stdout.read_to_end(&mut s); s }); loop { @@ -261,7 +412,7 @@ fn run_capture(cmd: &mut Command, timeout_s: u64) -> Option { Err(_) => return None, } } - out.push_str(&reader.join().ok()?); + out.extend_from_slice(&reader.join().ok()?); Some(out) } diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 1bda1dc4d..a0907cb1a 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -198,11 +198,17 @@ pub fn check(crates: &[Crate]) -> Vec { continue; } if d.workspace { - if matches!(class, Class::Standalone(_)) && !standalone_allowed(&c.name, &d.name) { + let dep_class = classify(&d.name); + // Test support is a dev-dependency only (`Class::Testkit`) and so never reaches + // the product graph a standalone crate publishes. Checking it against the + // standalone allow-list would stop `cos` using the shared oracles in its own + // tests, which is the one thing those crates exist for. + let testkit_dev = dep_class == Some(Class::Testkit) && d.kind == DepKind::Dev; + if matches!(class, Class::Standalone(_)) && !testkit_dev && !standalone_allowed(&c.name, &d.name) { out.push(Violation::StandaloneHasWorkspaceDep { krate: c.name.clone(), dep: d.name.clone() }); continue; } - match classify(&d.name) { + match dep_class { None => {} Some(Class::Testkit) if d.kind != DepKind::Dev && class != Class::Testkit => { out.push(Violation::TestkitAsNormalDep { krate: c.name.clone(), dep: d.name.clone() }); @@ -322,6 +328,17 @@ mod tests { assert!(check(&[c("pdfcraft-render", &[("pdfcraft-oracle", Dev, true)])]).is_empty()); let v = check(&[c("pdfcraft-render", &[("pdfcraft-oracle", Normal, true)])]); assert!(matches!(v[..], [Violation::TestkitAsNormalDep { .. }])); + + // A standalone crate may use test support in its own tests: a dev-dependency is not part + // of the graph the standalone rule protects. `cos` is where the qpdf oracle is needed. + assert!(check(&[c("pdfcraft-cos", &[("pdfcraft-oracle", Dev, true)])]).is_empty()); + assert!(check(&[c("pdfcraft-geom", &[("pdfcraft-testkit", Dev, true)])]).is_empty()); + // ...but only as a dev-dependency, and only for test support. Product crates are still + // off-limits to a standalone crate however they are depended on. + let v = check(&[c("pdfcraft-cos", &[("pdfcraft-oracle", Normal, true)])]); + assert!(matches!(v[..], [Violation::StandaloneHasWorkspaceDep { .. }]), "{v:?}"); + let v = check(&[c("pdfcraft-cos", &[("pdfcraft-model", Dev, true)])]); + assert!(matches!(v[..], [Violation::StandaloneHasWorkspaceDep { .. }]), "{v:?}"); } #[test] diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 70741d22b..f06c09944 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -33,6 +33,11 @@ const COMMANDS: &[(&str, &str, Command)] = &[ macos_integration::run, ), ("text-oracle", "Compare text extraction with pdftotext over corpus/ (word F1; target median ≥ 0.97)", gates::text_oracle), + ( + "render-oracle", + "Compare page rendering with Poppler over corpus/ (pixel disagreement rate; --dir, --dpi, --limit, --tolerance, --hide-annotations)", + gates::render_oracle, + ), ("screenshots", "Regenerate the README screenshots in docs/images/ and their ATTRIBUTION entries", screenshots::run), ("models", "Fetch the OCR models (ATTRIBUTION.toml kind = \"model\") into assets/models/, verified by SHA-256", assets::models), ("demo-pdf", "Build dist/demo/pdfcraft-showcase.pdf (needs Google Chrome or Chromium)", demo_pdf::run),