diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..1910ea4bd3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,32 @@ +version: 2 +updates: + # Maintain Rust / Cargo dependencies + - package-ecosystem: "cargo" + directories: + - "/" + - "/crates/filters/fuzz" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + # Consolidate updates into a single PR rather than one PR per crate + groups: + cargo-dependencies: + patterns: + - "*" + commit-message: + prefix: "chore(deps)" + + # Maintain GitHub Actions workflows + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + groups: + actions: + patterns: + - "*" + commit-message: + prefix: "ci(deps)" diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 0000000000..ce817da129 --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,33 @@ +# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation +# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice. +name: Nix + +on: + push: + branches: [main] + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + pull_request: + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: nix-${{ github.ref }} + cancel-in-progress: true + +jobs: + eval: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: cachix/install-nix-action@448cde739abec1eaf0646249e3d25bb8d9bc7702 # v31.11.2 + - name: nix flake check --no-build + run: | + if [ -f flake.nix ]; then + nix flake check --no-build --all-systems + else + echo "No flake.nix present in this repository; skipping check." + fi diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index c9acb0512a..5cfee8d55a 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -27,8 +27,7 @@ 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/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; reviewed by a native Vietnamese-speaking contributor | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `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 | | `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 +277,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile | | `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/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 | | `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 | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 3f3791f719..db3dd6124f 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 = "cefa955aa92790d6773cb1d1e15e19019b3165a00528d57d6617362e1d1988ab" +sha256 = "6eee0b34db6529f26235bfc07fa1fdb9030b8e7b0b10ede271ca9242cb491919" # 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 = "1de29a6cc089be31cb6206bf41e0715f8ddcf74ef887b45ccee235d743bed223" +sha256 = "9870c0fb9f7198155be16adee7f45d36cb9a254af90b545a3ce5b04cbbbf24d4" [[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 = "8c6d5b1505d76bc7ba606c98660afa60b1152c5b4c2e7d345104a6ef3d280ed2" +sha256 = "fc31e40877ce115cfb7033c57dd6acabcae3725b249364387e3fe23e04d94e5e" [[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 = "8a8aed18bd8fceabd1389580361ba75b0dccf141d845650a353d8dfe19c95536" +sha256 = "5de19834f293efaea68cb6b7f51a71d320391f53345f56cfebcd73dab828b665" [[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 = "1c197ac045810950aceb733152a296f2224f38857b1169032fdc7092ab4aa3f7" +sha256 = "cef0d22341d0722f029c6543fc846fce164331b7ad8f693a6754b7567b9d9e95" [[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 = "a9cf0e062b006b40f549ce2316b88d4b0196d70bd9cecd66c12d980d699bf679" +sha256 = "c0ca7761cbabd8096bc9af54cdf6b88b3ae0cd8210003db8b3356e2706e44fc8" [[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 = "ef5c84bd13ef8318f7deab7f42136cfe3c14cbef6fd5231014b94167baaecd2a" +sha256 = "a8afd51c3296f44b0446d6b06492b1348444b3e3c448a326ad213d18ce7af714" [[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 = "be689af8bb22537879269ad9ab9bdcc4b730665cf0b27c6bf4bad560cacf3b69" +sha256 = "7676a2dfaa7d66e2e9ef608d2d9de7f863557dcecbef6d94bafa981097405e6d" [[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 = "989d2e5f57cf22c21a85951835ca9fbd05e314da67eb4c17332df1f03679fa00" +sha256 = "9a22996c0308ea5b20ec5e328504cd83cbb560147763faaf0e6987f974844c29" [[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 = "425c42acb8d98ac31b216b13c05a285e0676318dd379dcfb9d57cc7329e9d7a5" +sha256 = "eacc972d65a2d951e94e1a8cf944638e8328f73f6c05759de6059a425f159cd7" [[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 = "988bcf2e0322a46c0464ff7b91ab5d90193b0ea7f454812d12595eb843de6fc7" +sha256 = "dfa4aa540cc7d61fa22a27a949e6eec0f78dcc5b883d0e4e004d695e460b95aa" [[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 = "ebe7aa9152baff38ae7e36aeb4f6a7ed2198166ab93425a612a66d53e43cb363" +sha256 = "27e02e7bb3d378bceca76988a9dceaa68d974f57cbee962954deca6184cf62aa" [[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 = "69d2da21949f02c3c40f71159e377eef2fdd1a87f749d6c504e1d151ef33f643" +sha256 = "9675edd36c5ae54d9893fd10c4325aab664941bf714c08fbfeef9134fa42056c" [[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 = "98359685aa555f391846f2259ac302ce1234c7a180fb4abe07c36e0c1c62211e" +sha256 = "ce18fc96f8516aa917e4a8bcfd290edec13a9800b3d1ee102e4b2a71b8467d2b" [[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 = "9f6296d9c79c848167fc80bb4cfaacc9e60f72edb70cc1d70f7b6088ada83578" +sha256 = "286a1e2e051748eb4bcee3906918dc78d0704f8a37bde22dc50d3376d5e75017" [[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 = "3528e85afd4117de2c22b9f37d32a380a073a49834fa5a3063d21b803606ffbf" +sha256 = "77593b7497c67f4a79cc31fa43fdd6eae11601dd87c079a8e666b25cdd9117ba" [[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 = "4db7e23074d44305f4584e2cd8b9a0fc35b433cb187dad493c8f7d78a8cd4ad2" +sha256 = "d7d93a03f22ab380696f69b20b12104bf30e78c4440b683df63594af716894e2" [[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 = "2af01de9509db4b4ae61193f41ff0f7f3b70ca5f91cd2afda667e90d34980b25" +sha256 = "889a0d0a9ea53ca0a818c2ba672c053c232d2cca140f8d63127353e6f36505fe" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,31 +217,20 @@ 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 = "de988b6959572931c4878a0588109c236bf8c5d46ef57c7a406b6ab42de51339" +sha256 = "bae8b21651ae2a69c71c91b69ba79727254dbfb078c1be312a13383332934927" [[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 = "63955758227bd57329b22dc7c16ed9cee8b0a15df9453477755849c886424dd5" +sha256 = "7fa70f68a9fa24d0b1cac05fe3aaabaeaca9750c57416124bd317a4eda806d5a" -[[asset]] -path = "crates/ui-egui/src/i18n/vi.tsv" -title = "Vietnamese interface translation catalog" -author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; reviewed by a native Vietnamese-speaking contributor" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "translation" -usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "b54adaa6515dbfb0de3859b30989054e5fcc4f56e66e9cc713c7bf509bdbbac3" - [[asset]] path = "assets/icons/accessibility.svg" title = "Lucide icon \"accessibility\"" @@ -3354,6 +3343,17 @@ kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" sha256 = "d1a443233345724859de8e4fb48968ba91e885e12ddbd710701548bd91b39428" +[[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 = "95b5fc6d10f7bc8371688772741870448de716fa2dd9af62fdfe4e009d269b45" + [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" title = "Slovene interface translation catalog" @@ -3363,9 +3363,10 @@ 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 = "5881cc7fbfe9da5f5f611309dacf267f95db3a143b916a113b649839019b292c" +sha256 = "3ac2b113045b7e399dd6d9ddb7773e5b0874c8b43c2ba4cca5854598a2a7ba92" [[asset]] + path = "vendor/vello_common/assets/probe.png" title = "vello_common probe reference image (rendered from probe.rgba)" author = "Linebender vello contributors" diff --git a/Cargo.lock b/Cargo.lock index 91ed3c4904..68a758c2fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1142,6 +1142,11 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-docpath" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" + [[package]] name = "craft-print" version = "0.1.0" @@ -3933,7 +3938,7 @@ dependencies = [ [[package]] name = "pdfcraft" -version = "0.8.0" +version = "0.9.0" dependencies = [ "craft-quit", "eframe", @@ -3958,7 +3963,7 @@ dependencies = [ [[package]] name = "pdfcraft-a11y" -version = "0.8.0" +version = "0.9.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3970,7 +3975,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.8.0" +version = "0.9.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -3981,7 +3986,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.8.0" +version = "0.9.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3992,7 +3997,7 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.8.0" +version = "0.9.0" dependencies = [ "base64 0.22.1", "flate2", @@ -4013,7 +4018,7 @@ dependencies = [ [[package]] name = "pdfcraft-cli" -version = "0.8.0" +version = "0.9.0" dependencies = [ "base64 0.22.1", "pdfcraft-audit", @@ -4021,6 +4026,7 @@ dependencies = [ "pdfcraft-cos", "pdfcraft-engine", "pdfcraft-platform", + "pdfcraft-preflight", "pdfcraft-render", "rustix 1.1.5", "serde_json", @@ -4029,18 +4035,18 @@ dependencies = [ [[package]] name = "pdfcraft-compare" -version = "0.8.0" +version = "0.9.0" [[package]] name = "pdfcraft-content" -version = "0.8.0" +version = "0.9.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-cos" -version = "0.8.0" +version = "0.9.0" dependencies = [ "hayro-syntax", "pdfcraft-crypt", @@ -4054,7 +4060,7 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.8.0" +version = "0.9.0" dependencies = [ "encoding_rs", "hayro-syntax", @@ -4070,7 +4076,7 @@ dependencies = [ [[package]] name = "pdfcraft-crypt" -version = "0.8.0" +version = "0.9.0" dependencies = [ "aes", "md-5", @@ -4082,7 +4088,7 @@ dependencies = [ [[package]] name = "pdfcraft-edit" -version = "0.8.0" +version = "0.9.0" dependencies = [ "hayro-syntax", "kurbo 0.13.1", @@ -4097,9 +4103,10 @@ dependencies = [ [[package]] name = "pdfcraft-engine" -version = "0.8.0" +version = "0.9.0" dependencies = [ "chrono", + "craft-docpath", "icu_properties", "idna", "image", @@ -4129,20 +4136,21 @@ dependencies = [ "pdfcraft-xfdf", "png 0.18.1", "thiserror 2.0.21", + "tiff", "unicode-segmentation", "url", ] [[package]] name = "pdfcraft-export" -version = "0.8.0" +version = "0.9.0" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-filters" -version = "0.8.0" +version = "0.9.0" dependencies = [ "flate2", "proptest", @@ -4152,7 +4160,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.8.0" +version = "0.9.0" dependencies = [ "harfrust", "pdfcraft-cos", @@ -4162,7 +4170,7 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.8.0" +version = "0.9.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4174,18 +4182,18 @@ dependencies = [ [[package]] name = "pdfcraft-geom" -version = "0.8.0" +version = "0.9.0" [[package]] name = "pdfcraft-js" -version = "0.8.0" +version = "0.9.0" dependencies = [ "boa_engine", ] [[package]] name = "pdfcraft-measure" -version = "0.8.0" +version = "0.9.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4198,14 +4206,14 @@ dependencies = [ [[package]] name = "pdfcraft-model" -version = "0.8.0" +version = "0.9.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.8.0" +version = "0.9.0" dependencies = [ "ocrs", "pdfcraft-fonts", @@ -4215,7 +4223,7 @@ dependencies = [ [[package]] name = "pdfcraft-optimize" -version = "0.8.0" +version = "0.9.0" dependencies = [ "image", "pdfcraft-annot", @@ -4228,11 +4236,11 @@ dependencies = [ [[package]] name = "pdfcraft-oracle" -version = "0.8.0" +version = "0.9.0" [[package]] name = "pdfcraft-organize" -version = "0.8.0" +version = "0.9.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4243,7 +4251,7 @@ dependencies = [ [[package]] name = "pdfcraft-platform" -version = "0.8.0" +version = "0.9.0" dependencies = [ "async-channel", "futures-lite", @@ -4253,16 +4261,18 @@ dependencies = [ [[package]] name = "pdfcraft-preflight" -version = "0.8.0" +version = "0.9.0" dependencies = [ + "flate2", "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", + "roxmltree", ] [[package]] name = "pdfcraft-print" -version = "0.8.0" +version = "0.9.0" dependencies = [ "craft-print", "pdfcraft-content", @@ -4273,7 +4283,7 @@ dependencies = [ [[package]] name = "pdfcraft-redact" -version = "0.8.0" +version = "0.9.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4289,7 +4299,7 @@ dependencies = [ [[package]] name = "pdfcraft-render" -version = "0.8.0" +version = "0.9.0" dependencies = [ "bytemuck", "flate2", @@ -4308,7 +4318,7 @@ dependencies = [ [[package]] name = "pdfcraft-sign" -version = "0.8.0" +version = "0.9.0" dependencies = [ "aes", "aws-lc-rs", @@ -4342,11 +4352,11 @@ dependencies = [ [[package]] name = "pdfcraft-testkit" -version = "0.8.0" +version = "0.9.0" [[package]] name = "pdfcraft-ui-egui" -version = "0.8.0" +version = "0.9.0" dependencies = [ "arboard", "base64 0.22.1", @@ -4390,7 +4400,7 @@ dependencies = [ [[package]] name = "pdfcraft-web" -version = "0.8.0" +version = "0.9.0" dependencies = [ "eframe", "egui", @@ -4402,7 +4412,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.8.0" +version = "0.9.0" dependencies = [ "base64 0.22.1", "image", @@ -4416,7 +4426,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfdf" -version = "0.8.0" +version = "0.9.0" dependencies = [ "pdfcraft-annot", "pdfcraft-cos", diff --git a/Cargo.toml b/Cargo.toml index 597dd316fa..ec89098021 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ exclude = ["vendor"] default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"] [workspace.package] -version = "0.8.0" +version = "0.9.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -18,40 +18,42 @@ craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", 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 = "666c358065261a2816b376fa3bb10538dbf53040" } +# Whether a path stored in a document may be opened (GoToR link targets). +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } # internal -pdfcraft-geom = { path = "crates/geom", version = "0.8.0" } -pdfcraft-filters = { path = "crates/filters", version = "0.8.0" } -pdfcraft-cos = { path = "crates/cos", version = "0.8.0" } -pdfcraft-crypt = { path = "crates/crypt", version = "0.8.0" } -pdfcraft-organize = { path = "crates/organize", version = "0.8.0" } -pdfcraft-measure = { path = "crates/measure", version = "0.8.0" } -pdfcraft-annot = { path = "crates/annot", version = "0.8.0" } -pdfcraft-audit = { path = "crates/audit", version = "0.8.0" } -pdfcraft-fonts = { path = "crates/fonts", version = "0.8.0" } -pdfcraft-model = { path = "crates/model", version = "0.8.0" } -pdfcraft-content = { path = "crates/content", version = "0.8.0" } -pdfcraft-redact = { path = "crates/redact", version = "0.8.0" } -pdfcraft-print = { path = "crates/print", version = "0.8.0" } -pdfcraft-xfdf = { path = "crates/xfdf", version = "0.8.0" } -pdfcraft-sign = { path = "crates/sign", version = "0.8.0" } -pdfcraft-optimize = { path = "crates/optimize", version = "0.8.0" } -pdfcraft-a11y = { path = "crates/a11y", version = "0.8.0" } -pdfcraft-oracle = { path = "crates/oracle", version = "0.8.0" } -pdfcraft-testkit = { path = "crates/testkit", version = "0.8.0" } -pdfcraft-ocr = { path = "crates/ocr", version = "0.8.0" } -pdfcraft-js = { path = "crates/js", version = "0.8.0" } -pdfcraft-compare = { path = "crates/compare", version = "0.8.0" } -pdfcraft-preflight = { path = "crates/preflight", version = "0.8.0" } -pdfcraft-export = { path = "crates/export", version = "0.8.0" } -pdfcraft-edit = { path = "crates/edit", version = "0.8.0" } -pdfcraft-create = { path = "crates/create", version = "0.8.0" } -pdfcraft-forms = { path = "crates/forms", version = "0.8.0" } -pdfcraft-xfa = { path = "crates/xfa", version = "0.8.0" } -pdfcraft-render = { path = "crates/render", version = "0.8.0" } -pdfcraft-engine = { path = "crates/engine", version = "0.8.0" } -pdfcraft-platform = { path = "crates/platform", version = "0.8.0" } -pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.8.0" } -pdfcraft-automation = { path = "crates/automation", version = "0.8.0", default-features = false } +pdfcraft-geom = { path = "crates/geom", version = "0.9.0" } +pdfcraft-filters = { path = "crates/filters", version = "0.9.0" } +pdfcraft-cos = { path = "crates/cos", version = "0.9.0" } +pdfcraft-crypt = { path = "crates/crypt", version = "0.9.0" } +pdfcraft-organize = { path = "crates/organize", version = "0.9.0" } +pdfcraft-measure = { path = "crates/measure", version = "0.9.0" } +pdfcraft-annot = { path = "crates/annot", version = "0.9.0" } +pdfcraft-audit = { path = "crates/audit", version = "0.9.0" } +pdfcraft-fonts = { path = "crates/fonts", version = "0.9.0" } +pdfcraft-model = { path = "crates/model", version = "0.9.0" } +pdfcraft-content = { path = "crates/content", version = "0.9.0" } +pdfcraft-redact = { path = "crates/redact", version = "0.9.0" } +pdfcraft-print = { path = "crates/print", version = "0.9.0" } +pdfcraft-xfdf = { path = "crates/xfdf", version = "0.9.0" } +pdfcraft-sign = { path = "crates/sign", version = "0.9.0" } +pdfcraft-optimize = { path = "crates/optimize", version = "0.9.0" } +pdfcraft-a11y = { path = "crates/a11y", version = "0.9.0" } +pdfcraft-oracle = { path = "crates/oracle", version = "0.9.0" } +pdfcraft-testkit = { path = "crates/testkit", version = "0.9.0" } +pdfcraft-ocr = { path = "crates/ocr", version = "0.9.0" } +pdfcraft-js = { path = "crates/js", version = "0.9.0" } +pdfcraft-compare = { path = "crates/compare", version = "0.9.0" } +pdfcraft-preflight = { path = "crates/preflight", version = "0.9.0" } +pdfcraft-export = { path = "crates/export", version = "0.9.0" } +pdfcraft-edit = { path = "crates/edit", version = "0.9.0" } +pdfcraft-create = { path = "crates/create", version = "0.9.0" } +pdfcraft-forms = { path = "crates/forms", version = "0.9.0" } +pdfcraft-xfa = { path = "crates/xfa", version = "0.9.0" } +pdfcraft-render = { path = "crates/render", version = "0.9.0" } +pdfcraft-engine = { path = "crates/engine", version = "0.9.0" } +pdfcraft-platform = { path = "crates/platform", version = "0.9.0" } +pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.9.0" } +pdfcraft-automation = { path = "crates/automation", version = "0.9.0", default-features = false } # pdf hayro = "0.7.1" diff --git a/README.md b/README.md index bd33ad23be..2a65b66457 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@

PdfCraft

- The PDF workbench; an open-source, clean-room reimplementation of Adobe Acrobat, rebuilt in pure Rust.
+ The PDF workbench; an open-source, modern PDF reader and editor, built in pure Rust.
Read, organize, combine, split and secure PDFs in a fast, native app, written in Rust from the ground up.
macOS · Windows · Linux · FreeBSD · the web

@@ -417,16 +417,16 @@ Logs, environment variables and other development notes are in [docs/development ## What's next -PdfCraft is young and moving fast. The aim is a workbench where you can view, organize, annotate, fill, sign and edit PDFs, at parity with Acrobat Pro. +PdfCraft is young and moving fast. The aim is a workbench where you can view, organize, annotate, fill, sign and edit PDFs, for professional and everyday workflows. -**Where it stands (October 2026), honestly:** PdfCraft is **alpha**. Most of Acrobat Pro's offline features exist in some form (57% of all tracked features, 94% of the must-haves). For everyday work (viewing, commenting, filling and signing forms, organizing pages) it is about 55% of the way to replacing Acrobat; against all of Acrobat Pro, about 49%; for someone who only needs the essentials, about 66%. Every everyday workflow can be completed, but converting and sharing is weak: no Office-to-PDF, and Word export that loses layout. +**Where it stands (October 2026), honestly:** PdfCraft is **alpha**. Most essential offline PDF features exist in some form (57% of the features tracked in [docs/target-app-parity.md](docs/target-app-parity.md), 94% of core essentials). For everyday work (viewing, commenting, filling and signing forms, organizing pages) readiness is about 55%; across all professional workflows, about 49%; for someone who only needs the essentials, about 66%. Every everyday workflow can be completed, but converting and sharing is weak: no office-document-to-PDF, and .docx export that loses layout. - **Good today:** viewing and search; organizing, combining and splitting; most kinds of comment; filling and authoring forms (with sandboxed JavaScript); passwords, redaction and sanitizing; basic digital signatures; printing; the Accessibility Checker; agent control through the CLI and MCP. - **Still borrowed:** pages are drawn by the `hayro` crate while our own renderer is built. -- **Thin or missing:** reliable editing of existing text (especially CJK and right-to-left), OCR beyond Latin script, Office import/export, signature tokens and online long-term validation, Preflight and PDF/X/UA, printing from the web build, physical printer fidelity, and full XFA forms. -- **Hardening:** fuzzing still turns up crashes and hangs on hostile files; each one is fixed with a regression test. Quality has not yet been compared with Acrobat side by side. +- **Thin or missing:** reliable editing of existing text (especially CJK and right-to-left), OCR beyond Latin script, office document import/export, signature tokens and online long-term validation, Preflight and PDF/X/UA, printing from the web build, physical printer fidelity, and full XFA forms. +- **Hardening:** fuzzing still turns up crashes and hangs on hostile files; each one is fixed with a regression test. Quality has not yet been benchmarked side by side. -**Next, in order:** Office-to-PDF and a Word export that keeps layout, the reported bugs and the Chinese interface font, a fidelity harness against Acrobat and the renderer, editing existing content, then the Pro workflows (OCR, Office, Preflight, signatures, accessibility tagging) and 1.0 polish. +**Next, in order:** office-document-to-PDF and a .docx export that keeps layout, the reported bugs and the Chinese interface font, a fidelity harness against a reference renderer, editing existing content, then professional workflows (OCR, office formats, Preflight, signatures, accessibility tagging) and 1.0 polish. The summary is in **[ROADMAP.md](ROADMAP.md)**; the full assessment by dimension and area is in [docs/target-app-parity.md](docs/target-app-parity.md), the ranked gaps in [docs/gaps.md](docs/gaps.md), and the milestones in [docs/roadmap.md](docs/roadmap.md). @@ -581,8 +581,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif part of this repository and PdfCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). Forks and modified versions must remove them. -Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. PdfCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with. -

ArtCraft
Made by the ArtCraft team and community. diff --git a/ROADMAP.md b/ROADMAP.md index c9958f6876..99f3e068ef 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,19 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged) +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged) +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M4, #1919: repeated page indices accumulate rotations; M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged) +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged) +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M4 #2997: Ctrl-drag (Option on macOS) in the Organize grid copies the pages into the drop gap; `page_duplicate` takes `at`; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M2/gap 12: the Annex D.2 alias codes read as the ink they draw in both tables; the text oracle scores reading order; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M4: indirect page rotations; M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M6: preserve XML/XFDF attribute whitespace; 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 (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-16 | **Last updated:** 2026-10-16 | **Change:** minor (M10 #1201 partial: lossless image Decode; 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) @@ -26,6 +39,7 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (#594 paragraph drags validated in the Linux test environment; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% 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-11 · **Change:** minor (architectural paper controls from #599; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% 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-11 · **Change:** minor (closed issues synced: #774, #756 and the #799–#821 batch; earlier: localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M6 #2892: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged) PdfCraft's one-page summary: how close it is to Adobe Acrobat Pro's offline feature set, and what comes next. Adobe's cloud services (Document Cloud, Acrobat Sign requests, shared review, the AI @@ -156,7 +170,37 @@ 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-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged. + +- **2026-10-18 (M6, #2892):** Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged. + +- **2026-10-18 (M7, #3212):** Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, with original content preserved. +- **2026-10-15 (M7, Edit text caret):** In a text region that mixes sizes (a 24 pt title over 12 pt lines), the inline editor's caret on a small row was as tall as the region's first size and reached into the row below: egui draws the caret at least one row of the text field's font tall, and that font had the paragraph's first size. The field's font is now half the region's smallest size, so each row's own height decides. Kittest regression; checked in the app on a real document. Estimates unchanged. +- **2026-10-16 (M3, #2781):** The Pages panel follows the current page, as Acrobat's page thumbnails do: going to a page whose thumbnail is out of sight (scrolling, Page Down, Go to page, a bookmark) scrolls the panel just enough to show it. A thumbnail already in view, or clicked, leaves the panel where it is, and a page change while the user scrolls the panel or drags thumbnails does not move it. Estimates unchanged. +- **2026-10-16 (M12, #1645):** CLI info, text and render use the engine's dynamic XFA layout rather than placeholder pages. Synthetic two-page and encrypted-form regressions cover counts, text, pixels, copy permissions and unchanged input bytes. Estimates unchanged. + +- **2026-10-18 (M4, PR #1919 review):** repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged. +- **2026-10-15 (M4, #1919 refresh):** Integrated upstream `99941daa` while retaining the indirect page rotation correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged. +- **2026-10-18 (M3, #3149):** Page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. +- **2026-10-19 (M3, #3360):** Switching between Read and the docking workspace retains the current document’s scroll position at fixed zoom, including within-page position; separate tabs retain their own offsets. Estimates unchanged. +- **2026-10-18 (M8, #1350):** Integrated current main while preserving content-stream comment boundaries. Hidden-text cleanup retires unused named replacement descriptions on rewritten pages and forms, including inherited/shared resource holders, without removing descriptions still used by surviving content. Synthetic saved-byte and preservation regressions cover the cleanup. Estimates unchanged. +- **2026-10-17 (M4, #2997):** Dragging page thumbnails in the Organize grid with Ctrl held (Option on macOS) drops copies at the gap instead of moving the pages: a "+" badge sits by the pointer, the copies land at the gap selected, and one undo step ("Duplicate pages") takes them out again. `page_duplicate` takes `at` (1-based, where the first copy goes; left out, after the last of them) so agents can drive the same thing, and the UI control channel's `ui.drag` modifiers now reach gestures that read the input state. Estimates unchanged. +- **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged. +- **2026-10-15 (M6, #1917 refresh):** Integrated upstream `99941daa` while retaining the XML/XFDF attribute whitespace correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged. +- **2026-10-15 (M1, #2267 refresh):** Integrated upstream `99941daa` while retaining the raw-deflate fallback correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged. +- **2026-10-16 (M10, #1201 partial):** Lossless image export applies each component's Decode pair. Indexed values round and clip before palette lookup. Color-key masks keep raw samples. The existing 16-bit precision limit remains. JPEG and mask Decode, ICC transforms, and calibrated colors remain outside this fix. Estimates unchanged. +- **2026-10-16 (M3, #2739):** Wrapped revision buttons stay above Binding in Document Properties. A synthetic regression checks control bounds and opens a saved revision at two viewport widths. Estimates unchanged. +- **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged. +- **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged. +- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of the public ISCIII form, with its button, is page 7 of 7). Acrobat Pro shows the form as 7 pages with that page last, as here; when printing, Acrobat keeps that page but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet. Synthetic regression tests. Estimates unchanged. +- **2026-10-15 (M8, #2447):** Removing links or comments (Remove Hidden Information, Sanitize) also drops the tagged-PDF structure tree's object references to them, so the full rewrite no longer keeps a removed link's URL or a comment's text. Regression seen to fail without the fix. Estimates unchanged. +- **2026-10-15 (M6, CSV tab-containing headers):** CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Synthetic export/import and incremental/full save regressions cover header tabs, quotes and line breaks, plus ordinary TSV controls. Formula-cell policy is unchanged. Estimates unchanged. +- **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged. +- **2026-10-13 (M6, data exchange fidelity):** XML and XFDF exports preserve tabs and line breaks in field names and comment metadata using character references. Saved-PDF regressions cover distinct names that XML would normalize to the same attribute value, restored field values and replacement of the original comment identities. Estimates unchanged. +- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. Synthetic regression test; the page area's `occur min` (a once-only page of its own) is still not laid out, recorded in `form.xfa-dynamic`. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. +- **2026-10-15 (M14, #2383 candidate):** Device-limit requests now clamp unsupported maxima and minimum alignments before enabling the adapter's full 2D texture extent. This addresses the reported V3D request of 16 shader variables on an adapter supporting 15. Synthetic regressions fail before the clamp and pass afterward, along with the existing HiDPI case and Windows GPU-required application tests. Raspberry Pi startup remains unverified; milestone and overall estimates are 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. @@ -166,6 +210,9 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design. - **2026-10-12 (M10, Edit on a scanned page):** Turning Edit on (its tab, Edit text & images or Add text) for a scanned page (an image covering at least half of a page with no text of its own; comments and text boxes over it do not count) recognizes the page first, like Acrobat's "Converting scanned page to Editable Text and Images", so its words open for editing. Once per page per window, one undoable "Recognize text" step, with the language of the Recognize Text dialog (English only for now, gap 7). Pages with real text (a flyer with a background picture), read-only files and other tools are left alone. The photographs on a scan are still part of the one page image. +- **2026-10-13 (M3, typed zoom):** The right rail's zoom percentage is an editable field: type 150, 150% or 62.5 % and press Enter, Escape discards, and nonsense keeps the current zoom. The control channel's zoom option shares its parser, which now refuses zero, negative and non-finite values. +- **2026-10-13 (M4, indirect page rotation):** Rotation queries and edits resolve indirect integer values on pages and their page-tree ancestors. Turning a page already at 270° by 90° now saves it at 0°, including when its rotation is inherited. Synthetic full/incremental save-and-reopen tests cover nested inheritance, sibling preservation, negative/large angles and invalid-value fallback. Estimates unchanged. +- **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. @@ -174,6 +221,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **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-15 (M2, renderer speed):** Three vendored hayro-interpret patches (31–33), pixel for pixel the same over the first three pages of every pdf.js corpus file. A sampled (Type 0) function keeps its samples in one flat array instead of a hash map rebuilt every time a shading using it is painted: `bug1721218` (InDesign) draws in 0.49 s (was 1.34 s), and a 2,097,152-sample ramp painted 30 times in under a second (was 38 s). One decode of a JPEG or Flate image serves every size it is drawn at (only JPX decodes for a size), so zooming a 300-dpi scan in and out costs 35–40% less render work. Coons and tensor patch meshes copy their vertices' colours less: mesh-gradient pages draw 10–16% faster (page 8 of #1584's annual report 773 → 696 ms). 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. @@ -284,6 +332,7 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-11 (M5, partial comment imports):** Replacing an editable named comment through XFDF or FDF (including an indirect name in the source PDF) preserves its object identity, so existing replies and review statuses remain attached after saving, reopening and exporting again. Replaced pop-ups are still removed. Locked comments, redaction marks and their pop-ups stay unchanged, including when their names are indirect; imported redactions remain visible. Synthetic round-trip tests cover both formats, incremental and full saves; milestone and effort estimates unchanged. +- **2026-10-15 (M9, #1895):** Preserve original signature artwork and show validation through a separate page overlay. Generic in-place appearance rewriting is deferred: neither a caption substring nor a unique drawing establishes an icon role. Signed regressions cover chamfered borders, mixed signer/caption text, image/text exports, both print modes and saves. Snapshot reuse and trust/cache refresh remain. Baked Not Verified captions are not replaced; #79 remains open. Estimates unchanged. - **2026-10-10 (M9, signature graphics):** A visible digital signature can show a graphic where the signer's name is large, as Acrobat's Configure Signature Appearance does: the saved Fill & Sign signature (drawn strokes smoothed as ink, typed outlines in the script font, or an image) or an imported PNG/JPEG. Sign as previews it and remembers the choice for the next signing; `sign_document` takes `graphic` (`image`, `text` or `strokes`). Graphics are checked and capped before drawing (non-finite or unbounded points, mismatched pixel buffers, oversized images are refused with an error the caller can act on); images keep their alpha as a soft mask. `sign.signature-appearance` stays partial (no named saved appearances or PDF-page graphics). Overall estimates unchanged. - **2026-10-11 (M14, absolute interface sizes):** A chosen Interface size is now absolute, as PhotoCraft's UI Scale is: "100%" is one screen pixel per point whatever scale the display reports, so on a 1080p screen whose system reports 200 % it draws at a true 100 % (#456, reported after the first change). Auto still keeps the display's scale and adds GNOME's text scaling, and its menu entry shows the size it draws at. The menu offers PhotoCraft's sizes (75–300 %). - **2026-10-11 (automation, #971):** `text_find` reads pages in growing batches (8, doubling to 128) and stops once `limit` is reached, so a bounded search on a long document no longer extracts and caches every page first. Results say `complete`, and a capped one gives `next` (`start_page`, `skip`) to continue. Still open from #971: a byte budget for the automation text cache and per-page warnings for pages whose text can't be extracted. @@ -489,8 +538,20 @@ Newest first: the date, what moved, and the new overall estimate. ## Revision history +**2026-10-15 · minor:** M14/#2383 candidate: GPU device requests clamp unsupported limits while retaining the adapter texture extent. Synthetic V3D/downlevel regressions and Windows GPU-required tests pass; Raspberry Pi hardware remains unverified. Estimates unchanged. + | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | M7: the Edit text caret is as tall as the row it is on in a region with several sizes; estimates unchanged. | +| 2026-10-18 | minor | M4, PR #1919 review: repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged. | +| 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. | +| 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. | +| 2026-10-13 | minor | M6: XML/XFDF attribute whitespace survives form and comment export/import; estimates unchanged | +| 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-13 | minor | M9: separate page validation overlays preserve signature artwork, and snapshot tabs reuse instead of multiplying; baked captions remain unchanged (#79 open); estimates unchanged | +| 2026-10-16 | minor | M10 #1201 partial: lossless image export applies component Decode pairs and Indexed mapping. Estimates unchanged. | +| 2026-10-15 | minor | M6: CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Estimates unchanged. | +| 2026-10-13 | minor | M4: rotation queries and edits resolve indirect page-tree values; inherited, negative and large rotations verified through both saves. 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. | @@ -511,7 +572,10 @@ 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: cleanup removes replacement descriptions only from changed marked-content spans, preserving surviving accessibility text. Estimates unchanged. | | 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). + +- **2026-10-18 (M8, #1350):** Published the history-preserving integration; simplified the inherited-form resource guard for Rust 1.96 Clippy. Formatting, 64 redaction tests, scoped Clippy, layering, assets and the scoped wasm check pass locally. CI remains pending; estimates unchanged. diff --git a/apps/pdfcraft-cli/Cargo.toml b/apps/pdfcraft-cli/Cargo.toml index d7b52fbd66..bab8c28a88 100644 --- a/apps/pdfcraft-cli/Cargo.toml +++ b/apps/pdfcraft-cli/Cargo.toml @@ -18,6 +18,7 @@ serde_json = { workspace = true } pdfcraft-engine = { workspace = true } pdfcraft-audit = { workspace = true } pdfcraft-cos = { workspace = true } +pdfcraft-preflight = { workspace = true } pdfcraft-automation = { workspace = true } pdfcraft-platform = { workspace = true } base64 = "0.22" diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index 0e24800d39..5888e51114 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -57,6 +57,7 @@ macro_rules! mcp_usage { const USAGE: &str = concat!( "\ pdfcraft-cli info [--password PW] document summary as JSON + (includes any declared PDF/A or PDF/UA level) pdfcraft-cli render --page N [--dpi 96] --out x.png (.png, .jpg, .tif or .pam) pdfcraft-cli text [--page N] extracted text (pages separated by form feeds) pdfcraft-cli edit --out out.pdf [--rotate 1,3:90] [--delete 2,4] [--move 5:1] @@ -298,29 +299,45 @@ fn info(args: &[String]) -> Result<(), CliError> { let path = *positional(args).first().ok_or("info: missing file")?; let bytes = read(path)?; let password = flag(args, "--password"); - let info = inspect(bytes.clone(), password).map_err(|e| e.to_string())?; + let mut info = inspect(bytes.clone(), password).map_err(|e| e.to_string())?; + let pdf_pages = info.pages.len(); + let acroform_fields = info.fields.len(); // Dynamic XFA forms: say what laying the template out gives (what the app shows). let xfa_layout = (info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) .then(|| { let mut s = pdfcraft_engine::Session::new(); let id = s.open("info.pdf", None, bytes.clone(), password).ok()?; let d = s.get(id)?; - d.xfa.as_ref().map(|x| serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })) + d.xfa.as_ref().map(|x| { + info = d.info.clone(); + // The in-memory layout is an appended revision, not the file being inspected. + info.file_size = bytes.len(); + serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings }) + }) }) .flatten(); + // What the document declares as a standard, read the way the Standards panel reads it, so + // `info` and the app agree. A file that declares nothing leaves these null and empty. + let declared = + pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map(|doc| pdfcraft_preflight::declared(&doc)).unwrap_or_default(); let json = serde_json::json!({ "file": path, "pdf_version": info.pdf_version, "file_size": info.file_size, "pages": info.pages.len(), + "pdf_pages": pdf_pages, "acroform_fields": acroform_fields, "first_page_pt": info.pages.first().map(|p| [p.width, p.height]), "title": info.title, "author": info.author, "producer": info.producer, "creator": info.creator, "encrypted": info.encrypted, "tagged": info.tagged, "javascript": info.has_javascript, + "pdfa": declared.pdfa.as_ref().map(|(part, conformance)| format!("PDF/A-{part}{}", conformance.to_lowercase())), + "pdfua": declared.pdfua, + "output_intents": declared.output_intents, "xfa": info.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }), "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(), + "custom_properties": info.custom.len(), "page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::>(), "warnings": info.warnings, }); @@ -348,7 +365,19 @@ fn text(args: &[String]) -> Result<(), CliError> { } } let password = flag(args, "--password"); - let bytes = read(path)?; + let mut bytes = read(path)?; + // Printing the text is copying it, so ask what `text_extract` asks (#1645): without the + // permissions password, a document that doesn't allow copying gives no text. + let mut session = pdfcraft_engine::Session::new(); + if let Some(doc) = session.open(path, None, bytes.clone(), password).ok().and_then(|id| session.get(id)) { + // The engine's hint is about editing in the app; on the command line the way in is + // `--password`. + doc.check_copying() + .map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?; + if doc.xfa.is_some() { + bytes = doc.display.clone(); + } + } let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: password.map(Arc::from), ..Default::default() }); // A document the renderer could not open (wrong or missing password, unparseable file) has // no pages, and "extract every page" of nothing would print nothing and exit 0 (#132). Ask @@ -359,15 +388,6 @@ fn text(args: &[String]) -> Result<(), CliError> { return Err("text: the document could not be parsed".into()); } } - // Printing the text is copying it, so ask what `text_extract` asks (#1645): without the - // permissions password, a document that doesn't allow copying gives no text. - let mut session = pdfcraft_engine::Session::new(); - if let Some(doc) = session.open(path, None, bytes, password).ok().and_then(|id| session.get(id)) { - // The engine's hint is about editing in the app; on the command line the way in is - // `--password`. - doc.check_copying() - .map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?; - } let pages: Vec = match selected_page { Some(page) => vec![page], None => (0..r.page_count()).collect(), @@ -588,16 +608,22 @@ fn render(args: &[String]) -> Result<(), CliError> { // 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( - pdfcraft_engine::display_bytes_for(bytes.clone(), flag(args, "--password")), - RenderConfig { password: flag(args, "--password").map(Arc::from), reject_oversize: true, ..Default::default() }, - ); + let password = flag(args, "--password"); + let display = if inspect(bytes.clone(), password).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) { + let mut session = pdfcraft_engine::Session::new(); + let id = session.open(path, None, bytes, password).map_err(|e| e.to_string())?; + session.get(id).ok_or("render: the document could not be opened")?.display.clone() + } else { + pdfcraft_engine::display_bytes_for(bytes, password) + }; + let mut r = PageRenderer::new(display, RenderConfig { password: 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 }); if let Some(e) = p.error { return Err(e.into()); } let bytes = match format { - Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f)?, + // With `reject_oversize` the page is drawn at `--dpi` or not at all, so the file can say so. + Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f, f64::from(dpi))?, None => { // PAM (netpbm RGB_ALPHA): the raw premultiplied pixels, for tools that read it. let mut pam = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", p.width, p.height).into_bytes(); diff --git a/apps/pdfcraft-cli/tests/info_standards.rs b/apps/pdfcraft-cli/tests/info_standards.rs new file mode 100644 index 0000000000..68be86228c --- /dev/null +++ b/apps/pdfcraft-cli/tests/info_standards.rs @@ -0,0 +1,87 @@ +//! `info` reports the standard a document declares, as the Standards panel does (#3319). A file +//! carrying `pdfaid:part` and `pdfaid:conformance` in its XMP metadata was summarised with no +//! mention of PDF/A at all, though the app reads the same declaration and shows it. + +use std::process::Command; + +const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); + +/// A one-page PDF, with `metadata` as the catalog's XMP stream when given. +fn fixture(metadata: Option<&str>) -> Vec { + let content = b"0 0 0 rg 10 10 40 20 re f"; + let catalog = match metadata { + Some(_) => "<< /Type /Catalog /Pages 2 0 R /Metadata 5 0 R >>".to_string(), + None => "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + }; + let mut objs: Vec> = vec![ + catalog.into_bytes(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 60] /Contents 4 0 R >>".to_vec(), + format!("<< /Length {} >> stream\n", content.len()).into_bytes(), + ]; + objs[3].extend_from_slice(content); + objs[3].extend_from_slice(b"\nendstream"); + if let Some(xmp) = metadata { + let mut stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >> stream\n", xmp.len()).into_bytes(); + stream.extend_from_slice(xmp.as_bytes()); + stream.extend_from_slice(b"\nendstream"); + objs.push(stream); + } + 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 off in &offsets { + out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn xmp(part: &str, conformance: &str) -> String { + format!( + "\n\ +\n \n\ + \n\ + {part}\n {conformance}\n\ + \n \n\n" + ) +} + +fn info_of(bytes: &[u8], name: &str) -> serde_json::Value { + let dir = std::env::temp_dir().join(format!("pdfcraft-info-standards-{}-{name}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("f.pdf"); + std::fs::write(&path, bytes).unwrap(); + let out = Command::new(BIN).args(["info", path.to_str().unwrap()]).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let json = serde_json::from_slice(&out.stdout).expect("info prints JSON"); + let _ = std::fs::remove_dir_all(&dir); + json +} + +#[test] +fn info_names_the_declared_pdfa_level() { + let declared = info_of(&fixture(Some(&xmp("2", "B"))), "pdfa2b"); + assert_eq!(declared["pdfa"], serde_json::json!("PDF/A-2b"), "{declared}"); + assert_eq!(declared["pdfua"], serde_json::Value::Null); + assert_eq!(declared["output_intents"], serde_json::json!([])); + // Lower-case conformance in the file still reads as the level's own spelling. + assert_eq!(info_of(&fixture(Some(&xmp("3", "a"))), "pdfa3a")["pdfa"], serde_json::json!("PDF/A-3a")); +} + +#[test] +fn info_declares_nothing_for_a_plain_pdf() { + let plain = info_of(&fixture(None), "plain"); + assert_eq!(plain["pdfa"], serde_json::Value::Null, "{plain}"); + assert_eq!(plain["pdfua"], serde_json::Value::Null); + // The rest of the summary is unaffected. + assert_eq!(plain["pages"], serde_json::json!(1)); + assert_eq!(plain["pdf_version"], serde_json::json!("1.7")); +} diff --git a/apps/pdfcraft-cli/tests/render.rs b/apps/pdfcraft-cli/tests/render.rs index b0ab245601..695431b65b 100644 --- a/apps/pdfcraft-cli/tests/render.rs +++ b/apps/pdfcraft-cli/tests/render.rs @@ -56,6 +56,17 @@ fn jpeg_and_tiff_names_get_those_formats() { assert!(bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*"), "not a TIFF: {:?}", &bytes[..4]); } +#[test] +fn the_file_records_the_dpi_it_was_rendered_at() { + // The engine's tests check each format; this checks that `--dpi` reaches the file. + let pdf = fixture("dpi"); + let jpg = tmp("dpi", "p1.jpg"); + assert!(render(&pdf, &jpg).status.success()); + let bytes = std::fs::read(&jpg).unwrap(); + assert_eq!(&bytes[6..11], b"JFIF\0"); + assert_eq!(bytes[13..18], [1, 0, 36, 0, 36], "JFIF density in dots per inch"); +} + #[test] fn a_pam_name_still_gets_the_netpbm_stream() { let pdf = fixture("pam"); diff --git a/apps/pdfcraft-cli/tests/xfa.rs b/apps/pdfcraft-cli/tests/xfa.rs new file mode 100644 index 0000000000..1b5ff7bc9d --- /dev/null +++ b/apps/pdfcraft-cli/tests/xfa.rs @@ -0,0 +1,136 @@ +//! CLI page commands must use the same dynamic XFA layout as the app and tools (#1645). + +use std::path::PathBuf; +use std::process::{Command, Output}; +use std::sync::Arc; + +const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); + +fn fixture(test: &str) -> PathBuf { + let xdp = r#" +"#; + let objects = [ + "<< /Type /Catalog /Pages 2 0 R /NeedsRendering true /AcroForm << /XFA 5 0 R /Fields [] >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>".into(), + "<< /Length 0 >>\nstream\n\nendstream".into(), + format!("<< /Length {} >>\nstream\n{xdp}\nendstream", xdp.len()), + ]; + 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{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(b"xref\n0 6\n0000000000 65535 f \n"); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + let dir = std::env::temp_dir().join(format!("pdfcraft-cli-xfa-{test}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("form.pdf"); + std::fs::write(&path, bytes).unwrap(); + path +} + +fn success(output: &Output) { + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); +} + +#[test] +fn info_reports_the_laid_out_pages_and_fields() { + let path = fixture("info"); + let original = std::fs::read(&path).unwrap(); + let output = Command::new(BIN).arg("info").arg(&path).output().unwrap(); + success(&output); + let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(info["xfa_layout"]["pages"], 2, "the synthetic form lays out into two pages"); + assert_eq!(info["pages"], 2, "top-level page count must agree with the app"); + assert_eq!(info["fields"], 1); + assert_eq!(info["pdf_pages"], 1); + assert_eq!(info["acroform_fields"], 0); + assert_eq!(info["first_page_pt"], serde_json::json!([200.0, 300.0])); + assert_eq!(info["file_size"], original.len()); + assert_eq!(std::fs::read(path).unwrap(), original, "inspection must not write the layout to the input"); +} + +#[test] +fn text_reads_the_layout_including_a_page_absent_from_the_placeholder() { + let path = fixture("text"); + let output = Command::new(BIN).arg("text").arg(&path).output().unwrap(); + success(&output); + let text = String::from_utf8(output.stdout).unwrap(); + assert!(text.contains("First XFA page") && text.contains("Second XFA page"), "{text:?}"); + let output = Command::new(BIN).arg("text").arg(&path).args(["--page", "2"]).output().unwrap(); + success(&output); + let text = String::from_utf8(output.stdout).unwrap(); + assert!(text.contains("Second XFA page") && !text.contains("First XFA page"), "{text:?}"); +} + +#[test] +fn render_matches_the_engine_layout_on_the_second_page() { + use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; + let path = fixture("render"); + let original = std::fs::read(&path).unwrap(); + let mut session = pdfcraft_engine::Session::new(); + let id = session.open("form.pdf", None, Arc::new(original.clone()), None).unwrap(); + let doc = session.get(id).unwrap(); + assert_eq!(doc.info.pages.len(), 2); + assert!(doc.text_lines(1).iter().any(|line| line.text.contains("Second XFA page"))); + let mut renderer = PageRenderer::new(doc.display.clone(), RenderConfig::default()); + let expected = renderer.render(RenderRequest { page: 1, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(expected.error.is_none(), "{:?}", expected.error); + assert!(expected.rgba.as_chunks::<4>().0.iter().filter(|[r, _, _, a]| *r < 128 && *a > 0).count() > 10, "the layout has visible ink"); + let out = path.with_extension("pam"); + let output = Command::new(BIN).arg("render").arg(&path).args(["--page", "2", "--dpi", "72", "--out"]).arg(&out).output().unwrap(); + success(&output); + let actual = std::fs::read(out).unwrap(); + let header = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", expected.width, expected.height); + assert!(actual.starts_with(header.as_bytes())); + assert_eq!(&actual[header.len()..], &expected.rgba[..]); + assert_eq!(std::fs::read(path).unwrap(), original); +} + +#[test] +fn a_laid_out_form_keeps_the_original_copy_permissions() { + let path = fixture("permissions"); + let mut doc = pdfcraft_cos::Document::open(Arc::new(std::fs::read(&path).unwrap())).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "openme", + owner_password: "boss", + permissions: -17, + encrypt_metadata: true, + seed: [17; 32], + }) + .unwrap(); + let original = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + std::fs::write(&path, &original).unwrap(); + let output = Command::new(BIN).arg("text").arg(&path).args(["--password", "openme"]).output().unwrap(); + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("don't allow copying")); + let output = Command::new(BIN).arg("text").arg(&path).args(["--password", "boss", "--page", "2"]).output().unwrap(); + success(&output); + assert!(String::from_utf8_lossy(&output.stdout).contains("Second XFA page")); + let output = Command::new(BIN).arg("info").arg(&path).args(["--password", "openme"]).output().unwrap(); + success(&output); + let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(info["pages"], 2); + assert_eq!(info["encrypted"], true); + assert_eq!(info["file_size"], original.len()); + assert_eq!(std::fs::read(path).unwrap(), original); +} diff --git a/apps/pdfcraft-web/src/main.rs b/apps/pdfcraft-web/src/main.rs index a46a4674b9..73d14f4a6c 100644 --- a/apps/pdfcraft-web/src/main.rs +++ b/apps/pdfcraft-web/src/main.rs @@ -70,6 +70,10 @@ fn main() { app.host_save = Some(bridge.save_fn()); app.host_dirty = Some(bridge.dirty_fn()); bridge.post_ready(); + } else if query_param("host").as_deref() == Some("parent") { + eframe::web_sys::console::warn_1( + &"PdfCraft: ?host=parent needs a framing page on the same origin; saves download instead".into(), + ); } Ok(Box::new(app)) }), @@ -130,7 +134,8 @@ async fn fetch_bytes(url: &str) -> Result, String> { /// - `pdfcraft:dirty` (to the parent): `{ dirty }` whenever unsaved work appears or goes away. /// - `pdfcraft:failed` (to the parent): `{ error }` when the app couldn't start. /// -/// Only the parent window on the app's own origin is heard or answered. +/// Only the parent window on the app's own origin is heard or answered. With a parent on +/// another origin there is no bridge: the app runs as if unframed and saves download. #[cfg(target_arch = "wasm32")] mod host { use eframe::wasm_bindgen::{JsCast, JsValue, closure::Closure}; @@ -143,7 +148,9 @@ mod host { } impl Bridge { - /// `None` when the page isn't framed (there is no parent to talk to). + /// `None` when the page isn't framed (there is no parent to talk to), or when the parent + /// is on another origin: messages only go to this origin, so the browser would drop + /// every save without an error while the document showed as saved (#2167). pub fn new() -> Option { let window = web_sys::window()?; let parent = window.parent().ok().flatten()?; @@ -151,6 +158,10 @@ mod host { return None; } let origin = window.location().origin().ok()?; + // Reading another origin's location throws, which is `None` too. + if parent.location().origin().ok()? != origin { + return None; + } Some(Self { window, parent, origin }) } diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index a27401c700..3ce1c96ef3 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -54,8 +54,9 @@ 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). -winsafe = { version = "0.0.29", default-features = false, features = ["user"] } +# shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it), +# and `advapi` for the session id that names the single-instance socket (#3338). +winsafe = { version = "0.0.29", default-features = false, features = ["user", "advapi"] } # Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects (src/motion.rs), through WinRT's # `UISettings`, whose bindings are safe to call (winsafe's `SystemParametersInfo` is an unsafe # fn). Already in the tree through wgpu's DX12 backend; this adds one small namespace to it. diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 7a0bb20432..721b7da1b3 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -231,7 +231,10 @@ fn main() -> eframe::Result { // 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) { + // Absolute for this instance too: links relative to a document resolve against its folder, + // never the working directory. + let files = single_instance::absolute(&files); + let instance = match single_instance::claim(&files, may_hand_off) { single_instance::Claim::HandedOff => return Ok(()), single_instance::Claim::Primary(server) => Some(server), single_instance::Claim::Alone => None, @@ -377,6 +380,7 @@ fn app_creator<'a>( if let Some(json) = cc.storage.and_then(|s| s.get_string("pdfcraft").or_else(|| s.get_string(LEGACY_STORAGE_KEY))) { app.restore(&json); } + app.window_state = pdfcraft_ui_egui::window_state::WindowState::started_with(cc.storage); app.integrated_titlebar = integrated; app.window_controls = window_controls; app.system_text_scale = text_scale::system(); @@ -562,7 +566,7 @@ fn configure_gpu(native: &mut eframe::NativeOptions) { setup.instance_descriptor.flags = setup.instance_descriptor.flags.difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env(); // egui-wgpu requests only 8192 pixels even when the adapter can render larger surfaces. // A restored 3440-point window at 250% DPI requests 8600 pixels and otherwise panics in - // Surface::configure before the app starts. Keep the renderer's other device requirements, + // Surface::configure before the app starts. Keep the renderer's other compatible requirements, // but enable the adapter's actual 2D texture extent (also for smaller/downlevel adapters). let device_descriptor = std::sync::Arc::clone(&setup.device_descriptor); setup.device_descriptor = std::sync::Arc::new(move |adapter| { @@ -603,8 +607,9 @@ fn configure_gpu(native: &mut eframe::NativeOptions) { } /// Surface textures must fit the device's enabled limits, not just the physical GPU's limits. -/// Changing only the 2D extent preserves egui-wgpu's backend-specific downlevel requirements. +/// Clamp egui-wgpu's backend-specific requirements before enabling the adapter's full 2D extent. fn surface_texture_limits(mut required: eframe::wgpu::Limits, supported: &eframe::wgpu::Limits) -> eframe::wgpu::Limits { + required = required.or_worse_values_from(supported); required.max_texture_dimension_2d = supported.max_texture_dimension_2d; required } @@ -909,16 +914,51 @@ mod tests { fn surface_limits_preserve_other_requirements_and_never_overrequest() { for required in [eframe::wgpu::Limits::default(), eframe::wgpu::Limits::downlevel_webgl2_defaults()] { for extent in [4096, 8192, 16384] { - let supported = eframe::wgpu::Limits { max_texture_dimension_2d: extent, ..required.clone() }; + let supported = eframe::wgpu::Limits { + max_texture_dimension_2d: extent, + max_bind_groups: 8, + min_uniform_buffer_offset_alignment: 128, + min_storage_buffer_offset_alignment: 128, + ..required.clone() + }; let enabled = super::surface_texture_limits(required.clone(), &supported); let mut expected = required.clone(); expected.max_texture_dimension_2d = extent; assert_eq!(enabled, expected); assert_eq!(enabled.max_texture_dimension_2d, supported.max_texture_dimension_2d); + assert!(enabled.check_limits(&supported)); } } } + #[test] + fn surface_limits_fit_v3d_shader_variable_limit() { + // Issue #2383: V3D supports 15 variables, below the default request of 16. + let required = eframe::wgpu::Limits::default(); + let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required.clone() }; + let enabled = super::surface_texture_limits(required.clone(), &supported); + assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}"); + let expected = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required }; + assert_eq!(enabled, expected); + } + + #[test] + fn surface_limits_fit_downlevel_maxima_and_minimum_alignments() { + let required = eframe::wgpu::Limits::downlevel_webgl2_defaults(); + let supported = eframe::wgpu::Limits { + max_texture_dimension_2d: 2048, + max_bind_groups: 2, + max_vertex_attributes: 8, + min_uniform_buffer_offset_alignment: 512, + min_storage_buffer_offset_alignment: 512, + ..required.clone() + }; + let enabled = super::surface_texture_limits(required, &supported); + assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}"); + // Larger minimum alignments are weaker capabilities, unlike maximum limits. + assert_eq!(enabled, supported); + } + #[test] fn a_window_larger_than_the_gpu_limit_gets_a_surface_within_it() { use eframe::egui_wgpu::winit::surface_fit; diff --git a/apps/pdfcraft/src/single_instance.rs b/apps/pdfcraft/src/single_instance.rs index ecfa12f5c3..751785d0c5 100644 --- a/apps/pdfcraft/src/single_instance.rs +++ b/apps/pdfcraft/src/single_instance.rs @@ -153,7 +153,7 @@ pub fn claim(files: &[String], may_hand_off: bool) -> Claim { /// `%LOCALAPPDATA%` is unset. #[cfg(windows)] fn socket_path() -> Option { - let name = socket_name(std::env::var("SESSIONNAME").ok().as_deref()); + let name = socket_name(session_id(), std::env::var("SESSIONNAME").ok().as_deref()); // %LOCALAPPDATA% is private to the user's sign-in, so other accounts can't receive this // user's file paths or send it files to open. A portable copy uses it too (#889), under its // own name so it never hands files to an installed copy (which keeps its settings elsewhere). @@ -184,13 +184,31 @@ fn display_socket_name(display: Option<&str>) -> String { 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. +/// The Windows session this process runs in, from its access token: the console, each Remote +/// Desktop sign-in and session 0 (services) each have their own. `None` if Windows won't say. +#[cfg(windows)] +fn session_id() -> Option { + use winsafe::{HACCESSTOKEN, TokenInfo, co::TOKEN_INFORMATION_CLASS}; + match HACCESSTOKEN::GetCurrentProcessToken().GetTokenInformation(TOKEN_INFORMATION_CLASS::SessionId) { + Ok(TokenInfo::SessionId(id)) => Some(*id), + _ => None, + } +} + +/// The socket's file name for a Windows session. 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. The session is the token's session id (`session`): a launch from Task Scheduler, +/// a service or a remote shell has no `SESSIONNAME` but still runs in a session, so it finds the +/// window of the session it runs in (#3338), and never one in another session (review on #3340). +/// Only if the id can't be read does `SESSIONNAME` (`Console`, `RDP-Tcp#3`…) name it, letters, +/// digits and `-` kept. #[cfg_attr(not(windows), allow(dead_code))] -fn socket_name(session: Option<&str>) -> String { - let session: String = session.unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').take(32).collect(); - if session.is_empty() { "instance.sock".to_string() } else { format!("instance-{session}.sock") } +fn socket_name(session: Option, session_name: Option<&str>) -> String { + if let Some(id) = session { + return format!("instance-s{id}.sock"); + } + let name: String = session_name.unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').take(32).collect(); + if name.is_empty() { "instance.sock".to_string() } else { format!("instance-{name}.sock") } } /// Held while a launch finds out whether it is the first, so launches that start together (several @@ -359,12 +377,20 @@ mod tests { #[test] fn each_windows_session_gets_its_own_socket_name() { - assert_eq!(socket_name(Some("Console")), "instance-Console.sock"); - assert_eq!(socket_name(Some("RDP-Tcp#3")), "instance-RDP-Tcp3.sock"); - assert_ne!(socket_name(Some("RDP-Tcp#3")), socket_name(Some("RDP-Tcp#4"))); - assert_eq!(socket_name(Some(r"..\x/y:z")), "instance-xyz.sock", "never a path"); - assert_eq!(socket_name(None), "instance.sock"); - assert_eq!(socket_name(Some("")), "instance.sock"); + // The session id decides: #3338, a launch from Task Scheduler (no SESSIONNAME) in the + // console's session finds the window opened from Explorer there... + assert_eq!(socket_name(Some(1), None), socket_name(Some(1), Some("Console"))); + // ...and (review on #3340) one in a Remote Desktop session never reaches the console's. + assert_ne!(socket_name(Some(2), None), socket_name(Some(1), Some("Console"))); + assert_ne!(socket_name(Some(2), Some("RDP-Tcp#3")), socket_name(Some(3), Some("RDP-Tcp#3"))); + assert_eq!(socket_name(Some(4_000_000_000), None), "instance-s4000000000.sock"); + // Only without an id does SESSIONNAME name it. + assert_eq!(socket_name(None, Some("Console")), "instance-Console.sock"); + assert_eq!(socket_name(None, Some("RDP-Tcp#3")), "instance-RDP-Tcp3.sock"); + assert_ne!(socket_name(None, Some("RDP-Tcp#3")), socket_name(None, Some("RDP-Tcp#4"))); + assert_eq!(socket_name(None, Some(r"..\x/y:z")), "instance-xyz.sock", "never a path"); + assert_eq!(socket_name(None, None), "instance.sock"); + assert_eq!(socket_name(None, Some("")), "instance.sock"); } #[test] @@ -394,11 +420,21 @@ mod tests { assert_eq!(display_socket_name(Some("")), "instance.sock"); } + /// The socket is named by the session the process runs in, which Windows always gives. + #[cfg(windows)] + #[test] + fn this_process_has_a_session_id() { + let id = super::session_id(); + assert!(id.is_some(), "the access token names the session"); + let name = socket_name(id, None); + assert!(socket_path().is_none_or(|p| p.file_name().is_some_and(|f| f.to_string_lossy().ends_with(&name))), "{name}"); + } + #[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)) + dir.join(socket_name(None, None)) } #[cfg(any(windows, all(unix, not(target_os = "macos"))))] diff --git a/contributors/people.toml b/contributors/people.toml index da3cfc6a77..cd8ef99f72 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -145,3 +145,6 @@ sync_github_name = false [people.Silvertree2010] display_name = "Netter" sync_github_name = false + +[people.MortenHusted] +sync_github_name = true diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index f4973aa021..b7f9d62697 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -2282,6 +2282,11 @@ fn line_endings_of(d: &Dict) -> Option> { /// Replace Text (Acrobat's proposal): strike out `quads` and add a caret at the end of the /// struck text holding `replacement`, grouped with the strikeout (`/IRT` + `/RT /Group`) so they /// move, list and delete as one. Returns the strikeout's index. +/// +/// The two annotations need their own `/NM`: XFDF names a reply by the `/NM` of what it replies +/// to, so one id for both writes `inreplyto` equal to the caret's own `name`, and a reader that +/// resolves names (Acrobat, and our own importer) loses the pair. `caret_meta` is a second id +/// from the caller, so the edit stays deterministic. #[allow(clippy::too_many_arguments)] pub fn add_text_replacement( doc: &mut Document, @@ -2292,6 +2297,7 @@ pub fn add_text_replacement( strike: &Style, caret: &Style, meta: &Meta, + caret_meta: &Meta, ) -> Result { let last = *quads.last().ok_or_else(|| AnnotError::Invalid("select the text to replace first".into()))?; let s = add_annotation( @@ -2317,7 +2323,7 @@ pub fn add_text_replacement( let c = add_annotation( doc, &NewAnnotation { page, shape: Shape::Caret { rect }, style: caret.clone(), contents: replacement.into(), author: author.into() }, - meta, + caret_meta, )?; let (_, cr) = annot_ref(doc, page, c)?; doc.update_dict(cr, |d| { diff --git a/crates/annot/src/links.rs b/crates/annot/src/links.rs index 2e45b4e4d4..4d3eef632b 100644 --- a/crates/annot/src/links.rs +++ b/crates/annot/src/links.rs @@ -178,6 +178,19 @@ fn write_style(d: &mut Dict, s: &LinkStyle) { d.set(b"H".to_vec(), Object::name(s.highlight.code())); } +/// Bytes of `u` as a 7-bit ASCII URI string (§12.6.4.8). +fn uri_bytes(u: &str) -> Vec { + let mut out = Vec::with_capacity(u.len()); + for &b in u.as_bytes() { + if (0x21..=0x7e).contains(&b) { + out.push(b); + } else { + out.extend_from_slice(format!("%{b:02X}").as_bytes()); + } + } + out +} + fn write_action(doc: &Document, d: &mut Dict, a: &LinkAction) -> Result<(), AnnotError> { match a { LinkAction::Page(p) => { @@ -194,7 +207,7 @@ fn write_action(doc: &Document, d: &mut Dict, a: &LinkAction) -> Result<(), Anno let mut act = Dict::new(); act.set(b"S".to_vec(), Object::name("URI")); // URIs are 7-bit ASCII strings (§12.6.4.8). - act.set(b"URI".to_vec(), PdfString::literal(u.as_bytes().to_vec())); + act.set(b"URI".to_vec(), PdfString::literal(uri_bytes(u))); d.set(b"A".to_vec(), Object::Dict(act)); } LinkAction::Other(_) => {} diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index faed409abb..d3d448e520 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -588,6 +588,25 @@ fn links_are_added_edited_listed_and_removed() { assert!(links::add(&mut doc, 0, [0.0, 0.0, 1.0, 1.0], &LinkAction::Page(0), &LinkStyle::default()).is_err(), "too small"); } +#[test] +fn link_uri_is_percent_encoded_to_7_bit_ascii() { + use crate::links::{self, LinkAction, LinkStyle}; + let mut doc = fixture(); + for url in ["https://example.com/ação", "https://example.com/a%20b"] { + links::add(&mut doc, 0, [10.0, 10.0, 110.0, 30.0], &LinkAction::Uri(url.into()), &LinkStyle::default()).unwrap(); + } + let doc = reopen(&doc); + let urls: Vec = links::list(&doc) + .into_iter() + .filter_map(|link| match link.action { + LinkAction::Uri(url) => Some(url), + _ => None, + }) + .collect(); + assert_eq!(urls, ["https://example.com/a%C3%A7%C3%A3o", "https://example.com/a%20b"]); + assert!(urls.iter().all(|url| url.is_ascii())); +} + #[test] fn link_edit_rejects_non_finite_and_overflowed_rectangles_without_mutating_the_pdf() { use crate::links::{self, LinkAction, LinkStyle}; @@ -784,7 +803,7 @@ fn replace_text_groups_a_strikeout_and_a_caret() { let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]]; let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None }; let blue = Style { color: [0.0, 0.47, 0.84], opacity: 1.0, width: 1.0, fill: None }; - let i = add_text_replacement(&mut doc, 0, &quads, "new words", "Ada", &red, &blue, &meta("r")).unwrap(); + let i = add_text_replacement(&mut doc, 0, &quads, "new words", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).unwrap(); let doc = reopen(&doc); let a = list(&doc, 0); let strike = &a[i]; @@ -792,13 +811,21 @@ fn replace_text_groups_a_strikeout_and_a_caret() { let caret = a.iter().find(|d| d.name(b"Subtype") == Some(b"Caret")).unwrap(); assert_eq!((text(caret, b"Contents").as_str(), caret.name(b"RT")), ("new words", Some(&b"Group"[..]))); assert!(caret.reference(b"IRT").is_some()); + // #2666: the pair needs its own `/NM` each. XFDF names a reply by the `/NM` of what it + // replies to, so sharing one id writes `inreplyto` equal to the caret's own `name` and the + // pair does not survive an export and import. + assert_eq!(text(caret, b"NM").as_str(), "r-caret"); + assert_eq!(text(strike, b"NM").as_str(), "r"); + assert_ne!(text(caret, b"NM"), text(strike, b"NM"), "the caret must not carry the strikeout's name"); + let irt = caret.reference(b"IRT").unwrap(); + assert_eq!(text(doc.get(irt).as_dict().unwrap(), b"NM").as_str(), "r", "the caret replies to the strikeout"); let r = rect(caret); assert!(r[0] < 200.0 && r[2] > 200.0 && r[1] < 706.0, "at the end of the line, below its baseline: {r:?}"); // Deleting the strikeout takes the caret with it (the group). let mut doc = doc; delete_annotation(&mut doc, 0, i).unwrap(); assert!(!list(&doc, 0).iter().any(|d| d.name(b"Subtype") == Some(b"Caret"))); - assert!(add_text_replacement(&mut doc, 0, &[], "x", "Ada", &red, &blue, &meta("r")).is_err()); + assert!(add_text_replacement(&mut doc, 0, &[], "x", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).is_err()); } #[test] diff --git a/crates/audit/README.md b/crates/audit/README.md index 5dd15de3af..bda5cc977e 100644 --- a/crates/audit/README.md +++ b/crates/audit/README.md @@ -16,9 +16,9 @@ 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 +A rectangle covers a text line when it hides at least half of one of the line's words, 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: @@ -29,8 +29,8 @@ Limits, by design and documented rather than hidden: - 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; -- 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. +- a cover that hides less than half of every word it touches, less than 40% of a line's box and + less than 40% of its words (a sliver of a long value) 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". diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index 204d237b3d..723d086d63 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -87,6 +87,9 @@ pub enum AuditError { /// 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; +/// Fraction of one word's area that must lie under a cover to count as hidden on its own, however +/// long the line around it (see [`covers`]). +const WORD_FRACTION: f64 = 0.5; /// WCAG contrast ratio below which the text is unreadable against the cover. const COVER_CONTRAST: f64 = 3.0; @@ -138,7 +141,10 @@ fn is_cover(rect: &cover::FilledRect, line: &TextLine) -> bool { rect.pos > line.paint_pos() && covers(&rect.rect, line) && contrast(line.color, rect.rgb) < COVER_CONTRAST } -/// Does `cover` hide a substantial part of what `line` draws? Two ways, either is enough: +/// Does `cover` hide a substantial part of what `line` draws? Three ways, any is enough: +/// +/// - Half of one word lies under the cover. A box over one number in a long line hides a few +/// percent of the line, and the number all the same (#2662). /// /// - 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 @@ -155,6 +161,9 @@ fn covers(cover: &[f64; 4], line: &TextLine) -> bool { let total: f64 = boxes.iter().map(area).sum(); if total > 0.0 { boxes.iter().map(hidden).sum::() / total } else { 0.0 } }; + if line.ink.iter().any(|w| area(w) > 0.0 && hidden(w) / area(w) >= WORD_FRACTION) { + return true; + } 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); diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index d7d83a7db3..63d9220cca 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -175,6 +175,47 @@ fn unapplied_redact_mark_is_found() { assert!(findings[0].covered_text.contains("DO NOT SHARE")); } +/// #2662: a box over one number of a long line hides a few percent of the line, and all of the +/// number. Found as a cover, and as an unapplied mark over the same word. +#[test] +fn a_cover_over_one_word_of_a_long_line_is_found() { + // Helvetica 11: "The contract value is " ends near x 132, "98765" spans about 132-163. + let line = b"BT /F1 11 Tf 30 100 Td (The contract value is 98765 and the rest of this long line continues here.) Tj ET +"; + let mut content = line.to_vec(); + content.extend_from_slice( + b"0 g +131 96 33 14 re f +", + ); + let findings = audit_redactions(&open(one_page(&content, ""))).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].kind, FindingKind::CoveredText); + assert!(findings[0].covered_text.contains("98765"), "{findings:?}"); + + 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("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>"); + b.add_stream(line); + b.add(FONT); + b.add("<< /Type /Annot /Subtype /Redact /Rect [131 96 164 110] >>"); + let findings = audit_redactions(&open(b.build())).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].kind, FindingKind::UnappliedMark); +} + +/// A box that only grazes the words beside it (less than half of each) is not a cover. +#[test] +fn a_box_grazing_a_word_is_not_flagged() { + let content = b"BT /F1 11 Tf 30 100 Td (The contract value is 98765 and the rest of this long line continues here.) Tj ET +0 g +131 96 6 14 re f +"; + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert!(findings.is_empty(), "{findings:?}"); +} + #[test] fn background_before_text_is_not_flagged() { // An opaque dark rectangle painted *before* the text is a background, not a cover. @@ -303,15 +344,17 @@ fn partial_value_is_found(rotated: bool) { /// 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", + // The second box of the first case hides most of the number (less than 40% of the line): a + // finding of its own since covers are also measured per word (#2662). + let cases: [(&[u8], usize); 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", 2), + (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", 1), + (b"BT /F1 11 Tf 8 Tc 72 742 Td (Account: 1234) Tj ET\n0 g\n145 738 90 16 re f\n", 1), + (b"BT /F1 11 Tf 250 Tz 72 742 Td (Account: 1234) Tj ET\n0 g\n100 738 160 16 re f\n", 1), ]; - for content in cases { + for (content, expected) 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)); + assert_eq!(findings.len(), expected, "{}: {findings:?}", String::from_utf8_lossy(content)); } } @@ -457,3 +500,32 @@ fn forms_nested_past_depth_limit_fail_closed() { let result = audit_redactions(&open(nested_forms(33))); assert!(matches!(result, Err(crate::AuditError::TooDeep)), "{result:?}"); } + +#[test] +fn comment_across_streams_neither_invents_text_nor_a_cover() { + let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Visible text) Tj ET % note", b"0 g 70 690 200 40 re f")); + assert!(audit_redactions(&doc).unwrap().is_empty(), "commented cover must not be reported"); + let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Before comment) Tj ET % note", b"BT /F1 24 Tf 72 650 Td (After comment) Tj ET")); + let text: String = pdfcraft_edit::text_lines(&doc, 0).unwrap().into_iter().map(|line| line.text).collect(); + assert!(!text.contains("After comment"), "editor exposed phantom text: {text}"); +} + +#[test] +fn reading_blocks_respect_stream_comments_and_split_operands() { + let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Before comment) Tj ET % note", b"BT /F1 24 Tf 72 650 Td (After comment) Tj ET")); + let text: String = pdfcraft_edit::reading_blocks(&doc, 0).unwrap().into_iter().map(|block| block.editor_text).collect(); + assert_eq!(text, "Before comment"); + let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Split operand)", b"Tj ET")); + let text: String = pdfcraft_edit::reading_blocks(&doc, 0).unwrap().into_iter().map(|block| block.editor_text).collect(); + assert_eq!(text, "Split operand"); + // Marked content opened in one stream applies to text in the next: its /ActualText replaces + // the glyphs, as when the streams are read joined. + for (s1, s2) in [ + (b"BT /F1 24 Tf 72 700 Td /Span <> BDC (Glyphs) Tj EMC ET".as_slice()), + (b"BT /F1 24 Tf 72 700 Td /Span <> BDC", b"(Glyphs) Tj EMC ET"), + ] { + let doc = open(two_streams(s1, s2)); + let text: String = pdfcraft_edit::reading_blocks(&doc, 0).unwrap().into_iter().map(|block| block.editor_text).collect(); + assert_eq!(text, "Replacement text", "{}", String::from_utf8_lossy(s1)); + } +} diff --git a/crates/automation/README.md b/crates/automation/README.md index 4e27702a32..669ea61463 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -64,6 +64,20 @@ semantics; they never silently act on partial text. ## Shared field properties +For an individual push button, `form_set_props` also edits its visible normal label: + +```json +{"doc": 1, "field": "clear_button", "caption": "Clear Form", "widget": 1} +``` + +`widget` is a 1-based index (default 1), used only with `caption`; an empty caption clears the +label. Neither renames the field. `form_fields` returns the first widget's `caption` and all +`captions` as `[{"widget": 1, "caption": "Clear Form"}]`. Other widgets and icon-only layouts +keep their settings. CLI and MCP use the same edit, including undo/redo and save/reopen. +The desktop exposes it as **Button Properties → Options → Label**. Rollover/down labels and +icon layout are not edited here; the existing caption font and unsupported-character warning +policy apply. + `form_set_props` accepts either `field` (one name) or `fields` (1–1000 unique names): ```json @@ -75,7 +89,7 @@ The batch is atomic and creates one undo step. A missing or locked field, invali unknown argument or malformed appearance refuses the whole edit. Set `locked: false` to unlock and change fields together. Only supplied properties change: each field/widget keeps its other colours, fonts, values and unknown PDF dictionary keys. `"none"` removes a border -or fill colour. Names, rectangles and rotations require the single-field form. Results keep +or fill colour. Names, rectangles, rotations and captions require the single-field form. Results keep `field` for single edits and return `fields` for batches. CLI and MCP use the same tool. ## MCP server diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 1e8d71bd1c..8130ed084d 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -918,11 +918,16 @@ impl Automation { let list: Vec = found .iter() .map(|(p, c)| { - let kind = match c.kind { - pdfcraft_engine::detect::Kind::Text => "text", - pdfcraft_engine::detect::Kind::CheckBox => "checkbox", + let (kind, comb) = match c.kind { + pdfcraft_engine::detect::Kind::Text => ("text", None), + pdfcraft_engine::detect::Kind::CheckBox => ("checkbox", None), + pdfcraft_engine::detect::Kind::Comb { cells } => ("text", Some(cells)), }; - json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) }) + let mut v = json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) }); + if let Some(cells) = comb { + v["comb"] = json!(cells); + } + v }) .collect(); if a.opt_bool("add")?.unwrap_or(true) && !found.is_empty() { diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs index 710952d7c2..faefc5fa7c 100644 --- a/crates/automation/src/content.rs +++ b/crates/automation/src/content.rs @@ -138,7 +138,7 @@ impl Automation { .added() .iter() .filter(|x| x.page == page) - .nth((index - 1).max(0) as usize) + .nth(usize::try_from(index.saturating_sub(1)).unwrap_or(usize::MAX)) .filter(|_| index >= 1) .ok_or_else(|| failed(format!("page {} has no added item {index} (see content_list)", page + 1)))?; Ok((page, index as usize - 1, it.content.clone(), ph)) diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 7c60425a8c..f2eab5ddc0 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -233,6 +233,19 @@ impl Automation { o.insert("unsupported_scripts".into(), json!(f.actions.unsupported)); } match f.kind { + FormFieldKind::PushButton => { + o.insert("caption".into(), json!(w.and_then(|w| w.caption.as_deref()).unwrap_or_default())); + o.insert( + "captions".into(), + json!( + f.widgets + .iter() + .enumerate() + .map(|(i, w)| { json!({"widget": i.saturating_add(1), "caption": w.caption.as_deref().unwrap_or_default()}) }) + .collect::>() + ), + ); + } FormFieldKind::Radio => { o.insert("options".into(), json!((0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect::>())); } @@ -327,6 +340,9 @@ impl Automation { } pub(crate) fn form_set_props(&mut self, a: &Args) -> Result { + if a.get("widget").is_some() && a.get("caption").is_none() { + return Err(bad("widget requires caption")); + } let single = a.opt_str("field")?; let bulk = a.get("fields").is_some(); if single.is_some() == bulk { @@ -344,7 +360,7 @@ impl Automation { return Err(bad(format!("field {name:?} is listed more than once"))); } } - for key in ["name", "rect", "rotation"] { + for key in ["name", "rect", "rotation", "caption", "widget"] { if a.get(key).is_some() { return Err(bad(format!("{key} applies to one field; use field instead of fields"))); } @@ -398,6 +414,15 @@ impl Automation { rect, name: a.opt_str("name")?.map(str::to_owned), tooltip: a.opt_str("tooltip")?.map(str::to_owned), + caption: a + .opt_str("caption")? + .map(|caption| -> Result<_> { + let wi = a.opt_int("widget")?.unwrap_or(1); + let wi = + usize::try_from(wi).ok().and_then(|wi| wi.checked_sub(1)).ok_or_else(|| bad("widget must be a positive 1-based index"))?; + Ok((wi, caption.to_owned())) + }) + .transpose()?, read_only: a.opt_bool("read_only")?, required: a.opt_bool("required")?, multiline: a.opt_bool("multiline")?, diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index ad18f71124..0e2e0dd698 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -283,6 +283,13 @@ impl Automation { let to = self.position(&a, "to")?; self.apply(&a, Edit::MovePages { pages, to })? } + "page_reverse" => { + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(&a, "pages")?, + None => (0..self.doc(&a)?.info.pages.len()).collect(), + }; + self.apply(&a, Edit::ReversePages { pages })? + } "page_insert_blank" => self.insert_blank(&a)?, "page_insert_file" => self.insert_file(&a)?, "page_extract" => self.page_extract(&a)?, @@ -373,7 +380,8 @@ impl Automation { } "page_duplicate" => { let pages = self.pages(&a, "pages")?; - self.apply(&a, Edit::DuplicatePages { pages })? + let at = self.opt_position(&a, "at")?; + self.apply(&a, Edit::DuplicatePages { pages, at })? } "page_set_box" => self.page_set_box(&a)?, "page_transition_set" => self.page_transition_set(&a)?, @@ -932,11 +940,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 { @@ -1733,16 +1754,21 @@ impl Automation { let mut used = std::collections::HashSet::new(); for (i, (first, last, bytes)) in parts.iter().enumerate() { let safe = |t: &str| t.chars().map(|c| if c.is_alphanumeric() || " -_.,()".contains(c) { c } else { '_' }).collect::(); - let file = match titles.iter().find(|t| t.0 + 1 == *first) { - Some((_, t)) => format!("{stem}-{}.pdf", safe(t)), - None => format!("{stem}-part{}.pdf", i + 1), + let mut base = match titles.iter().find(|t| t.0 + 1 == *first) { + Some((_, t)) => format!("{stem}-{}", safe(t)), + None => format!("{stem}-part{}", i + 1), }; + // A file name is at most 255 bytes: keep the base to 200, cut between characters, which leaves room for ".pdf" and a -N suffix. + if let Some((end, _)) = base.char_indices().find(|(at, c)| at + c.len_utf8() > 200) { + base.truncate(end); + } + let file = format!("{base}.pdf"); // Equal titles (or titles that sanitize alike) get -2, -3, ... so no part overwrites another; compared case-insensitively. - let mut unique = file.clone(); + let mut unique = file; let mut n = 1; while !used.insert(unique.to_lowercase()) { n += 1; - unique = format!("{}-{n}.pdf", file.trim_end_matches(".pdf")); + unique = format!("{base}-{n}.pdf"); } let path = child(&dir, &unique); write_atomic(&path, bytes)?; @@ -1768,12 +1794,17 @@ impl Automation { /// A 1-based insertion position (1 = before the first page, n+1 = after the last) → 0-based. fn position(&self, a: &Args, key: &str) -> Result { + self.opt_position(a, key)?.ok_or_else(|| Args::missing(key)) + } + + /// `position`, but `None` when the key is absent. + fn opt_position(&self, a: &Args, key: &str) -> Result> { + let Some(at) = a.opt_int(key)? else { return Ok(None) }; let n = self.doc(a)?.info.pages.len(); - let at = a.int(key)?; if at < 1 || at as usize > n + 1 { return Err(ToolError::InvalidArgs(format!("{key} must be between 1 and {} (the document has {n} pages)", n + 1))); } - Ok(at as usize - 1) + Ok(Some(at as usize - 1)) } fn renderer(&mut self, id: DocId) -> Result<&mut PageRenderer> { @@ -2381,6 +2412,9 @@ fn info(d: &Document) -> Value { "pdf_version": i.pdf_version, "file_size": i.file_size, "title": i.title, "author": i.author, "subject": i.subject, "keywords": i.keywords, + // Custom document properties (Document Properties ▸ Custom): /Info entries beyond the + // ones the spec names, set and cleared with doc_set_info. + "custom": i.custom.iter().map(|(name, value)| json!({ "name": name, "value": value })).collect::>(), "creator": i.creator, "producer": i.producer, "tagged": i.tagged, "has_javascript": i.has_javascript, @@ -2958,6 +2992,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"); @@ -3057,4 +3122,23 @@ mod tests { std::fs::set_permissions(&target, perms).unwrap(); let _ = std::fs::remove_dir_all(&dir); } + + /// A recovered document, or a new one, rotated and then undone still has never been saved: + /// `doc_close` refuses it unless told to discard it (#2346 review). + #[test] + fn closing_a_recovered_or_new_document_after_undo_is_refused() { + let mut a = Automation::new(); + let recovered = a.session.open("Report.pdf", None, Arc::new(numbered_pages(2)), None).unwrap(); + a.session.mark_recovered(recovered, Some("Report.pdf".into())); + let new = a.session.open_new("Combined.pdf", Arc::new(numbered_pages(2))).unwrap(); + for id in [recovered, new] { + json(&mut a, "page_rotate", json!({"doc": id.0, "pages": [1], "degrees": 90})); + json(&mut a, "edit_undo", json!({"doc": id.0})); + let refused = a.call("doc_close", &json!({"doc": id.0})); + assert!(matches!(&refused, Err(e) if e.to_string().contains("unsaved changes")), "{id:?}: {refused:?}"); + assert!(a.session.get(id).is_some(), "still open"); + json(&mut a, "doc_close", json!({"doc": id.0, "discard_changes": true})); + assert!(a.session.get(id).is_none()); + } + } } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 5e89aef78a..91cd489c0c 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -156,7 +156,7 @@ pub fn tools() -> Vec { }), &["doc"], )), - t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords; an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value. Undoable.") + t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords, or a custom property under any other name (no spaces, delimiters, / or #; at most 127 bytes; doc_info lists them under custom); an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value. Undoable.") .with(schema(json!({ "doc": doc(), "key": { "type": "string" }, "value": { "type": "string" } }), &["doc", "key", "value"])), t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600).") .ro() @@ -224,6 +224,9 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "pages": pages("to move"), "to": { "type": "integer", "minimum": 1 } }), &["doc", "pages", "to"], )), + t("page_reverse", "Reverse pages", "Reverse the order of pages among the positions they occupy: the listed pages (at least two), or the whole document when pages is omitted. Undoable.") + .cmd("page.reverse") + .with(schema(json!({ "doc": doc(), "pages": pages("to reverse (default: all)") }), &["doc"])), t("page_insert_blank", "Insert a blank page", "Insert a blank page so it becomes page `at`. Size defaults to the neighbouring page. Undoable.") .cmd("page.insert_blank") .with(schema( @@ -318,7 +321,7 @@ pub fn tools() -> Vec { }), &["doc", "from", "to"], )), - t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags.") + t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based).") .ro() .with(schema(json!({ "doc": doc() }), &["doc"])), t( @@ -371,7 +374,7 @@ pub fn tools() -> Vec { t( "form_set_props", "Field properties", - "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), 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.", + "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), rotation and caption require a single field. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.", ) .with(schema( json!({ @@ -380,6 +383,8 @@ pub fn tools() -> Vec { "fields": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": 1000, "uniqueItems": true, "description": "Apply the same changes to these fields in one atomic edit. Use either field or fields." }, "name": { "type": "string" }, "tooltip": { "type": "string" }, + "caption": { "type": "string", "description": "Normal push-button label; an empty string clears it. Requires a single field." }, + "widget": { "type": "integer", "minimum": 1, "description": "Widget whose caption changes (1-based, default 1). Requires caption. See form_fields.captions." }, "read_only": { "type": "boolean" }, "required": { "type": "boolean" }, "multiline": { "type": "boolean" }, @@ -822,9 +827,9 @@ pub fn tools() -> Vec { t("page_replace", "Replace pages", "Replace the content of pages with pages of another PDF (same count). Links, comments, form fields and bookmarks on the original pages stay, as in Acrobat. Undoable.") .cmd("page.replace") .with(schema(json!({ "doc": doc(), "pages": pages("to replace"), "path": path_arg(), "from_pages": pages("of the other file, in order (default: its first pages)") }), &["doc", "pages", "path"])), - t("page_duplicate", "Duplicate pages", "Insert copies of pages after the last of them (fonts and images are shared, not copied). Undoable.") + t("page_duplicate", "Duplicate pages", "Insert copies of pages so the first copy becomes page `at`, or after the last of them when `at` is left out (fonts and images are shared, not copied). Undoable.") .cmd("page.duplicate") - .with(schema(json!({ "doc": doc(), "pages": pages("to duplicate") }), &["doc", "pages"])), + .with(schema(json!({ "doc": doc(), "pages": pages("to duplicate"), "at": { "type": "integer", "minimum": 1 } }), &["doc", "pages"])), t( "page_transition_set", "Set page transitions", @@ -1098,7 +1103,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; 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).") + 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, rows of comb 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); a row of comb boxes is one text field with comb set to its number of cells, created with the comb flag and that character limit.") .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).") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index f45aec3f57..f3bcfd21a4 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -214,6 +214,98 @@ fn page_transitions_are_set_and_removed_through_the_tools() { ); } +/// #1937 through the tools: a damaged file is repaired on open (`doc_info` lists the repair) and +/// `doc_save` over it writes the rebuilt file, so opening it again needs no repair. +#[test] +fn saving_a_repaired_file_puts_the_repair_on_disk() { + let dir = workdir("repaired-save"); + let good = fixture(2); + let mut broken = b"%PDF-1.7\n%shifted\n".to_vec(); + broken.extend_from_slice(&good[b"%PDF-1.7\n".len()..]); + std::fs::write(dir.join("broken.pdf"), &broken).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "broken.pdf" }))["doc"].as_u64().unwrap(); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + assert!(info["repairs"].as_array().unwrap().iter().any(|r| r.as_str().unwrap_or("").contains("rebuilt")), "{}", info["repairs"]); + let saved = ok(&mut a, "doc_save", json!({ "doc": doc })); + assert_eq!(saved["incremental"], false, "{saved}"); + let bytes = std::fs::read(dir.join("broken.pdf")).unwrap(); + assert_ne!(bytes, broken, "the damaged bytes were written back"); + let reopened = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap(); + assert!(reopened.repair_log().is_empty(), "{:?}", reopened.repair_log()); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + assert_eq!(info["repairs"].as_array().unwrap().len(), 0, "{}", info["repairs"]); + assert_eq!(info["pages"].as_array().unwrap().len(), 2); +} + +/// Custom document properties through the tools: set and cleared with doc_set_info under any +/// name the spec does not use, listed by doc_info apart from the standard entries, bad names +/// refused without a change. +#[test] +fn custom_document_properties_are_set_listed_and_cleared_through_the_tools() { + let dir = workdir("custom-info"); + std::fs::write(dir.join("a.pdf"), fixture(1)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Title", "value": "Report" })); + ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Project", "value": "Apollo" })); + ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Revision", "value": "7" })); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + assert_eq!(info["title"], "Report"); + let mut custom: Vec<(String, String)> = + info["custom"].as_array().unwrap().iter().map(|c| (c["name"].as_str().unwrap().into(), c["value"].as_str().unwrap().into())).collect(); + custom.sort(); + assert_eq!(custom, [("Project".to_string(), "Apollo".to_string()), ("Revision".to_string(), "7".to_string())], "{}", info["custom"]); + assert!(a.call("doc_set_info", &json!({ "doc": doc, "key": "Bad Name", "value": "x" })).is_err(), "a name with a space is refused"); + assert!(a.call("doc_set_info", &json!({ "doc": doc, "key": "", "value": "x" })).is_err()); + ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Project", "value": "" })); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + assert_eq!(info["custom"].as_array().unwrap().len(), 1, "{}", info["custom"]); + assert_eq!(info["custom"][0]["name"], "Revision"); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out/custom.pdf" })); + let mut b = auto(&dir); + let re = ok(&mut b, "doc_open", json!({ "path": "out/custom.pdf" }))["doc"].as_u64().unwrap(); + let info = ok(&mut b, "doc_info", json!({ "doc": re })); + assert_eq!(info["custom"][0]["value"], "7", "{}", info["custom"]); +} + +/// The custom list `doc_info` shows when a file opens is the one it shows after an unrelated +/// edit: a name value, a blank or NUL-padded string and a number are judged the same way by the +/// open-time reader and by the engine's refresh. +#[test] +fn custom_document_properties_do_not_change_after_an_unrelated_edit() { + let dir = workdir("custom-info-stable"); + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>", + "<< /Producer (Test) /Project (Apollo) /Dept /RnD /Blank ( ) /Padded (Lab\\000\\000) /Revision 7 /Empty () /Mixed ( \\000Lab\\000 ) /Pad ( \\000 ) >>", + ]; + 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 /Info 4 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + std::fs::write(dir.join("custom.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "custom.pdf" }))["doc"].as_u64().unwrap(); + let opened = ok(&mut a, "doc_info", json!({ "doc": doc }))["custom"].clone(); + let names: Vec<&str> = opened.as_array().unwrap().iter().map(|c| c["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Project", "Padded", "Mixed"], "{opened}"); + assert_eq!(opened[1]["value"], "Lab", "{opened}"); + assert_eq!(opened[2]["value"], "Lab", "{opened}"); + ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Title", "value": "Report" })); + let edited = ok(&mut a, "doc_info", json!({ "doc": doc }))["custom"].clone(); + assert_eq!(edited, opened); +} + #[test] fn autotag_preview_proposes_text_without_changing_the_pdf() { let dir = workdir("autotag-preview"); @@ -711,11 +803,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 >>", @@ -743,10 +834,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 @@ -2074,6 +2174,34 @@ fn orphan_form_fields_through_tools() { assert_ne!(v, "Filled by an agent"); } +#[test] +fn reversing_pages_through_tools() { + let dir = workdir("reverse"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_reverse", json!({ "doc": doc })); + assert_eq!(page_text(&mut a, doc), ["Page 3", "Page 2", "Page 1"], "no pages reverses the whole document"); + ok(&mut a, "page_reverse", json!({ "doc": doc, "pages": [1, 2] })); + assert_eq!(page_text(&mut a, doc), ["Page 2", "Page 3", "Page 1"], "only the listed pages swap"); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + assert_eq!(page_text(&mut a, doc), ["Page 3", "Page 2", "Page 1"]); + assert!(matches!(a.call("page_reverse", &json!({ "doc": doc, "pages": [4] })), Err(ToolError::InvalidArgs(_)))); + assert!(matches!(a.call("page_reverse", &json!({ "doc": doc, "pages": [2] })), Err(ToolError::Failed(_))), "one page has nothing to reverse"); +} + +#[test] +fn page_duplicate_at_inserts_the_copies_there() { + let dir = workdir("duplicate-at"); + 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, "page_duplicate", json!({ "doc": doc, "pages": [2], "at": 1 })); + assert_eq!(r["pages"], 4); + assert_eq!(page_text(&mut a, doc), ["Page 2", "Page 1", "Page 2", "Page 3"]); + // `at` is 1-based and within 1..=pages+1, as for the other page inserts. + assert!(matches!(a.call("page_duplicate", &json!({ "doc": doc, "pages": [1], "at": 0 })), Err(ToolError::InvalidArgs(_))), "1-based"); + assert!(matches!(a.call("page_duplicate", &json!({ "doc": doc, "pages": [1], "at": 6 })), Err(ToolError::InvalidArgs(_))), "past the end"); +} + #[test] fn duplicating_and_cropping_through_tools() { let dir = workdir("boxes"); @@ -3574,6 +3702,24 @@ fn adding_content_through_tools() { assert!(matches!(a.call("content_delete", &json!({ "doc": doc, "page": 1, "index": 5 })), Err(ToolError::Failed(_)))); } +/// An out-of-range `index` (including `i64::MIN`, which overflowed `index - 1`) is a readable +/// failure for `content_update` and `content_delete`, not a panic. +#[test] +fn content_index_out_of_range_is_an_error_not_a_panic() { + let dir = workdir("content-index-range"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": "hi", "at": [10, 10] })); + for index in [i64::MIN, i64::MIN + 1, -1, 0, i64::MAX] { + for tool in ["content_delete", "content_update"] { + let args = json!({ "doc": doc, "page": 1, "index": index, "text": "x" }); + let args = if tool == "content_delete" { json!({ "doc": doc, "page": 1, "index": index }) } else { args }; + assert!(matches!(a.call(tool, &args), Err(ToolError::Failed(_))), "{tool} index {index}"); + } + } + assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["count"], 1); +} + /// Text styling on an added image is refused (#1331), as image transforms are on text, and the /// refusal leaves the content, the dirty flag and the undo and redo steps as they were. #[test] @@ -3798,6 +3944,35 @@ fn bookmark_split_with_equal_titles_keeps_every_part() { } } +#[test] +fn bookmark_split_with_long_titles_shortens_the_file_names() { + let dir = workdir("organize_long_titles"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + // Two equal titles far over the 255-byte name limit, then a multibyte one (3 bytes a character). + let long = format!("Chapter {}", "x".repeat(300)); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": long, "page": 1 })); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": long, "page": 2 })); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "章".repeat(100), "page": 3 })); + let s = ok(&mut a, "doc_split", json!({ "doc": doc, "bookmarks": true, "out_dir": "parts" })); + let files: Vec = s["files"] + .as_array() + .unwrap() + .iter() + .map(|f| std::path::Path::new(f["path"].as_str().unwrap()).file_name().unwrap().to_string_lossy().into_owned()) + .collect(); + assert_eq!(files.len(), 3, "{files:?}"); + let distinct: std::collections::HashSet<_> = files.iter().map(|f| f.to_lowercase()).collect(); + assert_eq!(distinct.len(), 3, "{files:?}"); + for f in &files { + assert!(f.len() <= 255, "{f} is {} bytes", f.len()); + assert!(f.ends_with(".pdf"), "{f}"); + assert!(dir.join("parts").join(f).exists(), "{f} missing"); + } + assert!(files[0].starts_with("a-Chapter x"), "{files:?}"); + assert!(files[2].starts_with("a-章"), "{files:?}"); +} + #[test] fn organizing_with_filters_bookmark_splits_and_extract_options() { let dir = workdir("organize2"); @@ -4527,11 +4702,11 @@ fn signature_appearances(path: &Path) -> String { 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": "secret1", "path": "ada.p12" })); + 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": "secret1", "page": 1, "rect": [10, 150, 190, 290], "reason": "Genehmigt", "location": "Berlin", "out": out }); + 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(); } @@ -4951,6 +5126,41 @@ fn detecting_form_fields_through_tools() { assert_eq!(f["fields"][1]["value"], "Lisbon", "{f}"); } +#[test] +fn detecting_comb_boxes_through_tools() { + let mut body = String::from("BT /F1 10 Tf 40 105 Td (Postcode) Tj ET\n"); + for i in 0..5 { + body.push_str(&format!("{} 100 16 20 re S\n", 120 + i * 16)); + } + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>".to_string(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_string(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let dir = workdir("detect-comb"); + std::fs::write(dir.join("comb.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "comb.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "form_detect_fields", json!({ "doc": doc })); + assert_eq!(r["fields"], json!([{ "page": 1, "kind": "text", "comb": 5, "name": "Postcode", "rect": [120.0, 100.0, 200.0, 120.0] }]), "{r}"); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "Postcode": "75001" } })); + assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], "75001"); +} + #[test] fn comparing_documents_through_tools() { let dir = workdir("compare"); diff --git a/crates/automation/tests/batch.rs b/crates/automation/tests/batch.rs index b2d3428942..af5e0c1272 100644 --- a/crates/automation/tests/batch.rs +++ b/crates/automation/tests/batch.rs @@ -11,7 +11,17 @@ fn result(auto: &mut Automation, args: Value) -> Value { // Fresh fixtures include the current timestamp; retain their bytes for output comparisons. fn pdf() -> Vec { - Session::new().create_from_text("Original", "Original text").unwrap().as_ref().clone() + // Reuse one synthetic document so byte comparisons cannot straddle the + // writer's modification-time clock. Each caller gets an independent buffer. + static PDF: std::sync::OnceLock> = std::sync::OnceLock::new(); + PDF.get_or_init(|| Session::new().create_from_text("Original", "Original text").unwrap().as_ref().clone()).clone() +} + +#[test] +fn pdf_fixture_remains_identical_across_metadata_clock_tick() { + let before = pdf(); + std::thread::sleep(std::time::Duration::from_millis(1100)); + assert_eq!(before, pdf()); } #[test] diff --git a/crates/automation/tests/button_caption.rs b/crates/automation/tests/button_caption.rs new file mode 100644 index 0000000000..cb1a22a026 --- /dev/null +++ b/crates/automation/tests/button_caption.rs @@ -0,0 +1,91 @@ +//! Editing a button's visible label through the CLI/MCP tool table (#2892). + +use pdfcraft_automation::{Automation, Content}; +use pdfcraft_engine::{DocId, Edit, NewField, Session}; +use serde_json::{Value, json}; + +fn tool(a: &mut Automation, name: &str, args: Value) -> Value { + match a.call(name, &args).unwrap().remove(0) { + Content::Json(value) => value, + other => panic!("expected JSON: {other:?}"), + } +} + +#[test] +fn button_caption_can_be_changed_after_creation() { + let dir = std::env::temp_dir().join(format!("pdfcraft-button-caption-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut session = Session::new(); + let bytes = session.create_blank(300.0, 400.0, 1).unwrap(); + let id = session.open("buttons.pdf", None, bytes, None).unwrap(); + session + .apply( + id, + Edit::AddField { + page: 0, + rect: [40.0, 300.0, 200.0, 330.0], + kind: NewField::Button { caption: String::new() }, + name: Some("clear_button".into()), + }, + ) + .unwrap(); + std::fs::write(dir.join("buttons.pdf"), session.save_bytes(id).unwrap().as_slice()).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap().with_clock(|| 1_700_000_000); + let id = tool(&mut a, "doc_open", json!({"path": "buttons.pdf"}))["doc"].as_u64().unwrap(); + let before = a.session().save_bytes(DocId(id)).unwrap(); + let (_, _, empty_pixels) = a.render_pixels(id, 1, 1.0).unwrap(); + tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": "Clear Form"})); + let fields = tool(&mut a, "form_fields", json!({"doc": id})); + assert_eq!(fields["fields"][0]["caption"], "Clear Form"); + assert_eq!(fields["fields"][0]["captions"], json!([{"widget": 1, "caption": "Clear Form"}])); + assert_eq!(a.session().get(DocId(id)).unwrap().can_undo(), Some("Change field properties")); + let (_, _, labelled_pixels) = a.render_pixels(id, 1, 1.0).unwrap(); + assert_ne!(empty_pixels, labelled_pixels, "the label must reach the actual page appearance"); + tool(&mut a, "edit_undo", json!({"doc": id})); + assert_eq!(a.session().save_bytes(DocId(id)).unwrap(), before); + tool(&mut a, "edit_redo", json!({"doc": id})); + tool(&mut a, "doc_save", json!({"doc": id, "path": "labelled.pdf"})); + let saved = std::sync::Arc::new(std::fs::read(dir.join("labelled.pdf")).unwrap()); + let doc = pdfcraft_cos::Document::open(saved).unwrap(); + let root = doc.get(doc.root().unwrap()); + let form = doc.resolve(root.as_dict().unwrap().get(b"AcroForm").unwrap()); + let fields = doc.resolve(form.as_dict().unwrap().get(b"Fields").unwrap()); + let button = doc.resolve(&fields.as_array().unwrap()[0]); + let mk = doc.resolve(button.as_dict().unwrap().get(b"MK").unwrap()); + assert_eq!(doc.resolve(mk.as_dict().unwrap().get(b"CA").unwrap()).as_string().unwrap().to_text(), "Clear Form"); + assert_eq!(doc.resolve(button.as_dict().unwrap().get(b"T").unwrap()).as_string().unwrap().to_text(), "clear_button"); + let reopened = tool(&mut a, "doc_open", json!({"path": "labelled.pdf"}))["doc"].as_u64().unwrap(); + let fields = tool(&mut a, "form_fields", json!({"doc": reopened})); + assert_eq!(fields["fields"][0]["caption"], "Clear Form"); + let (_, _, pixels) = a.render_pixels(reopened, 1, 1.0).unwrap(); + assert_eq!(pixels, labelled_pixels); + let out = tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": "提交"})); + assert!(out["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).any(|w| w.contains("U+63D0"))); + let fields = tool(&mut a, "form_fields", json!({"doc": id})); + assert_eq!(fields["fields"][0]["caption"], "提交", "unsupported characters are retained and reported"); + tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": "Clear Form"})); + for invalid in [ + json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": 0}), + json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": -1}), + json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": 2}), + json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": i64::MAX}), + json!({"doc": id, "field": "clear_button", "caption": 42}), + json!({"doc": id, "field": "clear_button", "widget": 1, "tooltip": "bad"}), + json!({"doc": id, "fields": ["clear_button"], "caption": "bad"}), + ] { + let before = a.session().save_bytes(DocId(id)).unwrap(); + assert!(a.call("form_set_props", &invalid).is_err(), "{invalid}"); + assert_eq!(a.session().save_bytes(DocId(id)).unwrap(), before, "{invalid}"); + } + tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "locked": true})); + let before = a.session().save_bytes(DocId(id)).unwrap(); + assert!(a.call("form_set_props", &json!({"doc": id, "field": "clear_button", "caption": "bad"})).is_err()); + assert_eq!(a.session().save_bytes(DocId(id)).unwrap(), before); + tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "locked": false})); + tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": ""})); + let fields = tool(&mut a, "form_fields", json!({"doc": id})); + assert_eq!(fields["fields"][0]["caption"], ""); + let (_, _, pixels) = a.render_pixels(id, 1, 1.0).unwrap(); + assert_eq!(pixels, empty_pixels); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index 32334e047b..c7c8bca9ee 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -60,6 +60,10 @@ pub struct Parsed { pub ops: Vec, /// Bytes that could not be read as operands or operators. pub skipped: usize, + /// For [`Pieces::parse`]: per piece, whether a comment runs into it from the piece before + /// and whether one runs out of it into the next. Only boundaries are recorded, so the size + /// depends on the number of pieces, not of comments. + comment_edges: Vec<(bool, bool)>, } fn is_ws(b: u8) -> bool { @@ -72,12 +76,25 @@ fn is_delim(b: u8) -> bool { /// Parse a (decoded) content stream. pub fn parse(data: &[u8]) -> Parsed { - let mut out = Parsed::default(); + parse_pieces(data, &[]) +} + +/// Parse `data`, recording which of the piece boundaries `ends` (as in [`Pieces`]) a comment +/// crosses. +fn parse_pieces(data: &[u8], ends: &[usize]) -> Parsed { + let mut out = Parsed { comment_edges: vec![(false, false); ends.len()], ..Parsed::default() }; + let mut boundary = 0; let mut lx = Lexer::new(data, 0); let mut operands: Vec = Vec::new(); let mut start: Option = None; loop { + let trivia = lx.pos; lx.skip_ws(); + // Only the whitespace the lexer skips here holds top-level comments: a `%` inside a + // string, a name, an array or dictionary operand or inline image data is not one. + if boundary < ends.len() { + comment_edges(data, trivia..lx.pos, ends, &mut boundary, &mut out.comment_edges); + } let Some(&b) = data.get(lx.pos) else { break }; let at = lx.pos; let operand_start = matches!(b, b'/' | b'(' | b'<' | b'[' | b'+' | b'-' | b'.') || b.is_ascii_digit(); @@ -148,6 +165,39 @@ pub fn parse(data: &[u8]) -> Parsed { out } +/// Record the piece boundaries crossed by the comments in `trivia` (skipped whitespace and +/// comments). `boundary` is the first boundary not yet passed; boundaries only move forward. +fn comment_edges(data: &[u8], trivia: std::ops::Range, ends: &[usize], boundary: &mut usize, edges: &mut [(bool, bool)]) { + let mut at = trivia.start; + while at < trivia.end { + if data.get(at) != Some(&b'%') { + at += 1; + continue; + } + let comment = at; + while at < trivia.end && !matches!(data.get(at), Some(b'\r' | b'\n')) { + at += 1; + } + // The separator of piece `boundary` is the last byte before its end. + while let Some(&end) = ends.get(*boundary) { + let separator = end.saturating_sub(1); + if separator >= at { + break; + } + if separator >= comment { + if let Some(edge) = edges.get_mut(*boundary) { + edge.1 = true; + } + // Includes a comment whose end of line is the next piece's first byte. + if let Some(edge) = edges.get_mut(boundary.saturating_add(1)) { + edge.0 = true; + } + } + *boundary += 1; + } + } +} + /// Read an inline image starting after `BI`: (parameters, data, position after `EI`). `Err` holds /// the position after the `EI` (or the end of the stream) of an image whose parameters can't be read. fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec, usize), usize> { @@ -305,7 +355,9 @@ pub fn serialize_ops(ops: &[Op]) -> Vec { /// A page's content streams joined into the one stream they are. Writers may split it between /// any two tokens (ISO 32000-2 §7.8.2), so an operator's operands can end one piece and its /// keyword start the next: parse the pieces joined ([`Pieces::parse`]) and map positions back -/// with [`Pieces::piece_of`]. A newline separates the pieces so tokens never run together. +/// with [`Pieces::piece_of`]. A space separates the pieces, as renderers join them (hayro, +/// MuPDF and Poppler alike): tokens never run together, and a comment at the end of a piece +/// runs on into the next one, up to its first end of line. #[derive(Clone, Debug, Default, PartialEq)] pub struct Pieces { data: Vec, @@ -319,7 +371,7 @@ impl Pieces { let mut ends = Vec::with_capacity(pieces.len()); for p in pieces { data.extend_from_slice(p.as_ref()); - data.push(b'\n'); + data.push(b' '); ends.push(data.len()); } Pieces { data, ends } @@ -341,12 +393,22 @@ impl Pieces { /// Parse the pieces as one stream. pub fn parse(&self) -> Parsed { - parse(&self.data) + parse_pieces(&self.data, &self.ends) + } + + /// Per piece, from `parsed` (this piece set's [`Pieces::parse`]): whether a comment runs + /// into it from the piece before, and whether one runs out of it into the next. A writer + /// that rewrites a piece without its comments must keep both: end the incoming comment + /// before its first operator, and leave a comment open at its end. + pub fn comment_edges(&self, parsed: &Parsed) -> Vec<(bool, bool)> { + let mut edges = parsed.comment_edges.clone(); + edges.resize(self.len(), (false, false)); + edges } /// The piece holding joined position `at` (the last piece for positions past the end). pub fn piece_of(&self, at: usize) -> usize { - self.ends.iter().position(|&e| at < e).unwrap_or(self.ends.len().saturating_sub(1)) + self.ends.partition_point(|&e| e <= at).min(self.ends.len().saturating_sub(1)) } /// The pieces an operator spans: (the one its first token is in, the one its keyword is in). diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs index 18e0b8cbcc..1119bdaf90 100644 --- a/crates/content/src/tests.rs +++ b/crates/content/src/tests.rs @@ -283,3 +283,108 @@ fn pieces_parse_as_one_stream_and_splice_back() { assert_eq!(out.len(), 3); assert!(Pieces::join::<&[u8]>(&[]).splice([(0..1, b"x".to_vec())]).is_empty()); } + +#[test] +fn stream_boundary_comment_matches_space_concatenation() { + for eol in [b"\n".as_slice(), b"\r", b"\r\n"] { + let mut tail = b"(HIDDEN) Tj".to_vec(); + tail.extend(eol); + tail.extend(b"(LIVE) Tj"); + let pieces = Pieces::join(&[b"BT % comment".as_slice(), b"(ALSO HIDDEN) Tj", &tail, b"ET"]); + let parsed = pieces.parse(); + let shown: Vec<_> = parsed.ops.iter().filter(|op| op.is("Tj")).collect(); + assert_eq!(shown.len(), 1); + assert_eq!(shown[0].operands[0].as_string().unwrap().bytes, b"LIVE"); + } +} + +#[test] +fn comment_edges_terminate_at_next_piece_byte_zero() { + for eol in [b"\n".as_slice(), b"\r", b"\r\n"] { + let mut next = eol.to_vec(); + next.extend_from_slice(b"q Q"); + let pieces = Pieces::join(&[b"% outgoing".as_slice(), next.as_slice()]); + let parsed = pieces.parse(); + assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, false)], "EOL {eol:?}"); + assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::>(), vec![b"q".as_slice(), b"Q"]); + } +} + +#[test] +fn comment_edges_span_empty_pieces_and_end_of_content() { + let pieces = Pieces::join(&[b"q % outgoing".as_slice(), b"", b"", b"ignored\nQ"]); + let parsed = pieces.parse(); + assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, true), (true, true), (true, false)]); + assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::>(), vec![b"q".as_slice(), b"Q"]); + + let pieces = Pieces::join(&[b"% outgoing".as_slice(), b"", b"still ignored"]); + let parsed = pieces.parse(); + assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, true), (true, true)]); + assert!(parsed.ops.is_empty()); + + let pieces = Pieces::join::<&[u8]>(&[]); + assert!(pieces.comment_edges(&pieces.parse()).is_empty()); +} + +#[test] +fn comment_edges_ignore_percent_inside_operands_and_image_data() { + for first in [ + b"(literal % not a comment) Tj".as_slice(), + b"/Percent#25Name MP", + b"[(literal % not a comment)] TJ", + b"/P << /ActualText (literal % not a comment) >> BDC EMC", + b"BI /W 1 /H 1 /BPC 8 /CS /G ID % EI", + ] { + let pieces = Pieces::join(&[first, b"q Q"]); + let parsed = pieces.parse(); + assert_eq!(pieces.comment_edges(&parsed), vec![(false, false), (false, false)], "{first:?}"); + assert_eq!(parsed.ops.last().unwrap().op, b"Q"); + assert_eq!(parsed.skipped, 0); + } +} + +#[test] +fn compound_operand_comment_split_is_owned_by_spanning_operator() { + for parts in [[b"[(A) % ignored".as_slice(), b"still ignored\n(B)] TJ"], [b"/P << /MCID 0 % ignored".as_slice(), b"still ignored\n>> BDC"]] { + let pieces = Pieces::join(&parts); + let parsed = pieces.parse(); + // The object lexer consumes this nested comment. Its containing operator spans + // both pieces, which forces both to be rewritten together by the redactor. + assert_eq!(pieces.comment_edges(&parsed), vec![(false, false), (false, false)]); + assert_eq!(parsed.ops.len(), 1); + assert_eq!(pieces.pieces_of(&parsed.ops[0]), (0, 1)); + assert_eq!(parsed.skipped, 0); + } +} + +#[test] +fn comment_edges_do_not_confuse_local_comments_with_boundary_comments() { + let pieces = Pieces::join(&[b"% local\nq % crossing".as_slice(), b"ignored\n% local again\nQ\n% final local\n", b"q Q"]); + let parsed = pieces.parse(); + assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, false), (false, false)]); + assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::>(), vec![b"q".as_slice(), b"Q", b"q", b"Q"]); +} + +#[test] +fn comment_edge_storage_depends_on_piece_count_not_comment_count() { + let mut first = b"%\n".repeat(16_384); + first.extend_from_slice(b"q % crossing"); + let pieces = Pieces::join(&[first.as_slice(), b"ignored\nQ"]); + let parsed = pieces.parse(); + assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, false)]); + assert_eq!(parsed.ops.len(), 2); + // Also exercise public parsing without piece-boundary metadata. + assert_eq!(parse(&first).ops.len(), 1); +} + +#[test] +fn piece_lookup_preserves_exact_boundary_and_empty_piece_mapping() { + // Joined layout: qQ; separator belongs to prior piece. + let pieces = Pieces::join(&[b"q".as_slice(), b"", b"Q"]); + for (at, expected) in [(0, 0), (1, 0), (2, 1), (3, 2), (4, 2), (5, 2), (usize::MAX, 2)] { + assert_eq!(pieces.piece_of(at), expected, "position {at}"); + } + let empty = Pieces::join::<&[u8]>(&[]); + assert_eq!(empty.piece_of(0), 0); + assert_eq!(empty.piece_of(usize::MAX), 0); +} diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index a0b1f56eeb..639edf582c 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -82,6 +82,8 @@ struct XrefSection { row_count: usize, max_num: u32, has_entries: bool, + /// A table subsection was read one number lower than it says ([`subsection_start`]). + renumbered: bool, } #[derive(Clone, Debug)] @@ -307,6 +309,10 @@ pub struct Revision { pub xref_offset: u64, /// `true` if the section is a cross-reference stream. pub is_stream: bool, + /// `true` if `xref_offset` counts from the `%PDF-` header rather than from the start of the + /// file. The two differ only in a file with bytes before its header, and an update appended + /// to such a file must count the same way as the section it points back to. + pub header_relative: bool, } #[derive(Clone, Debug)] @@ -337,6 +343,40 @@ impl PartialEq for SourceIdentity { impl Eq for SourceIdentity {} +/// What a document reads as: its source ([`SourceIdentity`]), its trailer and every object changed +/// since it was opened. A clone, or a snapshot that undo restores, has the same state, and every +/// change makes a different one. Changed objects are compared as allocations, which the state +/// keeps alive, so a later object can't take an address one of them had. +#[derive(Clone)] +pub struct ReadState { + source: SourceIdentity, + trailer: Dict, + overlay: BTreeMap, +} + +impl ReadState { + /// Whether `doc` reads as this state. + pub fn matches(&self, doc: &Document) -> bool { + self.source == doc.source_identity() + && self.trailer == doc.trailer + && self.overlay.len() == doc.overlay.len() + && self.overlay.iter().zip(&doc.overlay).all(|((a, x), (b, y))| { + a == b + && match (x, y) { + (Slot::Set(g, o), Slot::Set(h, p)) => g == h && Arc::ptr_eq(o, p), + (Slot::Freed(g), Slot::Freed(h)) => g == h, + _ => false, + } + }) + } +} + +impl std::fmt::Debug for ReadState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ReadState").field("changed_objects", &self.overlay.len()).finish_non_exhaustive() + } +} + #[derive(Clone)] pub struct Document { data: Arc>, @@ -801,6 +841,12 @@ impl Document { SourceIdentity(Arc::downgrade(&self.source_identity)) } + /// What the document reads as now (see [`ReadState`]): unlike [`Self::source_identity`], it + /// changes with every edit, so a value derived from the document can be cached against it. + pub fn read_state(&self) -> ReadState { + ReadState { source: self.source_identity(), trailer: self.trailer.clone(), overlay: self.overlay.clone() } + } + pub fn version(&self) -> &str { &self.version } @@ -817,6 +863,19 @@ impl Document { &self.revisions } + /// Position of the `%PDF-` header in the file: 0 unless bytes come before it. + pub fn header_offset(&self) -> usize { + self.header_offset + } + + /// There is no cross-reference chain to append to: the index was rebuilt from object + /// headers because the file's own was unusable (see [`Self::repair_log`]), or the document + /// was built from scratch ([`Self::new_empty`]). A save writes such a document in full, even + /// when nothing was edited: for a repaired file that is what puts the repair on disk. + pub fn reconstructed(&self) -> bool { + self.revisions.is_empty() + } + /// Where each revision of the file ends (oldest first): the byte after the `%%EOF` (and its /// end-of-line) that closes its cross-reference section. `data[..end]` is that revision as it /// was saved. A linearized file's first-page section belongs to the revision it precedes, so @@ -851,9 +910,10 @@ impl Document { self.repair_log = Arc::new(log); } - /// `true` when there are unsaved edits. + /// `true` when there are unsaved edits. Removing an `/Encrypt` dictionary written inline in + /// the trailer (as MuPDF writes it) changes no object, and is an edit all the same. pub fn is_modified(&self) -> bool { - !self.overlay.is_empty() + !self.overlay.is_empty() || self.encryption_changed } /// Object numbers changed since the document was opened (or last saved). @@ -1113,7 +1173,19 @@ impl Document { if let Some(s) = self.objstms.lock().map_err(|_| CosError::Poisoned)?.get(&num) { return Ok(s.clone()); } - let Object::Stream(s) = &*self.try_get(num)? else { return Err(CosError::MissingObject(num)) }; + // A file entry that cannot place the stream (one that says it is itself compressed + // inside another stream, as in #2367, or points nowhere) is tried by the stream's own + // header, as a plain object with a wrong offset is. An edited or freed stream, in the + // overlay or in a saved revision's table, is what that says it is: the file's copy + // must not come back. + let loaded = match self.try_get(num) { + Ok(o) if matches!(*o, Object::Stream(_)) => o, + _ if self.overlay.contains_key(&num) || matches!(self.xref_entry(num), Some(XrefEntry::Free { .. })) => { + return Err(CosError::MissingObject(num)); + } + _ => Arc::new(self.scan_for(num).map(|(id, o)| self.decrypted(id, o)).ok_or(CosError::MissingObject(num))?), + }; + let Object::Stream(s) = &*loaded else { return Err(CosError::MissingObject(num)) }; let n = s.dict.int(b"N").unwrap_or(0).clamp(0, 1_000_000) as usize; let first = s.dict.int(b"First").unwrap_or(0).max(0) as usize; let data = s.decoded_within(self.stream_limit)?; @@ -1345,13 +1417,13 @@ impl Document { 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, + let ((primary, sect_trailer, is_stream), header_relative) = match self.read_compact_section(off as usize) { + Ok(Some(v)) => (v, false), 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, + Some(v) => (v, true), None => return Ok(None), } } @@ -1359,7 +1431,10 @@ impl Document { // 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 }); + revisions.push(Revision { xref_offset: off, is_stream, header_relative }); + if primary.renumbered { + log.push(format!("xref subsection at {off} is numbered from 1 but begins with object 0's entry; read from 0")); + } 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); @@ -1434,6 +1509,7 @@ impl Document { let mut has_entries = false; let mut previous_end = None; let mut row_count = 0usize; + let mut renumbered = false; loop { lx.skip_ws(); if lx.eat_keyword(b"trailer") { @@ -1442,7 +1518,7 @@ impl Document { if !layout_ok { return Ok(None); } - return Ok(Some((XrefSection { data: XrefSectionData::Table, ranges, row_count, max_num, has_entries }, d, false))); + return Ok(Some((XrefSection { data: XrefSectionData::Table, ranges, row_count, max_num, has_entries, renumbered }, 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() }); @@ -1450,6 +1526,9 @@ impl Document { if !valid_xref_range(start, count) || count > 10_000_000 { return Err(CosError::Syntax { offset: lx.pos, detail: "invalid xref object range".into() }); } + let shifted = subsection_start(data, lx.pos, start, count); + renumbered |= shifted != start; + let start = shifted; 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); @@ -1602,7 +1681,14 @@ impl Document { } 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 section = XrefSection { + data: XrefSectionData::Stream { bytes: Arc::new(raw), widths }, + ranges, + row_count, + max_num, + has_entries: false, + renumbered: false, + }; let has_entries = section.has_any_entry(); let section = XrefSection { has_entries, ..section }; Ok(Some((section, stream.dict.clone(), true))) @@ -1636,13 +1722,13 @@ impl Document { let mut sect = BTreeMap::new(); // Object numbers whose entry in this section was set aside ([`Self::xref_oddities`]). let mut odd = Vec::new(); - let (sect_trailer, is_stream) = match self.read_section(off as usize, &mut sect, &mut odd) { - Ok(v) => v, + let ((sect_trailer, is_stream), header_relative) = match self.read_section(off as usize, &mut sect, &mut odd, log) { + Ok(v) => (v, false), Err(e) if self.header_offset > 0 => { log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header")); sect.clear(); odd.clear(); - self.read_section(off as usize + self.header_offset, &mut sect, &mut odd)? + (self.read_section(off as usize + self.header_offset, &mut sect, &mut odd, log)?, true) } Err(e) => { // An unreadable older section loses every object only it locates (the page @@ -1653,12 +1739,12 @@ impl Document { return Err(e); } }; - revisions.push(Revision { xref_offset: off, is_stream }); + revisions.push(Revision { xref_offset: off, is_stream, header_relative }); // Hybrid files (§7.5.8.4): the /XRefStm entries supplement the table and replace the // free entries it gives objects only a cross-reference stream can locate. if let Some(x) = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0) { let mut stm = BTreeMap::new(); - match self.read_section(x as usize, &mut stm, &mut odd) { + match self.read_section(x as usize, &mut stm, &mut odd, log) { Ok(_) => { for (num, e) in stm { match sect.get(&num) { @@ -1693,7 +1779,13 @@ impl Document { /// Read one section at `off`, adding entries not already known (newer sections win). `odd` /// collects the object numbers whose entry here is set aside ([`Self::xref_oddities`]). - fn read_section(&self, off: usize, entries: &mut BTreeMap, odd: &mut Vec) -> Result<(Dict, bool), CosError> { + fn read_section( + &self, + off: usize, + entries: &mut BTreeMap, + odd: &mut Vec, + log: &mut Vec, + ) -> Result<(Dict, bool), CosError> { let data = &self.data; let mut lx = Lexer::new(data, off); if lx.eat_keyword(b"xref") { @@ -1710,6 +1802,11 @@ impl Document { if !valid_xref_range(start, count) || count > 10_000_000 { return Err(CosError::Syntax { offset: lx.pos, detail: "invalid xref object range".into() }); } + let shifted = subsection_start(data, lx.pos, start, count); + if shifted != start { + log.push(format!("xref subsection at {off} is numbered from 1 but begins with object 0's entry; read from 0")); + } + let start = shifted; for i in 0..count { lx.skip_ws(); let o = lx.token(); @@ -1920,6 +2017,34 @@ fn generated_id(seed: &[u8; 32]) -> Vec { out } +/// The object number a classic xref subsection really starts at. A common producer bug heads the +/// table `1 n` and then writes object 0's free entry (`0000000000 65535 f`) as its first row, so +/// every row would be read one number too high and object 1 as free. Such a subsection is read +/// from 0 when its second row, read that way, locates an object that is indeed numbered 1. `rows` +/// is the position just after the subsection header. +fn subsection_start(data: &[u8], rows: usize, start: i64, count: i64) -> i64 { + if start != 1 || count < 2 { + return start; + } + let mut lx = Lexer::new(data, rows); + let mut row = || { + lx.skip_ws(); + let offset = std::str::from_utf8(lx.token()).ok().and_then(|s| s.parse::().ok()); + lx.skip_ws(); + let generation = std::str::from_utf8(lx.token()).ok().and_then(|s| s.parse::().ok()); + lx.skip_ws(); + (offset, generation, lx.token() == b"f") + }; + let (Some(0), Some(65535), true) = row() else { return start }; + let (Some(second), _, false) = row() else { return start }; + let Some(at) = usize::try_from(second).ok().filter(|at| *at < data.len()) else { return start }; + let mut obj = Lexer::new(data, at); + match (obj.object(), obj.object()) { + (Ok(Object::Int(1)), Ok(Object::Int(_))) if obj.eat_keyword(b"obj") => 0, + _ => start, + } +} + /// Object numbers must fit the representation used by references and the xref map. /// Validate the whole subsection before adding entries, so neither arithmetic overflow nor /// truncation can turn a damaged range into entries for unrelated objects. @@ -1935,6 +2060,28 @@ const MAX_XREF_OBJECTS: usize = 8_388_607; mod tests { use super::*; + #[test] + fn a_read_state_matches_a_clone_and_no_changed_document() { + let mut doc = Document::new_empty(); + let a = doc.add(Object::Int(1)); + let state = doc.read_state(); + let snapshot = doc.clone(); + assert!(state.matches(&doc) && state.matches(&snapshot)); + // The same value set again is still a change: another allocation. + doc.set(a, Object::Int(1)); + assert!(!state.matches(&doc) && state.matches(&snapshot)); + let edited = doc.read_state(); + doc.free(a); + assert!(!edited.matches(&doc)); + let mut trailer_changed = snapshot.clone(); + trailer_changed.trailer_mut().set(b"Info".to_vec(), Object::Null); + assert!(!state.matches(&trailer_changed)); + // Another document's state never matches, even with the same changes. + let mut other = Document::new_empty(); + other.add(Object::Int(1)); + assert!(!other.read_state().matches(&snapshot)); + } + /// Build a classic-xref file from object bodies (object i+1 = bodies[i]). pub(crate) fn build(bodies: &[&str], trailer: &str) -> Vec { let mut out = b"%PDF-1.7\n".to_vec(); @@ -2085,6 +2232,71 @@ mod tests { assert!(doc.xref_entry_is_exact(1) && doc.xref_entry_is_exact(2)); } + /// A classic table with `/Size` rows and object 0's free entry first, its subsection numbered + /// `1 n` instead of `0 n` (#2676): every row is one number off. + fn misnumbered_table(start: u32) -> Vec { + let bodies = ["<< /Marker (first object) >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Type /Catalog /Pages 2 0 R >>"]; + let mut out = b"%PDF-1.7 +" + .to_vec(); + let mut offsets = Vec::new(); + for (i, body) in bodies.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice( + format!( + "{} 0 obj +{body} +endobj +", + i + 1 + ) + .as_bytes(), + ); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n{start} 4\n0000000000 65535 f \n").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 4 /Root 3 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + out + } + + #[test] + fn a_table_numbered_from_1_that_begins_with_object_0_is_read_from_0() { + let doc = Document::open(Arc::new(misnumbered_table(1))).unwrap(); + let first = doc.get(ObjRef::new(1, 0)).as_dict().cloned().expect("object 1 is not free"); + assert!(first.contains(b"Marker")); + assert_eq!(doc.root(), Some(ObjRef::new(3, 0))); + assert!(doc.repair_log().iter().any(|l| l.contains("numbered from 1")), "{:?}", doc.repair_log()); + // A correct table reads the same and logs nothing. + let fine = Document::open(Arc::new(misnumbered_table(0))).unwrap(); + assert!(fine.get(ObjRef::new(1, 0)).as_dict().is_some_and(|d| d.contains(b"Marker"))); + assert!(!fine.repair_log().iter().any(|l| l.contains("numbered from 1"))); + } + + #[test] + fn a_table_that_really_starts_at_1_keeps_its_numbers() { + // Object 1 deleted for good (generation 65535) in a subsection that does start at 1: its + // second row locates object 2, so nothing is shifted. + let mut bytes = b"%PDF-1.7\n".to_vec(); + let pages = bytes.len(); + bytes.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n"); + let catalog = bytes.len(); + bytes.extend_from_slice(b"3 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + let xref = bytes.len(); + bytes.extend_from_slice( + format!( + "xref\n0 1\n0000000000 65535 f \n1 3\n0000000000 65535 f \n{pages:010} 00000 n \n{catalog:010} 00000 n \ntrailer\n<< /Size 4 /Root 3 0 R >>\nstartxref\n{xref}\n%%EOF\n" + ) + .as_bytes(), + ); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert!(doc.get(ObjRef::new(2, 0)).as_dict().is_some_and(|d| d.name(b"Type") == Some(b"Pages"))); + assert!(doc.get(ObjRef::new(1, 0)).as_dict().is_none(), "object 1 stays free"); + assert!(!doc.repair_log().iter().any(|l| l.contains("numbered from 1"))); + } + #[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. @@ -2535,6 +2747,93 @@ mod tests { assert!(old.get(ObjRef::new(1, 0)).as_dict().unwrap().get(b"Lang").is_none()); } + /// qpdf's `xref-compressed-in-compressed.pdf` in miniature: object 3 is an object stream + /// holding object 4, and the cross-reference stream says object 3 itself lives in stream 99. + fn compressed_in_compressed_fixture() -> Document { + let mut out = b"%PDF-1.5\n".to_vec(); + let at = |out: &mut Vec, num: u32, body: &str| { + let off = out.len(); + out.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes()); + off as u16 + }; + let o1 = at(&mut out, 1, "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>"); + let o2 = at(&mut out, 2, "<< /Type /Pages /Kids [] /Count 0 >>"); + let inner = "<< /Type /Outlines /Count 0 >>"; + at(&mut out, 3, &format!("<< /Type /ObjStm /N 1 /First 4 /Length {} >>\nstream\n4 0 {inner}\nendstream", 4 + inner.len())); + // /W [1 2 1]: object 3 is said to live in stream 99 (which does not exist), 4 in stream 3. + let rows: Vec<[u8; 4]> = vec![ + [0, 0, 0, 255], + [1, 0, o1 as u8, 0], + [1, 0, o2 as u8, 0], + [2, 0, 99, 0], + [2, 0, 3, 0], + [1, (out.len() >> 8) as u8, out.len() as u8, 0], + ]; + let rows: Vec = rows.concat(); + let xref = out.len(); + out.extend_from_slice( + format!("5 0 obj\n<< /Type /XRef /W [1 2 1] /Index [0 6] /Size 6 /Root 1 0 R /Length {} >>\nstream\n", rows.len()).as_bytes(), + ); + out.extend_from_slice(&rows); + out.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + Document::open(Arc::new(out)).unwrap() + } + + /// An object stream whose own cross-reference entry says it is compressed inside another + /// stream (which the format forbids, and which does not exist here: qpdf's + /// `xref-compressed-in-compressed.pdf`, #2367) could not be loaded, so every object in it + /// read as null and a full save wrote `/Outlines null`. The stream is now found by its own + /// header, as a plain object with a wrong offset is. + #[test] + fn an_object_stream_marked_as_compressed_is_found_by_its_header() { + let doc = compressed_in_compressed_fixture(); + assert_eq!(doc.revisions().len(), 1, "the chain itself reads: {:?}", doc.repair_log()); + let outlines = doc.get(ObjRef::new(4, 0)); + assert_eq!(outlines.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Outlines"[..]), "{outlines:?}"); + let saved = crate::write_full(&doc, &crate::SaveOptions::default()).unwrap(); + let back = Document::open(Arc::new(saved)).unwrap(); + let catalog = back.get(back.root().unwrap()); + let outlines = back.resolve(catalog.as_dict().unwrap().get(b"Outlines").unwrap()); + assert_eq!(outlines.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Outlines"[..]), "the full save keeps it: {outlines:?}"); + } + + /// The header fallback is for file entries only: once the stream is freed (or replaced) in + /// the overlay, its objects must not come back from the file's copy of it. + #[test] + fn a_freed_object_stream_is_not_found_by_its_header() { + let doc = compressed_in_compressed_fixture(); + assert!(doc.get(ObjRef::new(4, 0)).as_dict().is_some()); + let mut edited = doc.clone(); + edited.free(ObjRef::new(3, 0)); + let fresh = edited.object_reader().document; + assert!(matches!(*fresh.get(ObjRef::new(4, 0)), Object::Null), "{:?}", fresh.get(ObjRef::new(4, 0))); + let mut replaced = doc.clone(); + replaced.set(ObjRef::new(3, 0), Object::Int(0)); + let fresh = replaced.object_reader().document; + assert!(matches!(*fresh.get(ObjRef::new(4, 0)), Object::Null), "{:?}", fresh.get(ObjRef::new(4, 0))); + } + + /// The same once the free is saved: the revision's table says the stream is free, and + /// neither reading the file back nor rewriting it in full brings its objects back. + #[test] + fn a_container_freed_in_a_saved_revision_is_not_found_by_its_header() { + let mut edited = compressed_in_compressed_fixture(); + edited.free(ObjRef::new(3, 0)); + let incremental = crate::write_incremental(&edited, &crate::SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(incremental)).unwrap(); + assert_eq!(reopened.revisions().len(), 2); + assert!(matches!(reopened.xref_entry(3), Some(XrefEntry::Free { .. }))); + assert!(matches!(*reopened.get(ObjRef::new(4, 0)), Object::Null), "{:?}", reopened.get(ObjRef::new(4, 0))); + for object_streams in [true, false] { + let opts = crate::SaveOptions { object_streams, ..Default::default() }; + let full = crate::write_full(&reopened, &opts).unwrap(); + let back = Document::open(Arc::new(full)).unwrap(); + let catalog = back.get(back.root().unwrap()); + let outlines = catalog.as_dict().unwrap().get(b"Outlines").map(|o| back.resolve(o)); + assert!(outlines.as_ref().is_none_or(|o| matches!(**o, Object::Null)), "the full save keeps it out: {outlines:?}"); + } + } + #[test] fn damaged_xref_is_reconstructed() { let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R"); diff --git a/crates/cos/src/lib.rs b/crates/cos/src/lib.rs index 213ff341be..152daed2de 100644 --- a/crates/cos/src/lib.rs +++ b/crates/cos/src/lib.rs @@ -22,7 +22,7 @@ mod security; mod writer; pub use bytes::Bytes; -pub use document::{Document, Revision, SourceIdentity, XrefEntry}; +pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry}; pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream}; pub use parser::{Lexer, is_whitespace, parse_indirect}; pub use pdfcraft_crypt::{ diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index dc7648d4f9..438b8d5f35 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -482,6 +482,19 @@ mod tests { assert_eq!(stream.decoded_strict().unwrap(), b"abc"); } + #[test] + fn raw_flate_fallback_streams_preserve_the_caller_limit() { + // Raw deflate with a stored byte and a final empty block. 78 01 also + // passes the zlib header check, so decoding must retry the whole input. + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("FlateDecode")); + let stream = Stream::from_raw(dict, vec![0x78, 0x01, 0x00, 0xfe, 0xff, b'A', 0x01, 0x00, 0x00, 0xff, 0xff]); + let result = stream.decoded_within(0); + assert!(matches!(&result, Err(CosError::Filter(detail)) if detail.contains("exceeds the limit of 0 bytes")), "{result:?}"); + assert_eq!(stream.decoded_within(1).unwrap(), b"A"); + assert_eq!(stream.decoded_strict().unwrap(), b"A"); + } + #[test] fn first_filter_decodes_only_the_outer_wrapper() { let data = "0 0 m 100 100 l S\n".repeat(64).into_bytes(); diff --git a/crates/cos/src/parser.rs b/crates/cos/src/parser.rs index 8ac8a195bf..0458055961 100644 --- a/crates/cos/src/parser.rs +++ b/crates/cos/src/parser.rs @@ -337,11 +337,14 @@ impl<'a> Lexer<'a> { } return Ok(Object::Real(0.0)); } - match s.trim_start_matches('+').parse::() { + // Tolerate a doubled sign ("--280") like the real branch does. + let t = s.trim_start_matches('+'); + let cleaned = t.strip_prefix('-').filter(|r| r.starts_with('-')).unwrap_or(t); + match cleaned.parse::() { Ok(v) => Ok(Object::Int(v)), Err(_) => { // Out-of-range integers are treated as reals (§7.3.3 allows implementation limits). - s.parse::().map(Object::Real).map_err(|_| CosError::Syntax { offset: start, detail: format!("bad number {s:?}") }) + cleaned.parse::().map(Object::Real).map_err(|_| CosError::Syntax { offset: start, detail: format!("bad number {s:?}") }) } } } @@ -401,7 +404,16 @@ fn parse_indirect_in( if let Object::Dict(dict) = &obj && lx.eat_keyword(b"stream") { - // The keyword is followed by CRLF or LF (a lone CR is tolerated). + // The keyword is followed by CRLF or LF (a lone CR is tolerated). Microsoft Reporting + // Services writes a space first (`stream \r\n`); spaces and tabs count only when a line + // break follows, so they are never taken from the data. + let mut eol = lx.pos; + while matches!(data.get(eol), Some(b' ' | b'\t')) { + eol += 1; + } + if matches!(data.get(eol), Some(b'\r' | b'\n')) { + lx.pos = eol; + } match (data.get(lx.pos), data.get(lx.pos + 1)) { (Some(b'\r'), Some(b'\n')) => lx.pos += 2, (Some(b'\n'), _) | (Some(b'\r'), _) => lx.pos += 1, @@ -475,6 +487,20 @@ mod tests { Lexer::new(s.as_bytes(), 0).object().unwrap_or_else(|e| panic!("{s}: {e}")) } + #[test] + fn doubled_sign_integer_reads_like_the_real_form() { + // Some writers emit a TJ kerning as "--280"; the real form "--280.0" is already read. + assert_eq!(obj("--280.0"), Object::Real(-280.0)); + assert_eq!(obj("--280"), Object::Int(-280)); + match obj("[(Hello) --280 (World)]") { + Object::Array(items) => { + assert_eq!(items.len(), 3); + assert_eq!(items[1], Object::Int(-280)); + } + other => panic!("not an array: {other:?}"), + } + } + #[test] fn scalars() { assert_eq!(obj("true"), Object::Bool(true)); @@ -537,6 +563,25 @@ mod tests { assert_eq!(s.dict.int(b"Length"), Some(5)); } + #[test] + fn indirect_stream_with_a_space_before_the_line_break() { + // `stream \r\n` as Microsoft Reporting Services writes it: the data starts after the + // line break, so the /Length is still right and the data keeps no leading space. + for eol in [&b" \r\n"[..], b" \n", b"\t \r"] { + let mut data = b"7 0 obj\n<< /Length 5 >>\nstream".to_vec(); + data.extend_from_slice(eol); + data.extend_from_slice(b"HELLO\r\nendstream\nendobj\n"); + let (_, o) = parse_indirect(&data, 0, &|_| None).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected") }; + assert_eq!(&s.raw[..], b"HELLO", "{eol:?}"); + } + // Spaces with no line break after them are data, not a keyword terminator. + let data = b"7 0 obj\n<< /Length 7 >>\nstream HELLO\nendstream\nendobj\n"; + let (_, o) = parse_indirect(data, 0, &|_| None).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected") }; + assert_eq!(&s.raw[..], b" HELLO"); + } + #[test] fn indirect_length_is_resolved() { let data = b"7 0 obj\n<< /Length 8 0 R >>\nstream\r\nAB\r\nendstream\nendobj\n"; diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index f8d8fd3d80..331f97d443 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -183,7 +183,7 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result, } // Reconstructed files have no chain to append to; added or removed encryption must // rewrite every object; redaction must not leave the old revision behind. All need a full save. - if doc.revisions().is_empty() || doc.encryption_changed() || doc.full_save_required() { + if doc.reconstructed() || doc.encryption_changed() || doc.full_save_required() { return write_full(doc, opts); } let mut doc = doc.clone(); @@ -193,7 +193,11 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result, // allocated once at its exact size: growing a buffer that holds a large original past a // guessed capacity would copy the whole file again and keep up to as much again unused. let newline = !matches!(original.last(), Some(b'\n' | b'\r')); - let base = (original.len() + usize::from(newline)) as u64; + // A file with bytes before its `%PDF-` header may count its offsets from the header. The new + // section then has to as well: readers take `/Prev` and `startxref` in one convention, and a + // mix sends them into reconstruction (#2364). + let shift = if doc.revisions().last().is_some_and(|r| r.header_relative) { doc.header_offset() } else { 0 }; + let base = (original.len() + usize::from(newline)).saturating_sub(shift) as u64; let mut out = Vec::new(); let mut offsets: BTreeMap = BTreeMap::new(); // num → (offset, gen, in use) for (num, generation, obj) in doc.overlay_entries() { diff --git a/crates/cos/tests/encryption.rs b/crates/cos/tests/encryption.rs index af92cf67c7..e05161558d 100644 --- a/crates/cos/tests/encryption.rs +++ b/crates/cos/tests/encryption.rs @@ -336,3 +336,41 @@ fn an_earlier_revision_opens_with_the_documents_own_credentials() { assert_eq!(content(¤t.open_with_same_credentials(Arc::new(fixture())).unwrap()), SECRET, "{alg:?}"); } } + +/// `protect`, with the `/Encrypt` dictionary written inline in the trailer, as MuPDF writes it, +/// instead of as an indirect object. +fn protect_inline(alg: Algorithm, user: &str, owner: &str) -> Vec { + 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: [9; 32], + }) + .unwrap(); + let dict = doc.get(doc.trailer().reference(b"Encrypt").unwrap()).as_dict().cloned().unwrap(); + doc.trailer_mut().set(b"Encrypt".to_vec(), Object::Dict(dict)); + write_full(&doc, &SaveOptions::default()).unwrap() +} + +/// MuPDF writes `/Encrypt` inline in the trailer, so removing it changes no object. It is still +/// an unsaved change, and saving must write a file that opens without a password (#2661). +#[test] +fn removing_an_inline_encrypt_dictionary_leaves_an_unencrypted_file() { + for alg in ALL { + let bytes = Arc::new(protect_inline(alg, "user", "owner")); + assert!(matches!(Document::open(bytes.clone()), Err(CosError::NeedsPassword)), "{alg:?}"); + let mut doc = Document::open_with_password(bytes, Some("owner")).unwrap(); + assert!(matches!(doc.trailer().get(b"Encrypt"), Some(Object::Dict(_))), "{alg:?}: inline"); + assert!(!doc.is_modified(), "{alg:?}"); + doc.remove_encryption(); + assert!(doc.is_modified(), "{alg:?}: removing security is an unsaved change"); + let plain = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let again = Document::open(Arc::new(plain.clone())).unwrap_or_else(|e| panic!("{alg:?}: {e:?}")); + assert!(again.security().is_none() && !again.trailer().contains(b"Encrypt"), "{alg:?}"); + assert_eq!(content(&again), SECRET, "{alg:?}"); + assert!(hayro_syntax::Pdf::new(plain).is_ok(), "{alg:?}"); + } +} diff --git a/crates/cos/tests/writer.rs b/crates/cos/tests/writer.rs index ec18b13f36..ac04be2e94 100644 --- a/crates/cos/tests/writer.rs +++ b/crates/cos/tests/writer.rs @@ -392,3 +392,67 @@ fn full_save_preserves_unknown_trailer_data() { assert_eq!(trailer_note(&back), trailer_note(&doc)); assert_eq!(back.trailer().get(b"Version"), doc.trailer().get(b"Version")); } + +/// The `startxref` value at the end of `bytes`. +fn last_startxref(bytes: &[u8]) -> usize { + let at = bytes.windows(9).rposition(|w| w == b"startxref").unwrap(); + String::from_utf8_lossy(&bytes[at + 9..]).split_whitespace().next().unwrap().parse().unwrap() +} + +#[test] +fn incremental_saves_of_a_file_with_bytes_before_its_header_count_from_the_header() { + // Offsets written before the prefix was added count from `%PDF-`, as in the KillerPDF file + // of #2364; the appended section must count the same way as the `/Prev` it points back to. + let prefix = vec![b'#'; 128]; + let as_stream = write_full(&Document::open(Arc::new(fixture())).unwrap(), &SaveOptions::default()).unwrap(); + for (name, body, marker) in [("table", classic_table(), &b"xref"[..]), ("stream", as_stream, &b" 0 obj"[..])] { + let input = [prefix.as_slice(), &body].concat(); + let mut doc = Document::open(Arc::new(input.clone())).unwrap(); + assert!(doc.revisions().last().unwrap().header_relative, "{name}"); + doc.update_dict(ObjRef::new(1, 0), |d| d.set(b"Lang".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"en".to_vec())))).unwrap(); + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(saved.starts_with(&input), "{name}"); + + let xref = last_startxref(&saved) + prefix.len(); + let section = saved.get(xref..).unwrap_or_default(); + assert!(section.windows(marker.len()).position(|w| w == marker).is_some_and(|p| p < 16), "{name}: startxref misses its section"); + let back = Document::open(Arc::new(saved.clone())).unwrap(); + assert_eq!(back.revisions().len(), doc.revisions().len() + 1, "{name}"); + assert!(back.revisions().iter().all(|r| r.header_relative), "{name}: one convention across the chain"); + let edited = back.get(ObjRef::new(1, 0)); + assert_eq!(edited.as_dict().unwrap().get(b"Lang").and_then(|o| o.as_string()).map(|s| s.bytes.as_slice()), Some(b"en".as_slice())); + assert!(back.xref_entry_is_exact(1), "{name}"); + assert_qpdf_clean(&saved); + } +} + +#[test] +fn incremental_saves_of_a_file_whose_offsets_count_from_its_first_byte_keep_counting_so() { + // The other convention: the same file, but its offsets include the bytes before the header. + let prefix = vec![b'#'; 128]; + let mut input = [prefix.as_slice(), &classic_table()].concat(); + let xref = input.windows(6).rposition(|w| w == b"\nxref\n").unwrap() + 1; + let table: String = String::from_utf8_lossy(&input[xref..]) + .lines() + .map(|line| match line.strip_suffix(" 00000 n ").and_then(|o| o.parse::().ok()) { + Some(o) => format!("{:010} 00000 n \n", o + prefix.len()), + None if line.parse::().is_ok() => format!("{xref}\n"), + None => format!("{line}\n"), + }) + .collect(); + input.truncate(xref); + input.extend_from_slice(table.as_bytes()); + + let mut doc = Document::open(Arc::new(input.clone())).unwrap(); + assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log()); + assert!(!doc.revisions().last().unwrap().header_relative); + doc.update_dict(ObjRef::new(1, 0), |d| d.set(b"Lang".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"en".to_vec())))).unwrap(); + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(saved.get(last_startxref(&saved)..).unwrap_or_default().starts_with(b"xref")); + let back = Document::open(Arc::new(saved.clone())).unwrap(); + assert!(back.repair_log().is_empty(), "{:?}", back.repair_log()); + assert_eq!(back.revisions().len(), 2); + assert!(back.xref_entry_is_exact(1)); + // No qpdf check here: qpdf counts every offset from the header in a file with bytes before + // it, so it already reconstructs this input before anything is appended. +} diff --git a/crates/create/README.md b/crates/create/README.md index 88c6da2ea5..499081059a 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -23,3 +23,9 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2: line says so (`ESCAPED_NOTE`). Every function returns a `pdfcraft_cos::Document`; the caller writes it. + +Image export and Save Image As apply each lossless image's Decode pair before converting its color components. +Indexed Decode values select palette entries after rounding and clipping. Color-key masks compare the original samples. +Missing, null or malformed Decode arrays keep the default mapping. +The existing 16-bit path still uses each sample's high byte. JPEG and mask Decode handling remain limited. +ICC transforms and calibrated color conversion remain outside this export path. diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index 1034489458..768da4d1f1 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -126,13 +126,13 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> // A stencil mask (`ImageMask true`) has no /SMask or /Mask of its own (8.9.6.2). let mut alpha = if mask { None } else { soft_mask(doc, s, w, h).or_else(|| stencil_mask(doc, s, w, h)) }; let key = if mask { None } else { colour_key(doc, s) }; - // Decode arrays: only inversion ([1 0] per component) is honoured, the common case. - let inverted = s - .dict - .get(b"Decode") - .map(|d| doc.resolve(d)) - .and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64))) - .unwrap_or(false); + // Keep the existing JPEG and stencil-mask Decode handling separate from lossless colour images. + let inverted = (jpeg || mask) + && s.dict + .get(b"Decode") + .map(|d| doc.resolve(d)) + .and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64))) + .unwrap_or(false); // In a PDF a CMYK JPEG's samples are ink amounts unless /Decode inverts them, while image // viewers read a CMYK JPEG file as inverted (Adobe's convention): as is, it would show as // a negative. @@ -161,6 +161,7 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> (unpack(&data, w, h, space.components(), bpc).ok_or("the image data is shorter than its size says")?, bpc) }; let n = space.components(); + let decode = if jpeg || mask { None } else { decode(doc, s, n) }; if alpha.is_none() { alpha = key.and_then(|k| keyed_alpha(&samples, n, bpc, &k)); } @@ -168,10 +169,21 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> return as_is(); } let invert = inverted ^ mask; // a stencil mask paints its 0 samples + // `unpack` keeps only the high byte at 16 bits, so Decode uses an effective maximum of 255 too. let max = (1u32 << bpc.min(8)) - 1; let mut rgb = Vec::with_capacity(w * h * 3); for px in samples.chunks_exact(n) { + let decoded = |i: usize| { + decode.as_ref().and_then(|pairs| pairs.get(i)).map(|&[lo, hi]| { + let t = f64::from(px.get(i).copied().unwrap_or(0)) / f64::from(max); + // Avoid overflowing hi - lo for finite endpoints with opposite signs. + (1.0 - t) * lo + t * hi + }) + }; let v = |i: usize| { + if let Some(value) = decoded(i) { + return (value.clamp(0.0, 1.0) * 255.0).round() as u8; + } let x = (px.get(i).copied().unwrap_or(0) as u32 * 255 / max) as u8; if invert { 255 - x } else { x } }; @@ -180,8 +192,12 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> 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, 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; + // Explicit Decode maps into index units, rounded then clipped (8.6.6.3). + // Without Decode, retain raw indices rather than normalizing to hival. + let i = match decoded(0) { + Some(value) => value.round().clamp(0.0, f64::from(*hival)) as usize, + None => 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 { @@ -198,6 +214,24 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> png(w as u32, h as u32, &rgb, alpha.as_deref()).map(|p| ("png", p)) } +/// The explicit Decode intervals for a lossless colour image (8.9.5.2, Table 90). +/// +/// A malformed array (not an array, the wrong length, a non-number or non-finite entry) gives +/// the default mapping, so the image still exports. +fn decode(doc: &Document, s: &Stream, n: usize) -> Option> { + let value = doc.resolve(s.dict.get(b"Decode")?); + let values = value.as_array()?; + if Some(values.len()) != n.checked_mul(2) { + return None; + } + let endpoint = |value: &Object| doc.resolve(value).as_f64().filter(|v| v.is_finite()); + let mut pairs = Vec::with_capacity(n); + for [lo, hi] in values.as_chunks::<2>().0 { + pairs.push([endpoint(lo)?, endpoint(hi)?]); + } + Some(pairs) +} + /// Width and height as sizes, when both are positive and the image is at most `MAX_PIXELS`. fn size(w: i64, h: i64) -> Option<(usize, usize)> { let (w, h) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?); diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index a42ec84fa8..5717977b1e 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -5,6 +5,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; mod extract; +mod upright_image; pub use extract::{ExtractedImage, ImageExport, extract_images, image_file}; use pdfcraft_fonts::{literal, win_ansi, wrap}; @@ -528,7 +529,7 @@ pub fn source_kind(name: &str, bytes: &[u8]) -> Option { /// Detect the image format from its bytes; a TIFF may hold several pages. fn embed(name: &str, bytes: &[u8]) -> Result, CreateError> { if bytes.starts_with(&[0xFF, 0xD8]) { - Ok(vec![jpeg(name, bytes)?]) + Ok(vec![jpeg(name, &upright_image::upright(bytes))?]) } else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { Ok(vec![png_image(name, bytes)?]) } else if is_tiff(bytes) { diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 993c38d9f3..6356105699 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -124,6 +124,18 @@ fn text_is_wrapped_and_paginated() { assert!(content(p.last().unwrap()).contains("(After a form feed) Tj"), "a form feed starts a page"); } +#[test] +fn text_keeps_leading_spaces_and_tabs() { + let doc = reopen(&from_text("t", " indented\n\ttabbed\nplain", LETTER, 11.0).unwrap()); + let p = pages(&doc); + let c = doc.resolve(p[0].get(b"Contents").unwrap()); + let Object::Stream(s) = &*c else { panic!() }; + let content = String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(); + assert!(content.contains("( indented) Tj"), "{content}"); + assert!(content.contains("( tabbed) Tj"), "{content}"); + assert!(content.contains("(plain) Tj"), "{content}"); +} + /// Text Helvetica can't draw is refused with its line and character, never set as `?`; text /// PdfCraft generates writes such characters as [U+XXXX] and says so. #[test] @@ -875,6 +887,193 @@ fn indexed_hival_keeps_existing_short_palette_fallback() { assert_eq!(exported_png_pixels(&bytes), vec![255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); } +/// Save and reopen a synthetic image before checking both export entry points. +fn decode_fixture(dict: Dict, samples: Vec) -> (Document, pdfcraft_cos::ObjRef) { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |_| Stream::from_raw(dict, samples)); + let doc = reopen(&doc); + let page = pdfcraft_model::pages(&doc)[0].clone(); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()); + let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap()); + let image = xo.as_dict().unwrap().iter().next().unwrap().1.as_ref().unwrap(); + (doc, image) +} + +fn assert_decode_pixels(dict: Dict, samples: Vec, expected: &[u8]) { + let (doc, image) = decode_fixture(dict, samples); + 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), expected); + let (ext, bytes) = image_file(&doc, image).unwrap(); + assert_eq!(ext, "png"); + assert_eq!(exported_png_pixels(&bytes), expected); +} + +fn decode_array(values: &[f64]) -> Object { + Object::Array(values.iter().copied().map(Object::Real).collect()) +} + +#[test] +fn lossless_decode_maps_each_component_before_colour_conversion() { + for (space, samples, decode, expected) in [ + ("DeviceRGB", vec![0, 64, 255], vec![1., 0., 0., 1., 0., 1.], [255, 64, 255]), + ("DeviceRGB", vec![0, 64, 255], vec![0., 1., 1., 0., 0., 1.], [0, 191, 255]), + ("DeviceGray", vec![0], vec![0.2, 0.8], [51, 51, 51]), + ("DeviceGray", vec![128], vec![-1., 2.], [129, 129, 129]), + ("DeviceCMYK", vec![0, 0, 0, 0], vec![0., 1., 1., 0., 0., 1., 0., 1.], [255, 0, 255]), + ] { + let mut dict = image_dict(1, 1, space, 8); + dict.set(b"Decode".to_vec(), decode_array(&decode)); + assert_decode_pixels(dict, samples, &expected); + } +} + +#[test] +fn lossless_decode_scales_packed_samples_by_their_bit_depth() { + let mut dict = image_dict(4, 1, "DeviceGray", 2); + dict.set(b"Decode".to_vec(), decode_array(&[0.2, 0.8])); + assert_decode_pixels(dict, vec![0x1b], &[51, 51, 51, 102, 102, 102, 153, 153, 153, 204, 204, 204]); +} + +#[test] +fn lossless_decode_identity_and_defaults_keep_depth_and_row_padding() { + for (bpc, samples, expected) in [ + (1, vec![0x7f, 0xff], vec![0, 0, 0, 255, 255, 255]), + (2, vec![0x7f, 0xbf], vec![85, 85, 85, 170, 170, 170]), + (4, vec![0x5f, 0xaf], vec![85, 85, 85, 170, 170, 170]), + (8, vec![85, 170], vec![85, 85, 85, 170, 170, 170]), + (16, vec![85, 255, 170, 0], vec![85, 85, 85, 170, 170, 170]), + ] { + for decode in [None, Some(Object::Null), Some(decode_array(&[0., 1.]))] { + let mut dict = image_dict(1, 2, "DeviceGray", bpc); + if let Some(decode) = decode { + dict.set(b"Decode".to_vec(), decode); + } + assert_decode_pixels(dict, samples.clone(), &expected); + } + } +} + +#[test] +fn lossless_indexed_decode_rounds_in_index_units_then_clamps() { + let palette = vec![10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120]; + for (bpc, samples, decode, expected) in [ + (2, vec![0x1b], Some(vec![3., 0.]), vec![100, 110, 120, 70, 80, 90, 40, 50, 60, 10, 20, 30]), + (2, vec![0x1b], Some(vec![-1., 5.]), vec![10, 20, 30, 40, 50, 60, 100, 110, 120, 100, 110, 120]), + (2, vec![0x1b], Some(vec![0.5, 0.5]), vec![40, 50, 60, 40, 50, 60, 40, 50, 60, 40, 50, 60]), + (8, vec![0, 1, 2, 255], None, vec![10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120]), + // Existing 16-bit policy retains the high byte, 42 / 255 * 3 < 0.5. + (16, vec![0x2a, 0xab, 0, 0, 255, 255, 128, 0], Some(vec![0., 3.]), vec![10, 20, 30, 10, 20, 30, 100, 110, 120, 70, 80, 90]), + ] { + let (mut doc, image) = indexed_export_fixture(Object::Int(3), palette.clone(), false, false, bpc, samples); + if let Some(decode) = decode { + let Object::Stream(mut stream) = (*doc.get(image)).clone() else { panic!() }; + stream.dict.set(b"Decode".to_vec(), decode_array(&decode)); + doc.set(image, Object::Stream(stream)); + } + let doc = reopen(&doc); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{:?}", out.skipped); + assert_eq!(exported_png_pixels(&out.images[0].data), expected); + let (_, bytes) = image_file(&doc, out.images[0].object).unwrap(); + assert_eq!(exported_png_pixels(&bytes), expected); + } +} + +#[test] +fn lossless_decode_keeps_colour_key_comparisons_in_raw_samples() { + let mut dict = image_dict(2, 1, "DeviceGray", 8); + dict.set(b"Decode".to_vec(), decode_array(&[0.2, 0.8])); + dict.set(b"Mask".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0)])); + assert_decode_pixels(dict, vec![0, 255], &[51, 51, 51, 0, 204, 204, 204, 255]); + + 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!() }; + stream.dict.set(b"Decode".to_vec(), decode_array(&[3., 0.])); + stream.dict.set(b"Mask".to_vec(), Object::Array(vec![Object::Int(2), Object::Int(2)])); + doc.set(image, Object::Stream(stream)); + let doc = reopen(&doc); + let out = extract_images(&doc, &[0], 0); + let expected = [0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 0, 255, 0, 0, 255]; + assert_eq!(exported_png_pixels(&out.images[0].data), expected); + let (_, bytes) = image_file(&doc, out.images[0].object).unwrap(); + assert_eq!(exported_png_pixels(&bytes), expected); +} + +#[test] +fn lossless_decode_resolves_indirect_arrays_and_endpoints() { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let lo = doc.add(Object::Real(0.2)); + let hi = doc.add(Object::Real(0.8)); + let decode = doc.add(Object::Array(vec![Object::Ref(lo), Object::Ref(hi)])); + let mut dict = image_dict(1, 1, "DeviceGray", 8); + dict.set(b"Decode".to_vec(), Object::Ref(decode)); + Stream::from_raw(dict, vec![0]) + }); + let doc = reopen(&doc); + let out = extract_images(&doc, &[0], 0); + assert_eq!(exported_png_pixels(&out.images[0].data), [51, 51, 51]); + let (_, bytes) = image_file(&doc, out.images[0].object).unwrap(); + assert_eq!(exported_png_pixels(&bytes), [51, 51, 51]); + + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let decode = doc.add(Object::Null); + let mut dict = image_dict(1, 1, "DeviceGray", 8); + dict.set(b"Decode".to_vec(), Object::Ref(decode)); + Stream::from_raw(dict, vec![64]) + }); + let doc = reopen(&doc); + let out = extract_images(&doc, &[0], 0); + assert_eq!(exported_png_pixels(&out.images[0].data), [64, 64, 64]); + let (_, bytes) = image_file(&doc, out.images[0].object).unwrap(); + assert_eq!(exported_png_pixels(&bytes), [64, 64, 64]); +} + +#[test] +fn lossless_decode_interpolation_avoids_endpoint_difference_overflow() { + let (mut doc, image) = decode_fixture(image_dict(3, 1, "DeviceGray", 8), vec![0, 64, 255]); + let Object::Stream(mut stream) = (*doc.get(image)).clone() else { panic!() }; + stream.dict.set(b"Decode".to_vec(), decode_array(&[-f64::MAX, f64::MAX])); + doc.set(image, Object::Stream(stream)); + let expected = [0, 0, 0, 0, 0, 0, 255, 255, 255]; + let out = extract_images(&doc, &[0], 0); + assert_eq!(exported_png_pixels(&out.images[0].data), expected); + let (_, bytes) = image_file(&doc, image).unwrap(); + assert_eq!(exported_png_pixels(&bytes), expected); +} + +#[test] +fn lossless_decode_falls_back_to_the_default_mapping_when_malformed() { + for (space, samples, decode, expected) in [ + ("DeviceRGB", vec![0, 64, 255], decode_array(&[1., 0.]), [0, 64, 255]), + ("DeviceGray", vec![64], Object::name("bad"), [64, 64, 64]), + ("DeviceGray", vec![64], decode_array(&[]), [64, 64, 64]), + ("DeviceGray", vec![64], decode_array(&[0.]), [64, 64, 64]), + ("DeviceGray", vec![64], decode_array(&[1., 0., 0., 1.]), [64, 64, 64]), + ("DeviceGray", vec![64], Object::Array(vec![Object::Int(1), Object::name("bad")]), [64, 64, 64]), + ("DeviceGray", vec![64], Object::Array(vec![Object::Int(1), Object::Null]), [64, 64, 64]), + ] { + let mut dict = image_dict(1, 1, space, 8); + dict.set(b"Decode".to_vec(), decode); + assert_decode_pixels(dict, samples, &expected); + } + // Non-finite reals cannot be serialized as PDF numbers. Exercise the in-memory input. + for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let mut dict = image_dict(1, 1, "DeviceGray", 8); + dict.set(b"Decode".to_vec(), decode_array(&[1., value])); + let (mut doc, image) = decode_fixture(image_dict(1, 1, "DeviceGray", 8), vec![64]); + doc.set(image, Object::Stream(Stream::from_raw(dict, vec![64]))); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{:?}", out.skipped); + assert_eq!(exported_png_pixels(&out.images[0].data), [64, 64, 64]); + let (_, bytes) = image_file(&doc, image).unwrap(); + assert_eq!(exported_png_pixels(&bytes), [64, 64, 64]); + } +} + /// 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 { @@ -949,3 +1148,36 @@ fn cmyk_and_ycck_jpegs_export_in_the_colours_the_page_shows() { } } } + +/// A 60 x 20 JPEG at `dpi` x 72 dpi, tagged with EXIF `orientation` (6: shown turned 90 degrees +/// clockwise). +pub(crate) fn phone_jpeg(orientation: u8, dpi: u16) -> Vec { + use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit}; + let mut jpeg = Vec::new(); + let mut encoder = JpegEncoder::new(&mut jpeg); + encoder.set_pixel_density(PixelDensity { density: (dpi, 72), unit: PixelDensityUnit::Inches }); + encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap(); + let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec(); + exif.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]); + let mut out = jpeg[..2].to_vec(); + out.extend_from_slice(&[0xff, 0xe1]); + out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes()); + out.extend_from_slice(&exif); + out.extend_from_slice(&jpeg[2..]); + out +} + +#[test] +fn a_jpeg_with_an_exif_orientation_becomes_an_upright_page() { + let doc = reopen(&from_images(&[("photo.jpg".into(), phone_jpeg(6, 144)), ("plain.jpg".into(), phone_jpeg(1, 72))]).unwrap()); + // Stored 60 x 20 px at 144 x 72 dpi; turned it is 20 x 60 px at 72 x 144 dpi: 20 x 30 pt. + let m0 = media(&pages(&doc)[0]); + assert!((m0[2] - 20.0).abs() < 0.01 && (m0[3] - 30.0).abs() < 0.01, "{m0:?}"); + let img = image_of(&doc, 0); + assert_eq!((img.int(b"Width"), img.int(b"Height")), (Some(20), Some(60))); + // "As stored" stays as it is. + let m1 = media(&pages(&doc)[1]); + assert!((m1[2] - 60.0).abs() < 0.01 && (m1[3] - 20.0).abs() < 0.01, "{m1:?}"); + let img = image_of(&doc, 1); + assert_eq!((img.int(b"Width"), img.int(b"Height")), (Some(60), Some(20))); +} diff --git a/crates/engine/src/upright_image.rs b/crates/create/src/upright_image.rs similarity index 62% rename from crates/engine/src/upright_image.rs rename to crates/create/src/upright_image.rs index dc2b9cdbfc..9816dfc4b2 100644 --- a/crates/engine/src/upright_image.rs +++ b/crates/create/src/upright_image.rs @@ -1,6 +1,6 @@ //! Photos from phones are stored sideways with an EXIF Orientation tag. PDF image XObjects have -//! no such tag, so a JPEG embedded as it is would show up turned. Images placed into a page -//! (stamps, added images, replacements) go through here first. +//! no such tag, so a JPEG embedded as it is would show up turned. Every JPEG embedded by this +//! crate (pages made from images, stamps, added images, replacements) goes through here first. use std::borrow::Cow; use std::io::Cursor; @@ -79,24 +79,7 @@ fn jfif_density(bytes: &[u8]) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::{Edit, MarkFile}; - - /// A 60 x 20 JPEG at `dpi` x 72 dpi, tagged with EXIF `orientation` (6: shown turned 90 - /// degrees clockwise). - fn phone_jpeg(orientation: u8, dpi: u16) -> Vec { - let mut jpeg = Vec::new(); - let mut encoder = JpegEncoder::new(&mut jpeg); - encoder.set_pixel_density(PixelDensity { density: (dpi, 72), unit: PixelDensityUnit::Inches }); - encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap(); - let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec(); - exif.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]); - let mut out = jpeg[..2].to_vec(); - out.extend_from_slice(&[0xff, 0xe1]); - out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes()); - out.extend_from_slice(&exif); - out.extend_from_slice(&jpeg[2..]); - out - } + use crate::tests::phone_jpeg; #[test] fn orientation_is_applied_and_plain_images_are_left_alone() { @@ -111,23 +94,4 @@ mod tests { assert!(matches!(upright(b"\x89PNG\r\n\x1a\n"), Cow::Borrowed(_))); assert!(matches!(upright(b"\xff\xd8 not a jpeg"), Cow::Borrowed(_))); } - - #[test] - fn a_phone_photo_used_as_a_custom_stamp_is_embedded_upright() { - let mut session = crate::Session::new(); - let blank = session.create_blank(300.0, 400.0, 1).unwrap(); - let id = session.open_new("form.pdf", blank).unwrap(); - // A click places the stamp at its natural size: 30 x 20 pt as stored, 20 x 30 pt upright. - // Named "Signature" so `signature_image` finds its picture. - let file = MarkFile { name: "photo.jpg".into(), bytes: std::sync::Arc::new(phone_jpeg(6, 144)), page: 0 }; - let stamp = Edit::AddCustomStamp { page: 0, rect: [100.0, 100.0, 100.0, 100.0], name: "Signature".into(), file, author: "Ada".into() }; - session.apply(id, stamp).unwrap(); - let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos; - let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap(); - let obj = cos.get(picture); - let pdfcraft_cos::Object::Stream(s) = &*obj else { panic!() }; - assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(20), Some(60))); - let rect = pdfcraft_annot::summaries(cos)[0].rect; - assert!(((rect[2] - rect[0]) - 20.0).abs() < 0.01 && ((rect[3] - rect[1]) - 30.0).abs() < 0.01, "{rect:?}"); - } } diff --git a/crates/crypt/src/lib.rs b/crates/crypt/src/lib.rs index fd600cb9af..82b598688a 100644 --- a/crates/crypt/src/lib.rs +++ b/crates/crypt/src/lib.rs @@ -203,8 +203,22 @@ impl SecurityHandler { self.recovered_user.clone() } + /// For R5/R6, `/P` is stored in clear and again in `/Perms`, encrypted with the file key + /// (Algorithm 13). Where the two differ, a permission counts only when both grant it, so an + /// edited `/P` grants nothing extra. Without a readable `/Perms`, `/P` applies as before. pub fn permissions(&self) -> Permissions { - Permissions { bits: self.dict.p, owner: self.auth == Auth::Owner } + let bits = self.perms_p().map_or(self.dict.p, |p| self.dict.p & p); + Permissions { bits, owner: self.auth == Auth::Owner } + } + + /// `/P` as `/Perms` holds it: AES-256 ECB of one block (= CBC with a zero IV), marked `adb`. + fn perms_p(&self) -> Option { + if !matches!(self.dict.r, 5 | 6) { + return None; + } + let plain = aes_cbc_decrypt(&self.key, &[0; 16], self.dict.perms.first_chunk::<16>()?, false); + let p = i32::from_le_bytes(*plain.first_chunk::<4>()?); + (plain.get(9..12) == Some(&b"adb"[..])).then_some(p) } pub fn dict(&self) -> &EncryptDict { diff --git a/crates/crypt/src/tests.rs b/crates/crypt/src/tests.rs index 7272af9ea1..5110e79998 100644 --- a/crates/crypt/src/tests.rs +++ b/crates/crypt/src/tests.rs @@ -212,6 +212,28 @@ fn permission_bits_follow_table_22() { assert!(p.modify(), "owners ignore /P"); } +#[test] +fn perms_limits_what_an_edited_p_grants() { + let made = new(Algorithm::Aes256, "user pw", "owner pw", 0b0100); // print only + let open = |d: &EncryptDict, pw| SecurityHandler::open(d.clone(), ID, Some(pw)).unwrap().permissions(); + let mut d = made.dict().clone(); + assert_eq!(open(&d, "user pw").bits, d.p, "/P and /Perms agree"); + d.p = -4; // /P edited to allow everything, /Perms as written + let p = open(&d, "user pw"); + assert!(p.print() && !p.modify() && !p.copy() && !p.annotate() && !p.fill_forms() && !p.assemble()); + assert!(open(&d, "owner pw").modify(), "owners ignore /P"); + d.p = -3904; // a stricter /P still applies + assert!(!open(&d, "user pw").print()); + // No /Perms, or one that does not decrypt to an `adb` block: /P as before. + for perms in [Vec::new(), vec![0; 16], made.dict().perms[..15].to_vec()] { + let d = EncryptDict { p: -4, perms, ..made.dict().clone() }; + assert!(open(&d, "user pw").modify()); + } + // R2 to R4 have no /Perms: /P is part of their file key, so an edited /P does not open. + let d = EncryptDict { p: -4, ..new(Algorithm::Aes128, "user pw", "owner pw", 0b0100).dict().clone() }; + assert_eq!(SecurityHandler::open(d, ID, Some("user pw")).unwrap_err(), CryptError::WrongPassword); +} + proptest::proptest! { #[test] fn malformed_dictionaries_never_panic(o in proptest::collection::vec(proptest::num::u8::ANY, 0..60), diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs index 8202c18d83..3ac5468057 100644 --- a/crates/edit/src/flatten.rs +++ b/crates/edit/src/flatten.rs @@ -63,9 +63,15 @@ enum Which { fn selected(doc: &Document, d: &Dict, which: Which) -> bool { let subtype = d.name(b"Subtype").unwrap_or_default(); match which { + // Comments are what the comment list calls comments: links, pop-ups and the non-markup + // multimedia and print-production annotations (3D, Screen, Movie, RichMedia, + // PrinterMark, TrapNet, Watermark) are neither comments nor fields, and stay (#3087). Which::Comments { comments, fields } => { - let widget = subtype == b"Widget"; - !matches!(subtype, b"Link" | b"Popup") && (!widget || fields) && (widget || comments) + if subtype == b"Widget" { + fields + } else { + comments && pdfcraft_annot::is_comment_subtype(&String::from_utf8_lossy(subtype)) + } } Which::FillSign => !matches!(subtype, b"Link" | b"Popup" | b"Widget") && pdfcraft_annot::is_fill_sign(doc, d), } diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index fb62125880..79f2c2cd2a 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -4,8 +4,8 @@ //! or delete. Only the stream that draws it is rewritten, as a new object. use kurbo::{BezPath, Point, Shape}; -use pdfcraft_content::{Matrix, Op, parse, serialize_ops}; -use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_content::{Matrix, Op, Pieces, parse}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object}; use std::sync::Arc; use crate::EditError; @@ -30,6 +30,8 @@ pub struct PageImage { /// Pixel size; zero for vector/grouped Form artwork. pub width: u32, pub height: u32, + /// The content stream its `Do` (its first token) is in, and the `Do`'s index among the + /// operators of the page's streams joined ([`Pieces`]). stream: usize, op: usize, /// CTM before `Do`, excluding a Form's own Matrix. @@ -90,6 +92,11 @@ fn streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { .collect() } +/// The page's streams joined into the one stream they are: an operator may span two of them. +fn joined(streams: &[(Object, Vec)]) -> Pieces { + Pieces::join(&streams.iter().map(|(_, data)| data.as_slice()).collect::>()) +} + fn page_of(doc: &Document, page: usize) -> Result { pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page)) } @@ -214,72 +221,71 @@ pub fn page_images(doc: &Document, page: usize) -> Result, EditEr let p = page_of(doc, page)?; let xo = xobjects(doc, &p.dict); let mut out = Vec::new(); - // The graphics state carries over from one stream to the next. + // The streams are one stream in pieces, so the graphics state and operators carry over. let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); - for (si, (_, data)) in streams(doc, &p.dict).into_iter().enumerate() { - for (i, op) in parse(&data).ops.iter().enumerate() { - state.apply(op)?; - let ctm = state.placement.matrix; - if op.is("Do") { - let Some(name) = op.name(0) else { continue }; - let Some(resource) = xo.get(name) else { continue }; - let obj = doc.resolve(resource); - let Object::Stream(s) = &*obj else { continue }; - let is_form = s.dict.name(b"Subtype") == Some(b"Form"); - let (matrix, bounds) = if is_form { - let Some(bounds) = s.dict.get(b"BBox").map(|v| doc.resolve(v)).and_then(|v| { - v.as_array().and_then(|a| { - if a.len() != 4 { - return None; - } - let mut b = [0.0; 4]; - for (n, v) in b.iter_mut().zip(a) { - *n = v.as_f64()?; - } - Some(b) - }) - }) else { - continue; - }; - let matrix = match s.dict.get(b"Matrix") { - Some(v) => { - let v = doc.resolve(v); - let Some(m) = v.as_array().and_then(|a| Matrix::from_operands(a)) else { continue }; - m + let pieces = joined(&streams(doc, &p.dict)); + for (i, op) in pieces.parse().ops.iter().enumerate() { + state.apply(op)?; + let ctm = state.placement.matrix; + if op.is("Do") { + let Some(name) = op.name(0) else { continue }; + let Some(resource) = xo.get(name) else { continue }; + let obj = doc.resolve(resource); + let Object::Stream(s) = &*obj else { continue }; + let is_form = s.dict.name(b"Subtype") == Some(b"Form"); + let (matrix, bounds) = if is_form { + let Some(bounds) = s.dict.get(b"BBox").map(|v| doc.resolve(v)).and_then(|v| { + v.as_array().and_then(|a| { + if a.len() != 4 { + return None; } - None => Matrix::IDENTITY, - }; - (matrix.then(&ctm), bounds) - } else if s.dict.name(b"Subtype") == Some(b"Image") { - (ctm, [0.0, 0.0, 1.0, 1.0]) - } else { + let mut b = [0.0; 4]; + for (n, v) in b.iter_mut().zip(a) { + *n = v.as_f64()?; + } + Some(b) + }) + }) else { continue; }; - let rect = matrix.bbox(bounds); - if !bounds.iter().chain(matrix.0.iter()).chain(rect.iter()).all(|v| v.is_finite()) - || bounds[2] <= bounds[0] - || bounds[3] <= bounds[1] - || rect[2] <= rect[0] - || rect[3] <= rect[1] - { - continue; - } - out.push(PageImage { - rect, - visible_rect: intersection(rect, state.placement.clip), - clip_rect: Some(state.placement.clip), - matrix: matrix.0, - name: String::from_utf8_lossy(name).into_owned(), - object: resource.as_ref(), - is_form, - width: if is_form { 0 } else { s.dict.int(b"Width").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 }, - height: if is_form { 0 } else { s.dict.int(b"Height").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 }, - stream: si, - op: i, - draw_matrix: ctm.0, - clips: state.placement.clips.clone(), - }); + let matrix = match s.dict.get(b"Matrix") { + Some(v) => { + let v = doc.resolve(v); + let Some(m) = v.as_array().and_then(|a| Matrix::from_operands(a)) else { continue }; + m + } + None => Matrix::IDENTITY, + }; + (matrix.then(&ctm), bounds) + } else if s.dict.name(b"Subtype") == Some(b"Image") { + (ctm, [0.0, 0.0, 1.0, 1.0]) + } else { + continue; + }; + let rect = matrix.bbox(bounds); + if !bounds.iter().chain(matrix.0.iter()).chain(rect.iter()).all(|v| v.is_finite()) + || bounds[2] <= bounds[0] + || bounds[3] <= bounds[1] + || rect[2] <= rect[0] + || rect[3] <= rect[1] + { + continue; } + out.push(PageImage { + rect, + visible_rect: intersection(rect, state.placement.clip), + clip_rect: Some(state.placement.clip), + matrix: matrix.0, + name: String::from_utf8_lossy(name).into_owned(), + object: resource.as_ref(), + is_form, + width: if is_form { 0 } else { s.dict.int(b"Width").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 }, + height: if is_form { 0 } else { s.dict.int(b"Height").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 }, + stream: pieces.piece_of(op.span.start), + op: i, + draw_matrix: ctm.0, + clips: state.placement.clips.clone(), + }); } } Ok(out) @@ -298,15 +304,15 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi } fn walk( doc: &Document, - data: &[u8], + ops: &[Op], resources: &Dict, st: &mut ImageState, - stream: usize, + stream: &dyn Fn(&Op) -> usize, forms: &mut Forms, out: &mut Vec, ) -> Result<(), EditError> { let xo = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); - for (i, op) in parse(data).ops.iter().enumerate() { + for (i, op) in ops.iter().enumerate() { st.apply(op)?; let ctm = st.placement.matrix; if op.is("Do") { @@ -325,7 +331,8 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi ); let clip = bounds.map_or(st.placement.clip, |b| intersection(st.placement.clip, b).unwrap_or([0.0; 4])); let mut inner = ImageState::new(form.ctm, clip, st.placement.clips.clone()); - walk(doc, &form.data, &form.resources, &mut inner, stream, forms, out)?; + let piece = stream(op); + walk(doc, &parse(&form.data).ops, &form.resources, &mut inner, &|_| piece, forms, out)?; if form.obj.is_some() { forms.path.pop(); } @@ -347,7 +354,7 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi is_form: false, width: s.dict.int(b"Width").unwrap_or(0).max(0) as u32, height: s.dict.int(b"Height").unwrap_or(0).max(0) as u32, - stream, + stream: stream(op), op: i, draw_matrix: ctm.0, clips: st.placement.clips.clone(), @@ -361,9 +368,8 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi let mut out = Vec::new(); let mut st = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); let mut forms = Forms { path: Vec::new(), visits: 0 }; - for (si, (_, data)) in streams(doc, &p.dict).into_iter().enumerate() { - walk(doc, &data, &res, &mut st, si, &mut forms, &mut out)?; - } + let pieces = joined(&streams(doc, &p.dict)); + walk(doc, &pieces.parse().ops, &res, &mut st, &|op| pieces.piece_of(op.span.start), &mut forms, &mut out)?; Ok(out) } @@ -450,9 +456,9 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag let img = images.get(index).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1)))?; let p = page_of(doc, page)?; let all = streams(doc, &p.dict); - let (obj, data) = all.get(img.stream).cloned().ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?; - let mut ops = parse(&data).ops; - if ops.get(img.op).is_none() { + let pieces = joined(&all); + let ops = pieces.parse().ops; + if !ops.get(img.op).is_some_and(|op| op.is("Do")) { return Err(EditError::Invalid("the image operation is missing".into())); } let mut resources = None; @@ -479,16 +485,10 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag vec![Op::new("Do", vec![Object::name(&name)])] } }; - ops.splice(img.op..=img.op, replacement); - let mut dict = match &*doc.resolve(&obj) { - Object::Stream(s) => s.dict.clone(), - _ => Dict::new(), - }; - dict.remove(b"Length"); - let new = doc.add(Object::Stream(Stream::flate(dict, &serialize_ops(&ops)))); - let contents: Vec = all.iter().enumerate().map(|(i, (o, _))| if i == img.stream { Object::Ref(new) } else { o.clone() }).collect(); + let changed = crate::text::splice(&pieces, &ops, |index| if index == img.op { (replacement.clone(), false) } else { (Vec::new(), true) }); + let contents = crate::text::rewritten_contents(doc, &all, changed); doc.update_dict(p.obj, |d| { - d.set(b"Contents".to_vec(), if let [one] = contents.as_slice() { one.clone() } else { Object::Array(contents) }); + d.set(b"Contents".to_vec(), contents); if let Some(res) = resources { d.set(b"Resources".to_vec(), Object::Dict(res)); } @@ -599,22 +599,12 @@ fn transform_image( if !transform.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { return Err(EditError::Invalid("the image transform must be finite and no larger than 1e12".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 pieces = joined(streams); 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 target = image.op; + if !ops.get(target).is_some_and(|op| op.is("Do")) { + return Err(EditError::Invalid("the image operation could not be located".into())); + } // A private path crop moves with its image regardless of intervening state operators. // Shared crops retain the existing fixed-clip behavior, preserving neighbouring paint. let (start, end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(image.draw_matrix))); @@ -677,22 +667,12 @@ fn crop_image( 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 pieces = joined(streams); 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 target = image.op; + if !ops.get(target).is_some_and(|op| op.is("Do")) { + return Err(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)); @@ -758,67 +738,72 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize if !movement.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { return Err(EditError::Invalid("the image move is too large".into())); } - plans.insert((image.stream, image.op), (movement, placement, offset)); + plans.insert(image.op, (movement, placement, offset)); } - let mut data = Vec::with_capacity(all.len()); + let pieces = joined(&all); + let ops = pieces.parse().ops; let mut inside_text = false; - for (stream, (_, bytes)) in all.iter().enumerate() { - let pieces = pdfcraft_content::Pieces::join(&[bytes.as_slice()]); - let ops = pieces.parse().ops; - for (index, op) in ops.iter().enumerate() { - if op.is("BT") { - inside_text = true; - } else if op.is("ET") { - inside_text = false; - } else if inside_text && plans.contains_key(&(stream, index)) { - return Err(EditError::Invalid("an image inside a text object cannot be moved in a group".into())); - } + for (index, op) in ops.iter().enumerate() { + if op.is("BT") { + inside_text = true; + } else if op.is("ET") { + inside_text = false; + } else if inside_text && plans.contains_key(&index) { + return Err(EditError::Invalid("an image inside a text object cannot be moved in a group".into())); } - // Images alone in their own `q re W n cm Do Q` take the clip with them. - let mut clips = std::collections::BTreeMap::new(); - for index in 0..ops.len() { - let Some(&(_, placement, offset)) = plans.get(&(stream, index)) else { continue }; - let Some((at, cm)) = exclusive_clip(&ops, index) else { continue }; - let translation = Matrix([1.0, 0.0, 0.0, 1.0, offset[0], offset[1]]); - let Some(c) = clip_matrix(placement, cm, translation).filter(|c| c.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12)) else { - return Err(EditError::Invalid("the image move is too large".into())); - }; - 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 - // rather than rounding them to zero or amplifying an error on the page. - let mut out = vec![Op::new("q", vec![]), Op::new("cm", matrix.0.map(Object::Real).to_vec())]; - out.extend(original); - out.push(Op::new("Q", vec![])); - (out, false) - }); - data.push(changed.into_iter().next().unwrap_or_default()); } - if !plans.is_empty() { + // Images alone in their own `q re W n cm Do Q` take the clip with them. + let mut clips = std::collections::BTreeMap::new(); + for index in 0..ops.len() { + let Some(&(_, placement, offset)) = plans.get(&index) else { continue }; + let Some((at, cm)) = exclusive_clip(&ops, index) else { continue }; + let translation = Matrix([1.0, 0.0, 0.0, 1.0, offset[0], offset[1]]); + let Some(c) = clip_matrix(placement, cm, translation).filter(|c| c.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12)) else { + return Err(EditError::Invalid("the image move is too large".into())); + }; + plans.remove(&index); + // A `cm` an earlier move put before the clip takes this move too: `c · prev` is the + // same CTM as `prev` followed by another `cm c`, without one more operator per move. + let folded = at + .checked_sub(1) + .and_then(|i| Some((i, c.then(&Matrix(ops.get(i).filter(|op| op.is("cm"))?.nums::<6>()?))))) + .filter(|(_, m)| m.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12)); + match folded { + Some((i, m)) => clips.insert(i, (m, false)), + None => clips.insert(at, (c, true)), + }; + } + // 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(&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(&index); + folds.insert(at, folded); + } + if plans.keys().any(|index| !ops.get(*index).is_some_and(|op| op.is("Do"))) { return Err(EditError::Invalid("a selected image operation is missing".into())); } + let data = crate::text::splice(&pieces, &ops, |index| { + if let Some((c, keep)) = clips.remove(&index) { + return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], keep); + } + 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(&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 + // rather than rounding them to zero or amplifying an error on the page. + let mut out = vec![Op::new("q", vec![]), Op::new("cm", matrix.0.map(Object::Real).to_vec())]; + out.extend(original); + out.push(Op::new("Q", vec![])); + (out, false) + }); let contents = crate::text::rewritten_contents(doc, &all, data); doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; Ok(()) diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 515f17d8da..fcf2f19975 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -8,7 +8,8 @@ //! `/PCMark` entry (also in the stream dictionary) names the kind of mark, which is how Update and //! Remove find them again without decompressing page content. When //! anything is drawn on top, the page's original content is wrapped in `q … Q` (two tiny streams) -//! so its graphics state can't leak into the mark. +//! so its graphics state cannot reach the mark. The closing stream also ends known unfinished +//! marked-content sequences before the new mark. //! //! Text uses standard Helvetica (WinAnsi) added to the page resources as `/PCHelv`. @@ -327,7 +328,41 @@ fn add_resources(doc: &mut Document, page: &pdfcraft_model::Page, opacity: Optio } const WRAP_OPEN: &[u8] = b"q %PdfCraft\n"; -const WRAP_CLOSE: &[u8] = b"Q %PdfCraft\n"; +/// Starts with an end of line, so a comment left open at the end of the content before it +/// can't swallow the `Q`. Older files have the close without it. +const WRAP_CLOSE: &[u8] = b"\nQ %PdfCraft\n"; +const OLD_WRAP_CLOSE: &[u8] = b"Q %PdfCraft\n"; + +/// Whether `bytes` are a wrapper's close: canonical `EMC` closures, then the existing +/// graphics closures. Legacy closes without the leading newline remain valid on their own. +fn is_wrap_close(bytes: &[u8]) -> bool { + let mut rest = bytes; + while let Some(shorter) = rest.strip_prefix(b"\nEMC".as_slice()) { + rest = shorter; + } + let legacy = rest.len() == bytes.len(); + loop { + if rest == WRAP_CLOSE || (legacy && rest == OLD_WRAP_CLOSE) { + return true; + } + match rest.strip_prefix(b"\nQ".as_slice()) { + Some(shorter) => rest = shorter, + None => return false, + } + } +} + +/// A `Wrap`-tagged stream's role from its exact bytes: `Some(true)` for an open, `Some(false)` +/// for a close. +fn wrapper_role(doc: &Document, o: &Object) -> Option { + if stream_tag(doc, o).as_deref() != Some("Wrap") { + return None; + } + let Object::Stream(stream) = &*doc.resolve(o) else { return None }; + // Wrappers are a few bytes; one that decodes to more isn't PdfCraft's. + let bytes = stream.decoded_within(64 * 1024).ok()?; + if bytes == WRAP_OPEN { Some(true) } else { is_wrap_close(&bytes).then_some(false) } +} #[cfg(test)] fn stream_bytes(doc: &Document, o: &Object) -> Option> { @@ -350,27 +385,165 @@ fn tagged(tag: &str) -> Dict { d } +/// What placing a mark on top needs, from the page's actual operators (not the `/PCMark` tags, +/// which another writer may keep, copy or forge). +enum Isolation { + /// The content parses cleanly with balanced `q`/`Q` and closed text objects and paths. + /// `suffix`: the stream from which the content leaves graphics state set (or a comment open) + /// that a mark on top must not inherit, so it needs a wrapper from there to the end. + /// `wrappers`: PdfCraft wrapper pairs (open, close) as stream indexes. + /// `marked`: unfinished marked-content sequences to close before a new mark. + Clean { suffix: Option, wrappers: Vec<(usize, usize)>, marked: usize }, + /// The content could not be read that precisely (it doesn't decode, is too large, or has + /// stray bytes, unbalanced `q`/`Q` or an unfinished text object or path). `unclosed` is + /// the count of `q` left open at the end, when known. `marked` counts known unfinished + /// marked-content sequences independently of graphics malformation, or zero when unknown. + Unknown { unclosed: usize, marked: usize }, +} + +const ISOLATION_MAX_STREAMS: usize = 100_000; +const ISOLATION_MAX_BYTES: usize = 128 * 1024 * 1024; +const ISOLATION_MAX_OPS: usize = 1_000_000; +const ISOLATION_MAX_DEPTH: usize = 10_000; + +fn isolation(doc: &Document, list: &[Object]) -> Isolation { + let unknown = Isolation::Unknown { unclosed: 0, marked: 0 }; + if list.len() > ISOLATION_MAX_STREAMS { + return unknown; + } + let mut remaining = ISOLATION_MAX_BYTES; + let mut data = Vec::with_capacity(list.len()); + for object in list { + let resolved = doc.resolve(object); + let Object::Stream(stream) = &*resolved else { return unknown }; + let Ok(bytes) = stream.decoded_within(remaining) else { return unknown }; + let Some(left) = remaining.checked_sub(bytes.len().saturating_add(1)) else { return unknown }; + remaining = left; + data.push(bytes); + } + let pieces = pdfcraft_content::Pieces::join(&data); + let parsed = pieces.parse(); + if parsed.skipped != 0 || parsed.ops.len() > ISOLATION_MAX_OPS { + return unknown; + } + let edges = pieces.comment_edges(&parsed); + let (mut text_open, mut path_open, mut dirty, mut malformed) = (false, false, false, false); + let mut stack: Vec = Vec::new(); + let (mut marked, mut marked_known) = (0usize, true); + let mut split = 0; + let mut previous_last = None; + let mut wrappers = Vec::new(); + for op in &parsed.ops { + let (first, last) = pieces.pieces_of(op); + // A wrapper can only go in before a whole stream, outside graphics and marked-content + // scopes, between operators, where no comment would swallow the wrapper's `q`. + if stack.is_empty() + && marked_known + && marked == 0 + && !text_open + && !path_open + && !dirty + && previous_last.is_none_or(|previous| first > previous) + && !edges.get(first).is_some_and(|edge| edge.0) + { + split = first; + } + previous_last = Some(last); + // Match the reader's tolerant operands: BMC uses the last name, BDC the last + // name/object pair, and EMC ignores surplus operands. Invalid begins are ignored. + let begin_marked = match op.op.as_slice() { + b"BMC" => op.operands.last().is_some_and(|o| matches!(o, Object::Name(_))), + b"BDC" => op.operands.len().checked_sub(2).and_then(|i| op.operands.get(i)).is_some_and(|o| matches!(o, Object::Name(_))), + _ => false, + }; + if marked_known { + if begin_marked { + if marked < ISOLATION_MAX_DEPTH { + marked += 1; + } else { + // Once the budget is exceeded, later EMCs cannot restore a known depth. + marked_known = false; + marked = 0; + } + } else if op.is("EMC") { + marked = marked.saturating_sub(1); + } + } + match op.op.as_slice() { + b"BT" if !text_open => text_open = true, + b"ET" if text_open => text_open = false, + b"BT" | b"ET" => malformed = true, + b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re" | b"W" | b"W*" => path_open = true, + b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => path_open = false, + _ => {} + } + match op.op.as_slice() { + b"q" if op.operands.is_empty() && stack.len() < ISOLATION_MAX_DEPTH => stack.push(first), + b"Q" if op.operands.is_empty() => match stack.pop() { + Some(open) => { + // Only a standalone PdfCraft wrapper pair may later be removed again. + let wrap = |i: usize| list.get(i).is_some_and(|o| stream_tag(doc, o).as_deref() == Some("Wrap")); + if wrap(open) + && wrap(first) + && data.get(open).is_some_and(|d| d == WRAP_OPEN) + && data.get(first).is_some_and(|d| is_wrap_close(d)) + { + wrappers.push((open, first)); + } + } + None => malformed = true, + }, + b"q" | b"Q" => malformed = true, + _ if stack.is_empty() => dirty = true, + _ => {} + } + } + if malformed || text_open || path_open || !stack.is_empty() || !marked_known { + return Isolation::Unknown { unclosed: if malformed { 0 } else { stack.len() }, marked }; + } + let comment_open = edges.last().is_some_and(|edge| edge.1); + Isolation::Clean { suffix: (dirty || comment_open || marked != 0).then_some(split), wrappers, marked } +} + /// Put a mark's content on a page: behind (prepended) or on top (appended, after wrapping the /// original content in q/Q). fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, content: Vec, behind: bool) -> Result<(), EditError> { place_tagged(doc, page, kind.tag(), content, behind) } -/// Put content on a page, tagged `tag` (see [`place`]). +/// Put content on a page, tagged `tag` (see [`place`]). On top, whatever graphics state the +/// content before leaves set (a `cm`, a clip, text state…) must not reach the mark, including +/// content appended after an earlier PdfCraft wrapper by another tool (#3084): the stretch of +/// content that leaves state set is wrapped in q/Q, once. Any known unfinished marked-content +/// sequences are closed at the end too, so hidden optional content cannot hide the new mark. fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec, behind: bool) -> Result<(), EditError> { let mut list = contents(doc, page)?; + let wrap = if behind { + None + } else { + match isolation(doc, &list) { + Isolation::Clean { suffix, marked, .. } => suffix.map(|split| (split, 0, marked)), + // Content that can't be read precisely is wrapped whole, as before: after the + // behind-marks at the start. A `q` it leaves open is closed at its end, where + // closing it changes nothing already drawn. + Isolation::Unknown { unclosed, marked } => { + let behind_kinds = [MarkKind::Background.tag(), MarkKind::Watermark.tag()]; + let split = list.iter().position(|o| !stream_tag(doc, o).is_some_and(|t| behind_kinds.contains(&t.as_str()))).unwrap_or(list.len()); + (split < list.len()).then_some((split, unclosed, marked)) + } + } + }; let mark = Object::Ref(doc.add(Object::Stream(Stream::flate(tagged(tag), &content)))); if behind { list.insert(0, mark); } else { - let wrapped = list.iter().any(|o| stream_tag(doc, o).as_deref() == Some("Wrap")); - let original = list.iter().any(|o| stream_tag(doc, o).is_none()); - if !wrapped && original { + if let Some((split, unclosed, marked)) = wrap { + let mut close_bytes = b"\nEMC".repeat(marked); + close_bytes.extend_from_slice(&b"\nQ".repeat(unclosed)); + close_bytes.extend_from_slice(WRAP_CLOSE); let open = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), WRAP_OPEN.to_vec())))); - let close = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), WRAP_CLOSE.to_vec())))); - // Behind-marks stay outside the wrapper. - let split = list.iter().position(|o| stream_tag(doc, o).is_none()).unwrap_or(list.len()); - list.insert(split, open); + let close = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), close_bytes)))); + list.insert(split.min(list.len()), open); list.push(close); } list.push(mark); @@ -613,8 +786,9 @@ pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, repl Ok(()) } -/// Remove marks of `kind` from `pages`; returns how many were removed. When no mark is left on -/// top of a page, the q/Q wrapper goes too, restoring the original content list. +/// Remove marks of `kind` from `pages`; returns how many were removed. Wrappers left with +/// nothing after them go too, restoring the original content list; a wrapper that content +/// after it still relies on (another tool's appended stream) stays. pub fn remove_marks(doc: &mut Document, pages: &[usize], kind: MarkKind) -> Result { let all = page_list(doc); check(pages, all.len())?; @@ -625,12 +799,41 @@ pub fn remove_marks(doc: &mut Document, pages: &[usize], kind: MarkKind) -> Resu let before = list.len(); let mut kept: Vec = list.into_iter().filter(|o| stream_tag(doc, o).as_deref() != Some(kind.tag())).collect(); removed += before - kept.len(); - // Marks on top come after the closing wrapper; without any, the wrapper has no purpose. - let is_wrap = |o: &Object| stream_tag(doc, o).as_deref() == Some("Wrap"); - let close = kept.iter().rposition(&is_wrap); - let marks_on_top = close.is_some_and(|c| kept[c + 1..].iter().any(|o| stream_tag(doc, o).is_some_and(|t| t != "Wrap"))); - if !marks_on_top { - kept.retain(|o| !is_wrap(o)); + // Strip only wrapper pairs whose close ends the list (after stripping the pairs after + // it): nothing comes after them to need the state they restore. Pairs come from the + // operators; for content that can't be read that precisely, from the wrapper streams + // alone, so updating a mark there doesn't nest another wrapper each time. + if before != kept.len() { + let wrappers = match isolation(doc, &kept) { + Isolation::Clean { wrappers, .. } => wrappers, + Isolation::Unknown { .. } => { + let mut opens = Vec::new(); + let mut pairs = Vec::new(); + for (i, o) in kept.iter().enumerate() { + match wrapper_role(doc, o) { + Some(true) => opens.push(i), + Some(false) => pairs.extend(opens.pop().map(|open| (open, i))), + None => {} + } + } + pairs + } + }; + let pairs: std::collections::HashMap = wrappers.into_iter().map(|(open, close)| (close, open)).collect(); + let mut discarded = std::collections::HashSet::new(); + let mut end = kept.len(); + while let Some(close) = end.checked_sub(1) { + if discarded.contains(&close) { + end = close; + } else if let Some(&open) = pairs.get(&close) { + discarded.insert(open); + discarded.insert(close); + end = close; + } else { + break; + } + } + kept = kept.into_iter().enumerate().filter(|(i, _)| !discarded.contains(i)).map(|(_, o)| o).collect(); } if before != kept.len() { doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept)))?; @@ -665,5 +868,7 @@ pub use added::{ update_content, }; +#[cfg(test)] +mod marked_isolation_tests; #[cfg(test)] mod tests; diff --git a/crates/edit/src/marked_isolation_tests.rs b/crates/edit/src/marked_isolation_tests.rs new file mode 100644 index 0000000000..6da8322296 --- /dev/null +++ b/crates/edit/src/marked_isolation_tests.rs @@ -0,0 +1,122 @@ +use super::*; + +fn fixture(parts: &[&[u8]]) -> Document { + let mut doc = Document::new_empty(); + let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + let streams = parts.iter().map(|data| Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), data.to_vec()))))).collect(); + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Parent".to_vec(), Object::Ref(pages)); + page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()])); + page.set(b"Contents".to_vec(), Object::Array(streams)); + let page = doc.add(page); + doc.update_dict(pages, |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + d.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + doc +} + +fn placed(parts: &[&[u8]]) -> (Document, Vec) { + let mut doc = fixture(parts); + stamp(&mut doc, 0, "Probe", b"q Q\n".to_vec()).unwrap(); + let page = page_list(&doc).remove(0); + let list = contents(&mut doc, &page).unwrap(); + (doc, list) +} + +fn close(parts: &[&[u8]]) -> Vec { + let (doc, list) = placed(parts); + let object = &list[list.len() - 2]; + assert_eq!(stream_tag(&doc, object).as_deref(), Some("Wrap")); + let bytes = stream_bytes(&doc, object).unwrap(); + assert_eq!(wrapper_role(&doc, object), Some(false)); + bytes +} + +#[test] +fn closes_only_marked_operators_not_strings_names_comments_or_image_samples() { + for tail in + [b"/Span << /Text (BMC BDC EMC) >> DP".as_slice(), b"/BMC /EMC DP", b"% BMC BDC EMC", b"BI /W 11 /H 1 /BPC 8 /CS /G ID BMC BDC EMC EI\n"] + { + let mut input = b"/OC /L1 BDC\n".to_vec(); + input.extend_from_slice(tail); + assert_eq!(close(&[&input]), b"\nEMC\nQ %PdfCraft\n", "{tail:?}"); + } +} + +#[test] +fn safe_suffix_stays_outside_marked_content_across_balanced_graphics() { + let (doc, list) = placed(&[b"q /OC /L1 BDC Q", b"50 Tz"]); + assert_eq!(stream_bytes(&doc, &list[0]).unwrap(), WRAP_OPEN); + assert_eq!(stream_bytes(&doc, &list[1]).unwrap(), b"q /OC /L1 BDC Q"); + assert_eq!(stream_bytes(&doc, &list[list.len() - 2]).unwrap(), b"\nEMC\nQ %PdfCraft\n"); + assert_eq!(close(&[b"q /OC /L1 BDC Q"]), b"\nEMC\nQ %PdfCraft\n"); +} + +#[test] +fn known_marked_depth_survives_unknown_graphics_and_tolerant_operands() { + for (input, expected) in [ + (b"/OC /L1 BDC q".as_slice(), b"\nEMC\nQ\nQ %PdfCraft\n".as_slice()), + (b"Q /OC /L1 BDC", b"\nEMC\nQ %PdfCraft\n"), + (b"/OC /L1 BDC BT", b"\nEMC\nQ %PdfCraft\n"), + (b"/OC /L1 BDC 0 0 m", b"\nEMC\nQ %PdfCraft\n"), + (b"/OC /L1 BDC 123 BMC /Artifact BDC", b"\nEMC\nQ %PdfCraft\n"), + (b"EMC 123 /OC /L1 BDC 123 /Artifact BMC", b"\nEMC\nEMC\nQ %PdfCraft\n"), + (b"/OC /L1 BDC 123 EMC", b"\nQ %PdfCraft\n"), + (b"/Span 123 BDC", b"\nEMC\nQ %PdfCraft\n"), + ] { + assert_eq!(close(&[input]), expected, "{input:?}"); + } +} + +#[test] +fn closes_cross_stream_begins_without_closing_balanced_sequences_twice() { + assert_eq!(close(&[b"/OC", b"/L1 BDC"]), b"\nEMC\nQ %PdfCraft\n"); + assert_eq!(close(&[b"/OC /L1 BDC", b"EMC"]), WRAP_CLOSE); + assert_eq!(close(&[b"/OC /L1 BDC % EMC", b"EMC\n"]), b"\nEMC\nQ %PdfCraft\n"); +} + +#[test] +fn recognizes_only_canonical_new_closes_and_retains_legacy_closes() { + for bytes in [WRAP_CLOSE, OLD_WRAP_CLOSE, b"\nQ\nQ %PdfCraft\n", b"\nEMC\nQ %PdfCraft\n", b"\nEMC\nEMC\nQ\nQ %PdfCraft\n"] { + assert!(is_wrap_close(bytes), "{bytes:?}"); + } + for bytes in [ + b"\nEMCQ %PdfCraft\n".as_slice(), + b"\nEMC\nQQ %PdfCraft\n", + b"\nQ\nEMC\nQ %PdfCraft\n", + b"EMC\nQ %PdfCraft\n", + b"\nEMC 1 w\nQ %PdfCraft\n", + b"\nEMC\nQ %PdfCraft\nq Q", + ] { + assert!(!is_wrap_close(bytes), "{bytes:?}"); + } +} + +#[test] +fn depth_budget_bounds_emitted_closures_and_overflow_stays_unknown() { + let maximum = b"/Artifact BMC\n".repeat(ISOLATION_MAX_DEPTH); + let mut expected = b"\nEMC".repeat(ISOLATION_MAX_DEPTH); + expected.extend_from_slice(WRAP_CLOSE); + assert_eq!(close(&[&maximum]), expected); + let mut combined = maximum.clone(); + combined.extend_from_slice(&b"q\n".repeat(ISOLATION_MAX_DEPTH)); + let mut expected = b"\nEMC".repeat(ISOLATION_MAX_DEPTH); + expected.extend_from_slice(&b"\nQ".repeat(ISOLATION_MAX_DEPTH)); + expected.extend_from_slice(WRAP_CLOSE); + assert_eq!(close(&[&combined]), expected); + assert!(expected.len() < 64 * 1024, "maximum generated close remains recognizable for removal"); + let mut doc = fixture(&[&combined]); + let page = page_list(&doc).remove(0); + let original = contents(&mut doc, &page).unwrap(); + place_tagged(&mut doc, &page, MarkKind::HeaderFooter.tag(), b"q Q\n".to_vec(), false).unwrap(); + assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1); + assert_eq!(contents(&mut doc, &page).unwrap(), original, "maximum combined close can be removed without nesting wrappers"); + for suffix in [b"/Artifact BMC\n".as_slice(), b"/Artifact BMC\nEMC\n", b"/Artifact BMC\nEMC\nEMC\n"] { + let mut over = maximum.clone(); + over.extend_from_slice(suffix); + assert_eq!(close(&[&over]), WRAP_CLOSE, "overflow cannot resume speculative depth tracking after EMC"); + } +} diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index b542142df8..f2def2fd6a 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -77,7 +77,8 @@ fn header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content() let s0 = streams(&doc, 0); // q-wrapper, original, Q-wrapper, header/footer. assert_eq!(s0.len(), 4, "{s0:?}"); - assert_eq!((s0[0].as_str(), s0[2].as_str()), ("q %PdfCraft\n", "Q %PdfCraft\n")); + // The close starts on a new line, so a comment the original ends in can't swallow it. + assert_eq!((s0[0].as_str(), s0[2].as_str()), ("q %PdfCraft\n", "\nQ %PdfCraft\n")); let mark = &s0[3]; assert!(mark.contains("/PCMark /HeaderFooter") && mark.contains("(Page 1 of 3) Tj") && mark.contains("(Confidential \\(draft\\)) Tj"), "{mark}"); assert!(mark.contains("1 0 0 1 0 0 cm"), "upright page: identity"); @@ -1472,6 +1473,65 @@ fn skia_subset_page() -> Document { doc } +/// #2616: a subset whose ToUnicode has no space (as LaTeX and Sphinx output often has, spacing +/// words with TJ kerning) makes finding the space width read every font on the page. That ran +/// for every character of a multi-row draft, so each keystroke in a long paragraph took half a +/// second. It runs once per font now, however long the paragraph. +#[test] +fn a_long_draft_in_a_font_without_a_space_works_out_the_space_width_once() { + // Two rows of one paragraph (14 pt apart) in the Chrome-style subset of `skia_subset_page`, + // whose ToUnicode here has no entry for the space glyph. + let mut doc = + text_page("BT /F3 12 Tf 72 700 Td <0177031602E001FB03050255> Tj ET BT /F3 12 Tf 72 686 Td <008D0266035402FE01FB030503E6> 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 12 beginbfchar <0177> <0050> <0316> <006F> <02E0> <006C> <01FB> <0061> <0305> <006E> <0255> <0064> <008D> <0047> <0266> <0065> <0354> <0072> <02FE> <006D> <03E6> <0079> <1000> <007A> endbfchar", + ))); + let widths: Vec = + [(0x8D, 746), (0x177, 639), (0x1FB, 551), (0x255, 612), (0x266, 583), (0x2E0, 242), (0x2FE, 876), (0x305, 591), (0x316, 600)] + .into_iter() + .chain([(0x354, 376), (0x3E6, 515)]) + .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(); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 1, "one paragraph: {:?}", blocks.iter().map(|b| &b.text).collect::>()); + let draft = "Poland and Germany +" + .repeat(20); + text::SOURCE_SPACE_CALLS.with(|c| c.set(0)); + let layout = text::text_edit_layout(&doc, 0, 0, draft.trim_end(), &text::BlockStyle::default()).unwrap(); + let calls = text::SOURCE_SPACE_CALLS.with(std::cell::Cell::get); + assert!(layout.draft_rect.is_some_and(|r| r[2] > r[0]), "laid out: {:?}", layout.draft_rect); + assert!(calls <= 8, "the space width was worked out {calls} times for {} characters", draft.len()); +} + #[test] fn a_subset_keeps_its_font_for_glyphs_left_to_the_default_width() { let doc = skia_subset_page(); @@ -1920,6 +1980,31 @@ fn repeated_image_edits_change_one_transform_instead_of_nesting() { } } +#[test] +fn repeated_group_moves_of_a_cropped_image_change_one_clip_transform() { + // The crop is smaller than the image, so a clip left behind would shrink `visible_rect`. + let mut doc = image_page_with("q 150 120 100 50 re W n 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET"); + let operators = |doc: &Document| pdfcraft_content::parse(&page_content_bytes(doc, 0)).ops.len(); + let start = images::page_images(&doc, 0).unwrap()[0].clone(); + let mut first = 0; + for i in 0..20 { + images::translate_images(&mut doc, 0, &[0], [1.0, -2.0]).unwrap(); + if i == 0 { + first = operators(&doc); + } + } + let moved = |r: [f64; 4]| [r[0] + 20.0, r[1] - 40.0, r[2] + 20.0, r[3] - 40.0]; + let now = images::page_images(&doc, 0).unwrap()[0].clone(); + assert!(close(now.rect, moved(start.rect)), "{:?}", now.rect); + assert!(close(now.visible_rect.unwrap(), moved(start.visible_rect.unwrap())), "the crop moves with the image: {:?}", now.visible_rect); + assert_eq!(operators(&doc), first, "twenty group moves add no more operators than one"); + assert_eq!(q_depth(&doc), 1); + let doc = reopen(&doc); + assert!(close(images::page_images(&doc, 0).unwrap()[0].visible_rect.unwrap(), moved(start.visible_rect.unwrap()))); + let caption: Vec<_> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect(); + assert_eq!(caption, ["Caption"]); +} + #[test] fn page_images_move_turn_replace_and_delete() { let mut doc = image_page(); @@ -2220,6 +2305,72 @@ fn replacing_a_line_keeps_a_distinct_nearby_line() { } } +#[test] +fn editing_text_keeps_an_overlapping_watermark_and_covered_paragraphs() { + let listed = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().map(|l| l.text).collect::>(); + // #1756: a rotated watermark's bounding box covers the middle of the page, and a body line + // whose baseline happens to sit near the watermark's starting y is still unrelated text. + for rotation in [35.0, 45.0, 0.0] { + let mut base = text_page("BT /F1 14 Tf 60 700 Td (Heading) Tj ET"); + add_watermark( + &mut base, + &[0], + &Watermark { text: "TEST WATERMARK".into(), font_size: 48.0, rotation, opacity: 0.25, ..Watermark::default() }, + false, + ) + .unwrap(); + let wm_y = text::text_lines(&base, 0).unwrap().into_iter().find(|l| l.text == "TEST WATERMARK").unwrap().origin_baseline(); + let mut doc = text_page(&format!("BT /F1 14 Tf 1 0 0 1 60 {wm_y:.2} Tm (Body paragraph one with several words here) Tj ET")); + add_watermark( + &mut doc, + &[0], + &Watermark { text: "TEST WATERMARK".into(), font_size: 48.0, rotation, opacity: 0.25, ..Watermark::default() }, + false, + ) + .unwrap(); + let line_idx = text::text_lines(&doc, 0).unwrap().iter().position(|l| l.text.starts_with("Body")).unwrap(); + let mut line_edited = doc.clone(); + text::replace_line(&mut line_edited, 0, line_idx, "Edited body line").unwrap(); + assert_eq!(listed(&reopen(&line_edited)), ["Edited body line", "TEST WATERMARK"], "rotation {rotation}"); + + let block_idx = text::text_blocks(&doc, 0).unwrap().iter().position(|b| b.text.starts_with("Body")).unwrap(); + let mut preview = doc.clone(); + text::clear_block(&mut preview, 0, block_idx).unwrap(); + assert_eq!(listed(&preview), ["TEST WATERMARK"], "clear_block keeps the watermark at rotation {rotation}"); + + text::rewrite_block(&mut doc, 0, block_idx, Some("Edited body paragraph"), &text::BlockStyle::default()).unwrap(); + assert_eq!(listed(&reopen(&doc)), ["Edited body paragraph", "TEST WATERMARK"], "rotation {rotation}"); + } + + // #1368: dragging one paragraph onto another paragraph's baseline and moving it on again + // leaves the covered paragraph intact. + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (First paragraph) Tj ET BT /F1 12 Tf 72 600 Td (Second paragraph) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { offset: Some([0.0, -100.0]), ..Default::default() }).unwrap(); + let second = text::text_blocks(&doc, 0).unwrap().iter().position(|b| b.text == "Second paragraph"); + assert!(second.is_some(), "the covered paragraph stays on the page: {:?}", listed(&doc)); + let first = text::text_blocks(&doc, 0).unwrap().iter().position(|b| b.text == "First paragraph").unwrap(); + text::rewrite_block(&mut doc, 0, first, None, &text::BlockStyle { offset: Some([0.0, -100.0]), ..Default::default() }).unwrap(); + assert_eq!(listed(&reopen(&doc)), ["First paragraph", "Second paragraph"]); + + // Even when a large horizontal watermark shows the same word as a body line at the same y, + // or a rotated 12 pt line crosses a horizontal 12 pt line of the same word at the same origin, + // editing one keeps the other. + let mut same_word = text_page("BT /F1 12 Tf 1 0 0 1 200 400 Tm (DRAFT) Tj ET BT /F1 48 Tf 1 0 0 1 150 400 Tm (DRAFT) Tj ET"); + text::replace_line(&mut same_word, 0, 0, "FINAL").unwrap(); + let after = text::text_lines(&reopen(&same_word), 0).unwrap(); + assert_eq!(after.iter().map(|l| (&l.text[..], l.size as i32)).collect::>(), [("FINAL", 12), ("DRAFT", 48)]); + + let mut crossed = text_page("BT /F1 12 Tf 1 0 0 1 100 500 Tm (Crossed) Tj ET BT /F1 12 Tf 0.707 0.707 -0.707 0.707 100 500 Tm (Crossed) Tj ET"); + text::replace_line(&mut crossed, 0, 0, "Horizontal").unwrap(); + assert_eq!(listed(&reopen(&crossed)), ["Horizontal", "Crossed"]); + + // An invisible OCR text layer (`3 Tr`) on the same baseline and size as a visible line is + // still removed when the visible line is edited, even if OCR misread a letter. + let mut ocr = text_page("BT /F1 12 Tf 0 Tr 1 0 0 1 72 700 Tm (Hello world) Tj ET BT /F1 12 Tf 3 Tr 1 0 0 1 72 700 Tm (Rello world) Tj ET"); + text::replace_line(&mut ocr, 0, 0, "Goodbye world").unwrap(); + assert_eq!(listed(&reopen(&ocr)), ["Goodbye world"]); +} + #[test] fn paragraphs_move_and_rewrap_to_a_new_width() { let para = "BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj ET"; @@ -3285,6 +3436,55 @@ fn latin_ligature_actual_text_stays_on_its_line() { } } +/// #3110: `/ActualText` may come from a named property list (`/Span /MC0 BDC`) looked up in the +/// page's or the form's `/Properties`, as well as from an inline one. +#[test] +fn actual_text_from_named_property_list() { + let properties = "/Properties << /MC0 << /ActualText (Hello World) >> /MC1 << /Lang (en) >> >>"; + let page_with = |content: &str| { + build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + &format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> {properties} >> /Contents 4 0 R >>"), + &stream("", content), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + ]) + }; + for (content, expected) in [ + ("/Span /MC0 BDC BT /F1 24 Tf 72 700 Td (Glyphs) Tj ET EMC", "Hello World"), + ("/Span <> BDC BT /F1 24 Tf 72 700 Td (Glyphs) Tj ET EMC", "Hello World"), + ("/Span /MC1 BDC BT /F1 24 Tf 72 700 Td (Glyphs) Tj ET EMC", "Glyphs"), + ("/Span /Missing BDC BT /F1 24 Tf 72 700 Td (Glyphs) Tj ET EMC", "Glyphs"), + ] { + let doc = page_with(content); + for doc in [&doc, &reopen(&doc)] { + let lines = text::text_lines(doc, 0).unwrap(); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::>(), [expected], "{content}"); + assert_eq!(text::text_blocks(doc, 0).unwrap()[0].editor_text, expected, "{content}"); + let reading: Vec = text::reading_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(reading, [expected], "{content}"); + } + } + + // A form's named list is looked up in the form's own /Properties, not the page's. + let fm = stream( + "/Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> /Properties << /MC0 << /ActualText (Form text) >> >> >>", + "/Span /MC0 BDC BT /F1 24 Tf 72 600 Td (Glyphs) Tj ET EMC", + ); + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + &format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> /XObject << /Fm1 6 0 R >> {properties} >> /Contents 4 0 R >>" + ), + &stream("", "/Fm1 Do"), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + &fm, + ]); + let reading: Vec = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(reading, ["Form text"]); +} + #[test] fn actual_text_and_visual_rtl_glyphs_keep_their_own_reading_order() { let hex: String = "שלום".encode_utf16().map(|u| format!("{u:04X}")).collect(); @@ -3728,6 +3928,74 @@ fn private_image_crop_with_intervening_state_resizes_across_streams() { } } +/// [`image_page`] with `pieces` as its `/Contents` array and a Form /Fm0 drawing 72,500–172,600. +fn split_image_page(pieces: &[&[u8]]) -> Document { + let mut doc = image_page(); + let page = pdfcraft_model::pages(&doc)[0].obj; + let mut form = Dict::new(); + form.set(b"Type".to_vec(), Object::name("XObject")); + form.set(b"Subtype".to_vec(), Object::name("Form")); + form.set(b"BBox".to_vec(), Object::Array([72, 500, 172, 600].map(Object::Int).to_vec())); + let form = doc.add(Object::Stream(Stream::flate(form, b"0.5 g 72 500 100 100 re f"))); + let image = doc.get(pdfcraft_model::pages(&doc)[0].obj); + let mut res = image.as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| r.as_dict().cloned()).unwrap(); + let mut xo = res.get(b"XObject").and_then(|x| x.as_dict().cloned()).unwrap(); + xo.set(b"Fm0".to_vec(), Object::Ref(form)); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + let refs = pieces.iter().map(|b| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), b))))).collect(); + doc.update_dict(page, |d| { + d.set(b"Contents".to_vec(), Object::Array(refs)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + }) + .unwrap(); + doc +} + +#[test] +fn an_image_whose_operator_starts_the_next_stream_can_be_selected_moved_and_deleted() { + // #3113: an operator's operands end one piece and its keyword starts the next. + let square = [72.0, 500.0, 172.0, 600.0]; + let cases: [(&[&[u8]], bool); 4] = [ + (&[b"q 100 0 0 100 72 500 cm /Im0", b"Do Q"], false), + (&[b"q 100 0 0 100 72 500", b"cm /Im0 Do Q"], false), + (&[b"q /Fm0", b"Do Q"], true), + (&[b"q 100 0 0 100 72 500 cm /Im0 Do Q"], false), + ]; + for (pieces, is_form) in cases { + let doc = split_image_page(pieces); + let label: Vec<_> = pieces.iter().map(|p| String::from_utf8_lossy(p)).collect(); + let images = images::page_images(&doc, 0).unwrap(); + assert_eq!(images.len(), 1, "{label:?}"); + assert!(close(images[0].rect, square), "{label:?}: {:?}", images[0].rect); + assert_eq!(images[0].is_form, is_form); + assert!(images[0].contains([122.0, 550.0]), "{label:?}"); + if !is_form { + let read = images::reading_images(&doc, 0).unwrap(); + assert!(read.len() == 1 && close(read[0].rect, square), "{label:?}: {read:?}"); + } + + let mut deleted = doc.clone(); + images::change_image(&mut deleted, 0, 0, &images::ImageChange::Delete).unwrap(); + let deleted = reopen(&deleted); + assert!(images::page_images(&deleted, 0).unwrap().is_empty(), "{label:?}"); + let (joined, ops) = joined_ops(&deleted); + assert!(!ops.iter().any(|o| o.is("Do")), "{joined}"); + + let target = [300.0, 500.0, 400.0, 600.0]; + let mut moved = doc.clone(); + images::change_image(&mut moved, 0, 0, &images::ImageChange::Transform(images::rect_to_rect(square, target))).unwrap(); + let moved = reopen(&moved); + let after = images::page_images(&moved, 0).unwrap(); + assert!(after.len() == 1 && close(after[0].rect, target), "{label:?}: {after:?} {:?}", joined_ops(&moved).0); + + let mut shifted = doc.clone(); + images::translate_images(&mut shifted, 0, &[0], [228.0, 0.0]).unwrap(); + let shifted = reopen(&shifted); + let after = images::page_images(&shifted, 0).unwrap(); + assert!(after.len() == 1 && close(after[0].rect, target), "{label:?}: {after:?} {:?}", joined_ops(&shifted).0); + } +} + #[test] fn curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points() { let mut doc = image_page(); @@ -3906,6 +4174,31 @@ fn turned_paragraph_keeps_its_line_breaks_when_a_word_is_added() { assert!(text::rewrite_block(&mut doc, 0, 0, Some("Only line"), &style).is_err()); } +/// A paragraph drawn at a slant rewraps at the width its rows have along their baseline, as the +/// same paragraph upright does. Its upright bounding box turned into the baseline's frame is wider +/// by the rows' height times |sin 2θ|, which let a word that doesn't fit stay on its row. +#[test] +fn a_paragraph_at_a_slant_rewraps_at_the_width_it_has_upright() { + let rows = ["Rotated paragraph, first row of text", "that continues on a second row and", "ends on the third row here."]; + let draft = "Rotated paragraph, first row of text that continues on a second row and now carries a few extra words \ + before it ends on the third row here."; + let rewrapped = |degrees: f64| { + let (sin, cos) = degrees.to_radians().sin_cos(); + let shown = rows.iter().map(|r| format!("({r}) Tj")).collect::>().join(" T* "); + let mut doc = text_page(&format!("BT /F1 12 Tf 14.4 TL {cos:.6} {sin:.6} {:.6} {cos:.6} 300 400 Tm {shown} ET", -sin)); + let block = text::text_blocks(&doc, 0).unwrap().remove(0); + assert_eq!(block.editor_text, rows.join("\n"), "{degrees}°"); + text::rewrite_block(&mut doc, 0, 0, Some(draft), &text::BlockStyle::default()).unwrap(); + text::text_lines(&reopen(&doc), 0).unwrap().into_iter().map(|l| l.text).collect::>() + }; + let upright = rewrapped(0.0); + // The widest source row is 189.44 pt; "now carries a few extra words before" is 196.07 pt. + assert_eq!(upright[2], "now carries a few extra words", "{upright:?}"); + for degrees in [30.0, 45.0, 60.0, 90.0, 135.0, 210.0, 330.0] { + assert_eq!(rewrapped(degrees), upright, "{degrees}°"); + } +} + /// 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] @@ -3960,6 +4253,76 @@ fn default_width_subset_paragraph_keeps_its_rows_when_a_word_is_added() { } } +/// #2578: a single turned row grows along its baseline only up to the page's 36 pt margin and wraps +/// onto further rows from there, instead of running off the page. +#[test] +fn turned_single_row_wraps_at_the_page_margin() { + let draft = "A single turned row that keeps growing with many more words added until it is far longer than the page is tall, so it has to stop or wrap somewhere"; + // Reading upwards (90° CCW) from y = 600 on the 600 × 800 test page: top margin is at y = 764. + let before = text_page("BT /F1 12 Tf 0 1 -1 0 300 600 Tm (A single turned row) Tj ET"); + let layout = text::text_edit_layout(&before, 0, 0, draft, &text::BlockStyle::default()).unwrap(); + let mut doc = before; + text::rewrite_block(&mut doc, 0, 0, Some(draft), &text::BlockStyle::default()).unwrap(); + let reopened = reopen(&doc); + let lines = text::text_lines(&reopened, 0).unwrap(); + assert!(lines.len() > 1, "expected the turned row to wrap at the top margin: {lines:?}"); + for line in &lines { + assert!((line.rect[1] - 600.0).abs() < 0.5, "row starts at y = 600: {:?}", line.rect); + assert!(line.rect[3] <= 800.0 - 36.0 + 0.5, "row stays within the top margin: {:?}", line.rect); + } + let blocks = text::text_blocks(&reopened, 0).unwrap(); + assert_eq!(blocks.len(), 1, "wrapped turned rows stay one paragraph: {blocks:?}"); + assert_eq!(blocks[0].text, draft); + assert_eq!(layout.draft_rect, Some(blocks[0].rect)); + + // Reading downwards (90° CW) from y = 200: bottom margin is at y = 36. + let mut doc = text_page("BT /F1 12 Tf 0 -1 1 0 300 200 Tm (A single turned row) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, Some(draft), &text::BlockStyle::default()).unwrap(); + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + assert!(lines.len() > 1, "expected 90° CW row to wrap at the bottom margin: {lines:?}"); + for line in &lines { + assert!((line.rect[3] - 200.0).abs() < 0.5, "row starts at y = 200: {:?}", line.rect); + assert!(line.rect[1] >= 36.0 - 0.5, "row stays within the bottom margin: {:?}", line.rect); + } + + // Reading upside down (180°) from x = 200: left margin is at x = 36. + let mut doc = text_page("BT /F1 12 Tf -1 0 0 -1 200 600 Tm (A single turned row) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, Some(draft), &text::BlockStyle::default()).unwrap(); + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + assert!(lines.len() > 1, "expected 180° row to wrap at the left margin: {lines:?}"); + for line in &lines { + assert!((line.rect[2] - 200.0).abs() < 0.5, "row starts at x = 200: {:?}", line.rect); + assert!(line.rect[0] >= 36.0 - 0.5, "row stays within the left margin: {:?}", line.rect); + } + + // Slanted (45°) from (400, 600): baseline stops where it first reaches a 36 pt margin. + let (sin, cos) = 45.0_f64.to_radians().sin_cos(); + let mut doc = text_page(&format!("BT /F1 12 Tf {cos:.6} {sin:.6} {:.6} {cos:.6} 400 600 Tm (A single turned row) Tj ET", -sin)); + text::rewrite_block(&mut doc, 0, 0, Some(draft), &text::BlockStyle::default()).unwrap(); + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + assert!(lines.len() > 1, "expected 45° slanted row to wrap at the margin: {lines:?}"); + for line in &lines { + assert!(line.rect[2] <= 600.0 && line.rect[3] <= 800.0, "slanted row stays on the page: {:?}", line.rect); + } + + // Moving a turned single row with `offset` reduces the room left before the margin. + let mut doc = text_page("BT /F1 12 Tf 0 1 -1 0 300 500 Tm (A single turned row) Tj ET"); + let moved = text::BlockStyle { offset: Some([0.0, 150.0]), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(draft), &moved).unwrap(); + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + assert!(lines.len() > 1, "{lines:?}"); + for line in &lines { + assert!((line.rect[1] - 650.0).abs() < 0.5, "moved row starts at y = 650: {:?}", line.rect); + assert!(line.rect[3] <= 800.0 - 36.0 + 0.5, "moved row stops at the top margin: {:?}", line.rect); + } + + // A turned row that already starts inside the 36 pt margin keeps its own width instead of collapsing. + let mut doc = text_page("BT /F1 12 Tf 0 1 -1 0 300 770 Tm (Edge label) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, Some("Edge label updated"), &text::BlockStyle::default()).unwrap(); + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + assert!(lines.len() >= 2, "{lines:?}"); +} + /// 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. @@ -4249,3 +4612,366 @@ fn japanese_paragraphs_wrap_between_characters_without_closing_marks_at_a_row_st } } } + +#[test] +fn rewritten_paragraph_is_drawn_after_backgrounds_between_its_rows() { + // Each row has its own background box drawn immediately before its BT..ET, as cards and + // highlighted headings exported from LibreOffice or browsers do (#2377). + let card = "0 0.45 0.25 rg 60 696 240 16 re f BT /F1 10 Tf 1 1 1 rg 64 700 Td (SEMINARIO ONLINE) Tj ET \ + 0 0.45 0.25 rg 60 668 240 24 re f BT /F1 16 Tf 1 1 1 rg 64 674 Td (Dalla teoria alla pratica:) Tj ET \ + 0 0.45 0.25 rg 60 632 240 32 re f BT /F1 24 Tf 1 1 1 rg 64 640 Td (gestire con metodo) Tj ET \ + 0 0.45 0.25 rg 60 596 240 32 re f BT /F1 24 Tf 1 1 1 rg 64 604 Td (un progetto complesso) Tj ET"; + let shown_at = |ops: &[pdfcraft_content::Op], needle: &str| { + ops.iter() + .position(|op| { + matches!(op.op.as_slice(), b"Tj" | b"TJ") + && op.operands.iter().any(|o| matches!(o, Object::String(s) if String::from_utf8_lossy(&s.bytes).contains(needle))) + }) + .unwrap() + }; + + // 1. Editing the title rows emits all rewritten rows after all four background fills. + let mut doc = text_page(card); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 1); + let edited = "SEMINARIO ONLINE\nDalla teoria alla pratica:\ngestire con criterio\nun progetto nuovo"; + text::rewrite_block(&mut doc, 0, 0, Some(edited), &text::BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, edited); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let fills: Vec = ops.iter().enumerate().filter_map(|(i, op)| op.is("f").then_some(i)).collect(); + assert_eq!(fills.len(), 4); + assert!( + shown_at(&ops, "criterio") > fills[3] && shown_at(&ops, "nuovo") > fills[3], + "rewritten title rows must be drawn after their row background fills (fills at {fills:?}): {ops:?}" + ); + + // 2. Partial edit with a kept suffix row: rewritten rows 0..=2 are drawn after fills[0..=2] + // and still before kept row 3, preserving reading order. + let mut doc = text_page(card); + let partial = "WORKSHOP ONLINE\nDalla teoria alla pratica:\ngestire con criterio\nun progetto complesso"; + text::rewrite_block(&mut doc, 0, 0, Some(partial), &text::BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, partial); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let fills: Vec = ops.iter().enumerate().filter_map(|(i, op)| op.is("f").then_some(i)).collect(); + assert!( + shown_at(&ops, "WORKSHOP") > fills[2] + && shown_at(&ops, "pratica:") > fills[2] + && shown_at(&ops, "criterio") > fills[2] + && shown_at(&ops, "criterio") < shown_at(&ops, "complesso") + && shown_at(&ops, "complesso") > fills[3], + "rewritten prefix rows must follow their fills and precede the kept final row" + ); + + // 3. Editing row 2 so that it wraps pushes row 3 down; all rewritten rows must follow fills[3]. + let mut doc = text_page(card); + let wrapped_edit = "SEMINARIO ONLINE\nDalla teoria alla pratica:\ngestire con criterio e metodo approfondito\nun progetto complesso"; + text::rewrite_block(&mut doc, 0, 0, Some(wrapped_edit), &text::BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let last_fill = ops.iter().rposition(|op| op.is("f")).unwrap(); + assert!( + shown_at(&ops, "approfondito") > last_fill && shown_at(&ops, "complesso") > last_fill, + "wrapped rows and pushed-down suffix row must follow all row background fills" + ); + + // 4. Single-style paragraph path across a CTM change between rows, with movement, underline, + // and a trailing unrelated line sharing the second row's BT..ET. + let cross_ctm = "0.9 0.9 0.2 rg 60 696 240 18 re f BT /F1 12 Tf 0 0 0 rg 64 700 Td (First highlighted row) Tj ET \ + q 1 0 0 1 10 12 cm 0.9 0.9 0.2 rg 50 668 240 18 re f \ + BT /F1 12 Tf 0 0 0 rg 54 672 Td (Second highlighted row) Tj 246 -40 Td (Side footer) Tj ET Q"; + let mut doc = text_page(cross_ctm); + let para = text::text_blocks(&doc, 0).unwrap().into_iter().position(|b| b.text.starts_with("First highlighted")).unwrap(); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[para].rows.len(), 2); + text::rewrite_block( + &mut doc, + 0, + para, + Some("Updated first highlighted row and updated second highlighted row"), + &text::BlockStyle { offset: Some([6.0, -4.0]), underline: Some(true), ..Default::default() }, + ) + .unwrap(); + let doc = reopen(&doc); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let last_fill = ops.iter().rposition(|op| op.is("f")).unwrap(); + assert!( + shown_at(&ops, "Updated first") > last_fill && shown_at(&ops, "Side footer") > shown_at(&ops, "Updated first"), + "cross-CTM single-style rewrite must follow both background fills and precede the shared BT's trailing line" + ); + let lines = text::text_lines(&doc, 0).unwrap(); + let updated_first = lines.iter().find(|l| l.text.starts_with("Updated first")).unwrap(); + let side_footer = lines.iter().find(|l| l.text == "Side footer").unwrap(); + assert!( + (updated_first.baseline()[0] - 70.0).abs() < 0.01 && (updated_first.baseline()[1] - 696.0).abs() < 0.01, + "offset baseline in user space across CTM: {:?}", + updated_first.baseline() + ); + assert!( + (side_footer.baseline()[0] - 310.0).abs() < 0.01 && (side_footer.baseline()[1] - 644.0).abs() < 0.01, + "trailing line in shared BT keeps its tlm across CTM: {:?}", + side_footer.baseline() + ); +} + +/// Small graphics-state oracle for synthetic streams: every tracked state change must +/// have been restored when a probe XObject would draw. No external assets are involved. +fn probe_states(doc: &Document) -> Vec, Vec>> { + let bytes = streams(doc, 0); + let joined = pdfcraft_content::Pieces::join(&bytes); + let mut state = std::collections::BTreeMap::new(); + let mut stack = Vec::new(); + let mut probes = Vec::new(); + for op in joined.parse().ops { + match op.op.as_slice() { + b"q" => stack.push(state.clone()), + b"Q" => state = stack.pop().expect("balanced graphics state"), + b"cm" | b"W" | b"W*" | b"Tz" | b"Tc" | b"Tw" | b"Tr" | b"cs" => { + state.insert(op.op, op.operands); + } + b"Do" if op.name(0) == Some(b"PCProbe") => probes.push(state.clone()), + _ => {} + } + } + probes +} + +fn isolation_fixture(parts: &[(&str, &str)]) -> Document { + let mut doc = fixture(); + let page = page_list(&doc).remove(0); + let list = parts + .iter() + .map(|(tag, bytes)| { + Object::Ref(doc.add(Object::Stream(Stream::from_raw(if tag.is_empty() { Dict::new() } else { tagged(tag) }, bytes.as_bytes().to_vec())))) + }) + .collect(); + doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list))).unwrap(); + doc +} + +#[test] +fn new_marks_isolate_state_appended_after_old_wrappers() { + for dirt in ["0.5 0 0 0.5 0 0 cm", "0 0 200 200 re W n", "50 Tz", "5 Tc 20 Tw", "3 Tr", "/Pattern cs"] { + for tag in ["", "OCR", "Wrap"] { + let mut doc = isolation_fixture(&[ + ("Wrap", "q %PdfCraft\n"), + ("", "2 0 0 2 0 0 cm"), + ("Wrap", "Q %PdfCraft\n"), + ("Added", "q /PCProbe Do Q"), + (tag, dirt), + ]); + let before = probe_states(&doc); + stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap(); + let after = probe_states(&reopen(&doc)); + assert_eq!(&after[..before.len()], &before, "existing content must not change"); + assert!(after.last().unwrap().is_empty(), "tag={tag}, dirt={dirt}: {after:?}"); + } + } +} + +#[test] +fn new_marks_isolate_dirty_tagged_only_pages() { + let mut doc = isolation_fixture(&[("OCR", "50 Tz 5 Tc 20 Tw")]); + stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap(); + assert!(probe_states(&doc).last().unwrap().is_empty()); +} + +#[test] +fn new_marks_do_not_accumulate_wrappers_for_self_contained_marks() { + let mut doc = isolation_fixture(&[("", "2 0 0 2 0 0 cm")]); + for _ in 0..10 { + stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap(); + } + assert_eq!(streams(&doc, 0).len(), 13); + assert!(probe_states(&doc).iter().all(|s| s.is_empty())); +} + +#[test] +fn new_marks_close_wrapper_after_an_unterminated_comment() { + let mut doc = isolation_fixture(&[("", "50 Tz % no final newline")]); + stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap(); + // Check literal concatenation too: a separator must exist in the saved stream itself, + // not just in the inspection parser, or the comment can swallow the closing Q. + let bytes = streams(&doc, 0).concat(); + let parsed = pdfcraft_content::parse(bytes.as_bytes()); + assert_eq!(parsed.ops.iter().filter(|op| op.is("Q")).count(), 2); + assert!(probe_states(&doc).last().unwrap().is_empty()); +} + +#[test] +fn new_marks_wrap_unbalanced_graphics_state_whole_without_changing_it() { + // Content that can't be isolated precisely is wrapped whole, as before #3084, instead of + // refusing: redaction stamps its boxes after removing the text, so it can't back out. + for (data, probe_clean) in [("q 50 Tz", true), ("50 Tz q", true), ("q q Q", true), ("Q 50 Tz", false), ("q Q Q 50 Tz", false)] { + let mut doc = isolation_fixture(&[("Wrap", data)]); + let page = page_list(&doc).remove(0); + place_tagged(&mut doc, &page, "Probe", b"q /PCProbe Do Q".to_vec(), false).unwrap(); + let after = streams(&doc, 0); + assert_eq!(after.len(), 4, "{data}: {after:?}"); + assert_eq!((after[0].as_str(), after[1].as_str()), ("q %PdfCraft\n", data), "{data}"); + // A `q` the content leaves open is closed after it, so the mark starts clean. A stray + // `Q` pops the wrapper itself: nothing placed after it can undo that. + let probe = probe_states_lenient(&doc); + assert_eq!(probe.last().unwrap().is_empty(), probe_clean, "{data}: {after:?}"); + } +} + +/// [`probe_states`] for unbalanced content: a `Q` with nothing saved leaves the state as is, +/// as renderers do. +fn probe_states_lenient(doc: &Document) -> Vec, Vec>> { + let joined = pdfcraft_content::Pieces::join(&streams(doc, 0)); + let mut state = std::collections::BTreeMap::new(); + let mut stack = Vec::new(); + let mut probes = Vec::new(); + for op in joined.parse().ops { + match op.op.as_slice() { + b"q" => stack.push(state.clone()), + b"Q" => state = stack.pop().unwrap_or(state), + b"cm" | b"W" | b"W*" | b"Tz" | b"Tc" | b"Tw" | b"Tr" | b"cs" => { + state.insert(op.op, op.operands); + } + b"Do" if op.name(0) == Some(b"PCProbe") => probes.push(state.clone()), + _ => {} + } + } + probes +} + +#[test] +fn new_marks_preserve_cross_stream_operands_and_q_pairs() { + let mut doc = isolation_fixture(&[("", "q 2 0 0 2"), ("", "0 0 cm Q 50"), ("", "Tz")]); + stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap(); + assert!(probe_states(&doc).last().unwrap().is_empty()); +} + +#[test] +fn removing_marks_keeps_wrappers_needed_by_later_external_content() { + let mut doc = isolation_fixture(&[ + ("Wrap", "q %PdfCraft\n"), + ("", "2 0 0 2 0 0 cm"), + ("Wrap", "Q %PdfCraft\n"), + ("HeaderFooter", "q /PCProbe Do Q"), + ("", "/PCProbe Do"), + ]); + remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(); + assert_eq!(probe_states(&doc).len(), 1); + assert!(probe_states(&doc)[0].is_empty(), "the external content still needs the original reset"); +} + +#[test] +fn new_marks_wrap_unfinished_text_paths_and_operands_whole() { + for data in ["BT", "ET", "BT BT ET", "0 0 10 10 re", "q 0 0 m Q", "50"] { + let mut doc = isolation_fixture(&[("Background", "q Q"), ("", data)]); + let page = page_list(&doc).remove(0); + place_tagged(&mut doc, &page, "Probe", b"q /PCProbe Do Q".to_vec(), false).unwrap(); + // The behind-mark stays outside the wrapper and the content itself is untouched. + assert_eq!(streams(&doc, 0), ["q Q", "q %PdfCraft\n", data, "\nQ %PdfCraft\n", "q /PCProbe Do Q"], "{data}"); + } +} + +#[test] +fn new_marks_do_not_reveal_cross_stream_comments() { + let mut doc = isolation_fixture(&[("", "q 50 Tz % comment continues"), ("", "Q 20 Tc\nQ 5 Tw")]); + stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap(); + assert!(probe_states(&doc).last().unwrap().is_empty()); + let joined = pdfcraft_content::Pieces::join(&streams(&doc, 0)); + assert!(!joined.parse().ops.iter().any(|op| op.is("Tc")), "commented content must remain inert"); +} + +#[test] +fn new_marks_follow_clean_streams_ending_in_comments() { + let mut doc = isolation_fixture(&[("Added", "q 50 Tz Q % no newline")]); + stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap(); + assert_eq!(probe_states(&doc).len(), 1, "the new mark must not be swallowed by a comment"); + assert!(probe_states(&doc)[0].is_empty()); +} + +#[test] +fn removing_marks_does_not_strip_arbitrary_wrap_tagged_content() { + let mut doc = isolation_fixture(&[("Wrap", "q /PCProbe Do Q"), ("HeaderFooter", "q Q")]); + assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1); + assert_eq!(probe_states(&doc).len(), 1); +} + +#[test] +fn removing_marks_strips_nested_trailing_wrapper_pairs() { + let mut doc = isolation_fixture(&[ + ("Wrap", "q %PdfCraft\n"), + ("Wrap", "q %PdfCraft\n"), + ("", "2 0 0 2 0 0 cm"), + ("Wrap", "Q %PdfCraft\n"), + ("Wrap", "Q %PdfCraft\n"), + ("HeaderFooter", "q Q"), + ]); + assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1); + assert_eq!(streams(&doc, 0), ["2 0 0 2 0 0 cm"]); +} + +#[test] +fn updating_marks_on_unreadable_content_does_not_nest_wrappers() { + for data in ["q 50 Tz", "Q 50 Tz", "BT"] { + let mut doc = isolation_fixture(&[("", data)]); + let page = page_list(&doc).remove(0); + for _ in 0..3 { + place_tagged(&mut doc, &page, MarkKind::HeaderFooter.tag(), b"q /PCProbe Do Q".to_vec(), false).unwrap(); + assert_eq!(streams(&doc, 0).len(), 4, "{data}: {:?}", streams(&doc, 0)); + assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1); + assert_eq!(streams(&doc, 0), [data], "{data}: the original list comes back"); + } + } +} + +#[test] +fn removing_marks_strips_wrappers_that_closed_open_state() { + let mut doc = isolation_fixture(&[("", "q 2 0 0 2 0 0 cm")]); + stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap(); + assert!(probe_states(&doc).last().unwrap().is_empty()); + // Now balanced, the page reads cleanly: the close (with its extra `Q`) still pairs. + let page = page_list(&doc).remove(0); + place_tagged(&mut doc, &page, MarkKind::Watermark.tag(), b"q /PCProbe Do Q\n".to_vec(), false).unwrap(); + assert_eq!(streams(&doc, 0).len(), 5, "no second wrapper: {:?}", streams(&doc, 0)); + let mut doc = isolation_fixture(&[("", "q 2 0 0 2 0 0 cm")]); + let page = page_list(&doc).remove(0); + place_tagged(&mut doc, &page, MarkKind::Watermark.tag(), b"q /PCProbe Do Q\n".to_vec(), false).unwrap(); + assert_eq!(remove_marks(&mut doc, &[0], MarkKind::Watermark).unwrap(), 1); + assert_eq!(streams(&doc, 0), ["q 2 0 0 2 0 0 cm"]); +} + +/// #3087: flattening comments leaves alone what isn't a comment: links, and the non-markup +/// multimedia and print-production annotations (3D, Screen, Movie, RichMedia, PrinterMark, +/// TrapNet, Watermark), which it used to bake into the page and delete with their data. +/// Flattening fields only leaves them alone too; real comments are still flattened. +#[test] +fn flattening_comments_keeps_annotations_that_are_not_comments() { + let kept = ["Widget", "Link", "3D", "Screen", "Movie", "RichMedia", "PrinterMark", "TrapNet", "Watermark"]; + let flattened = ["Text", "Square", "FileAttachment"]; + let all: Vec<&str> = flattened.iter().chain(kept.iter()).copied().collect(); + let form = "<< /Type /XObject /Subtype /Form /BBox [0 0 40 20] /Length 26 >>\nstream\n0 0 1 rg 0 0 40 20 re f\nendstream"; + let annots: Vec = all + .iter() + .enumerate() + .map(|(i, s)| { + format!("<< /Type /Annot /Subtype /{s} /Rect [{x} 700 {r} 720] /AP << /N 4 0 R >> /Contents ({s}) >>", x = 20 + 50 * i, r = 60 + 50 * i) + }) + .collect(); + let refs: Vec = (0..all.len()).map(|i| format!("{} 0 R", 6 + i)).collect(); + let page = format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 5 0 R /Annots [{}] >>", refs.join(" ")); + let content = stream("", " "); + let mut objs: Vec<&str> = vec!["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", &page, form, &content]; + objs.extend(annots.iter().map(String::as_str)); + let left = |doc: &Document| -> Vec { + let p = &pdfcraft_model::pages(doc)[0]; + let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a)) else { return Vec::new() }; + a.as_array() + .unwrap() + .iter() + .filter_map(|o| doc.resolve(o).as_dict().and_then(|d| d.name(b"Subtype")).map(|s| String::from_utf8_lossy(s).into_owned())) + .collect() + }; + let mut doc = build(&objs); + assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), flattened.len()); + assert_eq!(left(&reopen(&doc)), kept, "only the comments were flattened"); + let mut doc = build(&objs); + assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1, "the widget"); + let without_widget: Vec<&str> = all.iter().copied().filter(|s| *s != "Widget").collect(); + assert_eq!(left(&reopen(&doc)), without_widget, "flattening fields touches no annotation that isn't a widget"); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 9c83cf9a39..32d63d5c80 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -50,6 +50,10 @@ pub struct TextLine { pub visual_order: bool, /// The Japanese or Hebrew fallback face the font subsets, when the line is in one (see [`same_font`]). face: Option, + /// Its box along and across its baseline, `[along0, across0, along1, across1]` (see + /// [`TextLine::frame_rect`]). Upright text has `rect`; turned text has its own box, which is + /// narrower than `rect` turned into the same frame. + frame: [f64; 4], /// The content stream piece the line starts in, and its operators (indices into the page's /// streams parsed joined). stream: usize, @@ -470,12 +474,7 @@ impl TextLine { /// 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)]) + self.frame } } @@ -493,6 +492,11 @@ 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) } +/// The box covering both `[x0, y0, x1, y1]` boxes. +fn union(a: [f64; 4], b: [f64; 4]) -> [f64; 4] { + [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])] +} + /// What replacing a line did. #[derive(Clone, Debug, PartialEq)] pub struct LineEdit { @@ -639,6 +643,8 @@ struct Shown { bt_state: Ts, text: String, rect: [f64; 4], + /// Its box along and across its baseline (see [`TextLine::frame`]). + frame: [f64; 4], /// The boxes of its words (see [`TextLine::ink`]). ink: Vec<[f64; 4]>, baseline: f64, @@ -734,7 +740,8 @@ fn interpret( cache: &mut HashMap, (Rc, Option)>, carry: &mut Carry, mut invoked: Option<&mut Vec>, - logical_text: bool, + // `Some(properties)`: read `/ActualText`, looking named property lists up in `properties`. + logical_text: Option<&Dict>, ) -> Vec { let mut out: Vec = Vec::new(); let mut ts = carry.ts.clone(); @@ -751,11 +758,16 @@ fn interpret( // artifacts and inner spans, which inherit their enclosing region. let region = matches!(op.name(0), Some(b"P" | b"H" | b"H1" | b"H2" | b"H3" | b"H4" | b"H5" | b"H6" | b"LI" | b"TD" | b"TH")); marked.push(if region { Some(i) } else { marked.last().copied().flatten() }); - let logical = op.operands.get(1).and_then(Object::as_dict).and_then(|d| d.get(b"ActualText")).and_then(|s| match s { + // The property list is inline or named in the resources' /Properties (§14.6.2). + let list = logical_text.and_then(|properties| match op.operands.get(1) { + Some(Object::Name(n)) => properties.get(n).and_then(|o| doc.dict(o)), + other => other.and_then(Object::as_dict).cloned(), + }); + let logical = list.as_ref().and_then(|d| d.get(b"ActualText")).and_then(|s| match s { Object::String(s) if s.bytes.len() <= 4_000_000 => Some(s.to_text()), _ => None, }); - actual.push(if logical_text { logical.map(|s| (out.len(), i, s)) } else { None }); + actual.push(logical.map(|s| (out.len(), i, s))); } b"EMC" => { marked.pop(); @@ -773,6 +785,7 @@ fn interpret( first.rect[2].max(shown.rect[2]), first.rect[3].max(shown.rect[3]), ]; + first.frame = union(first.frame, shown.frame); first.ink.extend(shown.ink); } first.extra_ops.extend([open, i]); @@ -936,6 +949,13 @@ 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)]); + // The same corners along and across the baseline: the run's own box, not its + // upright bounding box turned (which is wider by the box's height on a slant). + let dir = direction(&trm0); + let frame = corners + .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)]); let ink = words .iter() .map(|&(a, b)| { @@ -963,6 +983,7 @@ fn interpret( bt_state: at_bt.1.clone(), text, rect, + frame, ink, baseline: start.1, start_x: start.0, @@ -1003,7 +1024,8 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro // joined, so an operator whose operands end one piece still has them. 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 properties = res.get(b"Properties").and_then(|o| doc.dict(o)).unwrap_or_default(); + let shown = interpret(doc, &ops, &fonts_res, &mut cache, &mut carry, None, Some(&properties)); 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 @@ -1040,6 +1062,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro } 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.frame = union(l.frame, s.frame); l.ink.extend(s.ink.iter().copied()); l.ops.push(s.op); l.ops.extend(s.extra_ops.iter().copied()); @@ -1051,6 +1074,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro lines.push(TextLine { text: s.text.clone(), rect: s.rect, + frame: s.frame, ink: s.ink.clone(), font: String::from_utf8_lossy(&s.font).into_owned(), base_font: s.base_font.clone(), @@ -1128,6 +1152,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream } 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.frame = union(l.frame, s.frame); l.ink.extend(s.ink.iter().copied()); l.ops.push(s.op); l.ops.extend(s.extra_ops.iter().copied()); @@ -1138,6 +1163,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream lines.push(TextLine { text: s.text.clone(), rect: s.rect, + frame: s.frame, ink: s.ink.clone(), font: String::from_utf8_lossy(&s.font).into_owned(), base_font: s.base_font.clone(), @@ -1237,12 +1263,15 @@ fn reading_lines(doc: &Document, page: usize) -> Result, EditError path: Vec, visits: usize, } - fn walk(w: &mut Walk, ops: &[Op], resources: &Dict, carry: &mut Carry, stream: usize) { + /// `stream` gives the stream number of the operator at an index of `ops`. + fn walk(w: &mut Walk, ops: &[Op], resources: &Dict, carry: &mut Carry, stream: &dyn Fn(usize) -> usize) { let fonts_res = resources.get(b"Font").map(|f| w.doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); let mut cache = HashMap::new(); let mut invoked = Vec::new(); - let shown = interpret(w.doc, ops, &fonts_res, &mut cache, carry, Some(&mut invoked), true); + let properties = resources.get(b"Properties").and_then(|o| w.doc.dict(o)).unwrap_or_default(); + let shown = interpret(w.doc, ops, &fonts_res, &mut cache, carry, Some(&mut invoked), Some(&properties)); let mut last = None; + let mut last_stream = None; let mut forms = invoked.into_iter().peekable(); for s in shown { while let Some((_, name, ts)) = forms.next_if(|f| f.0 < s.op) { @@ -1250,7 +1279,12 @@ fn reading_lines(doc: &Document, page: usize) -> Result, EditError // Text after a form starts a new line. last = None; } - add_shown(&mut w.lines, &mut last, s, stream); + let si = stream(s.op); + // Text from another stream starts a new line, as when each stream was read alone. + if last_stream.replace(si).is_some_and(|prev| prev != si) { + last = None; + } + add_shown(&mut w.lines, &mut last, s, si); } for (_, name, ts) in forms { enter(w, resources, &name, ts); @@ -1266,7 +1300,7 @@ fn reading_lines(doc: &Document, page: usize) -> Result, EditError if let Some(r) = form.obj { w.path.push(r); } - walk(w, &ops, &form.resources, &mut carry, stream); + walk(w, &ops, &form.resources, &mut carry, &|_| stream); if form.obj.is_some() { w.path.pop(); } @@ -1276,9 +1310,14 @@ fn reading_lines(doc: &Document, page: usize) -> Result, EditError let streams = content_streams(doc, &p.dict); let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), visits: 0 }; let mut carry = Carry::new(); - for (si, (_, data)) in streams.into_iter().enumerate() { - walk(&mut w, &parse(&data).ops, &res, &mut carry, si); - } + // Read the page's streams as the one stream they are, in one pass, as rendering and editing + // do: a comment, an operator's operands or a marked-content sequence (its /ActualText too) + // can run on from one stream into the next. Text still counts as the stream its operator + // starts in, which keeps lines from different streams apart. + let pieces = Pieces::join(&streams.into_iter().map(|(_, data)| data).collect::>()); + let ops = pieces.parse().ops; + let stream_of = |i: usize| ops.get(i).map_or(0, |op| pieces.pieces_of(op).0); + walk(&mut w, &ops, &res, &mut carry, &stream_of); let mut lines = w.lines; // A visual-order Indic font draws pre-base vowel signs before their consonant. for l in lines.iter_mut().filter(|l| l.visual_order && !l.actual_text) { @@ -1630,7 +1669,15 @@ fn source_encodes(metrics: &Metrics, text: &str) -> bool { text.split(' ').all(|word| metrics.encode(word).is_some()) } +#[cfg(test)] +thread_local! { + /// How often [`source_space`] ran on this thread, for the test that pins it out of per-character loops. + pub(crate) static SOURCE_SPACE_CALLS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + fn source_space(doc: &Document, fonts: &Dict, metrics: &Metrics) -> f64 { + #[cfg(test)] + SOURCE_SPACE_CALLS.with(|c| c.set(c.get().saturating_add(1))); let width = |m: &Metrics| m.encode(" ").map(|b| m.codes(&b).iter().map(|(c, _)| m.width(*c)).sum::()); if let Some(w) = width(metrics) { return w; @@ -1772,7 +1819,8 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi } // Rebuild: the line's first operator becomes the replacement, its others go. Copies of the // line drawn on the same baseline go too, so the replacement is all that shows. - let drops = coincident_ops(&lines, &[&target]); + let mut drops = coincident_ops(&lines, &[&target]); + drops.extend(target.ops.iter().copied()); let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) }); let contents = rewritten_contents(doc, &streams, new_data); let page_ref = p.obj; @@ -1804,7 +1852,10 @@ fn coincident_ops(lines: &[TextLine], targets: &[&TextLine]) -> std::collections let area = |r: [f64; 4]| ((r[2] - r[0]) * (r[3] - r[1])).max(0.0); let mut drop = std::collections::HashSet::new(); for line in lines { - let copy = targets.iter().any(|target| overlaps_half(line.rect, target.rect, area) && on_same_baseline(line, target)); + let copy = targets.iter().any(|target| { + let same_text = line.text.trim() == target.text.trim() || (line.origin.state.render_mode == 3) != (target.origin.state.render_mode == 3); + same_text && overlaps_half(line.rect, target.rect, area) && on_same_baseline(line, target) + }); if copy { drop.extend(line.ops.iter().copied()); } @@ -1822,13 +1873,16 @@ fn overlaps_half(a: [f64; 4], b: [f64; 4], area: impl Fn([f64; 4]) -> f64) -> bo /// Perpendicular distance between baselines, in user space. Horizontal text compares height; /// turned text compares the distance across the writing direction. fn on_same_baseline(line: &TextLine, target: &TextLine) -> bool { + let (dir, target_dir) = (line.direction(), target.direction()); + let same_dir = (dir.0 - target_dir.0).abs() < 1e-3 && (dir.1 - target_dir.1).abs() < 1e-3; + let same_size = line.size.min(target.size) >= line.size.max(target.size) * 0.8; let angle = target.rotation().to_radians(); let (sin, cos) = if angle.is_finite() { angle.sin_cos() } else { (0.0, 1.0) }; let dx = line.origin.x - target.origin.x; let dy = line.origin.baseline - target.origin.baseline; let offset = dx.mul_add(-sin, dy * cos).abs(); let size = line.size.min(target.size).max(1.0); - offset.is_finite() && offset < size * 0.3 + same_dir && same_size && offset.is_finite() && offset < size * 0.3 } /// The paragraphs on a page (0-based). @@ -1853,7 +1907,7 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(), 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 shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, false); + let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, None); let mut patches = HashMap::new(); for s in shown.into_iter().filter(|s| drops.contains(&s.op)) { if s.state.render_mode >= 4 { @@ -2060,6 +2114,8 @@ fn rewrite_block_from( // The characters to show, for choosing fonts: the breaks are moves, not glyphs. let flat = text.replace('\n', " "); let o = &first.origin; + let last = members.last().copied().unwrap_or(first); + let emit = &last.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); // The size in text space: points ÷ the text-to-user scale. @@ -2120,7 +2176,7 @@ fn rewrite_block_from( // paragraph leaves the document as it was. The move is in page space, so it is taken back through // the CTM's linear part into the space the paragraph is drawn in. let movement = if dx != 0.0 || dy != 0.0 { - let c = o.ctm; + let c = emit.ctm; let back = Matrix([c[0], c[1], c[2], c[3], 0.0, 0.0]) .invert() .ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it can't be moved in".into()))?; @@ -2128,6 +2184,12 @@ fn rewrite_block_from( } else { None }; + let tm = if o.ctm == emit.ctm { + o.tm + } else { + let emit_inv = Matrix(emit.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?; + Matrix(o.tm).then(&Matrix(o.ctm)).then(&emit_inv).0 + }; 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) { @@ -2149,9 +2211,27 @@ fn rewrite_block_from( }; t * o_state.scale * k }; - // A turned single line grows to fit, as far as the largest page PDF allows. + // A turned single line grows to fit, up to where its baseline reaches the page's margin. let width = if !upright && style.width.is_none() && members.len() == 1 { - text.split('\n').map(&advance).fold(width, f64::max).min(14_400.0) + let crop = p.crop(doc); + let (x0, y0) = (first.origin.x + dx, first.origin.baseline + dy); + let (ux, uy) = first.direction(); + let tx = if ux > 1e-6 { + (crop[2] - 36.0 - x0) / ux + } else if ux < -1e-6 { + (crop[0] + 36.0 - x0) / ux + } else { + f64::INFINITY + }; + let ty = if uy > 1e-6 { + (crop[3] - 36.0 - y0) / uy + } else if uy < -1e-6 { + (crop[1] + 36.0 - y0) / uy + } else { + f64::INFINITY + }; + let cap = width.max(tx.min(ty)).min(14_400.0); + text.split('\n').map(&advance).fold(width, f64::max).min(cap) } else { width }; @@ -2205,7 +2285,7 @@ fn rewrite_block_from( let n = pdfcraft_content::num; // In its own graphics state, so a new colour (or anything else) stops at the paragraph. let mut block_ops = vec![Op::new("q", vec![])]; - block_ops.extend(reveal_ops(&b, &lines, style, o.ctm)?); + block_ops.extend(reveal_ops(&b, &lines, style, emit.ctm)?); // Moved: a translation inside that state. if let Some((ax, ay)) = movement { block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(ax), n(ay)])); @@ -2220,7 +2300,7 @@ fn rewrite_block_from( state.fill = vec![Op::new("rg", vec![n(r), n(g), n(bl)])]; } block_ops.extend(state.ops()); - block_ops.push(Op::new("Tm", o.tm.iter().map(|v| n(*v)).collect())); + block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect())); // Alignment: each line's offset from the left edge, in text space. let offset = |line: &str| -> f64 { let free = (width - advance(line)).max(0.0) / k; @@ -2303,7 +2383,7 @@ fn rewrite_block_from( }) .collect(); block_ops.push(Op::new("q", vec![])); - block_ops.push(Op::new("cm", o.tm.iter().map(|v| n(*v)).collect())); + block_ops.push(Op::new("cm", tm.iter().map(|v| n(*v)).collect())); block_ops.extend(colour); block_ops.push(Op::new("w", vec![n(size * 0.06)])); for (x0, x1, y) in underlines { @@ -2318,24 +2398,24 @@ fn rewrite_block_from( // second copy, an invisible text layer): the new text is all that may show. let mut drop = coincident_ops(&lines, &members); drop.extend(members.iter().flat_map(|l| l.ops.iter().copied())); - // Text shown earlier in the same text object stays first: the paragraph then goes where it - // was, between two halves of the text object, so it keeps its place in reading order. - let start = first.ops.first().copied().unwrap_or(o.bt_op); + // Text shown earlier in the same text object stays first: the paragraph then goes where its + // last row was, after any backgrounds between the rows, or between two halves of the text object. + let start = last.ops.first().copied().unwrap_or(emit.bt_op); let split = ops - .get(o.bt_op..start) + .get(emit.bt_op..start) .unwrap_or_default() .iter() .enumerate() - .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(o.bt_op + i))); + .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(emit.bt_op + i))); // What the text after the paragraph expects: the state at its BT (or where it was split). - let after = if split { &o.state } else { &o.bt_state }; + let after = if split { &emit.state } else { &emit.bt_state }; block_ops.extend(after.ops().into_iter().filter(|op| !op.is("Tf") || after.font.is_some())); if split { block_ops.insert(0, Op::new("ET", vec![])); block_ops.push(Op::new("BT", vec![])); - block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect())); + block_ops.push(Op::new("Tm", emit.tlm.iter().map(|v| n(*v)).collect())); } - let at = if split { start } else { o.bt_op }; + let at = if split { start } else { emit.bt_op }; let new_data = splice(&pieces, &ops, |i| { let insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }; (insert, !drop.contains(&i)) @@ -2484,7 +2564,7 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize let fonts = res.get(b"Font").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; - let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, false); + let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, None); let mut patches = HashMap::new(); // Keep full precision: a tiny rounded text-space change can be magnified by a CTM, // and restoring rise/spacing must not alter following unselected text. diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 2d7baf519f..e08068d9f5 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -17,10 +17,6 @@ fn same_plane(a: &TextLine, b: &TextLine) -> bool { }) } -fn union(a: [f64; 4], b: [f64; 4]) -> [f64; 4] { - [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])] -} - fn row_rect(row: &[usize], lines: &[TextLine]) -> Option<[f64; 4]> { row.iter().filter_map(|i| lines.get(*i)).map(|l| l.rect).reduce(union) } @@ -493,6 +489,20 @@ pub(super) fn rewrite_styled_block( }; // A source run may carry TJ kerning/justification that is absent from its text state. // Keep its measured advance when rebuilding the run instead of making it wrap on entry. + // Each font's space width, worked out once: when a font can't show a space, finding one + // reads every font on the page, and doing that for every character of a paragraph made each + // keystroke in a long paragraph take half a second (#2616). + // Only for the fonts the paragraph uses, when first asked: working out every page font's width + // up front would scan the page once per font for a short edit on a page of many fonts. + let spaces: std::cell::RefCell> = std::cell::RefCell::new(HashMap::new()); + let space_of = |name: &str, m: &Metrics| { + if let Some(w) = spaces.borrow().get(name) { + return *w; + } + let w = source_space(doc, &fonts, m); + spaces.borrow_mut().insert(name.to_owned(), w); + w + }; let mut calibration = HashMap::new(); for (index, source) in lines.iter().enumerate() { if let Some(m) = metrics.get(&source.font) { @@ -500,7 +510,7 @@ pub(super) fn rewrite_styled_block( m, &source.text, source.origin.state.font.as_ref().map_or(1.0, |(_, s)| *s), - source_space(doc, &fonts, m), + space_of(&source.font, m), source.origin.state.char_spacing, source.origin.state.word_spacing, ) * source.origin.state.scale @@ -517,7 +527,7 @@ pub(super) fn rewrite_styled_block( m, &item.0.to_string(), size_of(source, item.1), - source_space(doc, &fonts, m), + space_of(&item.2, m), overrides .get(&item.1) .and_then(|r| r.char_spacing) @@ -620,19 +630,20 @@ pub(super) fn rewrite_styled_block( 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("\""))) - }); + // The rewrite goes where the last rewritten row was drawn, after any backgrounds painted + // between the rows. A dropped `'` or `"` also moved to the next line, which a kept row relies on. + let lead = + rewritten.last().copied().filter(|_| !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) + let all: Vec<&TextLine> = block.lines.iter().filter_map(|i| lines.get(*i)).collect(); + let last = all.last().copied().unwrap_or(first); + (all, last) } }; + let o = &lead.origin; 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)) { @@ -668,8 +679,8 @@ pub(super) fn rewrite_styled_block( } let n = pdfcraft_content::num; let mut block_ops = vec![Op::new("q", vec![])]; - block_ops.extend(reveal_ops(block, lines, style, first.origin.ctm)?); - let back = Matrix([first.origin.ctm[0], first.origin.ctm[1], first.origin.ctm[2], first.origin.ctm[3], 0.0, 0.0]) + block_ops.extend(reveal_ops(block, lines, style, o.ctm)?); + let back = Matrix([o.ctm[0], o.ctm[1], o.ctm[2], o.ctm[3], 0.0, 0.0]) .invert() .ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it cannot be edited in".into()))?; let (tx, ty) = back.apply(dx, dy); @@ -741,7 +752,7 @@ pub(super) fn rewrite_styled_block( if let Some([r, g, b]) = overrides.get(&item.1).and_then(|r| r.color).or(style.color) { state.fill = vec![Op::new("rg", vec![n(r), n(g), n(b)])]; } - let inverse = Matrix(first.origin.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?; + let inverse = Matrix(o.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?; let mut tm = Matrix(source.origin.tm).then(&Matrix(source.origin.ctm)).then(&inverse).0; let (ax, ay) = back.apply(x - source.origin.x, baseline - source.origin.baseline); tm[4] += ax; @@ -781,7 +792,7 @@ pub(super) fn rewrite_styled_block( m, &string, size_of(source, item.1), - source_space(doc, &fonts, m), + spaces.borrow().get(&item.2).copied().unwrap_or_else(|| source_space(doc, &fonts, m)), state.char_spacing, state.word_spacing, )?); @@ -790,10 +801,10 @@ pub(super) fn rewrite_styled_block( if underlines.get(&item.1).copied().or(style.underline) == Some(true) { let [r, g, b] = overrides.get(&item.1).and_then(|r| r.color).or(style.color).unwrap_or(source.color); let underline_size = size_of(source, item.1) * source.origin.k; - let (x0, y0) = back.apply(x - first.origin.ctm[4], baseline - first.origin.ctm[5] - underline_size * 0.12); - let (x1, y1) = back.apply(x + segment_width - first.origin.ctm[4], baseline - first.origin.ctm[5] - underline_size * 0.12); + let (x0, y0) = back.apply(x - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12); + let (x1, y1) = back.apply(x + segment_width - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12); block_ops.push(Op::new("RG", vec![n(r), n(g), n(b)])); - block_ops.push(Op::new("w", vec![n(underline_size * 0.06 / first.origin.ctm[2].hypot(first.origin.ctm[3]).max(1e-6))])); + block_ops.push(Op::new("w", vec![n(underline_size * 0.06 / o.ctm[2].hypot(o.ctm[3]).max(1e-6))])); block_ops.push(Op::new("m", vec![n(x0), n(y0)])); block_ops.push(Op::new("l", vec![n(x1), n(y1)])); block_ops.push(Op::new("S", vec![])); @@ -810,7 +821,6 @@ pub(super) fn rewrite_styled_block( block_ops.push(Op::new("Q", vec![])); let mut drop = coincident_ops(lines, &members); drop.extend(members.iter().flat_map(|l| l.ops.iter().copied())); - let o = &lead.origin; let start = lead.ops.first().copied().unwrap_or(o.bt_op); let split = ops .get(o.bt_op..start) diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 7de0287634..ff5e7611d8 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true workspace = true [dependencies] +craft-docpath = { workspace = true } pdfcraft-measure = { workspace = true } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "tiff"] } pdfcraft-render = { workspace = true } @@ -36,6 +37,9 @@ pdfcraft-model = { workspace = true } pdfcraft-geom = { workspace = true } thiserror = { workspace = true } png = "0.18" +# Already in the graph (through image and pdfcraft-create): image's TiffEncoder can't set the +# resolution or the compression of an exported page. +tiff = "0.11" # Already in the graph (url and idna through webbrowser, icu_properties through boa): they show # the host of a document's link the way the browser reads it (links::display_host). url = "2.5" diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index b46d88acf1..3d2ba30820 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -206,6 +206,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ item("Set page boxes", "square-dashed-mouse-pointer", "page.boxes", Ready), item("Crop pages", "crop", "page.crop", Ready), item("Duplicate pages", "copy-plus", "page.duplicate", Ready), + item("Reverse pages", "arrow-up-down", "page.reverse", Ready), item("Page labels", "tag", "page.number", Ready), item("Page transitions", "presentation", "page.transitions", Planned("M4")), ], diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index b28758e241..fcd56efee4 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -362,6 +362,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("page.insert_blank", "Insert blank page", PAGES, None, Assembly, "file-plus"), c("page.rotate_dialog", "Rotate pages…", PAGES, None, Assembly, "rotate-cw"), c("page.duplicate", "Duplicate pages", PAGES, None, Assembly, "copy-plus"), + c("page.reverse", "Reverse pages", PAGES, None, Assembly, "arrow-up-down"), c("page.crop", "Crop pages", PAGES, None, Assembly, "crop"), c("page.boxes", "Set page boxes…", PAGES, None, Assembly, "square-dashed-mouse-pointer"), c("page.insert", "Insert pages from file…", PAGES, None, Assembly, "file-input"), diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 579a483efc..0bf4e2f79d 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -27,7 +27,10 @@ pub struct ExportSource { } impl Document { - /// The current state, for exporting on another thread. + /// What an export reads, for exporting on another thread. These are the displayed + /// bytes, so they may carry missing comment appearances PdfCraft drew. Signature + /// artwork stays original; viewer validation badges are not document content and + /// do not enter image/text exports or Print as Image. Saving uses the working file. pub fn export_source(&self) -> ExportSource { ExportSource { bytes: self.display.clone(), @@ -90,14 +93,14 @@ impl Exporter { encode_png(r.width, r.height, &r.rgba) } - /// Page `page` as an image file of `format`. + /// Page `page` as an image file of `format`, which says the resolution it was drawn at. pub fn image(&mut self, page: usize, dpi: f64, format: ImageFormat) -> Result, String> { self.check(page)?; let r = self.renderer.render(RenderRequest { page, scale: (dpi.clamp(18.0, 1200.0) / 72.0) as f32, ..Default::default() }); if let Some(e) = r.error { return Err(format!("page {}: {e}", page + 1)); } - encode_image(r.width, r.height, &r.rgba, format) + encode_image(r.width, r.height, &r.rgba, format, self.dpi_used(page, dpi)) } /// The reading-order text of a page. @@ -124,12 +127,14 @@ impl Exporter { } } -/// Premultiplied RGBA → an image file of `format`. -pub fn encode_image(width: u32, height: u32, premultiplied: &[u8], format: ImageFormat) -> Result, String> { +/// Premultiplied RGBA → an image file of `format`. A JPEG or TIFF says it is `dpi` dots per inch, +/// so other programs show and print it at the page's size. A PNG says nothing: its `pHYs` counts +/// pixels per metre, so 72 dpi would read back as 72.009 and a page made from it would shrink. +pub fn encode_image(width: u32, height: u32, premultiplied: &[u8], format: ImageFormat, dpi: f64) -> Result, String> { match format { ImageFormat::Png => encode_png(width, height, premultiplied), - ImageFormat::Jpeg { quality } => encode_jpeg(width, height, premultiplied, quality), - ImageFormat::Tiff => encode_tiff(width, height, premultiplied), + ImageFormat::Jpeg { quality } => encode_jpeg(width, height, premultiplied, quality, dpi), + ImageFormat::Tiff => encode_tiff(width, height, premultiplied, dpi), } } @@ -196,23 +201,37 @@ fn over_white(premultiplied: &[u8]) -> Vec { .collect() } -/// Premultiplied RGBA → baseline JPEG (pages are opaque: transparency becomes white paper). -pub fn encode_jpeg(width: u32, height: u32, premultiplied: &[u8], quality: u8) -> Result, String> { +/// Premultiplied RGBA → baseline JPEG at `dpi` (pages are opaque: transparency becomes white paper). +pub fn encode_jpeg(width: u32, height: u32, premultiplied: &[u8], quality: u8, dpi: f64) -> Result, String> { use image::ImageEncoder; + use image::codecs::jpeg::{JpegEncoder, PixelDensity}; let rgb = over_white(premultiplied); let mut out = Vec::new(); - image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100)) - .write_image(&rgb, width, height, image::ExtendedColorType::Rgb8) - .map_err(|e| e.to_string())?; + let mut enc = JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100)); + // The JFIF density is whole dots per inch. + enc.set_pixel_density(PixelDensity::dpi(dpi.round() as u16)); + enc.write_image(&rgb, width, height, image::ExtendedColorType::Rgb8).map_err(|e| e.to_string())?; Ok(out) } -/// Premultiplied RGBA → TIFF (RGB, LZW-compressed by the encoder's default). -pub fn encode_tiff(width: u32, height: u32, premultiplied: &[u8]) -> Result, String> { - use image::ImageEncoder; +/// Premultiplied RGBA → TIFF (RGB, LZW-compressed) at `dpi`. Written with `tiff` directly: +/// `image`'s `TiffEncoder` always says 1 × 1 with no unit and leaves the strips uncompressed. +/// No predictor: LZW alone is the compression TIFF readers support most widely after none. +pub fn encode_tiff(width: u32, height: u32, premultiplied: &[u8], dpi: f64) -> Result, String> { + use tiff::encoder::{Compression, Rational, TiffEncoder, colortype::RGB8}; + use tiff::tags::{ResolutionUnit, Tag}; + let err = |e: tiff::TiffError| e.to_string(); let rgb = over_white(premultiplied); let mut out = std::io::Cursor::new(Vec::new()); - image::codecs::tiff::TiffEncoder::new(&mut out).write_image(&rgb, width, height, image::ExtendedColorType::Rgb8).map_err(|e| e.to_string())?; + let mut tiff = TiffEncoder::new(&mut out).map_err(err)?.with_compression(Compression::Lzw); + let mut image = tiff.new_image::(width, height).map_err(err)?; + // In hundredths: a page drawn at a lowered resolution (`Exporter::dpi_used`) is rarely a whole number. + let resolution = Rational { n: (dpi * 100.0).round() as u32, d: 100 }; + let tags = image.encoder(); + tags.write_tag(Tag::ResolutionUnit, ResolutionUnit::Inch).map_err(err)?; + tags.write_tag(Tag::XResolution, resolution.clone()).map_err(err)?; + tags.write_tag(Tag::YResolution, resolution).map_err(err)?; + image.write_data(&rgb).map_err(err)?; Ok(out.into_inner()) } diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index 8958688d2d..b797bc8d83 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -6,7 +6,7 @@ //! Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript is [`Session::set_javascript`]; with it //! off, scripts don't run and AF calls keep working natively. -use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, ScriptResult, Scripts}; +use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, FieldProps, ScriptResult, Scripts}; pub use pdfcraft_js::{Limits, Outcome, Request}; use crate::{DocId, Edit, EditError, Session}; @@ -343,19 +343,24 @@ impl Session { 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: tall > 30.0 }, - pdfcraft_forms::detect::Kind::CheckBox => pdfcraft_forms::NewField::CheckBox, - }, - name: Some(c.name.clone()), - }); - if let Some(props) = props { + let (kind, comb) = match c.kind { + pdfcraft_forms::detect::Kind::Text => (pdfcraft_forms::NewField::Text { multiline: tall > 30.0 }, None), + pdfcraft_forms::detect::Kind::CheckBox => (pdfcraft_forms::NewField::CheckBox, None), + pdfcraft_forms::detect::Kind::Comb { cells } => (pdfcraft_forms::NewField::Text { multiline: false }, Some(cells)), + }; + let mut props = FieldProps::default(); + if turn != 0 { + // A turned field keeps its place: the rectangle is already the one on the page. + props.rotation = Some((0, turn)); + props.rect = Some((0, c.rect)); + } + if let Some(cells) = comb { + props.max_len = Some(Some(cells)); + props.flags = vec![(pdfcraft_forms::flags::COMB, true)]; + } + edits.push(Edit::AddField { page, rect: c.rect, kind, name: Some(c.name.clone()) }); + if turn != 0 || comb.is_some() { edits.push(Edit::SetFieldProps { name: c.name, props: Box::new(props) }); } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 01f162ae7d..8f39248abd 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -36,7 +36,6 @@ 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; @@ -306,6 +305,10 @@ struct Editor { saved: Arc>, /// Passwords that open [`Self::saved`]. saved_keys: Keys, + /// [`Self::saved`] was never written where the document belongs: a new document (combine, + /// extract, create) or one restored from recovery. It stays unsaved until a save, whatever + /// undo brings back, or closing it would lose the whole document without asking. + unsaved_file: bool, undo: Vec, redo: Vec, keys: Keys, @@ -317,15 +320,15 @@ struct Editor { text_blocks: TextBlocksCache, } -/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the unmodified document state they -/// were read from. Opening a paragraph and applying its edit both need them, and grouping a long -/// page is not cheap. Only an unmodified document is cached: its identity changes on every edit, -/// undo and save (each reopens the working file), so a stale entry never matches. +/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the document state they were read +/// from. Opening a paragraph and applying its edit both need them, and grouping a long page is not +/// cheap. The state ([`pdfcraft_cos::ReadState`]) changes with every edit and save and returns +/// with undo, so a stale entry never matches, also after the document has been edited. #[derive(Default)] struct TextBlocksCache(std::sync::Mutex>); /// The document state, the page (0-based) and its text blocks. -type CachedTextBlocks = (pdfcraft_cos::SourceIdentity, usize, Arc>); +type CachedTextBlocks = (pdfcraft_cos::ReadState, usize, Arc>); impl Clone for TextBlocksCache { fn clone(&self) -> Self { @@ -337,20 +340,16 @@ impl Clone for TextBlocksCache { impl Editor { /// The text blocks on `page` (0-based), from the cache when the document hasn't changed. fn text_blocks(&self, page: usize) -> Result>, EditError> { - let source = (!self.cos.is_modified()).then(|| self.cos.source_identity()); - if let Some(source) = &source - && let Ok(cache) = self.text_blocks.0.lock() - && let Some((s, p, blocks)) = cache.as_ref() - && s == source + if let Ok(cache) = self.text_blocks.0.lock() + && let Some((state, p, blocks)) = cache.as_ref() && *p == page + && state.matches(&self.cos) { return Ok(blocks.clone()); } let blocks = Arc::new(pdfcraft_edit::text_blocks(&self.cos, page)?); - if let Some(source) = source - && let Ok(mut cache) = self.text_blocks.0.lock() - { - *cache = Some((source, page, blocks.clone())); + if let Ok(mut cache) = self.text_blocks.0.lock() { + *cache = Some((self.cos.read_state(), page, blocks.clone())); } Ok(blocks) } @@ -832,6 +831,11 @@ impl Document { self.editor.as_ref().and_then(|e| pdfcraft_organize::info(&e.cos, key)) } + /// The custom document properties as they are now (name, text), Document Properties ▸ Custom. + pub fn custom_info(&self) -> Vec<(String, String)> { + self.editor.as_ref().map(|e| pdfcraft_organize::custom_info(&e.cos)).unwrap_or_default() + } + /// The name shown on the tab and window: the document title when the document asks for it /// (Initial View ▸ Show: Document Title) and has a real one, else the file name. pub fn display_name(&self) -> String { @@ -872,23 +876,29 @@ fn is_placeholder_title(title: &str) -> bool { address || t == "untitled" } -/// 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 working file plus appearances for comments that have none. Signature artwork +/// is preserved; validation badges belong to the viewer and never enter these bytes, +/// which image/text exports and Print as Image also consume. +fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc>) -> Arc> { + let Some(e) = editor else { return bytes.clone() }; + let Some(display) = pdfcraft_annot::with_missing_appearances_cached(&e.cos, &mut e.appearances) else { + return bytes.clone(); + }; + match write_incremental(&display, &SaveOptions::default()) { + Ok(written) => Arc::new(written), + Err(err) => { + e.cos.note_repair(format!("missing comment appearances could not be drawn ({err})")); + bytes.clone() + } + } +} + /// 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(); - }; - // Writing fails only as saving the document would; the page then shows as it always did. - write_incremental(&display, &SaveOptions::default()).map(Arc::new).unwrap_or_else(|_| bytes.clone()) -} - /// The copy PDFCraft displays when opening `bytes`: with appearances drawn, in memory only, for /// comments that have none. Render paths outside the app, such as the CLI, use this so their page /// images agree with the app and independent viewers. It never changes the source document, and an @@ -978,6 +988,27 @@ fn render_threads() -> usize { std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 8) - 1 } +/// Write `layers`' visibility into the default optional-content configuration of `cos` (a copy +/// made for printing): `/BaseState /ON`, `/ON` the visible layers, `/OFF` the hidden ones. Its +/// other entries (`/Order`, `/RBGroups`, `/AS`…) are kept. A document without `/OCProperties` +/// is left alone. +fn set_default_layer_state(cos: &mut pdfcraft_cos::Document, layers: &[Layer]) { + use pdfcraft_cos::{Dict, ObjRef, Object}; + let Some(root_ref) = cos.trailer().get(b"Root").and_then(Object::as_ref) else { return }; + let Some(mut root) = cos.get(root_ref).as_dict().cloned() else { return }; + let Some(properties) = root.get(b"OCProperties").cloned() else { return }; + let Some(mut properties) = cos.resolve(&properties).as_dict().cloned() else { return }; + let mut config = properties.get(b"D").and_then(|d| cos.resolve(d).as_dict().cloned()).unwrap_or_else(Dict::new); + let refs = + |visible: bool| Object::Array(layers.iter().filter(|l| l.visible == visible).map(|l| Object::Ref(ObjRef::new(l.id.0, l.id.1))).collect()); + config.set(b"BaseState".to_vec(), Object::name("ON")); + config.set(b"ON".to_vec(), refs(true)); + config.set(b"OFF".to_vec(), refs(false)); + properties.set(b"D".to_vec(), Object::Dict(config)); + root.set(b"OCProperties".to_vec(), Object::Dict(properties)); + cos.set(root_ref, Object::Dict(root)); +} + /// Render `doc` with the layer visibility in its `info.layers`. fn use_layer_choices(doc: &mut Document) { let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect(); @@ -1241,14 +1272,20 @@ pub enum Edit { pages: Vec, to: usize, }, + /// `pages` reversed among the positions they occupy. + ReversePages { + pages: Vec, + }, InsertBlankPage { at: usize, width: f64, height: f64, }, - /// Copies of `pages` inserted after the last of them. + /// Copies of `pages` inserted at `at` (an index in the page order as it is now), or after + /// the last of them when `at` is `None`. DuplicatePages { pages: Vec, + at: Option, }, /// Replace Pages: the content of `pages` is replaced by `src_pages` of another PDF; the /// pages' links, comments and bookmarks stay. @@ -1771,6 +1808,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Delete pages", "Move page", "Move pages", + "Reverse pages", "Insert blank page", "Duplicate page", "Duplicate pages", @@ -1910,8 +1948,9 @@ impl Edit { Edit::RotatePages { pages, .. } => plural("Rotate page", "Rotate pages", pages.len()), Edit::DeletePages { pages } => plural("Delete page", "Delete pages", pages.len()), Edit::MovePages { pages, .. } => plural("Move page", "Move pages", pages.len()), + Edit::ReversePages { .. } => Label::of("Reverse pages"), Edit::InsertBlankPage { .. } => Label::of("Insert blank page"), - Edit::DuplicatePages { pages } => plural("Duplicate page", "Duplicate pages", pages.len()), + Edit::DuplicatePages { pages, .. } => plural("Duplicate page", "Duplicate pages", pages.len()), 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"), @@ -2082,6 +2121,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed Edit::RotatePages { .. } | Edit::DeletePages { .. } | Edit::MovePages { .. } + | Edit::ReversePages { .. } | Edit::InsertBlankPage { .. } | Edit::DuplicatePages { .. } | Edit::ReplacePages { .. } @@ -2292,11 +2332,12 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Edit::RotatePages { pages, degrees } => pdfcraft_organize::rotate_pages(doc, pages, *degrees)?, Edit::DeletePages { pages } => pdfcraft_organize::delete_pages(doc, pages)?, Edit::MovePages { pages, to } => pdfcraft_organize::move_pages(doc, pages, *to)?, + Edit::ReversePages { pages } => pdfcraft_organize::reverse_pages(doc, pages)?, Edit::InsertBlankPage { at, width, height } => { pdfcraft_organize::insert_blank_page(doc, *at, *width, *height)?; } Edit::SetInfo { key, value } => pdfcraft_organize::set_info(doc, key, value)?, - Edit::DuplicatePages { pages } => pdfcraft_organize::duplicate_pages(doc, pages)?, + Edit::DuplicatePages { pages, at } => pdfcraft_organize::duplicate_pages(doc, pages, *at)?, Edit::ReplacePages { pages, name, bytes, src_pages } => { let src = open_source(name, bytes)?; pdfcraft_organize::replace_pages(doc, pages, &src, src_pages)?; @@ -2368,7 +2409,10 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> pdfcraft_annot::erase_ink(doc, *page, *index, path, *radius, &cx.meta())?; } Edit::ReplaceText { page, quads, text, author, strike, caret } => { - pdfcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &cx.meta())?; + // One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by + // the strikeout's `/NM` (#2666). + let (strike_meta, caret_meta) = (cx.meta(), cx.meta()); + pdfcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &strike_meta, &caret_meta)?; } Edit::MarkAnnotation { page, index, marked, author } => { pdfcraft_annot::set_marked(doc, *page, *index, *marked, author, &cx.meta())?; @@ -3206,8 +3250,8 @@ fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Result Arc> { - Arc::new(pdfcraft_sign::pdf::list_cached(cos, bytes, trust, cache)) +fn signatures_of(cos: &pdfcraft_cos::Document, bytes: &[u8], trust: &TrustStore, cache: &pdfcraft_sign::DigestCache) -> Vec { + pdfcraft_sign::pdf::list_cached(cos, bytes, trust, cache) } impl Session { @@ -3520,6 +3564,7 @@ impl Session { cos, saved, saved_keys: keys.clone(), + unsaved_file: false, undo: Vec::new(), redo: Vec::new(), keys, @@ -3532,6 +3577,8 @@ impl Session { Ok(Err(e)) => (None, Some(e.to_string())), Err(_) => (None, Some("the document structure could not be read for editing".into())), }; + 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(); let display = display_bytes(editor.as_mut(), &bytes); let renderer = RenderPool::new(display.clone(), render_threads(), config.clone()); let (form, adopted) = editor @@ -3553,8 +3600,6 @@ impl Session { }; e.cos.note_repair(line); } - 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 { id: DocId(0), name, @@ -3571,7 +3616,7 @@ impl Session { form: Arc::new(form), marker_scan: OnceLock::new(), links: OnceLock::new(), - signatures, + signatures: Arc::new(signatures), trust: trust.clone(), sig_cache, editor, @@ -3817,7 +3862,7 @@ impl Session { /// 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)); + doc.dirty = doc.editor.as_ref().is_some_and(|e| e.unsaved_file || e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved)); } /// Use the current state's password for viewing (protection edits change it). @@ -3867,9 +3912,11 @@ impl Session { doc.info.keywords = info("Keywords"); doc.info.creator = info("Creator"); doc.info.producer = info("Producer"); + doc.info.custom = pdfcraft_organize::custom_info(&editor.cos); doc.info.file_size = bytes.len(); if !doc.signatures.is_empty() { - doc.signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); + let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); + doc.signatures = Arc::new(signatures); } doc.bytes = bytes; return Ok(()); @@ -3912,10 +3959,9 @@ impl Session { } doc.info.file_size = bytes.len(); doc.form = form; - if !doc.signatures.is_empty() { - doc.signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); - } + let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; doc.display = display_bytes(Some(editor), &bytes); + doc.signatures = Arc::new(signatures); doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); doc.bytes = bytes; Ok(()) @@ -3958,10 +4004,9 @@ impl Session { doc.marker_scan = OnceLock::new(); } doc.info.file_size = bytes.len(); - if !doc.signatures.is_empty() { - doc.signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); - } + let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; doc.display = display_bytes(Some(editor), &bytes); + doc.signatures = Arc::new(signatures); doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); doc.bytes = bytes; doc.fonts_stale = true; @@ -4034,8 +4079,9 @@ impl Session { if invalidate_links { doc.links = OnceLock::new(); } - doc.signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); + let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); doc.display = display_bytes(Some(editor), &bytes); + doc.signatures = Arc::new(signatures); doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); doc.bytes = bytes; Ok(()) @@ -4043,11 +4089,16 @@ impl Session { /// The bytes to write for Save: an incremental update of the file as opened/last saved, /// with `/ModDate` stamped. Call `mark_saved` after writing them successfully. + /// + /// A file whose cross-reference data had to be rebuilt is written in full even when nothing + /// was edited: its bytes as opened are the damaged file, and this save is how the repair the + /// user was told about reaches the disk (#1937). Not a signed one: a full rewrite would + /// break its signatures, so with no edits it keeps its bytes, damage and all. 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()) }; let on_saved = Arc::ptr_eq(editor.cos.bytes(), &editor.saved); - if !editor.cos.is_modified() && on_saved { + if !editor.cos.is_modified() && on_saved && (!editor.cos.reconstructed() || doc.is_signed()) { return Ok(editor.saved.clone()); } let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; @@ -4102,6 +4153,7 @@ impl Session { 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; + editor.unsaved_file = false; } 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()); @@ -4165,7 +4217,7 @@ impl Session { editor.undo.clear(); editor.redo.clear(); Self::adopt_keys(doc); - doc.dirty = false; + Self::sync_dirty(doc); doc.generation += 1; Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) } @@ -4326,12 +4378,18 @@ impl Session { }; return pdfcraft_print::impose(&pictures, &remapped).map_err(|e| EditError::Print(e.to_string())); } - let cos = match doc.editor.as_ref() { + let mut cos = match doc.editor.as_ref() { Some(e) => e.cos.clone(), None => { pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? } }; + // #3046: the layers print as they are shown. Once the Layers panel (or a set-layer + // action) has changed one, the print copy's default configuration says which are on, so a + // hidden layer stays off on paper too; untouched, the document's own configuration prints. + if !doc.config.layers.is_empty() { + set_default_layer_state(&mut cos, &doc.info.layers); + } pdfcraft_print::impose(&cos, settings).map_err(|e| EditError::Print(e.to_string())) } @@ -4639,6 +4697,9 @@ impl Session { let id = self.open(name, None, bytes, None)?; if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { d.dirty = true; + if let Some(e) = d.editor.as_mut() { + e.unsaved_file = true; + } // Unsaved work with no edit yet still needs its first recovery snapshot. d.generation += 1; } @@ -4691,6 +4752,9 @@ impl Session { if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { d.path = path; d.dirty = true; + if let Some(e) = d.editor.as_mut() { + e.unsaved_file = true; + } d.generation += 1; d.snapshot_generation = d.generation; // its bytes are already in the recovery store } @@ -4767,14 +4831,20 @@ impl Session { &self.trust.certs } - /// Replace the trusted certificates and revalidate every open document's signatures. + /// Replace the trusted certificates, revalidate every open document's signatures, + /// and rebuild its display copy and renderer to match (working file untouched, + /// generation/dirty unchanged: trust alone is not an edit). pub fn set_trusted_certificates(&mut self, certs: Vec) { self.trust = Arc::new(TrustStore { certs }); for doc in &mut self.docs { doc.trust = self.trust.clone(); - if let Some(e) = doc.editor.as_ref() { - doc.signatures = signatures_of(&e.cos, &doc.bytes, &doc.trust, &doc.sig_cache); - } + let Some(editor) = doc.editor.as_mut() else { continue }; + // Keep verdict refresh and renderer refresh together. Viewer caches are + // invalidated by the UI trust path; trust alone changes no document artwork. + let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &doc.bytes, &doc.trust, &doc.sig_cache) }; + doc.display = display_bytes(Some(editor), &doc.bytes); + doc.signatures = Arc::new(signatures); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); } } @@ -4894,7 +4964,7 @@ fn embed_image( name: &str, bytes: &[u8], ) -> Result<(pdfcraft_cos::ObjRef, (f64, f64)), pdfcraft_create::CreateError> { - pdfcraft_create::image_xobject(doc, name, &upright_image::upright(bytes)) + pdfcraft_create::image_xobject(doc, name, bytes) } /// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image. diff --git a/crates/engine/src/links.rs b/crates/engine/src/links.rs index 5dd9aff5b5..4902d72f53 100644 --- a/crates/engine/src/links.rs +++ b/crates/engine/src/links.rs @@ -137,7 +137,8 @@ pub enum RemoteDocumentPathError { Empty, TooLong, Hidden, - UrlOrNetwork, + /// A URL, a network or device path, or a path that climbs above its root. + NotLocal(craft_docpath::Refused), MissingSourcePath, /// The target doesn't end in `.pdf`. NotPdf, @@ -155,7 +156,7 @@ impl std::fmt::Display for RemoteDocumentPathError { Self::Empty => f.write_str("the file specification is empty"), Self::TooLong => f.write_str("the file specification is too long"), Self::Hidden => f.write_str("the file specification contains hidden or control characters"), - Self::UrlOrNetwork => f.write_str("only local document paths are supported"), + Self::NotLocal(r) => write!(f, "only local document paths are supported: {r}"), Self::MissingSourcePath => f.write_str("a relative link needs a source document path"), Self::NotPdf => f.write_str("the linked file is not a .pdf file"), Self::NotAFile => f.write_str("the linked path is not a regular file"), @@ -173,7 +174,9 @@ pub fn remote_document_path_is_absolute(raw: &str) -> bool { } /// Resolve a PDF remote-Go-To file specification to a local path without opening it. -/// Relative paths are based on the source document; an in-memory source has no fallback to CWD. +/// Relative paths are based on the source document, which should be an absolute path; an +/// in-memory source has no fallback to CWD. URLs (including `file:`), network and device paths are +/// refused ([`craft_docpath::resolve`]). pub fn resolve_remote_document_path(source: Option<&Path>, raw: &str) -> Result { if raw.is_empty() || raw.trim().is_empty() { return Err(RemoteDocumentPathError::Empty); @@ -184,25 +187,22 @@ pub fn resolve_remote_document_path(source: Option<&Path>, raw: &str) -> Result< if raw.chars().any(hides_text) { return Err(RemoteDocumentPathError::Hidden); } - if raw.starts_with("//") || raw.starts_with("\\\\") || raw.starts_with("/\\") || raw.starts_with("\\/") { - return Err(RemoteDocumentPathError::UrlOrNetwork); - } - let path = Path::new(raw); - let drive_absolute = remote_document_path_is_absolute(raw); + // A GoToR file specification is a path, not a URL: any scheme of two or more characters is + // refused (one letter is a drive), `file:` and names such as `custom:target.pdf` included. if let Some((scheme, _)) = raw.split_once(':') { - let valid_scheme = scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) + let is_scheme = scheme.len() > 1 + && scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) && scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')); - let windows_drive = scheme.len() == 1 && drive_absolute; - if valid_scheme && !windows_drive { - return Err(RemoteDocumentPathError::UrlOrNetwork); + if is_scheme { + return Err(RemoteDocumentPathError::NotLocal(craft_docpath::Refused::Url)); } } - if drive_absolute { - return Ok(path.to_path_buf()); + let path = craft_docpath::resolve(raw, source).map_err(RemoteDocumentPathError::NotLocal)?; + // A relative result would be read against the working directory. + if !path.is_absolute() { + return Err(RemoteDocumentPathError::MissingSourcePath); } - let source = source.ok_or(RemoteDocumentPathError::MissingSourcePath)?; - let parent = source.parent().ok_or(RemoteDocumentPathError::MissingSourcePath)?; - Ok(parent.join(path)) + Ok(path) } /// Largest linked PDF a GoToR link may open (1 GiB, the largest size the large-file work measures). @@ -503,23 +503,56 @@ mod tests { use super::{RemoteDocumentPathError as E, resolve_remote_document_path as resolve}; use std::path::Path; - let source = Path::new("/docs/chapter/source.pdf"); - assert_eq!(resolve(Some(source), "../target.pdf").as_deref(), Ok(Path::new("/docs/chapter/../target.pdf"))); + let source = std::env::temp_dir().join("chapter").join("source.pdf"); + let source = source.as_path(); + let folder = source.parent().unwrap(); + assert_eq!(resolve(Some(source), "target.pdf"), Ok(folder.join("target.pdf"))); + assert_eq!(resolve(Some(source), "../target.pdf").as_deref(), Ok(folder.parent().unwrap().join("target.pdf").as_path())); + #[cfg(unix)] assert_eq!(resolve(Some(source), "/manual/target.pdf").as_deref(), Ok(Path::new("/manual/target.pdf"))); assert_eq!(resolve(None, "target.pdf"), Err(E::MissingSourcePath)); + assert_eq!(resolve(Some(Path::new("source.pdf")), "target.pdf"), Err(E::MissingSourcePath), "a relative source is not used"); assert_eq!(resolve(Some(source), ""), Err(E::Empty)); assert_eq!(resolve(Some(source), " "), Err(E::Empty)); - for url in ["file:///tmp/a.pdf", "https://example.org/a.pdf", "smb://host/share/a.pdf", "ftp://host/a.pdf", "custom:target.pdf"] { - assert_eq!(resolve(Some(source), url), Err(E::UrlOrNetwork), "{url}"); - } - for network_path in ["//server/share/a.pdf", "\\\\server\\share\\a.pdf", "\\\\?\\C:\\a.pdf", "\\\\.\\device\\a.pdf", "/\\server/share/a.pdf"] + for url in + ["file:///tmp/a.pdf", "file://h/s/a.pdf", "https://example.org/a.pdf", "smb://host/share/a.pdf", "ftp://host/a.pdf", "custom:target.pdf"] { - assert_eq!(resolve(Some(source), network_path), Err(E::UrlOrNetwork), "{network_path:?}"); + assert!(matches!(resolve(Some(source), url), Err(E::NotLocal(_))), "{url}"); } assert_eq!(resolve(Some(source), "folder/hidden\u{200B}.pdf"), Err(E::Hidden)); assert_eq!(resolve(Some(source), &"a".repeat(super::MAX_REMOTE_DOCUMENT_PATH_BYTES + 1)), Err(E::TooLong)); } + #[test] + fn remote_document_paths_to_other_computers_and_devices_are_refused() { + use super::{RemoteDocumentPathError as E, resolve_remote_document_path as resolve}; + + let source = std::env::temp_dir().join("chapter").join("source.pdf"); + let source = source.as_path(); + // Refused before any filesystem access: none of these exist, and a stat of a network path + // would contact its host. + for not_local in [ + "//server/share/a.pdf", + "\\\\server\\share\\a.pdf", + "\\\\h\\s\\a.pdf", + "\\\\?\\UNC\\h\\s\\a.pdf", + "\\??\\UNC\\h\\s\\a.pdf", + "/??/x", + "\\??\\C:\\a.pdf", + "\\\\.\\device\\a.pdf", + "/\\server/share/a.pdf", + "NUL.pdf", + ] { + assert!(matches!(resolve(Some(source), not_local), Err(E::NotLocal(_))), "{not_local:?}"); + } + // Linux reads `//tmp/x` as `/tmp/x`, but on Windows it names a share. + #[cfg(unix)] + { + let doubled = format!("/{}", source.with_file_name("a.pdf").display()); + assert!(matches!(resolve(Some(source), &doubled), Err(E::NotLocal(_))), "{doubled}"); + } + } + #[test] fn urls_follow_the_artcraft_scheme() { assert_eq!(super::APP_PAGE, format!("{}/apps/{}", super::WEBSITE, super::APP)); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 26b6a0678b..df1a858073 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -519,6 +519,63 @@ fn save_is_incremental_stamps_mod_date_and_rebases() { assert_eq!(reopened.revisions().len(), 3); } +/// #1937: a file whose cross-reference data had to be rebuilt saved as its original bytes when +/// nothing was edited, so the damage Document Properties reported as repaired was still in the +/// file. Save writes the rebuilt file in full; reopened, it needs no repair. +#[test] +fn a_repaired_file_is_rewritten_by_save_even_without_edits() { + let good = fixture(2); + // Every offset in the table is off by the inserted line: the chain reads but leads nowhere. + let mut shifted = b"%PDF-1.7\n%shifted\n".to_vec(); + shifted.extend_from_slice(&good[b"%PDF-1.7\n".len()..]); + // No cross-reference table at all. + let mut lost = good.clone(); + lost.truncate(good.windows(9).rposition(|w| w == b"startxref").unwrap()); + lost.extend_from_slice(b"startxref\n999999\n%%EOF\n"); + for (name, damaged) in [("shifted", shifted), ("lost", lost)] { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("broken.pdf", Some("/tmp/broken.pdf".into()), Arc::new(damaged.clone()), None).unwrap(); + let doc = s.get(id).unwrap(); + assert!(doc.repair_log().iter().any(|l| l.contains("rebuilt cross-reference index")), "{name}: {:?}", doc.repair_log()); + assert_eq!(doc.info.pages.len(), 2, "{name}"); + assert!(!doc.dirty, "{name}: a repair is not an edit"); + let saved = s.save_bytes(id).unwrap(); + assert_ne!(&saved[..], &damaged[..], "{name}: the damaged bytes are not what Save writes"); + let reopened = pdfcraft_cos::Document::open(saved.clone()).unwrap(); + assert!(reopened.repair_log().is_empty(), "{name}: {:?}", reopened.repair_log()); + assert_eq!(reopened.revisions().len(), 1, "{name}"); + s.mark_saved(id, saved.clone(), None).unwrap(); + let doc = s.get(id).unwrap(); + assert!(doc.repair_log().is_empty(), "{name}: {:?}", doc.repair_log()); + assert_eq!(doc.info.pages.len(), 2, "{name}"); + // Sound now: saving again without edits writes the same bytes. + assert_eq!(s.save_bytes(id).unwrap(), saved, "{name}"); + } +} + +/// A damaged file that is signed keeps its bytes on a Save without edits: rewriting it in full +/// to put the repair on disk would invalidate its signatures without a word. +#[test] +fn a_signed_repaired_file_keeps_its_bytes_when_saved_without_edits() { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let digital_id = sign::pkcs12::open(&p12, "test").unwrap(); + // After the test certificate's start date (2026-10-02). + let mut s = Session::new().with_clock(|| 1_800_000_000); + let id = s.open("fixture.pdf", None, Arc::new(fixture(1)), None).unwrap(); + let opts = SignOptions { page: 0, rect: Some([20.0, 20.0, 180.0, 60.0]), ..SignOptions::default() }; + let signed = s.sign(id, &digital_id, opts).unwrap(); + // A trailing `startxref` that points nowhere: the signed revision's bytes are untouched, but + // the file's cross-reference data has to be rebuilt. + let mut damaged = signed.to_vec(); + damaged.extend_from_slice(b"\nstartxref\n999999\n%%EOF\n"); + let mut t = Session::new().with_clock(|| 1_800_000_000); + let tid = t.open("damaged.pdf", Some("/tmp/damaged.pdf".into()), Arc::new(damaged.clone()), None).unwrap(); + let doc = t.get(tid).unwrap(); + assert!(doc.repair_log().iter().any(|l| l.contains("rebuilt cross-reference index")), "{:?}", doc.repair_log()); + assert!(doc.is_signed()); + assert_eq!(&t.save_bytes(tid).unwrap()[..], &damaged[..], "a signed file is not rewritten without edits"); +} + #[test] fn undo_still_works_after_save() { let (mut s, id) = session_with(2); @@ -813,7 +870,7 @@ fn form_with_field(name: &str) -> Vec { fn duplicating_a_page_with_a_field_keeps_the_field_name() { let mut s = Session::new().with_clock(|| 1_700_000_000); let id = s.open("a.pdf", None, Arc::new(form_with_field("Text1")), None).unwrap(); - s.apply(id, Edit::DuplicatePages { pages: vec![0] }).unwrap(); + s.apply(id, Edit::DuplicatePages { pages: vec![0], at: None }).unwrap(); let doc = s.get(id).unwrap(); assert_eq!(doc.info.pages.len(), 2); assert!(doc.form.iter().all(|f| f.name == "Text1"), "{:?}", doc.form.iter().map(|f| &f.name).collect::>()); @@ -1186,6 +1243,22 @@ fn the_permissions_password_unlocks_an_open_document() { assert!(pdfcraft_cos::Document::open(plain).unwrap().security().is_none(), "security removed"); } +#[test] +fn remove_security_from_a_file_with_its_encrypt_dictionary_in_the_trailer() { + // MuPDF writes /Encrypt inline in the trailer rather than as an object (#2661). + let mut doc = pdfcraft_cos::Document::open_with_password(protected("user", "owner", -1), Some("owner")).unwrap(); + let r = doc.trailer().reference(b"Encrypt").unwrap(); + let dict = doc.get(r).as_dict().cloned().unwrap(); + doc.trailer_mut().set(b"Encrypt".to_vec(), pdfcraft_cos::Object::Dict(dict)); + let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()); + let mut s = Session::new(); + let id = s.open("m.pdf", None, bytes, Some("owner")).unwrap(); + s.apply(id, Edit::RemoveProtection).unwrap(); + assert!(!s.get(id).unwrap().info.encrypted); + let plain = s.save_bytes(id).unwrap(); + assert!(pdfcraft_cos::Document::open(plain).unwrap().security().is_none(), "security removed"); +} + #[test] fn unlocking_needs_the_permissions_password_not_the_open_password() { let mut s = Session::new(); @@ -1987,6 +2060,59 @@ fn oversized_redaction_overlay_preserves_the_document_and_undo() { assert_eq!(page_texts(&s, id), ["Page 1"]); } +/// #3046: a layer hidden in the Layers panel stays off on paper, as it is on screen; with the +/// layers untouched, the document's own configuration prints. +#[test] +fn printing_keeps_hidden_layers_hidden() { + let content = b"/OC /A BDC 1 0 0 rg 10 10 80 80 re f EMC /OC /B BDC 0 1 0 rg 110 10 80 80 re f EMC"; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R] /D << /Order [5 0 R 6 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 200 100] /Contents 4 0 R /Resources << /Properties << /A 5 0 R /B 6 0 R >> >> >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", content.len()).as_bytes(), content, b"\nendstream"].concat(), + b"<< /Type /OCG /Name (Layer A) >>".to_vec(), + b"<< /Type /OCG /Name (Layer B) >>".to_vec(), + ]; + 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", i + 1).as_bytes()); + pdf.extend_from_slice(o); + pdf.extend_from_slice(b"\nendobj\n"); + } + 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()); + + // Red and green pixels on the first printed sheet. + let printed = |s: &Session, id: DocId| { + let bytes = s.print_pdf(id, &print::Settings { pages: vec![0], ..Default::default() }).unwrap(); + let mut out = Session::new(); + let printed = out.open("print.pdf", None, Arc::new(bytes), None).unwrap(); + let page = render_page(&out, printed); + let px = page.rgba.as_chunks::<4>().0; + let red = px.iter().filter(|p| p[0] > 200 && p[1] < 60 && p[2] < 60).count(); + let green = px.iter().filter(|p| p[1] > 200 && p[0] < 60 && p[2] < 60).count(); + (red, green) + }; + let mut s = Session::new(); + let id = s.open("layers.pdf", None, Arc::new(pdf), None).unwrap(); + let (red, green) = printed(&s, id); + assert!(red > 100 && green > 100, "untouched layers both print: {red} red, {green} green"); + let a = s.get(id).unwrap().info.layers.iter().position(|l| l.name == "Layer A").unwrap(); + assert!(s.set_layer_visible(id, a, false)); + let (red, green) = printed(&s, id); + assert_eq!(red, 0, "Layer A was hidden, so it doesn't print"); + assert!(green > 100, "Layer B still prints: {green} green"); + assert!(s.set_layer_visible(id, a, true)); + let (red, _) = printed(&s, id); + assert!(red > 100, "shown again, Layer A prints again: {red} red"); +} + #[test] fn printing_lays_out_sheets_that_render() { let (s, id) = session_with(5); @@ -2057,7 +2183,7 @@ fn crop_and_duplicate_pages_show_in_the_viewer() { assert_eq!(d.info.pages[0].crop, [10.0, 20.0, 170.0, 260.0]); assert_eq!((d.info.pages[0].width, d.info.pages[0].height), (160.0, 240.0), "the viewer shows the cropped size"); assert_eq!(d.page_boxes()[1][1], [0.0, 0.0, 200.0, 300.0]); - s.apply(id, Edit::DuplicatePages { pages: vec![1] }).unwrap(); + s.apply(id, Edit::DuplicatePages { pages: vec![1], at: None }).unwrap(); assert_eq!(page_texts(&s, id), ["Page 1", "Page 2", "Page 2"]); assert_eq!(s.get(id).unwrap().can_undo(), Some("Duplicate page")); } @@ -2448,6 +2574,48 @@ fn pages_export_as_png_jpeg_and_tiff() { let tif = ex.image(0, 144.0, ImageFormat::Tiff).unwrap(); let decoded = image::load_from_memory_with_format(&tif, image::ImageFormat::Tiff).unwrap(); assert_eq!((decoded.width(), decoded.height()), (400, 600)); + let mut tags = tiff::decoder::Decoder::new(std::io::Cursor::new(&tif)).unwrap(); + let compression = tags.get_tag_u32(tiff::tags::Tag::Compression).unwrap(); + let predictor = tags.get_tag_u32(tiff::tags::Tag::Predictor).unwrap(); + assert_eq!((compression, predictor), (5, 1), "LZW, without a predictor"); +} + +/// A TIFF's resolution unit (2 = inch) and its horizontal and vertical resolution. +fn tiff_resolution(bytes: &[u8]) -> (u32, f64, f64) { + use tiff::{decoder::ifd::Value, tags::Tag}; + let mut d = tiff::decoder::Decoder::new(std::io::Cursor::new(bytes)).unwrap(); + let mut res = |tag| match d.get_tag(tag).unwrap() { + Value::Rational(n, d) => f64::from(n) / f64::from(d), + v => panic!("{tag:?} is {v:?}"), + }; + let (x, y) = (res(Tag::XResolution), res(Tag::YResolution)); + (d.get_tag_u32(Tag::ResolutionUnit).unwrap(), x, y) +} + +/// An exported TIFF or JPEG says the resolution it was drawn at, so other programs show and print it +/// at the page's size. A TIFF said 1 × 1 with no unit, and a JPEG said nothing. +#[test] +fn exported_pages_record_the_resolution_they_were_drawn_at() { + use crate::export::{Exporter, ImageFormat}; + let (s, id) = session_with(1); + let mut ex = Exporter::new(s.get(id).unwrap()); + assert_eq!(tiff_resolution(&ex.image(0, 144.0, ImageFormat::Tiff).unwrap()), (2, 144.0, 144.0)); + let jpg = ex.image(0, 144.0, ImageFormat::Jpeg { quality: 85 }).unwrap(); + assert_eq!(&jpg[6..11], b"JFIF\0"); + assert_eq!(jpg[13..18], [1, 0, 144, 0, 144], "JFIF density in dots per inch"); + // pHYs counts pixels per metre: 72 dpi would read back as 72.009, and a page made from the PNG + // (doc_create from images) would come out 0.01% small. + let png = ex.image(0, 72.0, ImageFormat::Png).unwrap(); + assert!(png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap().info().pixel_dims.is_none(), "a PNG states no resolution"); + + // A page too large for the renderer at the asked resolution says the one it was drawn at. + let mut s = Session::new().with_clock(|| 1_700_000_000); + let bytes = s.create_blank(14_400.0, 792.0, 1).unwrap(); + let id = s.open("wide.pdf", None, bytes, None).unwrap(); + let mut ex = Exporter::new(s.get(id).unwrap()); + let used = ex.dpi_used(0, 600.0); + let (unit, x, y) = tiff_resolution(&ex.image(0, 600.0, ImageFormat::Tiff).unwrap()); + assert!(unit == 2 && (x - used).abs() < 0.01 && (y - used).abs() < 0.01, "{x} × {y}, drawn at {used}"); } #[test] @@ -2763,6 +2931,400 @@ fn signing_saving_trusting_and_commenting_afterwards() { assert!(!s.get(id).unwrap().dirty && s.get(id).unwrap().can_undo().map(str::to_owned) == before); } +#[test] +fn validated_signatures_without_layers_keep_their_appearance() { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let digital_id = sign::pkcs12::open(&p12, "test").unwrap(); + // After the test certificate's start date (2026-10-02). + let mut s = Session::new().with_clock(|| 1_800_000_000); + let id = s.open("fixture.pdf", None, Arc::new(fixture(1)), None).unwrap(); + let opts = SignOptions { page: 0, rect: Some([20.0, 20.0, 180.0, 60.0]), ..SignOptions::default() }; + let signed = s.sign(id, &digital_id, opts).unwrap(); + s.mark_signed(id, signed, None).unwrap(); + s.set_trusted_certificates(vec![digital_id.certificate.clone()]); + let doc = s.get(id).unwrap(); + let sig = &doc.signatures[0]; + assert_eq!(sig.status, SignatureStatus::Valid); + // Our own flat appearance has no validation layers: nothing is substituted, and + // the display copy carries no generated caption. + let display = doc.display.clone(); + assert!(!display.windows(b"Signature valid".len()).any(|w| w == b"Signature valid")); + // With nothing to draw, the display copy aliases the file exactly as before. + assert!(Arc::ptr_eq(&doc.display, &doc.bytes)); +} + +#[test] +fn signed_pages_render_trust_save_and_reopen() { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let digital_id = sign::pkcs12::open(&p12, "test").unwrap(); + // After the test certificate's start date (2026-10-02). + let mut s = Session::new().with_clock(|| 1_800_000_000); + let id = s.open("fixture.pdf", None, Arc::new(fixture(1)), None).unwrap(); + let opts = SignOptions { page: 0, rect: Some([20.0, 20.0, 180.0, 60.0]), ..SignOptions::default() }; + let signed = s.sign(id, &digital_id, opts).unwrap(); + // The signed bytes open elsewhere too: validity is in the file, not the session. + let mut t = Session::new().with_clock(|| 1_800_000_000); + let tid = t.open("signed.pdf", None, signed.clone(), None).unwrap(); + assert!(t.get(tid).unwrap().signatures.iter().any(|x| x.signed && x.status == SignatureStatus::Unknown)); + s.mark_signed(id, signed, None).unwrap(); + // The signed page renders, from the file and from the display copy alike. + for bytes in [s.get(id).unwrap().bytes.clone(), s.get(id).unwrap().display.clone()] { + let mut renderer = pdfcraft_render::PageRenderer::new(bytes, s.get(id).unwrap().config.clone()); + let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(rendered.error.is_none(), "{:?}", rendered.error); + assert!(rendered.width > 0 && rendered.height > 0); + } + // Trusting the signer makes it valid; saving keeps it valid across sessions. + s.set_trusted_certificates(vec![digital_id.certificate.clone()]); + assert_eq!(s.get(id).unwrap().signatures[0].status, SignatureStatus::Valid); + let saved = s.save_bytes(id).unwrap(); + let mut u = Session::new().with_clock(|| 1_800_000_000); + u.set_trusted_certificates(vec![digital_id.certificate.clone()]); + let uid = u.open("saved.pdf", None, saved.clone(), None).unwrap(); + assert_eq!(u.get(uid).unwrap().signatures[0].status, SignatureStatus::Valid); +} + +#[test] +fn signed_documents_round_trip_behind_a_user_password() { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let digital_id = sign::pkcs12::open(&p12, "test").unwrap(); + // After the test certificate's start date (2026-10-02). + let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes128, + user_password: "user", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let encrypted = Arc::new(pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap()); + // Wrong passwords refuse; the right one signs, validates, saves and reopens. + let mut e = Session::new().with_clock(|| 1_800_000_000); + assert!(e.open("enc.pdf", None, encrypted.clone(), Some("wrong")).is_err(), "wrong passwords refuse"); + let eid = e.open("enc.pdf", None, encrypted, Some("user")).unwrap(); + let signed = e.sign(eid, &digital_id, SignOptions { page: 0, rect: Some([20.0, 20.0, 180.0, 60.0]), ..SignOptions::default() }).unwrap(); + e.mark_signed(eid, signed, None).unwrap(); + e.set_trusted_certificates(vec![digital_id.certificate.clone()]); + assert_eq!(e.get(eid).unwrap().signatures[0].status, SignatureStatus::Valid); + let saved = e.save_bytes(eid).unwrap(); + let mut f = Session::new().with_clock(|| 1_800_000_000); + f.set_trusted_certificates(vec![digital_id.certificate.clone()]); + assert!(f.open("enc-saved.pdf", None, saved.clone(), Some("wrong")).is_err()); + let fid = f.open("enc-saved.pdf", None, saved.clone(), Some("user")).unwrap(); + assert_eq!(f.get(fid).unwrap().signatures[0].status, SignatureStatus::Valid); +} + +/// ByteRange placeholder values: fixed width, patched after writing. +const LAYERED_BR_MARK: [i64; 3] = [1_111_111_111, 2_222_222_222, 3_333_333_333]; + +/// A one-page file whose signature field carries a layered appearance on the +/// page (background, words, vector glyph, border, unverified caption): object +/// numbers 1 catalog, 2 pages, 3 page, 4 field, 5 AcroForm, 6 /N, 7 frame, +/// 8 background, 9 words, 10 glyph, 11 caption, 12 border, 13 Helvetica, +/// 14 page content. The field is unsigned: `sign_prepared` adds the value. +fn layered_signable_fixture() -> Vec { + fn stream(bbox: &str, resources: &str, ops: &str) -> String { + format!("<< /Type /XObject /Subtype /Form /BBox {bbox} /Length {} /Resources {resources} >>\nstream\n{ops}\nendstream", ops.len()) + } + let helv = "<< /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >>"; + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 5 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 14 0 R /Resources << /Font << /F1 13 0 R >> >> /Annots [4 0 R] >>" + .to_string(), + "<< /FT /Sig /T (Signature1) /Subtype /Widget /Rect [10 10 110 60] /P 3 0 R /AP << /N 6 0 R >> >>".to_string(), + "<< /Fields [4 0 R] /SigFlags 3 >>".to_string(), + stream("[0 0 50 30]", "<< /XObject << /frame 7 0 R >> >>", "q /frame Do Q"), + stream( + "[0 0 50 30]", + "<< /XObject << /bg 8 0 R /words 9 0 R /glyph 10 0 R /bd 12 0 R /cap 11 0 R >> >>", + "q /bg Do /words Do /glyph Do /bd Do /cap Do Q", + ), + stream("[0 0 100 100]", "<< >>", "0 g 0 0 100 100 re f"), + stream("[0 0 100 100]", helv, "BT /F1 10 Tf 5 50 Td (Signed by Test) Tj ET"), + stream("[0 0 50 30]", "<< >>", "0.8 0.1 0.1 RG 3 w 5 5 m 20 5 l 12 22 l h S"), + stream("[0 21 50 30]", helv, "BT /F1 9 Tf 2 22 Td (Signature Not Verified) Tj ET"), + stream("[0 0 100 100]", "<< >>", "0 g 0 0 100 100 re f"), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), + "<< /Length 44 >>\nstream\nBT /F1 14 Tf 20 150 Td (Contract text) Tj ET\nendstream".to_string(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (k, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", k + 1).as_bytes()); + } + let start = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n", objs.len() + 1).as_bytes()); + out.extend_from_slice(b"0000000000 65535 f \n"); + for o in &offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF", objs.len() + 1).as_bytes()); + out +} + +/// Whether `haystack` holds `needle`. +fn bytes_has(haystack: &[u8], needle: &[u8]) -> bool { + !needle.is_empty() && haystack.windows(needle.len()).any(|w| w == needle) +} + +/// Sign `doc`, whose field already carries its layered appearance, without +/// touching that appearance: reserve `/ByteRange` and `/Contents`, hash the +/// ranges and embed a detached CMS over them. The signed bytes keep the +/// prepared appearance, so they validate exactly like a product signer's file. +fn sign_prepared(doc: &mut pdfcraft_cos::Document, id: &sign::pkcs12::DigitalId, date: &str) -> Vec { + use pdfcraft_cos::{Dict, Object, PdfString}; + let reserve = 8192 + id.certificate.raw.len() + id.chain.iter().map(|c| c.raw.len()).sum::(); + let mut v = Dict::new(); + v.set(b"Type".to_vec(), Object::name("Sig")); + v.set(b"Filter".to_vec(), Object::name("Adobe.PPKLite")); + v.set(b"SubFilter".to_vec(), Object::name("ETSI.CAdES.detached")); + v.set(b"ByteRange".to_vec(), Object::Array([0].iter().chain(LAYERED_BR_MARK.iter()).map(|n| Object::Int(*n)).collect())); + v.set(b"Contents".to_vec(), Object::String(PdfString { bytes: vec![0; reserve], hex: true })); + v.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec())); + let sig = doc.add(Object::Dict(v)); + // The field by name, never by number: a full save (encryption, repairs) is + // free to renumber objects, and a hardcoded reference would stamp the value + // onto whatever moves into that slot. + let root = doc.root().expect("catalog"); + let af = doc.get(root).as_dict().expect("catalog dict").get(b"AcroForm").cloned().expect("AcroForm"); + let fields = doc.resolve(&af).as_dict().expect("AcroForm dict").get(b"Fields").cloned().expect("Fields"); + let field = doc.resolve(&fields).as_array().expect("Fields array").iter().filter_map(|o| o.as_ref()).next().expect("signature field"); + doc.update_dict(field, |d| d.set(b"V".to_vec(), Object::Ref(sig))).unwrap(); + let save = pdfcraft_cos::SaveOptions { object_streams: false, ..pdfcraft_cos::SaveOptions::default() }; + let mut out = pdfcraft_cos::write_incremental(doc, &save).unwrap(); + let mark = format!("0 {} {} {}", LAYERED_BR_MARK[0], LAYERED_BR_MARK[1], LAYERED_BR_MARK[2]); + let br = out.windows(mark.len()).position(|w| w == mark.as_bytes()).expect("ByteRange placeholder"); + let start = out[..br].windows(3).rposition(|w| w == b"obj").expect("signature object"); + let end = br + out[br..].windows(6).position(|w| w == b"endobj").expect("signature end"); + let mut zeros = vec![b'<']; + zeros.extend(std::iter::repeat_n(b'0', reserve * 2)); + zeros.push(b'>'); + let region = &out[start..end]; + let c = region.windows(zeros.len()).position(|w| w == zeros.as_slice()).expect("Contents placeholder"); + let (cstart, cend) = (start + c, start + c + zeros.len()); + let mut br_text = format!("0 {cstart} {cend} {}", out.len() - cend).into_bytes(); + assert!(br_text.len() <= mark.len(), "ByteRange patch fits its placeholder"); + br_text.resize(mark.len(), b' '); + out[br..br + mark.len()].copy_from_slice(&br_text); + let alg = id.key.preferred_digest(); + let digest = alg.digest(&[&out[..cstart], &out[cend..]]); + let cms = sign::cms::sign_detached(&id.key, &id.certificate, &id.chain, alg, &digest).unwrap(); + assert!(cms.len() <= reserve, "signature fits its placeholder"); + let hex: Vec = cms.iter().flat_map(|b| format!("{b:02X}").into_bytes()).collect(); + out[cstart + 1..cstart + 1 + hex.len()].copy_from_slice(&hex); + out +} + +/// Replace one synthetic appearance stream while retaining its resource dictionary. +fn signature_layer_ops(doc: &mut pdfcraft_cos::Document, number: u32, ops: &[u8]) { + let r = pdfcraft_cos::ObjRef::new(number, 0); + let dict = doc.resolve(&pdfcraft_cos::Object::Ref(r)).as_dict().unwrap().clone(); + doc.set(r, pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::from_raw(dict, ops.to_vec()))); +} + +fn printed_pixels(bytes: Vec) -> (u32, u32, Vec) { + let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), Default::default()); + let r = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(r.error.is_none(), "{:?}", r.error); + (r.width, r.height, r.rgba.to_vec()) +} + +/// Validation belongs to viewer chrome, not to document artwork or export sources. +fn assert_signature_artwork_survives_trust(mut cos: pdfcraft_cos::Document, expected_text: &[&str]) { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let identity = sign::pkcs12::open(&p12, "test").unwrap(); + let anchor = identity.chain.first().cloned().unwrap_or_else(|| identity.certificate.clone()); + let signed = Arc::new(sign_prepared(&mut cos, &identity, "D:20261002120000+01'00'")); + let mut session = Session::new().with_clock(|| 1_800_000_000); + let id = session.open("artwork.pdf", None, signed.clone(), None).unwrap(); + let original_display = session.get(id).unwrap().display.clone(); + let mut exporter = export::Exporter::new(session.get(id).unwrap()); + let original_text = exporter.text_of(&[0]).unwrap(); + let original_png = exporter.png(0, 72.0).unwrap(); + for text in expected_text { + assert!(original_text.contains(text), "fixture text {text:?} must be exported: {original_text:?}"); + } + let mut settings = print::Settings { pages: vec![0], paper: (200.0, 200.0), ..Default::default() }; + let original_vector_print = printed_pixels(session.print_pdf(id, &settings).unwrap()); + settings.raster = Some(print::Raster { dpi: 72.0, grayscale: false }); + let original_raster_print = printed_pixels(session.print_pdf(id, &settings).unwrap()); + session.set_trusted_certificates(vec![anchor.clone()]); + let doc = session.get(id).unwrap(); + assert_eq!(doc.signatures[0].status, SignatureStatus::Valid); + assert!(doc.display == original_display, "trust must preserve all original appearance bytes"); + let mut exporter = export::Exporter::new(doc); + assert_eq!(exporter.text_of(&[0]).unwrap(), original_text, "text exports retain signer details and baked caption"); + assert_eq!(exporter.png(0, 72.0).unwrap(), original_png, "image exports exclude the viewer overlay"); + assert_eq!(printed_pixels(session.print_pdf(id, &settings).unwrap()), original_raster_print, "Print as Image retains document artwork"); + settings.raster = None; + assert_eq!(printed_pixels(session.print_pdf(id, &settings).unwrap()), original_vector_print); + assert_eq!(session.save_bytes(id).unwrap(), signed, "unchanged saves retain the signed file exactly"); + session.apply(id, Edit::SetInfo { key: "Title".into(), value: "Preserved artwork".into() }).unwrap(); + let saved = session.save_bytes(id).unwrap(); + assert!(saved.starts_with(&signed), "a real save appends without replacing signed appearance streams"); + let mut reopened = Session::new().with_clock(|| 1_800_000_000); + reopened.set_trusted_certificates(vec![anchor]); + let id = reopened.open("saved.pdf", None, saved, None).unwrap(); + let doc = reopened.get(id).unwrap(); + assert_eq!(doc.signatures[0].status, SignatureStatus::Valid); + let mut exporter = export::Exporter::new(doc); + assert_eq!(exporter.text_of(&[0]).unwrap(), original_text); + assert_eq!(exporter.png(0, 72.0).unwrap(), original_png); +} + +#[test] +fn chamfered_signature_borders_survive_trust_exports_printing_and_save() { + for raster_glyph in [false, true] { + let mut doc = pdfcraft_cos::Document::open(Arc::new(layered_signable_fixture())).unwrap(); + signature_layer_ops(&mut doc, 12, b"0 G 1 w 10 1 m 90 1 l 99 10 l 99 90 l 90 99 l 10 99 l 1 90 l 1 10 l h S"); + signature_layer_ops(&mut doc, 10, b"0 G 2 w 0 0 50 30 re S"); + if raster_glyph { + let mut image = pdfcraft_cos::Dict::new(); + image.set(b"Subtype", pdfcraft_cos::Object::name("Image")); + image.set(b"Width", pdfcraft_cos::Object::Int(8)); + image.set(b"Height", pdfcraft_cos::Object::Int(8)); + image.set(b"ColorSpace", pdfcraft_cos::Object::name("DeviceRGB")); + image.set(b"BitsPerComponent", pdfcraft_cos::Object::Int(8)); + let image = doc.add(pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::from_raw(image, vec![0; 8 * 8 * 3]))); + let mut xobjects = pdfcraft_cos::Dict::new(); + xobjects.set(b"im", pdfcraft_cos::Object::Ref(image)); + xobjects.set(b"unused", pdfcraft_cos::Object::Ref(pdfcraft_cos::ObjRef::new(8, 0))); + let mut resources = pdfcraft_cos::Dict::new(); + resources.set(b"XObject", pdfcraft_cos::Object::Dict(xobjects)); + doc.update_dict(pdfcraft_cos::ObjRef::new(10, 0), |d| d.set(b"Resources", pdfcraft_cos::Object::Dict(resources))).unwrap(); + signature_layer_ops(&mut doc, 10, b"q 20 0 0 20 5 5 cm /im Do Q"); + } + assert_signature_artwork_survives_trust(doc, &["Not Verified"]); + } +} + +#[test] +fn mixed_signature_caption_details_survive_trust_exports_printing_and_save() { + let mut doc = pdfcraft_cos::Document::open(Arc::new(layered_signable_fixture())).unwrap(); + signature_layer_ops(&mut doc, 11, b"BT /F1 5 Tf 2 27 Td (Alice Signer) Tj 0 -3 Td (alice@example.org) Tj 0 -3 Td (Signature Not Verified) Tj ET"); + assert_signature_artwork_survives_trust(doc, &["Alice Signer", "alice@example.org", "Not Verified"]); +} + +/// A real layered signature through `Session`: validity, original artwork, +/// rendering, saving and reopening. `password` opens the signed bytes. +fn check_layered_round_trip(signed: Arc>, password: Option<&str>, anchor: &sign::x509::Certificate) { + let mut s = Session::new().with_clock(|| 1_800_000_000); + let vid = s.open("layered.pdf", None, signed.clone(), password).unwrap(); + let doc = s.get(vid).unwrap(); + let sig = &doc.signatures[0]; + assert!(sig.signed && sig.field == "Signature1" && sig.page == Some(0) && sig.visible); + assert_eq!(sig.status, SignatureStatus::Unknown, "untrusted at first"); + // Trust changes the verdict while preserving every appearance byte. The page + // overlay is UI chrome and is not part of the document's display/export source. + s.set_trusted_certificates(vec![anchor.clone()]); + let doc = s.get(vid).unwrap(); + assert_eq!(doc.signatures[0].status, SignatureStatus::Valid); + assert!(Arc::ptr_eq(&doc.display, &doc.bytes), "trust does not build a signature display copy"); + assert!(bytes_has(&doc.display, b"Not Verified")); + assert!(!bytes_has(&doc.display, b"Signature valid")); + let mut renderer = pdfcraft_render::PageRenderer::new(doc.display.clone(), doc.config.clone()); + let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(rendered.error.is_none(), "{:?}", rendered.error); + // Saving keeps the original appearance and the signature: the validated + // state never reaches the file. + let saved = s.save_bytes(vid).unwrap(); + assert!(bytes_has(&saved, b"Not Verified"), "saved bytes keep the baked appearance"); + assert!(!bytes_has(&saved, b"Signature valid")); + let mut t = Session::new().with_clock(|| 1_800_000_000); + t.set_trusted_certificates(vec![anchor.clone()]); + let tid = t.open("layered-saved.pdf", None, saved.clone(), password).unwrap(); + let rsig = &t.get(tid).unwrap().signatures[0]; + assert_eq!(rsig.status, SignatureStatus::Valid); +} + +/// A trusted layered signature whose original appearance remains document artwork. +fn trusted_layered_session() -> (Session, DocId) { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let id = sign::pkcs12::open(&p12, "test").unwrap(); + let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone()); + let mut doc = pdfcraft_cos::Document::open(Arc::new(layered_signable_fixture())).unwrap(); + let signed = Arc::new(sign_prepared(&mut doc, &id, "D:20261002120000+01'00'")); + let mut s = Session::new().with_clock(|| 1_800_000_000); + let vid = s.open("layered.pdf", None, signed, None).unwrap(); + s.set_trusted_certificates(vec![anchor]); + assert_eq!(s.get(vid).unwrap().signatures[0].status, SignatureStatus::Valid); + (s, vid) +} + +/// Metadata edits retain the original caption; the UI can still show its verdict badge. +#[test] +fn a_metadata_edit_keeps_the_original_signature_appearance() { + let (mut s, vid) = trusted_layered_session(); + s.apply(vid, Edit::SetInfo { key: "Title".into(), value: "Contract".into() }).unwrap(); + let doc = s.get(vid).unwrap(); + assert_eq!(doc.signatures[0].status, SignatureStatus::Valid); + assert!(bytes_has(&doc.display, b"Not Verified")); + assert!(!bytes_has(&doc.display, b"Signature valid")); +} + +/// An unchanged document never reaches the writer, so the edit is what makes this the +/// place the display copy would leak if saving ever read it. +#[test] +fn saving_after_an_edit_keeps_the_baked_appearance() { + let (mut s, vid) = trusted_layered_session(); + s.apply(vid, Edit::SetInfo { key: "Title".into(), value: "Contract".into() }).unwrap(); + let saved = s.save_bytes(vid).unwrap(); + assert!(bytes_has(&saved, b"Not Verified"), "the writer keeps the baked appearance: {saved:?}"); + assert!(!bytes_has(&saved, b"Signature valid"), "the validated state never reaches the file"); +} + +#[test] +fn real_layered_signatures_keep_their_original_appearance() { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let id = sign::pkcs12::open(&p12, "test").unwrap(); + let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone()); + let date = "D:20261002120000+01'00'"; + let mut doc = pdfcraft_cos::Document::open(Arc::new(layered_signable_fixture())).unwrap(); + let signed = Arc::new(sign_prepared(&mut doc, &id, date)); + check_layered_round_trip(signed, None, &anchor); +} + +#[test] +fn real_layered_signatures_round_trip_behind_a_user_password() { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let id = sign::pkcs12::open(&p12, "test").unwrap(); + let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone()); + let date = "D:20261002120000+01'00'"; + let mut doc = pdfcraft_cos::Document::open(Arc::new(layered_signable_fixture())).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes128, + user_password: "user", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let encrypted = Arc::new(pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap()); + let mut doc = pdfcraft_cos::Document::open_with_password(encrypted, Some("user")).unwrap(); + let signed = Arc::new(sign_prepared(&mut doc, &id, date)); + let mut probe = Session::new().with_clock(|| 1_800_000_000); + assert!(probe.open("enc.pdf", None, signed.clone(), Some("wrong")).is_err(), "wrong passwords refuse"); + // Encrypted appearance bytes remain unchanged through trust and save/reopen. + let mut s = Session::new().with_clock(|| 1_800_000_000); + let vid = s.open("enc.pdf", None, signed.clone(), Some("user")).unwrap(); + assert_eq!(s.get(vid).unwrap().signatures[0].status, SignatureStatus::Unknown); + s.set_trusted_certificates(vec![anchor.clone()]); + let doc = s.get(vid).unwrap(); + assert_eq!(doc.signatures[0].status, SignatureStatus::Valid); + assert!(Arc::ptr_eq(&doc.display, &doc.bytes), "encrypted signature artwork remains original"); + let saved = s.save_bytes(vid).unwrap(); + let mut t = Session::new().with_clock(|| 1_800_000_000); + t.set_trusted_certificates(vec![anchor.clone()]); + assert!(t.open("enc-saved.pdf", None, saved.clone(), Some("wrong")).is_err()); + let tid = t.open("enc-saved.pdf", None, saved.clone(), Some("user")).unwrap(); + assert_eq!(t.get(tid).unwrap().signatures[0].status, SignatureStatus::Valid); +} + #[test] #[ignore = "timing probe"] fn probe_edit_latency_on_a_large_signed_document() { @@ -2892,6 +3454,35 @@ fn recognize_text_reads_the_euro_sign() { assert!(text.contains("€250"), "{text}"); } +const SYNTHETIC_SCAN_DPI: f64 = 300.0; + +/// A scan of the OCR sentence at the source page's physical size, without a text layer. +fn synthetic_ocr_scan(s: &mut Session) -> (DocId, [f32; 2]) { + 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 original = &s.get(id).unwrap().info.pages[0]; + let size = [original.width, original.height]; + // 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, SYNTHETIC_SCAN_DPI).unwrap(); + // Exported PNGs have no resolution metadata. The 72 DPI import default would enlarge + // the page and change the PDF-point coordinates the placement assertions exercise. + let scan = s.create_from_images_with_resolution(&[("scan.png".into(), png)], ImageResolution::Dpi(SYNTHETIC_SCAN_DPI)).unwrap(); + (s.open("scan.pdf", None, scan, None).unwrap(), size) +} + +#[test] +fn synthetic_ocr_scan_keeps_the_original_page_size_without_models() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let (id, expected) = synthetic_ocr_scan(&mut s); + let page = &s.get(id).unwrap().info.pages[0]; + // Rounding to whole raster pixels may change a page edge by at most one pixel. + let tolerance = 72.0 / SYNTHETIC_SCAN_DPI; + for (actual, expected) in [page.width, page.height].into_iter().zip(expected) { + assert!(f64::from((actual - expected).abs()) <= tolerance, "page edge {actual} pt, expected {expected} pt"); + } + assert_eq!(page_texts(&s, id), [""], "the scan has no text until OCR runs"); +} + #[test] fn recognize_text_makes_a_scanned_page_searchable() { if !ocr::available() { @@ -2899,12 +3490,7 @@ fn recognize_text_makes_a_scanned_page_searchable() { return; } 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(); - // 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(); + let (id, _) = synthetic_ocr_scan(&mut s); assert_eq!(page_texts(&s, id), [""], "a picture has no text"); let found = s.recognize_text(id, &[], ocr::OcrSettings::default()).unwrap(); @@ -3300,6 +3886,34 @@ fn detecting_fields_on_a_scanned_form() { } } +#[test] +fn detecting_comb_boxes_adds_one_comb_field_per_row() { + // The rows from #1887: five 18 x 22 cells and ten 16 x 20 cells, each a stroked `re`. + let mut body = String::from("BT /F1 10 Tf 40 205 Td (Postcode A) Tj ET BT /F1 10 Tf 40 105 Td (Postcode B) Tj ET\n"); + for i in 0..5 { + body.push_str(&format!("{} 200 18 22 re S\n", 120 + i * 18)); + } + for i in 0..10 { + body.push_str(&format!("{} 100 16 20 re S\n", 120 + i * 16)); + } + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("comb.pdf", None, Arc::new(one_line_page("/Font << /F1 4 0 R >>", &body)), None).unwrap(); + let found: Vec<(String, pdfcraft_forms::detect::Kind)> = s.detect_fields(id, &[]).into_iter().map(|(_, c)| (c.name, c.kind)).collect(); + assert_eq!( + found, + [ + ("Postcode A".to_string(), pdfcraft_forms::detect::Kind::Comb { cells: 5 }), + ("Postcode B".to_string(), pdfcraft_forms::detect::Kind::Comb { cells: 10 }) + ] + ); + s.auto_detect_fields(id, &[]).unwrap(); + let d = s.get(id).unwrap(); + let combs: Vec<(&str, FormFieldKind, bool, Option)> = + d.form.iter().map(|f| (f.name.as_str(), f.kind, f.has(pdfcraft_forms::flags::COMB), f.max_len)).collect(); + assert_eq!(combs, [("Postcode A", FormFieldKind::Text, true, Some(5)), ("Postcode B", FormFieldKind::Text, true, Some(10))]); + assert_eq!(d.can_undo(), Some("Detect form fields")); +} + #[test] fn comparing_two_versions_of_a_document() { let mut s = Session::new().with_clock(|| 1_700_000_000); @@ -5463,3 +6077,88 @@ fn cropping_an_existing_image_clips_it_and_moves_with_it() { // No area, or a box off the image, is refused. assert!(s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop([10.0, 10.0, 10.0, 50.0]) }).is_err()); } + +/// A 60 x 20 JPEG at 144 x 72 dpi, tagged with EXIF Orientation 6 (shown turned 90 degrees clockwise). +fn phone_jpeg() -> Vec { + use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit}; + let mut jpeg = Vec::new(); + let mut encoder = JpegEncoder::new(&mut jpeg); + encoder.set_pixel_density(PixelDensity { density: (144, 72), unit: PixelDensityUnit::Inches }); + encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap(); + let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec(); + exif.extend_from_slice(&[6, 0, 0, 0, 0, 0, 0, 0]); + let mut out = jpeg[..2].to_vec(); + out.extend_from_slice(&[0xff, 0xe1]); + out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes()); + out.extend_from_slice(&exif); + out.extend_from_slice(&jpeg[2..]); + out +} + +#[test] +fn a_phone_photo_used_as_a_custom_stamp_is_embedded_upright() { + let mut session = Session::new(); + let blank = session.create_blank(300.0, 400.0, 1).unwrap(); + let id = session.open_new("form.pdf", blank).unwrap(); + // A click places the stamp at its natural size: 30 x 20 pt as stored, 20 x 30 pt upright. + // Named "Signature" so `signature_image` finds its picture. + let file = MarkFile { name: "photo.jpg".into(), bytes: Arc::new(phone_jpeg()), page: 0 }; + let stamp = Edit::AddCustomStamp { page: 0, rect: [100.0, 100.0, 100.0, 100.0], name: "Signature".into(), file, author: "Ada".into() }; + session.apply(id, stamp).unwrap(); + let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos; + let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap(); + let obj = cos.get(picture); + let pdfcraft_cos::Object::Stream(s) = &*obj else { panic!() }; + assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(20), Some(60))); + let rect = pdfcraft_annot::summaries(cos)[0].rect; + assert!(((rect[2] - rect[0]) - 20.0).abs() < 0.01 && ((rect[3] - rect[1]) - 30.0).abs() < 0.01, "{rect:?}"); +} + +/// Opening a paragraph and applying its edit read the page's text blocks; after an edit they are +/// still read once per document state, and undo and redo never get another state's blocks. +#[test] +fn text_blocks_stay_cached_after_an_edit_and_follow_undo_and_redo() { + let (mut session, id) = session_with(1); + let blocks = |s: &Session| s.get(id).unwrap().editor.as_ref().unwrap().text_blocks(0).unwrap(); + let before = blocks(&session); + assert_eq!(before[0].editor_text, "Page 1"); + let edit = Edit::EditTextBlock { page: 0, block: 0, text: "Edited".into(), style: pdfcraft_edit::BlockStyle::default() }; + session.apply(id, edit).unwrap(); + let edited = blocks(&session); + assert_eq!(edited[0].editor_text, "Edited"); + assert!(Arc::ptr_eq(&edited, &blocks(&session)), "the edited page is grouped once"); + session.undo(id).unwrap(); + assert_eq!(blocks(&session)[0].editor_text, "Page 1"); + session.redo(id).unwrap(); + let redone = blocks(&session); + assert_eq!(redone[0].editor_text, "Edited"); + assert!(Arc::ptr_eq(&redone, &blocks(&session))); +} + +/// A new document, or one restored from recovery, has never been written where it belongs: undo +/// back to the bytes it was opened from must not make it clean, or closing it would discard the +/// whole document without asking. Saving makes it clean; Revert of a saved one too. +#[test] +fn undo_never_makes_an_unsaved_document_clean() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let new = s.open_new("Combined.pdf", Arc::new(fixture(2))).unwrap(); + s.apply(new, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + s.undo(new).unwrap(); + assert!(s.get(new).unwrap().dirty, "a new document is unsaved until it is saved"); + assert_eq!(s.autosave_snapshots().len(), 1, "and still needs recovery"); + s.revert(new).unwrap(); + assert!(s.get(new).unwrap().dirty, "Revert goes back to the created bytes, which aren't saved anywhere"); + + let recovered = s.open("Report.pdf", None, Arc::new(fixture(2)), None).unwrap(); + s.mark_recovered(recovered, Some("Report.pdf".into())); + s.apply(recovered, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + s.undo(recovered).unwrap(); + assert!(s.get(recovered).unwrap().dirty, "the recovered work was never saved"); + + let saved = s.save_bytes(new).unwrap(); + s.mark_saved(new, saved, Some("Combined.pdf".into())).unwrap(); + assert!(!s.get(new).unwrap().dirty); + s.apply(new, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + s.undo(new).unwrap(); + assert!(!s.get(new).unwrap().dirty, "once saved, undo back to the saved state is clean"); +} diff --git a/crates/engine/tests/marked_content_isolation.rs b/crates/engine/tests/marked_content_isolation.rs new file mode 100644 index 0000000000..32a42d517a --- /dev/null +++ b/crates/engine/tests/marked_content_isolation.rs @@ -0,0 +1,274 @@ +//! Contributor-original pages. Added marks must not inherit an unfinished hidden layer. +use std::sync::Arc; + +use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_incremental}; +use pdfcraft_engine::{AddedText, Edit, HeaderFooter, Session, Watermark}; +use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind}; + +const VISIBLE: &str = "BT /F1 18 Tf 72 700 Td (Visible text) Tj ET\n"; +const HIDDEN: &str = "BT /F1 18 Tf 72 600 Td (Hidden note) Tj ET\n"; + +fn fixture(parts: &[String]) -> Arc> { + let mut doc = Document::new_empty(); + let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); + let mut fonts = Dict::new(); + fonts.set(b"F1".to_vec(), Object::Ref(doc.add(font))); + let mut group = Dict::new(); + group.set(b"Type".to_vec(), Object::name("OCG")); + group.set(b"Name".to_vec(), Object::String(pdfcraft_cos::PdfString::text("Hidden layer"))); + let group = doc.add(group); + let mut properties = Dict::new(); + properties.set(b"L1".to_vec(), Object::Ref(group)); + let mut resources = Dict::new(); + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + resources.set(b"Properties".to_vec(), Object::Dict(properties)); + let mut config = Dict::new(); + config.set(b"OFF".to_vec(), Object::Array(vec![Object::Ref(group)])); + let mut oc = Dict::new(); + oc.set(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(group)])); + oc.set(b"D".to_vec(), Object::Dict(config)); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"OCProperties".to_vec(), Object::Dict(oc))).unwrap(); + let streams = parts.iter().map(|s| Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), s.as_bytes().to_vec()))))).collect(); + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Parent".to_vec(), Object::Ref(pages)); + page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()])); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + page.set(b"Contents".to_vec(), Object::Array(streams)); + let page = doc.add(page); + doc.update_dict(pages, |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + d.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap()) +} + +fn render(bytes: Arc>) -> (Vec, String) { + let mut renderer = PageRenderer::new(bytes, Default::default()); + let pixels = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(pixels.error.is_none(), "{:?}", pixels.error); + let text = renderer.render(RenderRequest { page: 0, kind: RequestKind::Text, ..Default::default() }); + assert!(text.error.is_none(), "{:?}", text.error); + let text = text.text.unwrap(); + (pixels.rgba.to_vec(), text.text_of(0..text.glyphs.len())) +} + +fn edit(kind: &str) -> Edit { + match kind { + "HEADER" | "000001" => { + let mut settings = HeaderFooter::default(); + if kind == "HEADER" { + settings.text[1] = "HEADER".into(); + } else { + settings.text[5] = "<>".into(); + } + Edit::AddHeaderFooter { pages: vec![0], settings, replace: true } + } + "WATERMARK" => Edit::AddWatermark { + pages: vec![0], + settings: Watermark { text: kind.into(), opacity: 1.0, rotation: 0.0, font_size: 18.0, ..Default::default() }, + replace: true, + file: None, + }, + "ADDED" => Edit::AddText { page: 0, text: AddedText { text: kind.into(), rect: [72.0, 300.0, 250.0, 330.0], ..Default::default() } }, + _ => panic!("unknown mark"), + } +} + +fn add_and_save(input: Arc>, kind: &str) -> Arc> { + let mut session = Session::new(); + let id = session.open("fixture.pdf", None, input.clone(), None).unwrap(); + session.apply(id, edit(kind)).unwrap(); + let saved = session.save_bytes(id).unwrap(); + assert!(saved.starts_with(&input), "incremental save preserves the original file bytes"); + assert_original_streams(&input, &saved); + let mut reopened = Session::new(); + let id = reopened.open("saved.pdf", None, saved.clone(), None).unwrap(); + assert_eq!(reopened.save_bytes(id).unwrap().as_slice(), saved.as_slice()); + saved +} + +#[test] +fn all_four_mark_paths_remain_visible_after_saved_unfinished_marked_content() { + let mut failures = Vec::new(); + for (case, opening, hidden) in [("hidden", "/OC /L1 BDC\n", true), ("artifact", "/Artifact BMC\n", false), ("balanced", "/OC /L1 BDC\n", true)] { + let closed = fixture(&[format!("{VISIBLE}{opening}{HIDDEN}EMC\n")]); + let input = if case == "balanced" { closed.clone() } else { fixture(&[format!("{VISIBLE}{opening}{HIDDEN}")]) }; + let original = render(input.clone()); + assert!(original.1.contains("Visible text")); + assert_eq!(original.1.contains("Hidden note"), !hidden); + for kind in ["HEADER", "000001", "WATERMARK", "ADDED"] { + let saved = add_and_save(input.clone(), kind); + + let actual = render(saved); + let expected_bytes = add_and_save(closed.clone(), kind); + + let expected = render(expected_bytes); + let mut wanted = vec!["Visible text", kind]; + if !hidden { + wanted.push("Hidden note"); + } + wanted.sort_unstable(); + let pass = actual.0 == expected.0 && actual.1 == expected.1 && lines(&actual.1) == wanted; + eprintln!("{case}/{kind}: visible={} closed-control-pixels={}", actual.1.contains(kind), actual.0 == expected.0); + if !pass { + failures.push(format!("{case}/{kind}: {:?}", actual.1)); + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +fn stream_list(doc: &Document) -> Vec { + let page = pdfcraft_model::pages(doc).remove(0); + let contents = doc.resolve(page.dict.get(b"Contents").unwrap()); + match &*contents { + Object::Array(items) => items.clone(), + _ => vec![page.dict.get(b"Contents").unwrap().clone()], + } +} + +fn assert_original_streams(before: &Arc>, after: &Arc>) { + let original = Document::open(before.clone()).unwrap(); + let saved = Document::open(after.clone()).unwrap(); + let saved_list = stream_list(&saved); + for reference in stream_list(&original) { + assert!(saved_list.contains(&reference), "the original stream stays on the page"); + assert_eq!(original.resolve(&reference), saved.resolve(&reference), "original encoded stream object remains unchanged"); + } +} + +fn lines(text: &str) -> Vec<&str> { + let mut lines: Vec<_> = text.lines().filter(|line| !line.is_empty()).collect(); + lines.sort_unstable(); + lines +} + +#[test] +fn nested_stream_edges_and_malformed_graphics_keep_hidden_content_hidden() { + let cases: Vec<(&str, Vec, usize)> = vec![ + ("nested", vec![format!("{VISIBLE}/OC /L1 BDC /Artifact BMC {HIDDEN}")], 2), + ("split operands", vec![format!("{VISIBLE}/OC"), format!("/L1 BDC {HIDDEN}")], 1), + ("split close", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}"), "EMC\n".into()], 0), + ("trailing comment", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}% EMC")], 1), + ("cross-stream comment", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}% EMC"), "EMC BMC\n".into()], 1), + ("unclosed graphics", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}q")], 1), + ("balanced graphics open marked", vec![format!("q {VISIBLE}/OC /L1 BDC {HIDDEN}Q")], 1), + ("malformed graphics", vec![format!("Q {VISIBLE}/OC /L1 BDC {HIDDEN}")], 1), + ("invalid begins", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}123 BMC /Artifact BDC")], 1), + ("extra begins", vec![format!("{VISIBLE}123 /OC /L1 BDC {HIDDEN}123 /Artifact BMC")], 2), + ("extra close", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}123 EMC")], 0), + ("underflow", vec![format!("EMC {VISIBLE}/OC /L1 BDC {HIDDEN}")], 1), + ("operator strings", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}/Note <> DP")], 1), + ("inline samples", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}BI /W 11 /H 1 /BPC 8 /CS /G ID BMC BDC EMC EI\n")], 1), + ]; + for (case, parts, close_count) in cases { + let input = fixture(&parts); + let mut closed = parts.clone(); + closed.push(format!("\n{}", "EMC\n".repeat(close_count))); + let actual = render(add_and_save(input, "HEADER")); + let expected = render(add_and_save(fixture(&closed), "HEADER")); + assert_eq!(lines(&actual.1), lines("Visible text\nHEADER"), "{case}"); + assert!(actual.0 == expected.0, "{case}: pixels equal the explicitly closed control"); + } +} + +#[test] +fn replacing_and_removing_marks_restores_the_original_stream_list() { + let input = fixture(&[format!("{VISIBLE}/OC /L1 BDC {HIDDEN}")]); + let original = Document::open(input.clone()).unwrap(); + for kind in ["HEADER", "000001", "WATERMARK"] { + let mut session = Session::new(); + let mut id = session.open("fixture.pdf", None, input.clone(), None).unwrap(); + for _ in 0..3 { + session.apply(id, edit(kind)).unwrap(); + let saved = session.save_bytes(id).unwrap(); + let page = Document::open(saved.clone()).unwrap(); + assert_eq!(stream_list(&page).len(), 4, "{kind}: updating does not add wrappers"); + assert_original_streams(&input, &saved); + assert_eq!(lines(&render(saved.clone()).1), lines(&format!("Visible text\n{kind}"))); + session = Session::new(); + id = session.open("saved.pdf", None, saved, None).unwrap(); + } + let mark_kind = if kind == "WATERMARK" { pdfcraft_edit::MarkKind::Watermark } else { pdfcraft_edit::MarkKind::HeaderFooter }; + session.apply(id, Edit::RemoveMarks { kind: mark_kind }).unwrap(); + let saved = session.save_bytes(id).unwrap(); + let page = Document::open(saved.clone()).unwrap(); + assert_eq!(stream_list(&page), stream_list(&original), "{kind}: removal restores original references"); + assert_original_streams(&input, &saved); + assert!(render(saved) == render(input.clone()), "{kind}: removal restores original pixels and text"); + session.undo(id).unwrap(); + assert_eq!(lines(&render(session.save_bytes(id).unwrap()).1), lines(&format!("Visible text\n{kind}"))); + session.redo(id).unwrap(); + assert!(render(session.save_bytes(id).unwrap()) == render(input.clone()), "redo removal restores the original rendering"); + } +} + +#[test] +fn added_update_delete_and_readd_preserve_existing_wrapper_behavior() { + let input = fixture(&[format!("{VISIBLE}/OC /L1 BDC {HIDDEN}")]); + let mut session = Session::new(); + let mut id = session.open("fixture.pdf", None, input.clone(), None).unwrap(); + for _ in 0..3 { + session.apply(id, edit("ADDED")).unwrap(); + assert_eq!(session.get(id).unwrap().added().len(), 1); + let text = AddedText { text: "UPDATED".into(), rect: [72.0, 300.0, 250.0, 330.0], ..Default::default() }; + session.apply(id, Edit::UpdateContent { page: 0, index: 0, content: pdfcraft_edit::Content::Text(text) }).unwrap(); + let saved = session.save_bytes(id).unwrap(); + assert_eq!(lines(&render(saved.clone()).1), lines("Visible text\nUPDATED")); + assert_eq!(stream_list(&Document::open(saved.clone()).unwrap()).len(), 4); + assert_original_streams(&input, &saved); + session = Session::new(); + id = session.open("saved.pdf", None, saved, None).unwrap(); + session.apply(id, Edit::DeleteContent { page: 0, index: 0 }).unwrap(); + let saved = session.save_bytes(id).unwrap(); + assert_eq!(stream_list(&Document::open(saved.clone()).unwrap()).len(), 3, "Added deletion retains the established wrappers"); + assert!(render(saved.clone()) == render(input.clone()), "deleting Added restores the original rendering"); + assert_original_streams(&input, &saved); + session = Session::new(); + id = session.open("saved.pdf", None, saved, None).unwrap(); + } +} + +#[test] +fn an_existing_behind_watermark_does_not_inherit_or_reveal_hidden_content() { + let input = fixture(&[format!("{VISIBLE}/OC /L1 BDC {HIDDEN}")]); + let mut session = Session::new(); + let id = session.open("fixture.pdf", None, input.clone(), None).unwrap(); + session + .apply( + id, + Edit::AddWatermark { + pages: vec![0], + settings: Watermark { text: "BEHIND".into(), behind: true, font_size: 18.0, opacity: 1.0, rotation: 0.0, ..Default::default() }, + replace: false, + file: None, + }, + ) + .unwrap(); + session.apply(id, edit("HEADER")).unwrap(); + let saved = session.save_bytes(id).unwrap(); + assert_eq!(lines(&render(saved.clone()).1), lines("Visible text\nBEHIND\nHEADER")); + assert_original_streams(&input, &saved); +} + +#[test] +fn new_mark_closes_hidden_content_appended_after_an_old_wrapper() { + let bytes = fixture(&["q %PdfCraft\n".into(), VISIBLE.into(), "Q %PdfCraft\n".into(), format!("/OC /L1 BDC {HIDDEN}")]); + let mut doc = Document::open(bytes).unwrap(); + let list = stream_list(&doc); + for index in [0, 2] { + let reference = list[index].as_ref().unwrap(); + let Object::Stream(mut stream) = doc.get(reference).as_ref().clone() else { panic!("stream") }; + stream.dict.set(b"PCMark".to_vec(), Object::name("Wrap")); + doc.set(reference, Object::Stream(stream)); + } + let input = Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap()); + let saved = add_and_save(input, "HEADER"); + assert_eq!(lines(&render(saved).1), lines("Visible text\nHEADER")); +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 16dd2b06d2..30299b2d76 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -114,6 +114,34 @@ const COL_TOL: f64 = 4.0; /// its columns, so a hostile page laid out as a staircase of blocks would otherwise make /// (blocks / 2)² cells. const MAX_COLS: usize = 63; +/// Two blocks in a row this close (as a fraction of their font size) are one line of running +/// text that changed font or colour, such as a link inside a sentence, not two table cells. +const INLINE_GAP: f64 = 0.5; +/// How many blocks ending near a block's start are looked at to find the one it continues; a +/// page laid out so that many end at the same place stays quick. +const CONTINUATION_PROBE: usize = 64; +/// Per block, whether it continues another on the same line: it starts where that one ends (up to +/// [`INLINE_GAP`] later, or a point earlier for rounding), so it is the same line of text running +/// on (a link inside a sentence, a superscript), not a cell of its own. +fn continuations(blocks: &[Block]) -> Vec { + let mut by_end: Vec<(f64, usize)> = blocks.iter().enumerate().map(|(i, b)| (b.rect[2], i)).collect(); + by_end.sort_by(|a, b| a.0.total_cmp(&b.0)); + blocks + .iter() + .enumerate() + .map(|(bi, b)| { + let k = by_end.partition_point(|(end, _)| *end < b.rect[0] - INLINE_GAP * b.size); + by_end + .get(k..) + .unwrap_or_default() + .iter() + .take_while(|(end, _)| *end <= b.rect[0] + 1.0) + .take(CONTINUATION_PROBE) + .filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi)) + .any(|a| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1])) + }) + .collect() +} /// A cell is short: taller blocks are body text (or a multi-column layout), not table cells. fn is_cell_like(b: &Block) -> bool { (b.rect[3] - b.rect[1]) <= b.size * 5.0 && !b.text.trim().is_empty() @@ -264,13 +292,20 @@ pub(crate) fn tables(blocks: &[Block]) -> (Vec, Vec) { es.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL); es }; + let cont = continuations(blocks); let qual: Vec = rows .iter() .map(|row| { - let mut cs: Vec = row.iter().map(|(c, _)| *c).collect(); + // A block that continues another adds no column of its own, and the cells of a table row + // sit side by side: blocks that overlap are a paragraph's lines, not cells (#2806). + let cells: Vec<(usize, usize)> = row.iter().copied().filter(|(_, bi)| !cont.get(*bi).copied().unwrap_or(false)).collect(); + let mut spans: Vec<[f64; 2]> = cells.iter().filter_map(|(_, bi)| blocks.get(*bi)).map(|b| [b.rect[0], b.rect[2]]).collect(); + spans.sort_by(|a, b| a[0].total_cmp(&b[0])); + let apart = spans.windows(2).all(|w| w[1][0] >= w[0][1] - COL_TOL); + let mut cs: Vec = cells.iter().map(|(c, _)| *c).collect(); cs.sort_unstable(); cs.dedup(); - cs.len() >= 2 + apart && cs.len() >= 2 }) .collect(); let mut i = 0; @@ -1186,6 +1221,75 @@ mod tests { assert_eq!(grid_cols(&grid(64)), 0); } + fn block(t: &str, x0: f64, y0: f64, x1: f64, y1: f64) -> Block { + Block { text: t.into(), rect: [x0, y0, x1, y1], size: 12.0, bold: false, italic: false, color: [0.0; 3], rotation: 0.0 } + } + + /// #2806: a sentence whose link is set in another colour or font runs on as separate blocks + /// on one line. They are not table columns, even when several such lines share left edges. + #[test] + fn a_link_inside_a_sentence_is_not_a_table_column() { + let blue = |t: &str, x0: f64, y0: f64, x1: f64, y1: f64| Block { color: [0.02, 0.388, 0.757], ..block(t, x0, y0, x1, y1) }; + let p = Page { + width: 612.0, + height: 792.0, + blocks: vec![ + block("6.3. 2. All content shown at", 121.7, 710.3, 272.0, 724.9), + blue("http", 273.5, 710.3, 294.1, 724.9), + blue("s", 294.1, 709.8, 298.8, 725.8), + blue("://women", 298.8, 710.3, 348.0, 724.9), + blue("g", 348.1, 709.8, 353.7, 725.8), + blue("randprix.fide.com", 353.7, 710.3, 443.2, 724.9), + block("shall be reviewed and approved by the Press Officer.", 164.3, 622.5, 533.1, 724.9), + block("6.3. 3. The Organiser shall warrant that", 121.7, 601.8, 345.1, 616.5), + blue("https://womengrandprix.fide.com", 350.2, 601.3, 519.9, 617.4), + block("is managed in a professional way and furthermore that it is capable of", 164.3, 557.9, 533.0, 616.5), + block("6. 3. 4. The Press Officer shall report to the FIDE Head of PR.", 121.7, 516.6, 530.3, 551.9), + block("conducted during a WGP tournament.", 164.3, 502.0, 352.5, 516.6), + ], + images: Vec::new(), + }; + let h = html(std::slice::from_ref(&p), "Regulations"); + assert!(!h.contains("
"), "running text became a table: {h}"); + for b in &p.blocks { + assert!(h.contains(&b.text), "{} was lost: {h}", b.text); + } + } + + /// #2806: lines that each run on into a link starting at the same place are sentences, not a + /// two-column table, even three in a row. + #[test] + fn sentences_that_run_on_into_links_are_not_a_table() { + let blue = |t: &str, x0: f64, y0: f64, x1: f64, y1: f64| Block { color: [0.02, 0.388, 0.757], ..block(t, x0, y0, x1, y1) }; + let mut blocks = Vec::new(); + for (n, lead) in ["Rules are published at", "Pairings are posted at", "Results are listed at"].iter().enumerate() { + let y = 700.0 - 16.0 * n as f64; + blocks.push(block(lead, 72.0, y, 211.0, y + 12.0)); + blocks.push(blue("https://example.org/event", 212.5, y, 360.0, y + 12.0)); + } + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new() }; + let h = html(std::slice::from_ref(&p), "Event"); + assert!(!h.contains("
"), "sentences became a table: {h}"); + assert_eq!(h.matches("https://example.org/event").count(), 3, "{h}"); + } + + /// #2806: a superscript right after a number ("1" "st") continues its cell; the prize table + /// around it stays a table. + #[test] + fn a_superscript_beside_a_number_keeps_its_table() { + let mut blocks = vec![block("PLACE", 100.0, 700.0, 140.0, 712.0), block("PRIZE (euros)", 300.0, 700.0, 380.0, 712.0)]; + for (n, (row, sup)) in [("1", "st"), ("2", "nd"), ("3", "rd"), ("4", "th")].iter().enumerate() { + let y = 680.0 - 20.0 * n as f64; + blocks.push(block(row, 100.0, y, 106.0, y + 12.0)); + blocks.push(Block { size: 8.0, ..block(sup, 106.0, y + 5.0, 113.0, y + 13.0) }); + blocks.push(block(&format!("{},000", 30 - 5 * n), 300.0, y, 340.0, y + 12.0)); + } + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new() }; + let h = html(std::slice::from_ref(&p), "Prizes"); + assert!(h.contains("
"), "the prize table was lost: {h}"); + assert!(h.contains("30,000") && h.contains("15,000"), "{h}"); + } + #[test] fn two_column_body_text_is_not_mistaken_for_a_table() { let para = |t: &str, x: f64, y: f64| Block { diff --git a/crates/filters/README.md b/crates/filters/README.md index de7f8d2230..df64e4b7c0 100644 --- a/crates/filters/README.md +++ b/crates/filters/README.md @@ -24,7 +24,7 @@ encode_flate(data) -> Vec // zlib, level 6 - `chain` is applied first to last, as in a `/Filter` array. - Image codecs (DCT, JPX, JBIG2, CCITT), `Crypt` and unknown filters anywhere in a chain give `Unsupported`: callers keep such data encoded. Decryption belongs to `crypt`. -- `max_output` bounds every intermediate buffer (including the pre-predictor buffer) and gives `LimitExceeded(max_output)`. +- `max_output` bounds every intermediate buffer (including the pre-predictor buffer) and gives `LimitExceeded(max_output)`. This also holds when Flate retries raw deflate after a misleading zlib header, in both strict and tolerant decoding. - `decode_tolerant` turns `Corrupt` into "what was decoded so far" plus `partial = true`, then runs the rest of the chain on it (predictors are applied to partial output too). `LimitExceeded` and `Unsupported` are still errors. ## Behaviour diff --git a/crates/filters/src/flate.rs b/crates/filters/src/flate.rs index 5aa6443f69..dac0851e07 100644 --- a/crates/filters/src/flate.rs +++ b/crates/filters/src/flate.rs @@ -26,7 +26,12 @@ pub(crate) fn decode(data: &[u8], max: usize) -> Step { Some((_, flg)) if flg & 0x20 != 0 => Err(Failure::corrupt(NAME, "zlib preset dictionary is not allowed in PDF", Vec::new())), Some(_) => match inflate_raw(&data[2..], max) { // A header that only looked valid: retry the whole input as raw deflate. - Err(f) if f.partial.is_empty() && matches!(f.error, crate::FilterError::Corrupt { .. }) => inflate_raw(data, max).map_err(|_| f), + Err(f) if f.partial.is_empty() && matches!(f.error, crate::FilterError::Corrupt { .. }) => match inflate_raw(data, max) { + // Keep the original corruption diagnostic only if the retry is corrupt + // too. A limit error must remain fatal even for tolerant decoding. + Err(retry) if matches!(retry.error, crate::FilterError::Corrupt { .. }) => Err(f), + retry => retry, + }, r => r, }, None => inflate_raw(data, max), diff --git a/crates/filters/src/lib.rs b/crates/filters/src/lib.rs index ffaf08ca11..9a0124d859 100644 --- a/crates/filters/src/lib.rs +++ b/crates/filters/src/lib.rs @@ -295,6 +295,26 @@ mod tests { assert_eq!(decode(&chain, &hex, 100).unwrap().len(), 100); } + #[test] + fn raw_flate_fallback_preserves_output_limits() { + // RFC 1951 stored blocks: the first is nonfinal with LEN=257 and + // NLEN=!257, followed by a final empty block. Its ignored alignment + // bits make the first two bytes also look like a valid zlib header. + let data = vec![b'A'; 257]; + let mut raw = vec![0x78, 0x01, 0x01, 0xfe, 0xfe]; + raw.extend_from_slice(&data); + raw.extend_from_slice(&[0x01, 0x00, 0x00, 0xff, 0xff]); + let chain = [(Filter::Flate, Params::default())]; + for max in [0, 1, 128, 256] { + assert!(matches!(decode(&chain, &raw, max), Err(FilterError::LimitExceeded(m)) if m == max)); + assert!(matches!(decode_tolerant(&chain, &raw, max), Err(FilterError::LimitExceeded(m)) if m == max)); + } + for max in [257, 258] { + assert_eq!(decode(&chain, &raw, max).unwrap(), data); + assert_eq!(decode_tolerant(&chain, &raw, max).unwrap(), (data.clone(), false)); + } + } + #[test] fn tolerant_flate_returns_partial() { let data: Vec = (0..20_000u32).map(|i| (i % 97) as u8 ^ (i / 300) as u8).collect(); diff --git a/crates/fonts/src/embedding.rs b/crates/fonts/src/embedding.rs index 84801d5fd2..a5bb011e00 100644 --- a/crates/fonts/src/embedding.rs +++ b/crates/fonts/src/embedding.rs @@ -195,7 +195,7 @@ pub fn embed_mapping( // page; source subset programs stay untouched. let existing = written(doc, fonts).find(|w| w.has_program(doc, bytes)).map(|w| w.program).or_else(|| { doc.scan_objects().take(8192).find_map(|(r, o)| match o.as_ref() { - Object::Stream(s) if s.dict.get(b"PCFullFont").is_some() && s.raw.as_ref() == bytes => Some(Object::Ref(r)), + Object::Stream(s) if is_program(doc, s, bytes) => Some(Object::Ref(r)), _ => None, }) }); @@ -206,7 +206,7 @@ pub fn embed_mapping( d.set(b"Subtype".to_vec(), Object::name("OpenType")); } d.set(b"PCFullFont".to_vec(), Object::Bool(true)); - Object::Ref(doc.add(Object::Stream(Stream::from_raw(d, bytes.to_vec())))) + Object::Ref(doc.add(Object::Stream(Stream::flate(d, bytes)))) }); descriptor.set(if cff { b"FontFile3".to_vec() } else { b"FontFile2".to_vec() }, program); let desc = Object::Ref(doc.add(Object::Dict(descriptor))); @@ -264,15 +264,20 @@ struct Written { } impl Written { - /// Whether its program is `bytes`. The length it was written with is compared before decoding. + /// Whether its program is `bytes`. fn has_program(&self, doc: &Document, bytes: &[u8]) -> bool { - let Object::Stream(s) = doc.resolve(&self.program).as_ref().clone() else { return false }; - s.dict.get(b"PCFullFont").is_some() - && s.dict.get(b"Length1").and_then(|l| doc.resolve(l).as_int()) == i64::try_from(bytes.len()).ok() - && s.decoded_within(17 << 20).is_ok_and(|data| data == bytes) + matches!(doc.resolve(&self.program).as_ref(), Object::Stream(s) if is_program(doc, s, bytes)) } } +/// Whether `s` is a full program written for `bytes`, compressed or not. The length it was +/// written with is compared before decoding. +fn is_program(doc: &Document, s: &Stream, bytes: &[u8]) -> bool { + s.dict.get(b"PCFullFont").is_some() + && s.dict.get(b"Length1").and_then(|l| doc.resolve(l).as_int()) == i64::try_from(bytes.len()).ok() + && s.decoded_within(17 << 20).is_ok_and(|data| data == bytes) +} + /// The `PCFull` resources in `fonts`, in order. fn written<'a>(doc: &'a Document, fonts: &'a Dict) -> impl Iterator + 'a { fonts.iter().filter(|(key, _)| key.starts_with(b"PCFull")).take(256).filter_map(move |(key, value)| { @@ -379,4 +384,31 @@ mod tests { assert!(!fonts.contains(medium.as_bytes()), "{medium} is new"); assert_eq!(programs(&doc), 2); } + + #[test] + fn the_full_program_is_written_compressed_and_another_page_reuses_it() { + let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + let mut doc = Document::new_empty(); + let mut first = Dict::new(); + embed(&mut doc, &mut first, bytes, "Inter").unwrap(); + let program = || { + doc.scan_objects() + .find_map(|(_, o)| match o.as_ref() { + Object::Stream(s) if s.dict.get(b"PCFullFont").is_some() => Some(s.clone()), + _ => None, + }) + .unwrap() + }; + let stream = program(); + assert_eq!(stream.dict.name(b"Filter"), Some(b"FlateDecode".as_slice())); + assert!(stream.raw.len() < bytes.len(), "{} of {} bytes", stream.raw.len(), bytes.len()); + assert_eq!(stream.decoded().unwrap(), bytes); + // Another page's resources don't have it, so it is found among the document's objects. + let objects = doc.scan_objects().count(); + let mut second = Dict::new(); + embed(&mut doc, &mut second, bytes, "Inter").unwrap(); + let programs = doc.scan_objects().filter(|(_, o)| matches!(o.as_ref(), Object::Stream(s) if s.dict.get(b"PCFullFont").is_some())).count(); + assert_eq!(programs, 1, "one program for both pages"); + assert!(doc.scan_objects().count() > objects, "the second page gets its own resource"); + } } diff --git a/crates/fonts/src/encodings.rs b/crates/fonts/src/encodings.rs index 98666802bb..88f0de5d12 100644 --- a/crates/fonts/src/encodings.rs +++ b/crates/fonts/src/encodings.rs @@ -5,11 +5,13 @@ pub(crate) static WIN_ANSI: [u32; 256] = [ 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 0, 8364, 0, 8218, 402, 8222, 8230, 8224, 8225, 710, 8240, 352, 8249, 338, - 0, 381, 0, 0, 8216, 8217, 8220, 8221, 8226, 8211, 8212, 732, 8482, 353, 8250, 339, 0, 382, 376, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, - // 0xAD is `hyphen`, not U+00AD: WinAnsiEncoding's table (Annex D.2) gives codes 0x2D and 0xAD - // the same glyph name, so the byte a Windows codepage calls a soft hyphen draws, and extracts - // as, an ordinary hyphen. Emitting U+00AD instead hides the character from search and from - // redaction's pattern matching, because a soft hyphen is invisible and matches nothing. + // 0xA0 is `space` and 0xAD is `hyphen`, not U+00A0 and U+00AD: WinAnsiEncoding's table + // (Annex D.2) gives those codes the same glyph names as 0x20 and 0x2D, so the bytes a Windows + // codepage calls a non-breaking space and a soft hyphen draw, and extract as, an ordinary + // space and hyphen. Emitting the formatting characters instead hides them from search and + // from redaction's pattern matching: a soft hyphen is invisible and matches nothing, and a + // non-breaking space does not match the space a reader typed. + 0, 381, 0, 0, 8216, 8217, 8220, 8221, 8226, 8211, 8212, 732, 8482, 353, 8250, 339, 0, 382, 376, 32, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 45, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255, @@ -21,7 +23,8 @@ pub(crate) static MAC_ROMAN: [u32; 256] = [ 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 0, 196, 197, 199, 201, 209, 214, 220, 225, 224, 226, 228, 227, 229, 231, 233, 232, 234, 235, 237, 236, 238, 239, 241, 243, 242, 244, 246, 245, 250, 249, 251, 252, 8224, 176, 162, 163, 167, 8226, 182, 223, 174, 169, 8482, 180, 168, 8800, 198, 216, 8734, 177, 8804, 8805, 165, 181, 8706, 8721, 8719, 960, 8747, 170, 186, 937, 230, 248, 191, 161, 172, 8730, 402, - 8776, 8710, 171, 187, 8230, 160, 192, 195, 213, 338, 339, 8211, 8212, 8220, 8221, 8216, 8217, 247, 9674, 255, 376, 8260, 8364, 8249, 8250, 64257, + // 0xCA is `space`, the same glyph name Annex D.2 gives 0x20, for the reason WinAnsi 0xA0 is. + 8776, 8710, 171, 187, 8230, 32, 192, 195, 213, 338, 339, 8211, 8212, 8220, 8221, 8216, 8217, 247, 9674, 255, 376, 8260, 8364, 8249, 8250, 64257, 64258, 8225, 183, 8218, 8222, 8240, 194, 202, 193, 203, 200, 205, 206, 207, 204, 211, 212, 63743, 210, 218, 219, 217, 305, 710, 732, 175, 728, 729, 730, 184, 733, 731, 711, ]; diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 73b4fbb08b..a1f2ff2779 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -80,13 +80,19 @@ pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64) let mut lines = Vec::new(); for para in text.split(['\n', '\r']) { let mut line = String::new(); + // No word is on the line yet; an empty word (a leading space) still starts it. + let mut fresh = true; for word in para.split(' ') { - let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; - if measure(&candidate, size) <= width || line.is_empty() && measure(word, size) <= width { + let candidate = if fresh { word.to_string() } else { format!("{line} {word}") }; + if measure(&candidate, size) <= width || fresh && measure(word, size) <= width { line = candidate; + fresh = false; continue; } - if !line.is_empty() { + // Indentation alone is not a line: drop it before an over-long word. + if line.trim().is_empty() { + line.clear(); + } else { lines.push(std::mem::take(&mut line)); } for ch in word.chars() { @@ -95,6 +101,7 @@ pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64) } line.push(ch); } + fresh = line.is_empty(); } lines.push(line); } @@ -256,6 +263,20 @@ pub fn literal(bytes: &[u8]) -> Vec { mod tests { use super::*; + #[test] + fn wrap_keeps_leading_spaces() { + assert_eq!(wrap(" indented", 11.0, 500.0), [" indented"]); + assert_eq!(wrap(" a b\n c", 11.0, 500.0), [" a b", " c"]); + assert_eq!(wrap("", 11.0, 500.0), [""]); + // A single leading space before a word that must be broken leaves no blank first line. + let broken = wrap(" abcdefghijklmnopqrstuvwxyz", 11.0, 40.0); + assert!(broken.len() > 1 && broken.iter().all(|l| !l.is_empty()), "{broken:?}"); + // So does indentation of two or more spaces: no whitespace-only line, same as one space. + let broken = wrap(" abcdefghijklmnopqrstuvwxyz", 11.0, 40.0); + assert!(broken.len() > 1 && broken.iter().all(|l| !l.trim().is_empty()), "{broken:?}"); + assert_eq!(broken, wrap(" abcdefghijklmnopqrstuvwxyz", 11.0, 40.0)); + } + #[test] fn win_ansi_encodability_matches_the_encoder() { for c in ['a', 'é', 'ÿ', '€', '—', '™', '\t', '?'] { @@ -305,31 +326,45 @@ mod tests { assert_eq!(literal(b"a(b)\\c"), b"(a\\(b\\)\\\\c)"); } - /// WinAnsiEncoding's 0xAD is the `hyphen` glyph, not U+00AD. + /// WinAnsiEncoding's 0xAD is the `hyphen` glyph and its 0xA0 is `space`, not U+00AD and + /// U+00A0; MacRomanEncoding's 0xCA is `space` too. /// - /// Annex D.2 gives codes 0x2D and 0xAD the same glyph name, so the byte a Windows codepage - /// calls a soft hyphen draws an ordinary hyphen and must extract as one. Reading it as U+00AD - /// hid the character: a soft hyphen is invisible, so a search for "well-known" missed text - /// that drew exactly that, and `redact`'s pattern matching missed it too. One corpus fixture - /// alone extracted 104 of them. + /// Annex D.2 gives each of those codes the same glyph name as the ordinary one (0x2D, 0x20), + /// so the bytes a Windows codepage calls a soft hyphen and a non-breaking space draw ordinary + /// ink and must extract as it. Reading them as the formatting characters hid them: a soft + /// hyphen is invisible, so a search for "well-known" missed text that drew exactly that, and + /// `redact`'s pattern matching missed it too; a non-breaking space does not match the space a + /// reader types. Over the 989-file pdf.js corpus this takes the soft hyphens we extract from + /// 117 to 13 (104 of them in `XiaoBiaoSong.pdf` alone) and the non-breaking spaces from 30 to + /// 26; what remains comes from `/ToUnicode` maps, which say so explicitly and are not touched. + /// pdftotext reads both codes as the ordinary character. #[test] - fn win_ansi_soft_hyphen_byte_reads_as_an_ordinary_hyphen() { + fn the_alias_codes_read_as_the_glyph_they_draw() { assert_eq!(encodings::WIN_ANSI.get(0xAD), Some(&u32::from('-'))); assert_eq!(encodings::WIN_ANSI.get(0x2D), Some(&u32::from('-'))); - // Writing is unaffected: '-' takes the canonical low code, and a caller holding a literal - // U+00AD still writes the 0xAD byte, which is the one that draws a hyphen. + assert_eq!(encodings::WIN_ANSI.get(0xA0), Some(&u32::from(' '))); + assert_eq!(encodings::WIN_ANSI.get(0x20), Some(&u32::from(' '))); + assert_eq!(encodings::MAC_ROMAN.get(0xCA), Some(&u32::from(' '))); + assert_eq!(encodings::MAC_ROMAN.get(0x20), Some(&u32::from(' '))); + // MacRoman has no soft hyphen; 0xAD there is `notequal`, and keeps it. + assert_eq!(encodings::MAC_ROMAN.get(0xAD), Some(&0x2260)); + // Writing is unaffected: '-' and ' ' take the canonical low codes, and a caller holding a + // literal U+00AD or U+00A0 still writes the high byte, which is the one that draws them. assert_eq!(win_ansi_byte('-'), Some(0x2D)); + assert_eq!(win_ansi_byte(' '), Some(0x20)); assert_eq!(win_ansi_byte('\u{00ad}'), Some(0xAD)); - assert_eq!(win_ansi("a-b"), b"a-b"); + assert_eq!(win_ansi_byte('\u{00a0}'), Some(0xA0)); + assert_eq!(win_ansi("a-b c"), b"a-b c"); } #[test] fn win_ansi_covers_the_0x80_to_0x9f_glyphs() { // Every character in the WinAnsiEncoding table encodes to its code and back (#347), except - // where two codes share one glyph: 0xAD is `hyphen`, the same glyph as 0x2D, so it decodes - // to '-' and '-' encodes back to the canonical 0x2D. Round-tripping the *code* is what + // where two codes share one glyph: 0xAD is `hyphen`, the same glyph as 0x2D, and 0xA0 is + // `space`, the same glyph as 0x20, so each decodes to the ordinary character and that + // character encodes back to the canonical low code. Round-tripping the *code* is what // cannot hold there; round-tripping the character still does. - const ALIASES: &[u8] = &[0xAD]; + const ALIASES: &[u8] = &[0xA0, 0xAD]; for (code, &u) in encodings::WIN_ANSI.iter().enumerate() { let Some(c) = char::from_u32(u).filter(|_| u != 0) else { continue }; if ALIASES.contains(&(code as u8)) { diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 3ad34eeade..26685866b3 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -1,8 +1,8 @@ //! What content editing and redaction need from a PDF font: how to split a string into character //! codes, each code's advance width, the glyph height (ascent, descent), and what each code means //! (Unicode) — and back, which text the font can show. Widths come from the font dictionary -//! (`/Widths`, `/W`, `/DW`, `/MissingWidth`); a standard-14 font with no `/Widths` uses the exact -//! published metrics ([`crate::Std14`]). +//! (`/Widths`, `/W`, `/DW`, `/MissingWidth`); a simple font with no `/Widths` uses its embedded +//! sfnt program's advances, else the exact published standard-14 metrics ([`crate::Std14`]). //! //! Meanings come from `/ToUnicode`, else the encoding (`/Encoding` base and `/Differences` glyph //! names, ISO 32000-2 Annex D). A code may deliberately have **no** meaning: where the font's own @@ -167,6 +167,44 @@ fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec { } } +/// A simple font with no `/Widths` is drawn (and found by text search) with its embedded +/// program's own advances, so measure with them too; a standard-14 guess from the name can be +/// wider and leave the end of a line outside a redaction mark (#2130). The glyph is looked up +/// the way the renderer does: the encoding's character in the Unicode cmap, else the code itself +/// (as in a symbolic cmap), else `.notdef`. Only sfnt programs are read; others keep the guess. +fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics) { + use skrifa::instance::{LocationRef, Size}; + use skrifa::{GlyphId, MetadataProvider as _, raw::TableProvider as _}; + let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return }; + for key in [b"FontFile2".as_slice(), b"FontFile3".as_slice()] { + let Some(object) = descriptor.get(key) else { continue }; + let object = doc.resolve(object); + let Object::Stream(stream) = object.as_ref() else { continue }; + let Ok(bytes) = stream.decoded_within(16 << 20) else { continue }; + let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue }; + let upem = f64::from(face.metrics(Size::unscaled(), LocationRef::default()).units_per_em); + if upem == 0.0 { + continue; + } + let advances = face.glyph_metrics(Size::unscaled(), LocationRef::default()); + let (charmap, cmap) = (face.charmap(), face.cmap().ok()); + let found = |g: Option| g.filter(|g| *g != GlyphId::NOTDEF); + m.widths = (0..=255u32) + .map(|code| { + let glyph = found(m.unicode.encoding.get(&code).and_then(|t| only_char(t)).and_then(|c| charmap.map(c))) + .or_else(|| [0, 0xF000, 0xF100, 0xF200].into_iter().find_map(|o| found(cmap.as_ref()?.map_codepoint(code + o)))); + // A subset holds only the glyphs it draws: 0 keeps `has_glyph` false for the rest. + match glyph { + None if m.subset => 0.0, + _ => advances.advance_width(glyph.unwrap_or(GlyphId::NOTDEF)).map_or(0.0, |w| f64::from(w) * 1000.0 / upem), + } + }) + .collect(); + m.first = 0; + return; + } +} + /// Replace an existing cmap directory entry with a bounded Unicode format-12 table. /// Checked ranges prevent damaged sfnt offsets from indexing outside the source program. fn append_editor_cmap(bytes: &mut Vec, mappings: &std::collections::BTreeMap) -> Option<()> { @@ -607,6 +645,9 @@ impl Metrics { Codes::Ranges(r) => r.last().map_or(2, |x| x.0), }; m.unicode = unicode_map(doc, font, m.composite); + if m.std14.is_some() { + embedded_widths(doc, font, &mut m); + } let mut reverse = HashMap::new(); for (&code, text) in m.unicode.explicit.iter().map(|(c, (_, t))| (c, t)).chain(m.unicode.encoding.iter()) { if let Some(ch) = only_char(text) @@ -798,6 +839,11 @@ impl Metrics { fn has_glyph(&self, code: u32) -> bool { if self.composite { let cid = self.cid(code); + // CID 0 is `.notdef` (an unmapped code lands there too), whatever ToUnicode says: Word + // maps it to a space and gives it a width, and typing with it draws boxes (#3223). + if cid == 0 { + return false; + } // A CID /W leaves out takes /DW, and Skia (Chrome) leaves out every CID of that width: for // those, only the subset's program says whether the glyph is there. return self.cid_widths.contains_key(&cid) @@ -1218,6 +1264,37 @@ mod tests { assert!(embedded_sfnt(&doc, &Dict::new()).is_none()); } + /// #2130: with no `/Widths`, an embedded program's advances win over a guess from the name + /// ("Mono" would mean Courier, 600 units for every glyph). + #[test] + fn a_font_without_widths_measures_with_its_embedded_program() { + use pdfcraft_cos::Stream; + use skrifa::MetadataProvider as _; + let mut doc = Document::new_empty(); + let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + let face = skrifa::FontRef::new(bytes).unwrap(); + let (size, location) = (skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()); + let upem = f64::from(face.metrics(size, location).units_per_em); + let advance = |c: char| f64::from(face.glyph_metrics(size, location).advance_width(face.charmap().map(c).unwrap()).unwrap()) / upem; + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), bytes))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("TrueType")), + ("BaseFont", Object::name("HouseMono")), + ("Encoding", Object::name("WinAnsiEncoding")), + ("FontDescriptor", Object::Dict(descriptor)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + for c in ['i', 'l', 'W', ' '] { + assert!((m.width(u32::from(c)) - advance(c)).abs() < 1e-9, "{c}: {} vs {}", m.width(u32::from(c)), advance(c)); + } + assert!(m.width(u32::from('i')) < 0.4, "not Courier's 0.6"); + } + #[test] fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() { use skrifa::{MetadataProvider as _, raw::TableProvider as _}; @@ -1384,6 +1461,34 @@ mod tests { assert_eq!(m.encode("Z"), None); } + /// Word's Type 0 subsets map CID 0 as well as the real space glyph to U+0020 and give CID 0 a + /// width; CID 0 is `.notdef`, so a typed space takes the real one (#3223). + #[test] + fn composite_fonts_never_encode_with_cid_0() { + let mut doc = Document::new_empty(); + let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> endcodespacerange \ + 1 beginbfrange <0000> <0002> [<0020> <004D> <00E1>] endbfrange \ + 1 beginbfrange <0005> <0007> [<0065> <0072> <0020>] endbfrange end"; + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + let widths = [889, 444, 389, 278, 444, 333, 250].map(Object::Int).to_vec(); + desc.set(b"W".to_vec(), Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Int(778)]), Object::Int(1), Object::Array(widths)])); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("BCDEEE+TimesNewRomanPSMT")), + ("Encoding", Object::name("Identity-H")), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ("ToUnicode", Object::Ref(tu)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.encode("Me er").as_deref(), Some(&[0, 1, 0, 5, 0, 7, 0, 5, 0, 6][..])); + assert_eq!(m.decode(&[0, 1, 0, 7, 0, 2]), "M á"); + } + /// Chrome's subsets leave the glyphs of `/DW` width out of `/W` (#1443): "s" (CID 0x56) is /// still in the font, but nothing is when the font gives no default width to point at. #[test] @@ -1682,7 +1787,8 @@ mod tests { assert_eq!(got, expected.get(&code).cloned(), "code {code:#06x} in {cmap}"); } let mut reverse: HashMap = HashMap::new(); - for (&code, text) in &expected { + // Code 0 is CID 0, `.notdef`, which no character is encoded with. + for (&code, text) in expected.iter().filter(|&(&code, _)| code != 0) { if let Some(c) = only_char(text) { keep(&mut reverse, c, code); } @@ -1863,6 +1969,32 @@ mod encoding_tests { } } + /// The Annex D.2 alias codes read, through the whole decode path, as the ink they draw. + /// + /// This is the map `redact` matches its patterns on (`unicode_map`), so a code that decodes + /// to an invisible formatting character is a silent miss, not a cosmetic one: "well-known" + /// written with the 0xAD byte matched no search and no redaction pattern. + #[test] + fn the_alias_codes_decode_as_ordinary_ink_and_encode_back_to_the_low_code() { + let (doc, d) = simple("Helvetica", None, Some(Object::name("WinAnsiEncoding"))); + let m = Metrics::from_dict(&doc, &d); + assert_eq!(m.decode(b"well\xadknown"), "well-known"); + assert_eq!(m.decode(b"a\xa0b"), "a b"); + assert_eq!(m.encode("well-known a b").as_deref(), Some(&b"well-known a b"[..]), "the low code is the one written"); + + let (doc, d) = simple("Helvetica", None, Some(Object::name("MacRomanEncoding"))); + let m = Metrics::from_dict(&doc, &d); + assert_eq!(m.decode(b"a\xcab"), "a b"); + assert_eq!(m.decode(b"a\xadb"), "a\u{2260}b", "MacRoman 0xAD is `notequal`, not a hyphen"); + assert_eq!(m.encode("a b").as_deref(), Some(&b"a b"[..])); + + // An explicit /Differences name still says what it says: this is a property of those + // codes in those tables, not of the glyph names. + let (doc, d) = simple("Helvetica", None, Some(differences(vec![Object::Int(0x41), Object::name("sfthyphen"), Object::name("nbspace")]))); + let m = Metrics::from_dict(&doc, &d); + assert_eq!(m.decode(b"AB"), "\u{ad}\u{a0}"); + } + /// Pins a deliberate restraint, so it is not "fixed" into a regression: a great many ordinary /// Latin TrueType subsets set the `/FontDescriptor` symbolic flag and still rely on a Latin /// encoding. Suppressing the implicit table on the flag would lose real text on a large share diff --git a/crates/forms/README.md b/crates/forms/README.md index 3280cc2431..66fb919fe7 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -56,6 +56,13 @@ delete_field(&mut doc, "email")?; Button1, Date1, Signature1; `/Helv` is added to `/DR`; appearances are drawn at once. Radio buttons joining a group become kid widgets with their own export state. Renaming changes only the last part of a dotted name. +- `Widget::caption` reads a push button's normal label (`/MK /CA`, including indirect values). + `FieldProps { caption: Some((widget_index, text)), ..Default::default() }` changes or clears + that label, independently of the field name. The index is 0-based. A label-only edit redraws + just that widget, copying shared/indirect `/MK` and appearance dictionaries before editing. + Other widgets, actions, icons, rotation and unknown entries are preserved. Existing icon-only + layouts keep hiding the caption; caption rendering retains the existing Helvetica/WinAnsi + limits and reports unavailable characters as above. ## Not yet diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index b50ed4c1ed..129f7fa751 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -587,7 +587,9 @@ pub fn parse_date(text: &str, fmt: &str) -> Option { return None; } let lower = t.to_lowercase(); - let pm = lower.contains("pm") || lower.ends_with('p'); + // A bare trailing "p" ("5:30p") only counts when it follows a digit, not a word like "Sep". + let bare_p = lower.strip_suffix('p').is_some_and(|rest| rest.trim_end().ends_with(|c: char| c.is_ascii_digit())); + let pm = lower.contains("pm") || bare_p; let am = lower.contains("am"); // Tokens: numbers and month names. let mut nums: Vec = Vec::new(); @@ -1285,3 +1287,25 @@ mod never_crash_tests { let _ = format_date(dt, "dddd ddd"); } } + +#[cfg(test)] +mod pm_marker_tests { + use super::*; + + /// A month name ending in "p" ("Sep") is not a PM marker; only a "p" attached to a time is. + #[test] + fn month_name_ending_in_p_is_not_pm() { + let dt = parse_date("2026 05 Sep", "yyyy dd mmm HH:MM").expect("valid date"); + assert_eq!((dt.m, dt.d, dt.hh, dt.mm), (9, 5, 0, 0)); + let f = Format::Date("yyyy dd mmm HH:MM".into()); + assert_eq!(format_value(&f, "2026 05 Sep"), "2026 05 Sep 00:00"); + assert!(keystroke(&f, "date", "2026 05 Sep").is_ok()); + } + + #[test] + fn bare_p_after_a_time_is_still_pm() { + assert_eq!(parse_date("05/09/2026 5:30p", "mm/dd/yyyy h:MM").map(|d| d.hh), Some(17)); + assert_eq!(parse_date("05/09/2026 5:30 p", "mm/dd/yyyy h:MM").map(|d| d.hh), Some(17)); + assert_eq!(parse_date("05/09/2026 5:30 pm", "mm/dd/yyyy h:MM").map(|d| d.hh), Some(17)); + } +} diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index bb45f38d9f..521d1f5278 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -63,6 +63,9 @@ impl NewField { pub struct FieldProps { pub name: Option, pub tooltip: Option, + /// Normal push-button label (`/MK /CA`) of one widget (0-based index). + /// An empty string clears the label; other widgets and icon layouts are preserved. + pub caption: Option<(usize, String)>, pub read_only: Option, pub required: Option, pub multiline: Option, @@ -657,6 +660,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel locked: false, hidden: false, rotation: 0, + caption: None, list: None, }; let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export); @@ -677,9 +681,16 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel /// (`pdfcraft_fonts::UNDRAWN_MARK`) and other viewers are asked to redraw the form (see /// `ask_viewers_to_redraw`), as when a value is set. pub fn redraw_field(doc: &mut Document, name: &str) -> Result<(), FormError> { + redraw_widgets(doc, name, None) +} + +fn redraw_widgets(doc: &mut Document, name: &str, only: Option) -> Result<(), FormError> { let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; let mut undrawn = false; - for w in &f.widgets { + for (index, w) in f.widgets.iter().enumerate() { + if only.is_some_and(|only| only != index) { + continue; + } let mut missing = None; let ap = match f.kind { FieldKind::Text | FieldKind::Combo | FieldKind::List => { @@ -861,6 +872,15 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result> = None; @@ -1178,7 +1198,18 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result 1 && near(points[0], *points.last()?); if repeated_end { - points = &points[..points.len() - 1]; + points.pop(); } - if points.len() != 4 || !(self.closed || implicitly_closed || repeated_end) { + if !(self.closed || implicitly_closed || repeated_end) { + return None; + } + // A point on a straight side, between its neighbours, doesn't change the shape: drop it, + // so a cell drawn with a midpoint on a side is the rectangle it looks like (#2942). A + // point that turns back along the side is not between its neighbours and stays, so a + // path that doubles back is still not a rectangle. + let between = |a: f64, m: f64, b: f64| a.min(b) - 1e-6 <= m && m <= a.max(b) + 1e-6; + let mut i = 0; + while points.len() > 4 && i < points.len() { + let n = points.len(); + let (Some(&a), Some(&m), Some(&b)) = (points.get((i + n - 1) % n), points.get(i), points.get((i + 1) % n)) else { break }; + let on_row = (a[1] - m[1]).abs() < 1e-6 && (m[1] - b[1]).abs() < 1e-6 && between(a[0], m[0], b[0]); + let on_column = (a[0] - m[0]).abs() < 1e-6 && (m[0] - b[0]).abs() < 1e-6 && between(a[1], m[1], b[1]); + if on_row || on_column { + points.remove(i); + i = 0; + } else { + i += 1; + } + } + if points.len() != 4 { return None; } let mut horizontal = [false; 4]; @@ -157,7 +187,7 @@ pub fn page_shapes(doc: &Document, page: usize) -> Shapes { let (bx, by) = ctm.apply(x, y); let b = [bx, by]; if !path.complex { - if path.points.len() >= 5 { + if path.points.len() > MAX_RECT_POINTS { path.complex = true; } else { if path.points.is_empty() { @@ -284,6 +314,42 @@ fn label_for(words: &[Word], rect: [f64; 4], right_of: bool) -> String { clean(&on.iter().map(|w| w.text.as_str()).collect::>().join(" ")) } +/// Rows of two or more touching cells of one size, as (the row's rect, cell count). Two is +/// enough: dates split into day and month groups of two cells. Cells are square or taller +/// than wide; an empty table of wider cells is not a comb. +fn combs(boxes: &[[f64; 4]]) -> Vec<([f64; 4], usize)> { + let cells: Vec<[f64; 4]> = boxes + .iter() + .copied() + .filter(|r| { + let (w, h) = (r[2] - r[0], r[3] - r[1]); + (5.0..=30.0).contains(&w) && (8.0..=40.0).contains(&h) && w <= h * 1.2 + }) + .collect(); + // `b` comes right after `a` in a row: same size, its left edge on a's right edge. + let follows = |a: &[f64; 4], b: &[f64; 4]| { + (b[0] - a[2]).abs() <= 1.0 && (b[1] - a[1]).abs() <= 0.5 && (b[3] - a[3]).abs() <= 0.5 && ((b[2] - b[0]) - (a[2] - a[0])).abs() <= 0.5 + }; + let mut out = Vec::new(); + for first in &cells { + if cells.iter().any(|a| follows(a, first)) { + continue; + } + let (mut last, mut n) = (first, 1usize); + // Each step moves at least 4 pt right, so this ends; the bound is for safety. + while let Some(b) = cells.iter().find(|b| follows(last, b)) + && n < cells.len() + { + last = b; + n += 1; + } + if n >= 2 { + out.push(([first[0], first[1], last[2], first[3]], n)); + } + } + out +} + /// Propose fields for a page. `existing` are the page's widget rectangles (no candidate /// overlaps them); names are unique against `taken`. pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[String]) -> Vec { @@ -304,6 +370,15 @@ pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[S let l = label_for(&labels, rect, false); push(Kind::Text, rect, l, &mut out); } + // Comb boxes (postcodes, dates, IBANs): one text field per row of cells, found before + // check boxes so the cells don't each become one. + for (rect, cells) in combs(&shapes.boxes) { + if labels.iter().any(|x| overlaps(x.rect, rect)) { + continue; + } + let l = label_for(&labels, rect, false); + push(Kind::Comb { cells }, rect, l, &mut out); + } // Small squares: check boxes, labelled by the text to their right. for r in &shapes.boxes { let (w, h) = (r[2] - r[0], r[3] - r[1]); @@ -356,7 +431,7 @@ pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[S for c in &mut out { let base = if c.name.is_empty() { match c.kind { - Kind::Text => "Text".to_string(), + Kind::Text | Kind::Comb { .. } => "Text".to_string(), Kind::CheckBox => "Check Box".to_string(), } } else { diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 07f4c5484f..1dfa767262 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -103,6 +103,8 @@ pub struct Widget { pub hidden: bool, /// `/MK /R` as 0, 90, 180 or 270 degrees counterclockwise. Anything else is stored as 0. pub rotation: i64, + /// Push buttons: the normal label (`/MK /CA`), separate from the field's name. + pub caption: Option, /// List boxes: where the options are drawn, so a click can pick the one under the pointer. pub list: Option, } @@ -802,6 +804,13 @@ fn walk( locked: annot_flags & 128 != 0, hidden: annot_flags & (2 | 32) != 0, rotation: appearance::mk_rotation(doc, wd), + caption: (kind == FieldKind::PushButton) + .then(|| { + let mk = doc.resolve(wd.get(b"MK")?); + let value = doc.resolve(mk.as_dict()?.get(b"CA")?); + value.as_string().map(|s| s.to_text()) + }) + .flatten(), list: (kind == FieldKind::List).then(|| appearance::list_rows_of(doc, inh.da.as_deref().unwrap_or("/Helv 0 Tf 0 g"), Some(d), wd)), }) }) diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index ef545cd433..fef5bcfbd8 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -87,6 +87,108 @@ fn ap(doc: &Document, w: &Widget) -> String { String::from_utf8_lossy(&s.decoded().unwrap()).into_owned() } +#[test] +fn button_caption_preserves_other_widgets_and_shared_dictionaries() { + for indirect_caption in [false, true] { + let mut doc = one_page(); + let a = add_field(&mut doc, 0, [40.0, 600.0, 200.0, 630.0], &NewField::Button { caption: "Original".into() }, Some("first")).unwrap(); + let b = add_field(&mut doc, 0, [40.0, 500.0, 200.0, 530.0], &NewField::Button { caption: "Original".into() }, Some("second")).unwrap(); + let all = fields(&doc); + let (first, second) = (field(&all, &a).obj, field(&all, &b).obj); + let mut parent = Dict::new(); + parent.set(b"FT".to_vec(), Object::name("Btn")); + parent.set(b"Ff".to_vec(), Object::Int(flags::PUSH_BUTTON as i64)); + parent.set(b"T".to_vec(), PdfString::text("clear_button")); + parent.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(first), Object::Ref(second)])); + let mut action = Dict::new(); + action.set(b"S".to_vec(), Object::name("ResetForm")); + parent.set(b"A".to_vec(), Object::Dict(action.clone())); + let parent = doc.add(parent); + let mut mk = Dict::new(); + let ca = Object::String(PdfString::text("Original")); + let ca = if indirect_caption { Object::Ref(doc.add(ca)) } else { ca }; + mk.set(b"CA".to_vec(), ca); + mk.set(b"TP".to_vec(), Object::Int(0)); + mk.set(b"R".to_vec(), Object::Int(90)); + mk.set(b"VendorNote".to_vec(), PdfString::text("kept")); + let mk = doc.add(mk); + let mut shared_ap = doc.get(first).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().clone(); + let normal = shared_ap.get(b"N").unwrap().clone(); + shared_ap.set(b"R".to_vec(), normal.clone()); + shared_ap.set(b"D".to_vec(), normal); + shared_ap.set(b"VendorAP".to_vec(), PdfString::text("kept")); + let shared_ap = doc.add(shared_ap); + for widget in [first, second] { + doc.update_dict(widget, |d| { + for key in [b"FT".as_slice(), b"Ff", b"T"] { + d.remove(key); + } + d.set(b"Parent".to_vec(), Object::Ref(parent)); + d.set(b"MK".to_vec(), Object::Ref(mk)); + d.set(b"AP".to_vec(), Object::Ref(shared_ap)); + }) + .unwrap(); + } + let af = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"AcroForm").unwrap(); + doc.update_dict(af, |d| d.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(parent)]))).unwrap(); + let original = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(); + let original = Document::open(Arc::new(original)).unwrap(); + // Full saves can renumber objects: reacquire references from the reopened model. + let f = field(&fields(&original), "clear_button").clone(); + let first = f.widgets[0].obj; + let mk = original.get(first).as_dict().unwrap().reference(b"MK").unwrap(); + for full in [false, true] { + let mut doc = original.clone(); + let untouched = doc.get(first).clone(); + let shared_mk = doc.get(mk).clone(); + assert_eq!(field(&fields(&doc), "clear_button").widgets[1].caption.as_deref(), Some("Original")); + set_props(&mut doc, "clear_button", &FieldProps { caption: Some((1, "Clear Form".into())), ..Default::default() }).unwrap(); + assert_eq!(doc.get(first), untouched, "the other widget and all its appearances are untouched"); + assert_eq!(doc.get(mk), shared_mk, "shared MK is not patched in place"); + let bytes = if full { + pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap() + } else { + let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(bytes.starts_with(original.bytes().as_slice())); + assert!(bytes.len() > original.bytes().len()); + bytes + }; + let doc = Document::open(Arc::new(bytes)).unwrap(); + let f = field(&fields(&doc), "clear_button").clone(); + assert_eq!(f.widgets.iter().map(|w| w.caption.as_deref()).collect::>(), [Some("Original"), Some("Clear Form")]); + assert_eq!(f.button, Some(af::ButtonAction::Reset { fields: Vec::new(), exclude: false })); + assert_eq!(doc.get(f.obj).as_dict().unwrap().get(b"A"), Some(&Object::Dict(action.clone()))); + let w = doc.get(f.widgets[1].obj); + let mk = w.as_dict().unwrap().get(b"MK").unwrap().as_dict().unwrap(); + assert_eq!(mk.get(b"VendorNote").unwrap().as_string().unwrap().to_text(), "kept"); + assert_eq!(mk.get(b"R").unwrap().as_int(), Some(90)); + assert_eq!(mk.get(b"TP").unwrap().as_int(), Some(0)); + let apd = w.as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap(); + assert!(apd.contains(b"VendorAP")); + assert!(!apd.contains(b"R") && !apd.contains(b"D"), "stale hover/pressed text must not survive"); + assert!(ap(&doc, &f.widgets[1]).contains("(Clear Form) Tj")); + } + } +} + +#[test] +fn button_caption_rejects_wrong_type_and_index_before_mutation() { + let mut doc = fixture(); + for (name, wi) in [("name", 0), ("agree", 0), ("go", usize::MAX)] { + let before = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(); + let props = FieldProps { caption: Some((wi, "Clear Form".into())), name: Some("renamed".into()), ..Default::default() }; + assert!(set_props(&mut doc, name, &props).is_err()); + assert_eq!(pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(), before); + } + set_props(&mut doc, "go", &FieldProps { caption: Some((0, "提交".into())), ..Default::default() }).unwrap(); + assert_eq!(field(&fields(&doc), "go").widgets[0].caption.as_deref(), Some("提交")); + assert_eq!(undrawn_fields(&doc), [("go".to_string(), '提')]); + set_props(&mut doc, "go", &FieldProps { caption: Some((0, String::new())), ..Default::default() }).unwrap(); + assert_eq!(field(&fields(&doc), "go").widgets[0].caption.as_deref(), Some("")); + assert!(undrawn_fields(&doc).is_empty()); + assert!(!ap(&doc, &field(&fields(&doc), "go").widgets[0]).contains(" Tj")); +} + /// Read the drawn strings together with the appearance's actual font after saving and reopening. fn appearance_text(doc: &Document, w: &Widget, font_name: &[u8]) -> (Object, Vec>) { let widget = doc.get(w.obj); @@ -1411,6 +1513,54 @@ fn detection_finds_blanks_rules_boxes_and_names_them() { assert!(again.is_empty(), "{again:?}"); } +#[test] +fn detection_makes_a_row_of_touching_cells_one_comb_field() { + use crate::detect::*; + let w = |t: &str, x0: f64, y0: f64, x1: f64| Word { text: t.into(), rect: [x0, y0, x1, y0 + 10.0] }; + let words = vec![ + w("Postcode", 40.0, 205.0, 85.0), + w("A", 88.0, 205.0, 95.0), + w("Postcode", 40.0, 105.0, 85.0), + w("B", 88.0, 105.0, 95.0), + w("Yes", 70.0, 41.0, 90.0), + ]; + let row = |n: usize, x: f64, y: f64, cw: f64, ch: f64| (0..n).map(move |i| [x + i as f64 * cw, y, x + (i + 1) as f64 * cw, y + ch]); + // 18 x 22 cells (too tall for a check box), 16 x 20 cells (each one check-box sized) drawn + // right to left, and a lone square. + let mut boxes: Vec<[f64; 4]> = row(5, 120.0, 200.0, 18.0, 22.0).collect(); + boxes.extend(row(10, 120.0, 100.0, 16.0, 20.0).rev()); + boxes.push([50.0, 40.0, 62.0, 52.0]); + let found = detect(&words, &Shapes { boxes, rules: Vec::new() }, &[], &[]); + let got: Vec<(&str, &Kind, [f64; 4])> = found.iter().map(|c| (c.name.as_str(), &c.kind, c.rect)).collect(); + assert_eq!( + got, + [ + ("Postcode A", &Kind::Comb { cells: 5 }, [120.0, 200.0, 210.0, 222.0]), + ("Postcode B", &Kind::Comb { cells: 10 }, [120.0, 100.0, 280.0, 120.0]), + ("Yes", &Kind::CheckBox, [50.0, 40.0, 62.0, 52.0]), + ], + "{found:?}" + ); + // Squares with a gap between them stay check boxes, and so do touching squares of + // different sizes. + let apart = Shapes { + boxes: vec![[50.0, 40.0, 62.0, 52.0], [66.0, 40.0, 78.0, 52.0], [80.0, 40.0, 92.0, 52.0], [92.0, 40.0, 102.0, 52.0]], + rules: Vec::new(), + }; + let kinds: Vec = detect(&[], &apart, &[], &[]).into_iter().map(|c| c.kind).collect(); + assert_eq!(kinds, [Kind::CheckBox, Kind::CheckBox, Kind::CheckBox, Kind::CheckBox]); + // An empty table of cells wider than tall (an attendance sheet) is not a comb. + let grid = |cw: f64, ch: f64| { + (0..3).flat_map(move |r| { + (0..3).map(move |c| [100.0 + c as f64 * cw, 300.0 + r as f64 * ch, 100.0 + (c + 1) as f64 * cw, 300.0 + (r + 1) as f64 * ch]) + }) + }; + for (cw, ch) in [(24.0, 18.0), (28.0, 20.0)] { + let table = detect(&[], &Shapes { boxes: grid(cw, ch).collect(), rules: Vec::new() }, &[], &[]); + assert!(table.is_empty(), "{cw} x {ch}: {table:?}"); + } +} + #[test] fn page_shapes_reads_boxes_and_rules() { let mut doc = fixture(); @@ -2023,6 +2173,29 @@ fn page_shapes_accepts_rectangle_closure_and_paint_variants() { } } +/// #2942: a cell drawn with a point in the middle of each vertical side (the French 2042 form's +/// letter cells), or on every side, is the rectangle it looks like. A point that turns back +/// along a side is not on the way between its neighbours, so such a path is still not a box. +#[test] +fn page_shapes_reads_rectangles_with_points_along_their_sides() { + for content in [ + "100 100 m 111.1 100 l 111.1 107.35 l 111.1 114.7 l 100 114.7 l 100 107.35 l h f", + "100 100 m 105 100 l 111.1 100 l 111.1 107.35 l 111.1 114.7 l 105 114.7 l 100 114.7 l 100 107.35 l h S", + "100 107.35 m 100 100 l 111.1 100 l 111.1 114.7 l 100 114.7 l h f", + ] { + let shapes = detection_test_shapes(content.as_bytes()); + assert_eq!(shapes.boxes, [[100.0, 100.0, 111.1, 114.7]], "{content}"); + } + for content in [ + // Out past the corner and back: a spike, not a side. + "100 100 m 130 100 l 111.1 100 l 111.1 114.7 l 100 114.7 l h S", + // A sixth point off the sides. + "100 100 m 111.1 100 l 115 107 l 111.1 114.7 l 100 114.7 l 100 107.35 l h S", + ] { + assert!(detection_test_shapes(content.as_bytes()).boxes.is_empty(), "{content}"); + } +} + #[test] fn page_shapes_rejects_open_diagonal_curved_and_complex_rectangles() { for content in [ diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index 059276e936..dc32d457bf 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -712,6 +712,13 @@ fn num_text(n: f64) -> String { if s.is_empty() || s == "-0" { "0".into() } else { s.to_string() } } +/// `v` with `decimals` places, rounding exact halves away from zero (Rust's formatter rounds them to even). +fn fixed(v: f64, decimals: usize) -> String { + let k = 10f64.powi(decimals.min(20) as i32); + let scaled = (v * k).round() / k; + format!("{:.decimals$}", if scaled.is_finite() { scaled } else { v }) +} + /// The number a string holds: its leading numeric part (`12abc` → 12), `None` for none. fn parse_num(s: &str) -> Option { let t = s.trim(); @@ -1926,6 +1933,8 @@ fn days_in(y: i64, m: u32) -> u32 { } } +const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + fn format_date(y: i64, m: u32, d: u32, picture: &str) -> String { let pic: Vec = picture.chars().collect(); let mut out = String::new(); @@ -1948,6 +1957,19 @@ fn format_date(y: i64, m: u32, d: u32, picture: &str) -> String { ('D', 1) => out.push_str(&d.to_string()), (_, _) => out.push_str(&format!("{d:02}")), } + } else if pic[i] == 'E' { + let mut run = 1; + while i + run < pic.len() && pic[i + run] == 'E' { + run += 1; + } + i += run; + // Day 1 (1900-01-01) was a Monday, so the serial modulo 7 indexes from Sunday. + let weekday = days_from_civil(y, m, d).rem_euclid(7) as usize; + match run { + 1 | 2 => out.push_str(&(weekday + 1).to_string()), + 3 => out.push_str(WEEKDAYS.get(weekday).map_or("", |n| &n[..3])), + _ => out.push_str(WEEKDAYS.get(weekday).copied().unwrap_or("")), + } } else { out.push(pic[i]); i += 1; @@ -2166,7 +2188,7 @@ impl Interp<'_> { let v = self.arg_num(args, 0)?; let width = if args.len() > 1 { self.arg_num(args, 1)?.clamp(0.0, 1000.0) as usize } else { 10 }; let prec = if args.len() > 2 { self.arg_num(args, 2)?.clamp(0.0, 20.0) as usize } else { 0 }; - let text = format!("{v:.prec$}"); + let text = fixed(v, prec); Val::Str(if text.len() > width { "*".repeat(width) } else { format!("{text:>width$}") }) } "substr" => { @@ -2255,7 +2277,7 @@ impl Interp<'_> { let num = self.to_num(&v); let decimals = pic.split_once('.').map_or(0, |(_, f)| f.chars().filter(|c| matches!(c, '9' | 'z' | 'Z')).count()).min(20); let grouped = pic.contains(','); - let mut text = format!("{:.decimals$}", num.abs()); + let mut text = fixed(num.abs(), decimals); if grouped { let (int, frac) = text.split_once('.').map_or((text.as_str(), ""), |(a, b)| (a, b)); let mut g = String::new(); diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 55fa45760d..61d3a71e51 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -636,6 +636,34 @@ mod formcalc { assert_eq!(calc("; a comment\n// another\nSum(1,\n 2,\n 3)"), "6"); } + #[test] + fn date_pictures_render_the_weekday_letters() { + assert_eq!(calc(r#"Num2Date(45350, DateFmt(4))"#), "Thursday, February 29, 2024"); + assert_eq!(calc(r#"Num2Date(45350, "EEE, YYYY-MM-DD")"#), "Thu, 2024-02-29"); + assert_eq!(calc(r#"Num2Date(45350, "EEEE")"#), "Thursday"); + assert_eq!(calc(r#"Num2Date(45350, "E")"#), "5"); + assert_eq!(calc(r#"Num2Date(45351, "EEEE")"#), "Friday"); + assert_eq!(calc(r#"Num2Date(45352, "EEEE")"#), "Saturday"); + assert_eq!(calc(r#"Num2Date(45353, "EEEE")"#), "Sunday"); + assert_eq!(calc(r#"Num2Date(1, "EEEE")"#), "Monday"); + assert_eq!(calc(r#"Format("EEEE, YYYY-MM-DD", 45350)"#), "Thursday, 2024-02-29"); + // Other letters are still literal text. + assert_eq!(calc(r#"Num2Date(45350, "YYYY-MM-DD x")"#), "2024-02-29 x"); + } + + #[test] + fn str_and_format_round_exact_ties_away_from_zero() { + assert_eq!(calc("Str(2.5, 3, 0)"), " 3"); + assert_eq!(calc("Str(0.5, 3, 0)"), " 1"); + assert_eq!(calc("Str(-2.5, 4, 0)"), " -3"); + assert_eq!(calc("Str(1.125, 6, 2)"), " 1.13"); + assert_eq!(calc(r#"Format("9", 2.5)"#), "3"); + assert_eq!(calc(r#"Format("9.99", 0.125)"#), "0.13"); + assert_eq!(calc(r#"Format("9.9", 0.25)"#), "0.3"); + assert_eq!(calc(r#"Format("9.9", -0.25)"#), "-0.3"); + assert_eq!(calc(r#"Format("9.99", 2.344)"#), "2.34"); + } + #[test] fn built_in_functions_cover_the_common_set() { assert_eq!(calc("Abs(-2.5)"), "2.5"); diff --git a/crates/ocr/src/lib.rs b/crates/ocr/src/lib.rs index 8d56fa4aa4..affa4ab163 100644 --- a/crates/ocr/src/lib.rs +++ b/crates/ocr/src/lib.rs @@ -215,7 +215,10 @@ fn num(v: f64) -> String { /// WinAnsiEncoding), each word stretched to its box so selection and search highlight the /// right place. Marked content `/OCR` so it can be told apart from the page's own text. pub fn text_layer(words: &[PlacedWord]) -> Vec { - let mut out = b"/OCR BMC\nBT\n3 Tr\n/PCHelv 1 Tf\n".to_vec(); + // `3 Tr` and the font are graphics state, which `ET` doesn't reset: without `q … Q` they + // would stay set for every later content stream on the page, so text added after OCR (a + // header or footer) drew invisibly (#3029). + let mut out = b"/OCR BMC\nq\nBT\n3 Tr\n/PCHelv 1 Tf\n".to_vec(); for w in words { let width = helvetica_width(&w.text, 1.0); let height = w.up[0].hypot(w.up[1]); @@ -235,7 +238,7 @@ pub fn text_layer(words: &[PlacedWord]) -> Vec { out.extend(pdfcraft_fonts::literal(&pdfcraft_fonts::win_ansi(&w.text))); out.extend_from_slice(b" Tj\n"); } - out.extend_from_slice(b"ET\nEMC\n"); + out.extend_from_slice(b"ET\nQ\nEMC\n"); out } @@ -272,6 +275,26 @@ mod tests { assert!(s.starts_with("/OCR BMC") && s.ends_with("EMC\n")); } + /// #3029: the layer's invisible rendering mode and font end with the layer. Every operator + /// that sets them sits inside a `q … Q` pair that the layer closes itself, so content drawn + /// after it on the page starts from the graphics state it had before. + #[test] + fn the_layer_restores_the_graphics_state_it_changes() { + let w = PlacedWord { text: "Hi".into(), origin: [10.0, 20.0], across: [30.0, 0.0], up: [0.0, 9.3] }; + let s = String::from_utf8(text_layer(&[w])).unwrap(); + let mut depth = 0_i32; + for token in s.split_ascii_whitespace() { + match token { + "q" => depth += 1, + "Q" => depth -= 1, + "Tr" | "Tf" | "Tm" => assert!(depth > 0, "`{token}` outside q … Q in {s:?}"), + _ => {} + } + assert!(depth >= 0, "unbalanced Q in {s:?}"); + } + assert_eq!(depth, 0, "the layer leaves a q open: {s:?}"); + } + /// A word box is tight around the ink, so the text's size and baseline come from the letters: /// capitals, lowercase without ascenders, descenders. #[test] diff --git a/crates/oracle/README.md b/crates/oracle/README.md index 7d2996b77b..04eea193aa 100644 --- a/crates/oracle/README.md +++ b/crates/oracle/README.md @@ -38,7 +38,7 @@ match pdfcraft_oracle::pdftotext() { |---|---|---| | `qpdf()` | qpdf | `--check` structural validation; the independent owner-password oracle | | `pdftoppm()` | Poppler | page pixels, for comparing against our own raster output | -| `pdftotext()` | Poppler | text extraction (`cargo xtask text-oracle` scores word F1 against it) | +| `pdftotext()` | Poppler | text extraction (`cargo xtask text-oracle` scores word F1 and reading order against it) | Each returns `Result`. `Oracle::command()` hands back a fresh `Command`; `Absent` renders as an actionable sentence naming the install command. @@ -92,6 +92,30 @@ picture through every corner of the format, so a correct decoder renders them al render 1 distinct image across all 96; Poppler renders 8, drawing a blank page for seven of them. `crates/render/tests/jbig2_invariants.rs` pins that invariant, and needs no oracle to do it. +## Comparing text + +`cargo xtask text-oracle` scores two different things against `pdftotext`, because one number +hides the other. + +**Word F1** is a bag of words: how much of what each side extracted the other also extracted, +with multiplicity. It catches a character read wrong, a glyph not read at all, a font we cannot +map. It is blind to *order* — a page emitted back to front scores 1.000. + +**Reading order** is the longest common subsequence of the two word sequences, as a share of the +longer one. It catches the failures F1 cannot see: a column read into the middle of its +neighbour, a figure's labels threaded through a paragraph, a form's value parted from its label. +Both sides are cut to the first 3,000 words, because the comparison is quadratic and three pages +is plenty of signal; the run says how many files that bit. + +The second score exists because of a measurement. Reviewing #2431, a change that moves text and +alters nothing else, word F1 called 26 of the 29 files whose output changed *identical*, while +the order score separated 18 improvements from 6 regressions — the regressions on forms, where +a checkbox's number was parted from its label. + +Read it the same way as the pixel score, and then once more: Poppler's reading order is a second +opinion, not the truth, and the same split can score as a win on one file and a loss on another +purely because `pdftotext` happens to make it in one place and not the other. + ## Still to do - Acrobat itself as the reference, rather than other open renderers. diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index 3b2873abec..99c228c60f 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -113,13 +113,17 @@ pub fn pages(doc: &Document) -> Result, OrganizeError> { pub fn page_rotation(doc: &Document, index: usize) -> Result { let pages = walk(doc)?; let (_, attrs) = pages.get(index).ok_or(OrganizeError::NoSuchPage(index))?; - Ok(attrs.int(b"Rotate").unwrap_or(0).rem_euclid(360)) + Ok(resolved_rotation(doc, attrs).rem_euclid(360)) } pub fn page_count(doc: &Document) -> Result { Ok(walk(doc)?.len()) } +fn resolved_rotation(doc: &Document, attrs: &Dict) -> i64 { + attrs.get(b"Rotate").and_then(|value| doc.resolve(value).as_int()).unwrap_or(0) +} + /// Rebuild the page tree as one flat node with `order` as its kids (attributes pushed down). fn rebuild(doc: &mut Document, order: &[(ObjRef, Dict)]) -> Result<(), OrganizeError> { let root = pages_root(doc)?; @@ -201,7 +205,10 @@ pub fn rotate_pages(doc: &mut Document, indices: &[usize], degrees: i64) -> Resu let delta = degrees.rem_euclid(360); for &i in indices { let (page, inherited) = &all[i]; - let current = doc.get(*page).as_dict().and_then(|d| d.int(b"Rotate")).or_else(|| inherited.int(b"Rotate")).unwrap_or(0); + // Repeated indices must see earlier edits instead of the page walk's snapshot. + let current_page = doc.get(*page); + let current_attrs = current_page.as_dict().filter(|attrs| attrs.get(b"Rotate").is_some()).unwrap_or(inherited); + let current = resolved_rotation(doc, current_attrs); let new = (current.rem_euclid(360) + delta).rem_euclid(360); doc.update_dict(*page, |d| d.set(b"Rotate".to_vec(), Object::Int(new)))?; } @@ -472,9 +479,30 @@ pub fn move_pages(doc: &mut Document, indices: &[usize], to: usize) -> Result<() rebuild(doc, &rest) } -/// Duplicate pages: copies of `indices` (in order) are inserted after the last of them, sharing +/// Reverse the order of the pages at `indices` among the positions they occupy; every other +/// page stays where it is. Fewer than two distinct pages is refused, since nothing would change. +pub fn reverse_pages(doc: &mut Document, indices: &[usize]) -> Result<(), OrganizeError> { + let mut all = walk(doc)?; + check(indices, all.len())?; + let mut slots = indices.to_vec(); + slots.sort_unstable(); + slots.dedup(); + if slots.len() < 2 { + return Err(OrganizeError::Invalid("reversing needs at least two pages".into())); + } + let pages: Vec<(ObjRef, Dict)> = slots.iter().filter_map(|i| all.get(*i).cloned()).collect(); + for (slot, page) in slots.iter().zip(pages.into_iter().rev()) { + if let Some(entry) = all.get_mut(*slot) { + *entry = page; + } + } + rebuild(doc, &all) +} + +/// Duplicate pages: copies of `indices` (in order) are inserted at `at` in the page order +/// (clamped to the document's end), or after the last of them when `at` is `None`, sharing /// fonts and images with the originals. -pub fn duplicate_pages(doc: &mut Document, indices: &[usize]) -> Result<(), OrganizeError> { +pub fn duplicate_pages(doc: &mut Document, indices: &[usize], at: Option) -> Result<(), OrganizeError> { let n = page_count(doc)?; check(indices, n)?; let Some(&last) = indices.iter().max() else { return Ok(()) }; @@ -482,7 +510,7 @@ pub fn duplicate_pages(doc: &mut Document, indices: &[usize]) -> Result<(), Orga order.sort_unstable(); order.dedup(); let src = doc.clone(); - import_pages(doc, &src, &order, last + 1).map(|_| ()) + import_pages(doc, &src, &order, at.unwrap_or(last + 1)).map(|_| ()) } /// Replace Pages: the content of `targets` (in order) is replaced by that of `src_pages` of @@ -536,6 +564,54 @@ pub fn insert_blank_page(doc: &mut Document, at: usize, width: f64, height: f64) /// Document information keys editable in Document Properties ▸ Description. pub const INFO_KEYS: [&str; 4] = ["Title", "Author", "Subject", "Keywords"]; +/// The document-information entries the spec names (ISO 32000-2 table 349). Any other key in +/// `/Info` is a custom property (Document Properties ▸ Custom). +pub const STANDARD_INFO_KEYS: [&str; 9] = ["Title", "Author", "Subject", "Keywords", "Creator", "Producer", "CreationDate", "ModDate", "Trapped"]; + +/// Longest custom property name accepted, in bytes: the name length every reader is asked to +/// accept (§7.3.5, Annex C). +pub const MAX_CUSTOM_KEY_LEN: usize = 127; + +/// The custom document properties: every `/Info` entry whose key the spec does not name and whose +/// value is text, in the dictionary's order, as (name, text), the text without NUL or whitespace +/// padding and entries that are only padding left out. At most 256 are listed. +pub fn custom_info(doc: &Document) -> Vec<(String, String)> { + let Some(info) = doc.trailer().get(b"Info").map(|o| doc.resolve(o)) else { return Vec::new() }; + let Some(d) = info.as_dict() else { return Vec::new() }; + d.iter() + .filter(|(k, _)| !STANDARD_INFO_KEYS.iter().any(|s| s.as_bytes() == k.as_slice())) + .filter_map(|(k, v)| { + // Padding (\0s and whitespace, in any mix) is not part of the value, and a value that + // is only padding is no property. Filtered before the cap, so blank entries can't + // crowd real ones out. + let text = doc.resolve(v).as_string()?.to_text(); + let text = text.trim_matches(|c: char| c == '\0' || c.is_whitespace()); + (!text.is_empty()).then(|| (String::from_utf8_lossy(k).into_owned(), text.to_owned())) + }) + .take(256) + .collect() +} + +/// Whether `name` may be a custom property's key: not one the spec names (those are the +/// Description fields), not empty, at most [`MAX_CUSTOM_KEY_LEN`] bytes, and without the +/// characters a name cannot carry unescaped (whitespace, the delimiters `()<>[]{}/%` and `#`), +/// so every reader shows the same name. Acrobat refuses the standard and empty names too. +pub fn check_custom_key(name: &str) -> Result<(), OrganizeError> { + if name.is_empty() { + return Err(OrganizeError::Invalid("a custom property needs a name".into())); + } + if STANDARD_INFO_KEYS.contains(&name) { + return Err(OrganizeError::Invalid(format!("{name} is a standard document property, not a custom one"))); + } + if name.len() > MAX_CUSTOM_KEY_LEN { + return Err(OrganizeError::Invalid(format!("a custom property name is at most {MAX_CUSTOM_KEY_LEN} bytes"))); + } + if let Some(c) = name.chars().find(|c| c.is_whitespace() || c.is_control() || "()<>[]{}/%#".contains(*c)) { + return Err(OrganizeError::Invalid(format!("a custom property name can't contain {c:?}"))); + } + Ok(()) +} + /// Read a document-information entry as text. pub fn info(doc: &Document, key: &str) -> Option { let info = doc.trailer().get(b"Info").map(|o| doc.resolve(o))?; @@ -548,6 +624,9 @@ pub fn info(doc: &Document, key: &str) -> Option { /// in the XMP packet, and viewers that read XMP first show its value: the packet's property is /// changed (or removed) to match (see `xmp`). pub fn set_info(doc: &mut Document, key: &str, value: &str) -> Result<(), OrganizeError> { + if !STANDARD_INFO_KEYS.contains(&key) { + check_custom_key(key)?; + } let value = value.trim(); let entry = (!value.is_empty()).then(|| Object::String(PdfString::text(value))); set_info_entry(doc, key.as_bytes(), entry)?; diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index d34b1e431d..9bd3eb396a 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -16,6 +16,53 @@ fn page_rotation_resolves_inheritance_overrides_and_missing_pages() { assert_eq!(page_rotation(&doc, 3), Err(OrganizeError::NoSuchPage(3))); } +#[test] +fn indirect_page_rotation_is_read_and_updated_from_its_effective_value() { + let large = i64::MAX - i64::MAX % 90; + for (current, degrees, expected) in [(270, 90, 0), (-90, 90, 0), (large, 90, 90), (90, -180, 270)] { + for level in 0..3 { + let mut doc = open(fixture()); + let rotation = doc.add(Object::Int(current)); + let page = pages(&doc).unwrap()[0].obj; + let target = match level { + 0 => page, + 1 => doc.get(page).as_dict().unwrap().reference(b"Parent").unwrap(), + _ => pages_root(&doc).unwrap(), + }; + doc.update_dict(target, |d| d.set(b"Rotate".to_vec(), Object::Ref(rotation))).unwrap(); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + let mut doc = open(bytes); + let rotation = walk(&doc).unwrap()[0].1.reference(b"Rotate").unwrap(); + let initial = page_rotation(&doc, 0).unwrap(); + rotate_pages(&mut doc, &[0], degrees).unwrap(); + assert_eq!(*doc.get(rotation), Object::Int(current), "shared rotation object unchanged"); + for saved in [write_incremental(&doc, &SaveOptions::default()).unwrap(), write_full(&doc, &SaveOptions::default()).unwrap()] { + let reopened = open(saved.clone()); + let pdf = hayro_syntax::Pdf::new(saved).unwrap(); + let dimensions = if expected % 180 == 0 { (300.0, 400.0) } else { (400.0, 300.0) }; + assert_eq!(pdf.pages()[0].render_dimensions(), dimensions, "written PDF has the correct orientation"); + assert_eq!(page_rotation(&reopened, 0).unwrap(), expected); + assert_eq!(page_rotation(&reopened, 1).unwrap(), if level == 0 { 90 } else { current.rem_euclid(360) }, "sibling unchanged"); + assert_eq!(page_rotation(&reopened, 2).unwrap(), if level == 2 { current.rem_euclid(360) } else { 90 }, "other subtree unchanged"); + } + assert_eq!(initial, current.rem_euclid(360), "effective indirect rotation at level {level}"); + } + } +} + +#[test] +fn malformed_indirect_rotations_keep_the_zero_fallback() { + for value in [Object::Null, Object::name("invalid"), Object::Ref(ObjRef::new(99_999, 0))] { + let mut doc = open(fixture()); + let rotation = doc.add(value); + let page = pages(&doc).unwrap()[0].obj; + doc.update_dict(page, |d| d.set(b"Rotate".to_vec(), Object::Ref(rotation))).unwrap(); + assert_eq!(page_rotation(&doc, 0).unwrap(), 0); + rotate_pages(&mut doc, &[0], 90).unwrap(); + assert_eq!(page_rotation(&doc, 0).unwrap(), 90); + } +} + #[test] fn rotate_rejects_angles_that_are_not_multiples_of_90() { // The page tree only turns in quarter steps, so anything else used to be silently truncated @@ -244,6 +291,25 @@ fn move_pages_reorders() { assert_eq!(labels(&doc), ["Page 2", "Page 3", "Page 1"]); } +#[test] +fn reverse_pages_reorders() { + let mut doc = open(fixture()); + reverse_pages(&mut doc, &[0, 1, 2]).unwrap(); + assert_eq!(labels(&doc), ["Page 3", "Page 2", "Page 1"]); + // Unsorted, repeated indices name the same slots; page 1's slot stays put. + reverse_pages(&mut doc, &[2, 1, 2]).unwrap(); + let doc = save_and_reopen(&doc); + assert_eq!(labels(&doc), ["Page 3", "Page 1", "Page 2"]); +} + +#[test] +fn cannot_reverse_missing_or_fewer_than_two_pages() { + let mut doc = open(fixture()); + assert_eq!(reverse_pages(&mut doc, &[0, 3]), Err(OrganizeError::NoSuchPage(3))); + assert!(matches!(reverse_pages(&mut doc, &[1, 1]), Err(OrganizeError::Invalid(_)))); + assert!(!doc.is_modified(), "failed operations leave the document untouched"); +} + #[test] fn inserts_blank_page() { let mut doc = open(fixture()); @@ -317,6 +383,59 @@ fn page_transitions_are_set_removed_and_checked() { assert_eq!(page_dict(&doc, 2).get(b"Dur").and_then(Object::as_f64), Some(5.0)); } +/// Custom document properties (Document Properties ▸ Custom): any `/Info` key the spec does not +/// name, set and cleared like the standard ones, listed apart from them, and with the names +/// Acrobat refuses refused here too. +#[test] +fn custom_document_properties_are_listed_set_removed_and_their_names_checked() { + let mut doc = open(fixture()); + assert!(custom_info(&doc).is_empty()); + set_info(&mut doc, "Title", "Report").unwrap(); + set_info(&mut doc, "Project", "Apollo").unwrap(); + set_info(&mut doc, "Revision", "7").unwrap(); + let mut listed = custom_info(&doc); + listed.sort(); + assert_eq!(listed, [("Project".to_string(), "Apollo".to_string()), ("Revision".to_string(), "7".to_string())], "standard keys are not custom"); + assert_eq!(info(&doc, "Project").as_deref(), Some("Apollo")); + set_info(&mut doc, "Project", "").unwrap(); + let doc = save_and_reopen(&doc); + assert_eq!(custom_info(&doc), [("Revision".to_string(), "7".to_string())], "cleared, and the rest survives a save"); + assert_eq!(info(&doc, "Title").as_deref(), Some("Report")); + + for bad in ["", "Title", "ModDate", "Bad Name", "a/b", "tab\tbed", "hash#tag", "(x)"] { + assert!(check_custom_key(bad).is_err(), "{bad:?} must be refused"); + } + let long = "x".repeat(MAX_CUSTOM_KEY_LEN + 1); + assert!(check_custom_key(&long).is_err()); + for good in ["Project", "Révision", "部門", "key-2.0", &"y".repeat(MAX_CUSTOM_KEY_LEN)] { + assert!(check_custom_key(good).is_ok(), "{good:?} is a usable name"); + } + let mut doc = open(fixture()); + assert!(set_info(&mut doc, "Bad Name", "x").is_err(), "set_info checks custom names"); + assert!(custom_info(&doc).is_empty()); +} + +/// Padding is not part of a custom value, in any mix of NULs and whitespace, a value that is only +/// padding is not listed, and such entries don't count towards the 256 listed: a real property +/// after 300 blank ones is still there. +#[test] +fn custom_document_properties_drop_padding_before_the_cap() { + let mut doc = open(fixture()); + set_info(&mut doc, "Title", "Report").unwrap(); + let Some(Object::Ref(info_ref)) = doc.trailer().get(b"Info").cloned() else { panic!("an /Info reference") }; + let text = |s: &str| Object::String(pdfcraft_cos::PdfString { bytes: s.as_bytes().to_vec(), hex: false }); + doc.update_dict(info_ref, |d| { + d.set(b"Mixed".to_vec(), text(" \0Lab\0 ")); + d.set(b"Pad".to_vec(), text(" \0 \0")); + for i in 0..300 { + d.set(format!("Blank{i}").into_bytes(), text(" ")); + } + d.set(b"Keep".to_vec(), text("yes")); + }) + .unwrap(); + assert_eq!(custom_info(&doc), [("Mixed".to_string(), "Lab".to_string()), ("Keep".to_string(), "yes".to_string())]); +} + /// A one-page document whose catalog has `xmp` as its (Flate-compressed) XMP packet, and the /// same title and author in `/Info`, as LibreOffice writes them. fn doc_with_xmp(xmp: &[u8]) -> Document { @@ -1852,11 +1971,24 @@ fn page_boxes_default_inherit_and_set() { #[test] fn duplicate_pages_inserts_copies_after_the_last() { let mut doc = open(fixture()); - duplicate_pages(&mut doc, &[0, 1]).unwrap(); + duplicate_pages(&mut doc, &[0, 1], None).unwrap(); let doc = open(write_full(&doc, &SaveOptions::default()).unwrap()); assert_eq!(labels(&doc), ["Page 1", "Page 2", "Page 1", "Page 2", "Page 3"]); } +#[test] +fn duplicate_pages_at_inserts_the_copies_there() { + let mut doc = open(fixture()); + duplicate_pages(&mut doc, &[2], Some(0)).unwrap(); + let doc = open(write_full(&doc, &SaveOptions::default()).unwrap()); + assert_eq!(labels(&doc), ["Page 3", "Page 1", "Page 2", "Page 3"]); + // An index past the end lands at the end. + let mut doc = open(fixture()); + duplicate_pages(&mut doc, &[0], Some(99)).unwrap(); + let doc = open(write_full(&doc, &SaveOptions::default()).unwrap()); + assert_eq!(labels(&doc), ["Page 1", "Page 2", "Page 3", "Page 1"]); +} + #[test] fn replace_pages_swaps_content_but_keeps_annotations() { // Page 2 of the target gets an annotation; replacing its content keeps it. @@ -2463,7 +2595,7 @@ fn import_can_merge_into_same_named_fields_instead() { #[test] fn duplicating_a_page_with_a_field_keeps_the_field_name() { let mut doc = doc_with_field("Text1"); - crate::duplicate_pages(&mut doc, &[0]).unwrap(); + crate::duplicate_pages(&mut doc, &[0], None).unwrap(); assert_eq!(crate::page_count(&doc).unwrap(), 2); assert_eq!(form_field_names(&doc), ["Text1", "Text1"], "nothing renamed"); } @@ -2657,3 +2789,35 @@ fn a_chain_of_objects_too_deep_to_copy_is_refused_and_leaves_the_destination_alo .join(); assert!(run.is_ok(), "the copy must not overflow the stack"); } + +#[test] +fn rotate_repeated_indices_accumulate_from_the_current_page_value() { + for level in 0..3 { + for indices in [vec![0, 0], vec![0, 1, 0]] { + let mut doc = open(fixture()); + let rotation = doc.add(Object::Int(90)); + let page = pages(&doc).unwrap()[0].obj; + let target = match level { + 0 => page, + 1 => doc.get(page).as_dict().unwrap().reference(b"Parent").unwrap(), + _ => pages_root(&doc).unwrap(), + }; + doc.update_dict(target, |d| d.set(b"Rotate".to_vec(), Object::Ref(rotation))).unwrap(); + let mut doc = open(write_full(&doc, &SaveOptions::default()).unwrap()); + let rotation = walk(&doc).unwrap()[0].1.reference(b"Rotate").unwrap(); + let original_labels = labels(&doc); + rotate_pages(&mut doc, &indices, 90).unwrap(); + assert_eq!(page_rotation(&doc, 0).unwrap(), 270, "level {level}, indices {indices:?}"); + assert_eq!(*doc.get(rotation), Object::Int(90), "shared source angle is unchanged"); + for saved in [write_incremental(&doc, &SaveOptions::default()).unwrap(), write_full(&doc, &SaveOptions::default()).unwrap()] { + let reopened = open(saved.clone()); + assert_eq!(page_rotation(&reopened, 0).unwrap(), 270); + assert_eq!(page_rotation(&reopened, 1).unwrap(), if indices.len() == 3 { 180 } else { 90 }); + assert_eq!(page_rotation(&reopened, 2).unwrap(), 90); + assert_eq!(labels(&reopened), original_labels); + let independent = hayro_syntax::Pdf::new(saved).unwrap(); + assert_eq!(independent.pages()[0].render_dimensions(), (400.0, 300.0)); + } + } + } +} diff --git a/crates/platform/src/staging.rs b/crates/platform/src/staging.rs index f7200efb9e..fee0a16336 100644 --- a/crates/platform/src/staging.rs +++ b/crates/platform/src/staging.rs @@ -27,6 +27,20 @@ pub fn create_staging( name: &str, form: StagingName, suffixes: impl IntoIterator, +) -> std::io::Result<(PathBuf, std::fs::File)> { + create_staging_as(dir, name, form, suffixes, None) +} + +/// [`create_staging`], giving the new file the permissions `keep` on Unix. It is created readable +/// and writable by its owner only and then given `keep`, so its mode is never more open than that +/// of the file it will replace, not even while it is still empty (a handle opened then would keep +/// reading what is written later). Only the mode is copied: the owner and group are the saver's. +fn create_staging_as( + dir: &Path, + name: &str, + form: StagingName, + suffixes: impl IntoIterator, + keep: Option<&std::fs::Permissions>, ) -> std::io::Result<(PathBuf, std::fs::File)> { // At most 128 bytes of the target's name, cut between characters, so the staging name fits // the 255-byte (Linux, macOS) and 255-unit (Windows) limits however long that name is. @@ -42,8 +56,22 @@ pub fn create_staging( StagingName::SuffixThenTag => format!(".{stem}.{suffix:016x}.pdfcraft-tmp"), StagingName::TagThenSuffix => format!(".{stem}.pdfcraft-{suffix:016x}.tmp"), }); - match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) { - Ok(file) => return Ok((tmp, file)), + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + if keep.is_some() { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + match options.open(&tmp) { + Ok(file) => { + if let Some(keep) = keep { + // A volume that can't change modes (FAT, some network shares) leaves the file + // owner-only: stricter than the file it replaces, never looser. + let _ = file.set_permissions(keep.clone()); + } + return Ok((tmp, file)); + } Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} // Windows reports a folder at the name as "access denied"; it is taken all the same. Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied && tmp.symlink_metadata().is_ok() => {} @@ -69,14 +97,17 @@ pub fn staging_suffixes() -> impl Iterator { /// crash, a full disk or a refused flush never leaves a half-written file where a good one was, /// and the target is untouched until the new file is complete. The target's folder must exist; /// a folder at the target is refused by the rename. Flushing is the staging file's own -/// (`sync_all`); parent-directory durability after a power loss is not promised. +/// (`sync_all`); parent-directory durability after a power loss is not promised. On Unix a file +/// that is replaced keeps its permissions, so a private file stays private; a new file gets the +/// usual mode for a new file. On Windows and macOS a replaced file keeps its creation date, where +/// the volume stores one. pub fn write_atomically(path: &Path, bytes: &[u8], form: StagingName) -> std::io::Result<()> { write_atomically_with(path, bytes, form, staging_suffixes(), std::fs::File::sync_all) } /// [`write_atomically`] with the staging-name suffixes and the flush given, so tests can plant /// names and refuse flushes. A failure at any step removes the staging file and leaves the -/// target as it was. +/// target as it was. A read-only target is refused before anything is written. pub fn write_atomically_with( path: &Path, bytes: &[u8], @@ -85,14 +116,30 @@ pub fn write_atomically_with( sync_all: impl FnOnce(&std::fs::File) -> std::io::Result<()>, ) -> std::io::Result<()> { use std::io::Write as _; + // A read-only file is not replaced. Windows refuses the rename anyway, but on Unix a rename + // ignores the target's permissions: the file would be overwritten without a word, and come + // out writable (#2658). + if std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().readonly()) { + return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "the file is read-only; save a copy with Save As")); + } let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")); let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("save"); - let (tmp, file) = create_staging(dir, name, form, suffixes)?; + // A file that is replaced keeps its permissions (#1790). Unix modes only: Windows is as before. + #[cfg(unix)] + let keep = std::fs::metadata(path).ok().map(|m| m.permissions()); + #[cfg(not(unix))] + let keep = None; + // A saved file is the same document: it keeps its creation date (#2658). + #[cfg(any(windows, target_os = "macos"))] + let created = std::fs::metadata(path).ok().filter(|m| m.is_file()).and_then(|m| m.created().ok()); + let (tmp, file) = create_staging_as(dir, name, form, suffixes, keep.as_ref())?; let result = (|| { // Closed at the end of the block, before the rename (required on Windows). { let mut file = file; file.write_all(bytes)?; + #[cfg(any(windows, target_os = "macos"))] + keep_created(&file, created); sync_staged(&file, sync_all)?; } std::fs::rename(&tmp, path) @@ -103,6 +150,20 @@ pub fn write_atomically_with( result } +/// Give the staging file the creation date of the file it replaces, where the system keeps one +/// that a program can set (Windows, macOS). Best effort: a volume that can't store it leaves the +/// new file's own date, as before. +#[cfg(any(windows, target_os = "macos"))] +fn keep_created(file: &std::fs::File, created: Option) { + if let Some(created) = created { + #[cfg(target_os = "macos")] + use std::os::macos::fs::FileTimesExt as _; + #[cfg(windows)] + use std::os::windows::fs::FileTimesExt as _; + let _ = file.set_times(std::fs::FileTimes::new().set_created(created)); + } +} + /// Flush the staging file to the disk before it replaces the target. On macOS `sync_all` asks /// for a full flush (`F_FULLFSYNC`), which SMB and FUSE volumes, such as a mounted Cryptomator /// vault, refuse as unsupported: there a plain `fsync` is the strongest flush the volume offers. @@ -162,6 +223,33 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// #2658: a read-only file is refused before anything is staged, on Unix (where a rename would + /// replace it) as on Windows (where the rename fails). + #[test] + fn a_read_only_target_is_refused_and_kept() { + let dir = test_dir("readonly"); + let target = dir.join("out.pdf"); + std::fs::write(&target, "OLD").unwrap(); + let mut perms = std::fs::metadata(&target).unwrap().permissions(); + perms.set_readonly(true); + std::fs::set_permissions(&target, perms.clone()).unwrap(); + let staged = std::cell::Cell::new(false); + let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [7], |_: &std::fs::File| { + staged.set(true); + Ok(()) + }) + .unwrap_err(); + assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied, "{e}"); + assert!(!staged.get(), "nothing was staged"); + assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); + assert!(std::fs::metadata(&target).unwrap().permissions().readonly(), "still read-only"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "no staging file is left"); + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(false); + std::fs::set_permissions(&target, perms).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn running_out_of_suffixes_refuses_and_creates_nothing() { let dir = test_dir("empty"); @@ -215,4 +303,62 @@ mod tests { assert_eq!(names(&dir), ["out.pdf"]); let _ = std::fs::remove_dir_all(&dir); } + + /// #1790: a replaced file keeps its permissions, and its staging file already has them while + /// it is written; a new file gets the same mode as any other new file. + #[cfg(unix)] + #[test] + fn a_replaced_file_keeps_its_permissions() { + use std::os::unix::fs::PermissionsExt as _; + let dir = test_dir("modes"); + let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; + let target = dir.join("out.pdf"); + for keep in [0o600, 0o640, 0o664] { + std::fs::write(&target, b"OLD").unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(keep)).unwrap(); + let mut staged = 0; + write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [keep as u64], |f| { + staged = f.metadata()?.permissions().mode() & 0o777; + Ok(()) + }) + .unwrap(); + assert_eq!(staged, keep, "the staging file has the target's mode before it is flushed"); + assert_eq!(mode(&target), keep, "the saved file keeps mode {keep:o}"); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + } + let fresh = dir.join("new.pdf"); + write_atomically(&fresh, b"NEW", StagingName::TagThenSuffix).unwrap(); + let plain = dir.join("plain"); + std::fs::write(&plain, b"").unwrap(); + assert_eq!(mode(&fresh), mode(&plain), "a new file is not made private"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// #2658: a save replaces the file, but the document is the same one: it keeps its creation + /// date where the system lets a program set it. + #[cfg(any(windows, target_os = "macos"))] + #[test] + fn a_replaced_file_keeps_its_creation_date() { + #[cfg(target_os = "macos")] + use std::os::macos::fs::FileTimesExt as _; + #[cfg(windows)] + use std::os::windows::fs::FileTimesExt as _; + let dir = test_dir("created"); + let target = dir.join("doc.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + // 2020-01-02 03:04:05 UTC. + let then = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_577_934_245); + let file = std::fs::OpenOptions::new().write(true).open(&target).unwrap(); + file.set_times(std::fs::FileTimes::new().set_created(then)).unwrap(); + drop(file); + assert_eq!(std::fs::metadata(&target).unwrap().created().unwrap(), then, "the fixture's date is set"); + write_atomically(&target, b"NEW", StagingName::SuffixThenTag).unwrap(); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + assert_eq!(std::fs::metadata(&target).unwrap().created().unwrap(), then, "the saved file keeps its creation date"); + // A new file gets a date of its own. + let fresh = dir.join("new.pdf"); + write_atomically(&fresh, b"NEW", StagingName::SuffixThenTag).unwrap(); + assert!(std::fs::metadata(&fresh).unwrap().created().unwrap() > then); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/preflight/Cargo.toml b/crates/preflight/Cargo.toml index b79575fc0b..082e44fc5c 100644 --- a/crates/preflight/Cargo.toml +++ b/crates/preflight/Cargo.toml @@ -14,3 +14,7 @@ workspace = true pdfcraft-content = { workspace = true } pdfcraft-cos = { workspace = true } pdfcraft-model = { workspace = true } +roxmltree = "0.20" + +[dev-dependencies] +flate2 = { workspace = true } diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index a4d90d874b..2cda29fea1 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -165,7 +165,7 @@ fn metadata(doc: &Document) -> Option { let cat = catalog(doc); let m = doc.resolve(cat.get(b"Metadata")?); match &*m { - Object::Stream(s) => s.decoded().ok().map(|b| String::from_utf8_lossy(&b).into_owned()), + Object::Stream(s) => s.decoded_within(xmp::MAX_XMP_BYTES).ok().map(|b| String::from_utf8_lossy(&b).into_owned()), _ => None, } } @@ -174,10 +174,10 @@ fn metadata(doc: &Document) -> Option { pub fn declared(doc: &Document) -> Declared { let mut d = Declared::default(); if let Some(x) = metadata(doc) { - let part = xmp::value(&x, "pdfaid:part").and_then(|p| p.parse().ok()); - let conf = xmp::value(&x, "pdfaid:conformance").unwrap_or_default(); + let part = xmp::identifier(&x, xmp::PDFA_ID, "part").and_then(|p| p.parse().ok()); + let conf = xmp::identifier(&x, xmp::PDFA_ID, "conformance").unwrap_or_default(); d.pdfa = part.map(|p| (p, conf.to_uppercase())); - d.pdfua = xmp::value(&x, "pdfuaid:part").and_then(|p| p.parse().ok()); + d.pdfua = xmp::identifier(&x, xmp::PDFUA_ID, "part").and_then(|p| p.parse().ok()); } let cat = catalog(doc); if let Some(list) = cat.get(b"OutputIntents").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()) { @@ -423,8 +423,8 @@ pub fn verify(doc: &Document, level: Level) -> Vec { match metadata(doc) { None => issue("6.6.2.1", "The document has no XMP metadata", None, None, true), Some(x) => { - let part = xmp::value(&x, "pdfaid:part"); - let conf = xmp::value(&x, "pdfaid:conformance"); + let part = xmp::identifier(&x, xmp::PDFA_ID, "part"); + let conf = xmp::identifier(&x, xmp::PDFA_ID, "conformance"); if part.as_deref() != Some(&level.part().to_string()) || conf.as_deref().map(str::to_uppercase).as_deref() != Some("B") { issue("6.6.4", "The metadata doesn't identify the file as {}", Some(level.label().to_owned()), None, true); } diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 25d06669ed..80b1c63c49 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -371,3 +371,49 @@ fn device_colour_in_forms_patterns_and_glyphs_is_found() { assert!(declared(&doc).output_intents.is_empty(), "{what}: an sRGB output intent was added to a CMYK document: {:?}", r.fixed); } } + +#[test] +fn standards_identification_uses_namespace_not_prefix() { + fn with_xmp(xmp: &str) -> Document { + let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()); + build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], "") + } + let attribute = r#""#; + let element = r#"2B"#; + for xml in [attribute, element] { + let doc = with_xmp(xml); + assert_eq!(declared(&doc).pdfa, Some((2, "B".into()))); + assert!(!verify(&doc, Level::A2b).iter().any(|i| i.clause == "6.6.4")); + } + assert_eq!(declared(&with_xmp(attribute)).pdfua, Some(1)); + let wrong = r#""#; + let doc = with_xmp(wrong); + assert_eq!(declared(&doc).pdfa, None); + assert!(verify(&doc, Level::A2b).iter().any(|i| i.clause == "6.6.4")); + let nested = format!("{}{}", "".repeat(1100), "".repeat(1100)); + assert_eq!(declared(&with_xmp(&nested)).pdfa, None); +} + +#[test] +fn oversized_xmp_is_refused_while_decoding() { + let xmp = " ".repeat(xmp::MAX_XMP_BYTES + 1); + let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()); + let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], ""); + assert!(metadata(&doc).is_none()); + assert_eq!(declared(&doc).pdfa, None); + + use std::io::Write as _; + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + z.write_all(xmp.as_bytes()).unwrap(); + let compressed = z.finish().unwrap(); + let hex: String = compressed.iter().map(|byte| format!("{byte:02X}")).collect(); + let data = format!("{hex}>"); + let stream = + format!("<< /Type /Metadata /Subtype /XML /Filter [/ASCIIHexDecode /FlateDecode] /Length {} >>\nstream\n{data}\nendstream", data.len()); + let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], ""); + let cat = catalog(&doc); + let raw = doc.resolve(cat.get(b"Metadata").unwrap()); + let Object::Stream(stream) = &*raw else { panic!("fixture has no metadata stream") }; + assert_eq!(stream.decoded().unwrap().len(), xmp::MAX_XMP_BYTES + 1, "fixture must actually decompress"); + assert!(metadata(&doc).is_none(), "compressed metadata must be limited during decoding"); +} diff --git a/crates/preflight/src/xmp.rs b/crates/preflight/src/xmp.rs index 3057213d1a..0da4869cc6 100644 --- a/crates/preflight/src/xmp.rs +++ b/crates/preflight/src/xmp.rs @@ -3,6 +3,116 @@ use pdfcraft_cos::Document; +/// The largest XMP packet read; a larger one is treated as missing. +pub(crate) const MAX_XMP_BYTES: usize = 8 * 1024 * 1024; +/// Namespace URIs of the PDF/A and PDF/UA identification schemas. +pub(crate) const PDFA_ID: &str = "http://www.aiim.org/pdfa/ns/id/"; +pub(crate) const PDFUA_ID: &str = "http://www.aiim.org/pdfua/ns/id/"; + +/// Read an identification property by its expanded XML name, not its arbitrary prefix. A packet +/// that is not well-formed XML (an undeclared prefix, an illegal character) or is too deep or wide +/// to parse safely is read by the conventional prefix (`pdfaid:`, `pdfuaid:`) instead, as before. +pub(crate) fn identifier(xmp: &str, namespace: &str, local: &str) -> Option { + if xmp.len() > MAX_XMP_BYTES { + return None; + } + match parsed_identifier(xmp, namespace, local) { + Ok(found) => found, + Err(()) => { + let prefix = match namespace { + PDFA_ID => "pdfaid", + PDFUA_ID => "pdfuaid", + _ => return None, + }; + value(xmp, &format!("{prefix}:{local}")) + } + } +} + +/// [`identifier`] through an XML parser; `Err` when the packet can't be parsed within the limits. +fn parsed_identifier(xmp: &str, namespace: &str, local: &str) -> Result, ()> { + if !xml_limits_ok(xmp) { + return Err(()); + } + let options = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: 100_000 }; + let doc = roxmltree::Document::parse_with_options(xmp, options).map_err(|_| ())?; + for node in doc.descendants().filter(roxmltree::Node::is_element) { + if let Some(value) = node.attribute((namespace, local)) { + return Ok(Some(value.trim().to_owned())); + } + if node.tag_name().namespace() == Some(namespace) && node.tag_name().name() == local { + return Ok(node.text().map(|value| value.trim().to_owned())); + } + } + Ok(None) +} + +/// Reject deep XML and start tags with too many attributes before roxmltree checks duplicates. +/// Each unquoted `=` in a valid start tag introduces an attribute, including namespace bindings. +/// Every `b[i]` and `hay[p..]` below is guarded by `i < b.len()` / `p < hay.len()`. +fn xml_limits_ok(text: &str) -> bool { + fn find(hay: &[u8], from: usize, needle: &[u8]) -> Option { + (from..hay.len()).find(|&p| hay[p..].starts_with(needle)) + } + let b = text.as_bytes(); + let (mut i, mut depth, mut total_attributes) = (0usize, 0usize, 0usize); + while i < b.len() { + if b[i] != b'<' { + i += 1; + } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 3); + } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 3); + } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 2); + } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 1); + } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 1); + } else { + depth += 1; + if depth > 1024 { + return false; + } + // Skip the rest of the start tag, over any quoted attribute value, and remember its last + // significant byte: a tag that ends in `/` closes itself, so it is one level deep while it + // is read rather than a level left open. + i += 1; + let mut quote = 0u8; + let mut last = 0u8; + let mut attributes = 0usize; + while i < b.len() { + let c = b[i]; + if quote != 0 { + if c == quote { + quote = 0; + } + } else if c == b'"' || c == b'\'' { + quote = c; + } else if c == b'>' { + i += 1; + break; + } else if c == b'=' { + attributes += 1; + total_attributes += 1; + if attributes > 128 || total_attributes > 4096 { + return false; + } + last = c; + } else if !c.is_ascii_whitespace() { + last = c; + } + i += 1; + } + if last == b'/' { + depth -= 1; + } + } + } + true +} + /// The value of a simple XMP property, written either as an attribute (`pdfaid:part="2"`) or /// as an element (`2`, or inside an `rdf:Alt`/`rdf:Seq`). pub fn value(xmp: &str, key: &str) -> Option { @@ -133,3 +243,28 @@ mod tests { assert_eq!(iso_date("junk"), None); } } + +#[cfg(test)] +mod limit_tests { + use super::*; + + #[test] + fn attribute_and_namespace_budgets_bound_parser_work() { + let prefix = r#"(); + let at_limit = format!("{prefix}{}/>", attrs(126)); + assert_eq!(identifier(&at_limit, PDFA_ID, "part").as_deref(), Some("2")); + let over_limit = format!("{prefix}{}/>", attrs(127)); + assert_eq!(identifier(&over_limit, PDFA_ID, "part"), None); + let namespaces = (0..129).map(|i| format!("xmlns:n{i}=\"urn:{i}\" ")).collect::(); + assert_eq!(identifier(&format!(""), PDFA_ID, "part"), None); + let many_elements = format!("{}", r#""#.repeat(4097)); + assert!(!xml_limits_ok(&many_elements)); + // Not well-formed (the prefix is never declared): read by the conventional prefix, as before. + let undeclared = r#""#; + assert_eq!(identifier(undeclared, PDFA_ID, "part").as_deref(), Some("3")); + assert_eq!(identifier(undeclared, PDFA_ID, "conformance").as_deref(), Some("u")); + let over_bytes = format!("{}", " ".repeat(8 * 1024 * 1024)); + assert_eq!(identifier(&over_bytes, PDFA_ID, "part"), None); + } +} diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index d4b0c8ee1c..54cbbd4c7c 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -506,7 +506,8 @@ mod cups_options { let Some(record) = std::env::var_os(RECORD) else { return }; let args: Vec = std::env::args().skip_while(|a| a != "--").skip(1).collect(); std::fs::write(std::path::Path::new(&record).join("args"), args.join("\n")).unwrap(); - println!("{}", std::env::var(ANSWER).unwrap()); + // Serial libtest prints its test label without a newline before this helper's stdout. + println!("\n{}", std::env::var(ANSWER).unwrap()); } fn command(record: &std::path::Path, answer: &str) -> std::process::Command { diff --git a/crates/redact/README.md b/crates/redact/README.md index 3b2d54e814..7cd5ded1e4 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -25,7 +25,7 @@ clear_marks(&mut doc, None)?; // remove marks without app - **vectors**: covered paths are removed; partly covered paths and shadings are clipped so nothing paints inside the areas; - **form XObjects**: rewritten recursively into new objects (pages sharing the original keep it); -- **annotations** whose rectangle overlaps (with their pop-ups), and **form fields** with a +- **annotations** whose rectangle overlaps (with their pop-ups and their replies, `/IRT`), and **form fields** with a widget under a mark; - the marks themselves, replaced by boxes in their fill colour (and overlay text) drawn into the page as a `/PCMark /Redaction` stream. @@ -44,6 +44,11 @@ This limits appearance generation; it is not a limit on the input PDF or the who Structure cleanup resolves named MCIDs and indirect content references. Form content references are updated to the cleaned copy; unchanged sibling descriptions remain. +Replacement descriptions are removed only from marked-content spans changed by cleanup. +Unchanged spans retain their descriptions; unused property entries are retired during redaction +and hidden-content cleanup, including aliases in shared/inherited resources. Descriptions still +used by surviving pages or forms are kept. + A redacted page that inherits `/Resources` from the page tree gets them pinned (by reference; a direct inherited dictionary is made one shared object first) before it is rewritten. The parent's `/Resources` is then retired unless another page still inherits it, so it cannot keep a removed diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs index 23ef2559a5..9a19ba2f0b 100644 --- a/crates/redact/src/image.rs +++ b/crates/redact/src/image.rs @@ -1,5 +1,5 @@ //! Clearing the pixels of an image under redaction regions. The image is decoded, every pixel -//! whose centre maps into a region is set to zero (no ink for image masks), and the result is +//! whose footprint intersects a region is set to zero (no ink for image masks), and the result is //! written as a new Flate image; the original object is untouched (other pages may use it). //! Images whose codec PdfCraft can't decode (DCT, JPX, JBIG2, CCITT) return `None`, and the //! caller removes the whole image instead; that is fail-closed. @@ -33,7 +33,7 @@ fn components(doc: &Document, s: &Stream) -> Option { }) } -/// `Ok(Some(copy))` with the covered pixels cleared, `Ok(None)` when no pixel centre is covered, +/// `Ok(Some(copy))` with the covered pixels cleared, `Ok(None)` when no pixel intersects, /// `Err(())` when the image can't be cleared (the caller removes it). #[allow(clippy::result_unit_err)] pub(crate) fn clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]) -> Result, ()> { @@ -91,18 +91,20 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] }; let mut cleared = 0usize; for j in 0..h { - // Image space: (0,0) is the bottom-left of the unit square; row 0 is the top. - let v = 1.0 - (j as f64 + 0.5) / h as f64; - let (ax, ay) = ctm.apply(0.0, v); - let (bx, by) = ctm.apply(1.0, v); - let row_box = [ax.min(bx), ay.min(by), ax.max(bx), ay.max(by)]; - if !rects.iter().any(|r| overlaps(*r, [row_box[0], row_box[1] - 0.01, row_box[2], row_box[3] + 0.01], 0.0)) { + // Image space has its origin at the bottom left; row 0 is at the top. + let v0 = 1.0 - (j + 1) as f64 / h as f64; + let v1 = 1.0 - j as f64 / h as f64; + let row_box = ctm.bbox([0.0, v0, 1.0, v1]); + if !rects.iter().any(|r| overlaps(*r, row_box, 0.0)) { continue; } for i in 0..w { - let u = (i as f64 + 0.5) / w as f64; - let (x, y) = ctm.apply(u, v); - if !rects.iter().any(|r| x >= r[0] && x <= r[2] && y >= r[1] && y <= r[3]) { + let u0 = i as f64 / w as f64; + let u1 = (i + 1) as f64 / w as f64; + // The bounding box may cover extra pixels for a rotated or skewed image; that + // conservative over-clear prevents a touched sample surviving in the file. + let pixel_box = ctm.bbox([u0, v0, u1, v1]); + if !rects.iter().any(|r| overlaps(*r, pixel_box, 0.0)) { continue; } cleared += 1; diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index c76e119953..52ca6c276f 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -21,7 +21,7 @@ use std::collections::{HashMap, HashSet}; use std::rc::Rc; use pdfcraft_content::{Matrix, Op, Pieces, contains, num, overlaps, serialize_ops, string}; -use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_cos::{CosError, Dict, Document, ObjRef, Object, Stream}; use crate::{RedactError, Report, image, page_streams, tags}; use pdfcraft_fonts::pdf::Metrics; @@ -66,12 +66,16 @@ pub(crate) struct Output { /// dictionaries may name them too (an inherited or shared /Resources, or the caller's when a /// form has none of its own): [`unlink_doomed`] removes those entries. pub doomed: Vec, + /// Original property values retired by this scope or a nested form. Other resource holders + /// may still name them; unlink only entries no surviving content uses. + pub retired_properties: Vec, /// Glyphs / inline images still overlapping a region (verify mode). pub residue: usize, /// Apply mode met vertical-writing text (which it cannot remove) while a redaction region was /// in force: the operation must fail closed rather than leave that text under the mark. pub vertical: bool, pub tag_changes: Vec<(ObjRef, ObjRef, HashSet, HashSet)>, + pub error: Option, } pub(crate) struct Scope<'a> { @@ -246,9 +250,10 @@ fn content_draws(doc: &Document, content: &[Vec], xo: &Dict, name: &[u8], de /// An entry goes unless a page that draws through that dictionary still draws the name (the same /// image shown on another page, or again outside a mark). A dictionary that loses an entry is /// replaced by a private copy on its node, so a dictionary object shared with other nodes is -/// left as it is for them. Fails closed when a page's use of a name cannot be told. -pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef]) -> Result<(), RedactError> { - if doomed.is_empty() { +/// left as it is for them. Retired properties are likewise unlinked only when none of the +/// holder's pages or inheriting forms names them. Fails closed when use cannot be determined. +pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef], retired_properties: &[Object]) -> Result<(), RedactError> { + if doomed.is_empty() && retired_properties.is_empty() { return Ok(()); } let doomed: HashSet = doomed.iter().copied().collect(); @@ -278,11 +283,16 @@ pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef]) -> Result<(), } } let index: HashMap = pdfcraft_model::pages(doc).iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); + let holder_nodes: Vec = holders.iter().map(|(node, _)| *node).collect(); for (node, users) in holders { - let Some(res) = doc.get(node).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned()) else { continue }; + let Some(mut res) = doc.get(node).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned()) else { + continue; + }; let mut xo = res_dict(doc, &res, b"XObject"); let names: Vec> = xo.iter().filter(|(_, v)| v.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(n, _)| n.to_vec()).collect(); - if names.is_empty() { + let properties = res_dict(doc, &res, b"Properties"); + let property_names: Vec> = properties.iter().filter(|(_, v)| retired_properties.contains(v)).map(|(n, _)| n.to_vec()).collect(); + if names.is_empty() && property_names.is_empty() { continue; } let mut contents = Vec::with_capacity(users.len()); @@ -310,11 +320,84 @@ pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef]) -> Result<(), } } if pruned { - let mut res = res; res.set(b"XObject".to_vec(), Object::Dict(xo)); + } + pruned |= prune_retired_properties(doc, &mut res, &property_names, &contents)?; + if pruned { doc.update_dict(node, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; } } + // A surviving form can own the same old resource dictionary as a rewritten page. Its + // unused descriptions remain reachable too, even after every page holder is cleaned. + unlink_form_properties(doc, &holder_nodes, retired_properties)?; + Ok(()) +} + +/// Retire only properties removed from a rewritten scope, preserving names still used by any +/// content sharing this holder. Different pages' streams must never be concatenated together. +fn prune_retired_properties(doc: &Document, res: &mut Dict, names: &[Vec], contents: &[Vec>]) -> Result { + if names.is_empty() { + return Ok(false); + } + let mut used = HashSet::new(); + for content in contents { + for op in resource_ops(doc, res, content)? { + if matches!(op.op.as_slice(), b"BDC" | b"DP") + && let Some(name) = op.name(1) + { + used.insert(name.to_vec()); + } + } + } + let mut properties = res_dict(doc, res, b"Properties"); + let mut pruned = false; + for name in names { + if !used.contains(name) { + pruned |= properties.remove(name).is_some(); + } + } + if pruned { + if properties.is_empty() { + res.remove(b"Properties"); + } else { + res.set(b"Properties".to_vec(), Object::Dict(properties)); + } + } + Ok(pruned) +} + +fn unlink_form_properties(doc: &mut Document, holders: &[ObjRef], retired: &[Object]) -> Result<(), CosError> { + if retired.is_empty() { + return Ok(()); + } + let mut pending: Vec = holders + .iter() + .filter_map(|r| doc.get(*r).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned())) + .collect(); + let mut seen = HashSet::new(); + while let Some(resources) = pending.pop() { + for (_, value) in res_dict(doc, &resources, b"XObject").iter() { + let Some(reference) = value.as_ref() else { continue }; + if !seen.insert(reference) { + continue; + } + if seen.len() > 1_000_000 { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect shared form resources".into() }); + } + let object = doc.get(reference); + let Object::Stream(form) = &*object else { continue }; + if form.dict.name(b"Subtype") != Some(b"Form") { + continue; + } + let Some(mut res) = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()) else { continue }; + let names: Vec> = + res_dict(doc, &res, b"Properties").iter().filter(|(_, value)| retired.contains(value)).map(|(name, _)| name.to_vec()).collect(); + if !names.is_empty() && prune_retired_properties(doc, &mut res, &names, &[vec![form.decoded()?]])? { + doc.update_dict(reference, |d| d.set(b"Resources".to_vec(), Object::Dict(res.clone())))?; + } + pending.push(res); + } + } Ok(()) } @@ -341,6 +424,71 @@ fn clip_out(rects: &[[f64; 4]], ctm: &Matrix, around: [f64; 4]) -> Option]) -> Result, CosError> { + let parsed = Pieces::join(content).parse(); + if parsed.skipped > 0 || parsed.ops.len() > 2_000_000 { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect property resources".into() }); + } + let mut ops = parsed.ops; + let xobjects = res_dict(doc, res, b"XObject"); + let mut seen = HashSet::new(); + let mut cursor = 0; + while cursor < ops.len() { + let op = &ops[cursor]; + if op.is("Do") + && let Some(r) = op.name(0).and_then(|n| xobjects.get(n)).and_then(Object::as_ref) + && seen.insert(r) + && let Object::Stream(s) = &*doc.get(r) + && s.dict.name(b"Subtype") == Some(b"Form") + && s.dict.get(b"Resources").is_none_or(|r| doc.resolve(r).as_dict().is_none()) + { + let parsed = pdfcraft_content::parse(&s.decoded()?); + if seen.len() > 2_000_000 || ops.len().saturating_add(parsed.ops.len()) > 2_000_000 || parsed.skipped > 0 { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect inherited form resources".into() }); + } + ops.extend(parsed.ops); + } + cursor += 1; + } + Ok(ops) +} + +pub(crate) fn prune_properties(doc: &Document, res: &mut Dict, content: &[Vec]) -> Result, CosError> { + let mut properties = res_dict(doc, res, b"Properties"); + if properties.is_empty() { + return Ok(Vec::new()); + } + let mut retired = Vec::new(); + let ops = resource_ops(doc, res, content)?; + let names: Vec> = properties.iter().map(|(n, _)| n.to_vec()).collect(); + for name in names { + if !ops.iter().any(|op| matches!(op.op.as_slice(), b"BDC" | b"DP") && op.name(1) == Some(&name)) + && let Some(value) = properties.remove(&name) + { + retired.push(value); + } + } + if properties.is_empty() { + res.remove(b"Properties"); + } else { + res.set(b"Properties".to_vec(), Object::Dict(properties)); + } + Ok(retired) +} + +fn prune_form_resources(doc: &Document, res: &mut Dict, content: &[Vec]) -> Result<(), CosError> { + let ops = resource_ops(doc, res, content)?; + let mut xo = res_dict(doc, res, b"XObject"); + let names: Vec> = xo.iter().map(|(n, _)| n.to_vec()).collect(); + for name in names { + if !ops.iter().any(|op| op.is("Do") && op.name(0) == Some(&name)) { + xo.remove(&name); + } + } + res.set(b"XObject".to_vec(), Object::Dict(xo)); + Ok(()) +} + /// One glyph of a shown string. struct Glyph { bytes: std::ops::Range, @@ -380,6 +528,12 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< // next. Parse them joined, then give each operator back to the piece its keyword is in. let joined = Pieces::join(streams); let mut parsed = joined.parse(); + // A rewritten piece loses its comments, so keep where they began and ended: a comment that + // ran on into the next piece still hides that piece's first line. + let comment_edges = joined.comment_edges(&parsed); + // Layer removal also changes enclosing replacement spans, so remember the operators before + // any cleanup, not just the survivors of the layer pass. + let original = parsed.ops.clone(); if !scope.hidden_layers.is_empty() { let props = res_dict(doc, resources, b"Properties"); let (kept, removed) = strip_hidden_layers(doc, scope, parsed.ops, &props); @@ -405,6 +559,7 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< } } + let mut processed = Vec::new(); for (piece, mut changed) in pieces.into_iter().zip(rewrite) { let mut ops: Vec = Vec::with_capacity(piece.len()); for op in piece { @@ -622,11 +777,68 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< if !path.is_empty() { ops.append(&mut path); } - out.streams.push(changed.then(|| serialize_ops(&ops))); + processed.push((ops, changed)); + } + if scope.mode == Mode::Apply { + clean_replacements(doc, resources, &original, &mut processed, scope.report); + } + for ((ops, changed), (comment_in, comment_out)) in processed.into_iter().zip(comment_edges) { + out.streams.push(changed.then(|| { + // The piece before still ends inside a comment: end it before the first operator. + let mut bytes = if comment_in { b"\n".to_vec() } else { Vec::new() }; + bytes.extend(serialize_ops(&ops)); + // The piece ended inside a comment that hid the start of the next one: keep it open. + if comment_out { + bytes.push(b'%'); + } + bytes + })); } out } +fn clean_replacements(doc: &Document, resources: &Dict, original: &[Op], processed: &mut [(Vec, bool)], report: &mut Report) { + let originals: HashMap = original.iter().map(|op| (op.span.start, op)).collect(); + let unchanged: HashSet = + processed.iter().flat_map(|(ops, _)| ops).filter(|op| originals.get(&op.span.start) == Some(op)).map(|op| op.span.start).collect(); + let mut stack = Vec::new(); + let mut affected = HashSet::new(); + for op in original { + match op.op.as_slice() { + b"BDC" | b"BMC" => stack.push(op.span.start), + b"EMC" => { + stack.pop(); + } + _ if !unchanged.contains(&op.span.start) => affected.extend(stack.iter().copied()), + _ => {} + } + } + let properties = res_dict(doc, resources, b"Properties"); + for (ops, changed) in processed { + for op in ops { + if !op.is("BDC") || !affected.contains(&op.span.start) { + continue; + } + let property = match op.operands.get(1) { + Some(Object::Name(name)) => properties.get(name).map(|p| doc.resolve(p)), + Some(p) => Some(doc.resolve(p)), + None => None, + }; + if let Some(mut dict) = property.and_then(|p| p.as_dict().cloned()) { + let mut stripped = false; + for key in [&b"ActualText"[..], b"Alt", b"E", b"T"] { + stripped |= dict.remove(key).is_some(); + } + if stripped && let Some(property) = op.operands.get_mut(1) { + *property = Object::Dict(dict); + *changed = true; + report.replacement_text += 1; + } + } + } + } +} + /// Lay out a shown string (or `TJ` array) glyph by glyph, advancing `tm`. Returns the `TJ` /// items with the glyphs under a region replaced by displacements, and how many were removed. fn show(scope: &Scope<'_>, gs: &Gs, tm: &mut Matrix, items: &[Object]) -> (Vec, usize) { @@ -798,7 +1010,9 @@ fn xobject( out.residue += inner.residue; out.vertical |= inner.vertical; out.doomed.extend_from_slice(&inner.doomed); + out.retired_properties.extend(inner.retired_properties); out.tag_changes.extend(inner.tag_changes); + out.error = inner.error.or(out.error.take()); if scope.mode == Mode::Verify { return None; } @@ -808,19 +1022,35 @@ fn xobject( } let mut dict = s.dict.clone(); dict.remove(b"Length"); - // Rebuild the copy's /Resources whenever inner content gained or lost an XObject, so a - // `Do` the copy no longer makes stops keeping the original image or form reachable. + let mut cleaned_res = res.clone(); + // Install rewritten nested forms before finding live property names: a new form may + // still inherit its caller's resources, and its new Do name must resolve here. if !inner.xobjects.is_empty() || !inner.replaced.is_empty() { - let mut res = res.clone(); - let mut xo = res_dict(doc, &res, b"XObject"); - if let Some(d) = &new_data { - prune(&mut xo, &inner.replaced, std::slice::from_ref(d)); + let mut xo = res_dict(doc, &cleaned_res, b"XObject"); + if let Some(data) = &new_data { + prune(&mut xo, &inner.replaced, std::slice::from_ref(data)); } - for (n, r) in &inner.xobjects { - xo.set(n.clone(), Object::Ref(*r)); + for (name, reference) in &inner.xobjects { + xo.set(name.clone(), Object::Ref(*reference)); } - res.set(b"XObject".to_vec(), Object::Dict(xo)); - dict.set(b"Resources".to_vec(), Object::Dict(res)); + cleaned_res.set(b"XObject".to_vec(), Object::Dict(xo)); + } + if let Some(data) = &new_data { + let content = std::slice::from_ref(data); + match prune_properties(doc, &mut cleaned_res, content) { + Ok(retired) => out.retired_properties.extend(retired), + Err(error) => { + out.error = Some(error); + return None; + } + } + if let Err(error) = prune_form_resources(doc, &mut cleaned_res, content) { + out.error = Some(error); + return None; + } + } + if cleaned_res != res { + dict.set(b"Resources".to_vec(), Object::Dict(cleaned_res)); } let (changed, empty) = tags::touched(doc, &res, &data, new_data.as_deref().unwrap_or(&data)); let stream = match new_data { diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 61231bd982..a8dbf64d9b 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -119,6 +119,7 @@ pub struct Report { pub forms_removed: usize, pub annotations: usize, pub fields: usize, + pub replacement_text: usize, /// Structure elements that lost alternate/actual text or emptied marked content. pub tags: usize, /// The form was an AcroForm+XFA form and its `/XFA` packets (which still held redacted field @@ -334,6 +335,7 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result = Vec::new(); // The original image and form XObjects the rewrite drops or replaces, on any page. let mut doomed_xobjects: Vec = Vec::new(); + let mut retired_properties = Vec::new(); let widget_owner: Vec<(ObjRef, String)> = pdfcraft_forms::fields(doc).into_iter().flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::>()).collect(); @@ -353,10 +355,14 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result) -> Result) -> Result) -> Result = annots + .iter() + .filter_map(Object::as_ref) + .filter_map(|r| { + let obj = doc.get(r); + let d = obj.as_dict()?; + (d.name(b"Subtype") != Some(b"Widget")).then_some(())?; + Some((r, d.get(b"IRT").and_then(Object::as_ref)?)) + }) + .collect(); + loop { + let before = removed.len(); + for (r, parent) in &replies { + if !removed.contains(r) && removed.contains(parent) { + removed.push(*r); + report.annotations += 1; + } + } + if removed.len() == before { + break; + } + } let kept: Vec = annots .into_iter() .filter(|a| { @@ -487,7 +530,7 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result bool { fn email(s: &[char], i: usize) -> Option { let local = |c: &char| address_char(*c) || "._%+-".contains(*c); - if !address_char(s[i]) || i.checked_sub(1).and_then(|p| s.get(p)).is_some_and(local) { + // A match starts at the first character of a run of local characters, which may be + // punctuation ("_admin@host.com"), but the run needs a letter or digit. + if !local(&s[i]) || i.checked_sub(1).and_then(|p| s.get(p)).is_some_and(local) { return None; } let mut j = i; while j < s.len() && local(&s[j]) { j += 1; } - if s.get(j) != Some(&'@') { + if s.get(j) != Some(&'@') || !s[i..j].iter().any(|c| address_char(*c)) { return None; } let d0 = j + 1; @@ -181,12 +183,33 @@ fn credit_card(s: &[char], i: usize) -> Option { if !s[i].is_ascii_digit() || digit(i.checked_sub(1).and_then(|p| s.get(p))) { return None; } - let (end, n) = digit_run(s, i, &[' ', '-'], 23); - if !(13..=19).contains(&n) || digit(s.get(end)) { + // Room for the longest card (19 digits in groups, 23 characters) and a tail of up to four digits. + let (end, n) = digit_run(s, i, &[' ', '-'], 29); + // A run longer than that is some other, longer number. + if end == i || run_continues(s, end - 1, 1, &[' ', '-']) { return None; } - let digits: Vec = s[i..end].iter().filter_map(|c| c.to_digit(10)).collect(); - luhn(&digits).then_some(end - i) + // The longest run may carry a trailing number (an expiry, a year, a CVV), so try it and then + // each shorter run ending at a group boundary, longest first. The dropped tail is at most four + // digits, so a longer number (an IBAN's digits) doesn't match on a prefix. + let mut count = n; + for e in (i + 1..=end).rev() { + if n - count > 4 { + break; + } + if !s.get(e - 1).is_some_and(char::is_ascii_digit) { + continue; + } + let boundary = if e == end { !digit(s.get(end)) } else { matches!(s.get(e), Some(' ' | '-')) }; + if boundary && (13..=19).contains(&count) { + let digits: Vec = s[i..e].iter().filter_map(|c| c.to_digit(10)).collect(); + if luhn(&digits) { + return Some(e - i); + } + } + count = count.saturating_sub(1); + } + None } fn ssn(s: &[char], i: usize) -> Option { @@ -401,6 +424,18 @@ mod tests { ); } + #[test] + fn emails_with_a_leading_punctuation_character() { + for lead in ["_", "-", ".", "%", "+"] { + let address = format!("{lead}john@example.com"); + assert_eq!(found(Pattern::Email, &format!("Write to {address} now")), [address.as_str()], "lead {lead:?}"); + } + // A run of local characters is still matched whole, never from its middle. + assert_eq!(found(Pattern::Email, "a.b@x.com, foo_john@example.com"), ["a.b@x.com", "foo_john@example.com"]); + // Punctuation alone is no local part. + assert_eq!(found(Pattern::Email, "Write to ..@example.com or -@example.com"), Vec::::new()); + } + #[test] fn internationalized_emails() { assert_eq!( @@ -421,6 +456,18 @@ mod tests { ); } + #[test] + fn credit_card_followed_by_another_number() { + for tail in ["12/26", "2026", "123", "12 26"] { + assert_eq!(found(Pattern::CreditCard, &format!("Card 4111 1111 1111 1111 {tail}")), ["4111 1111 1111 1111"], "tail {tail}"); + } + assert_eq!(found(Pattern::CreditCard, "Card 3782-822463-10005 123"), ["3782-822463-10005"]); + // A run that is not a card at any group boundary still matches nothing. + assert_eq!(found(Pattern::CreditCard, "Card 4111 1111 1111 1112 12/26"), Vec::::new()); + // A longer grouped number doesn't match on a card-shaped prefix. + assert_eq!(found(Pattern::CreditCard, "IBAN 4111 1111 1111 1111 0000 0000"), Vec::::new()); + } + #[test] fn social_security_numbers() { assert_eq!( diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 64d6ceefce..0c9228ac02 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -146,6 +146,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re let mut total = 0; // The original image and form XObjects the rewrite drops or replaces, on any page. let mut doomed = Vec::new(); + let mut retired_properties = Vec::new(); let n = pdfcraft_model::pages(doc).len(); for pi in 0..n { let page = pdfcraft_model::pages(doc).swap_remove(pi); @@ -165,12 +166,16 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re } scope.hidden_layers = off.clone(); let out = process(doc, &mut scope, &data, &resources, Matrix::IDENTITY); + if let Some(error) = out.error { + return Err(error.into()); + } let blocks = scope.layer_blocks; total += out.residue + blocks + report.glyphs; if !write { continue; } doomed.extend_from_slice(&out.doomed); + retired_properties.extend(out.retired_properties); let mut new_list = list.clone(); let mut changed = false; for (i, new) in out.streams.into_iter().enumerate() { @@ -184,24 +189,24 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re changed = true; } if changed || !out.xobjects.is_empty() || !out.replaced.is_empty() { + // Pin inherited resources before replacing them. When no page still inherits the + // original holder, retiring it also retires descriptions no longer used anywhere. + crate::localize_resources(doc, &[pi])?; + let mut cleaned_page = page.dict.clone(); + cleaned_page.set(b"Contents".to_vec(), Object::Array(new_list.clone())); + let (_, page_content) = page_streams(doc, &cleaned_page, pi)?; let mut res = resources; if !out.xobjects.is_empty() || !out.replaced.is_empty() { let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); // A full save keeps whatever is still referenced: unlink the originals this page no // longer draws so the removed form or image bytes cannot survive in the saved file. - let page_content: Vec> = new_list - .iter() - .map(|o| match &*doc.resolve(o) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - _ => Vec::new(), - }) - .collect(); crate::interp::prune(&mut xo, &out.replaced, &page_content); for (n, r) in &out.xobjects { xo.set(n.clone(), Object::Ref(*r)); } res.set(b"XObject".to_vec(), Object::Dict(xo)); } + retired_properties.extend(crate::interp::prune_properties(doc, &mut res, &page_content)?); doc.update_dict(page.obj, |d| { d.set(b"Contents".to_vec(), Object::Array(new_list)); d.set(b"Resources".to_vec(), Object::Dict(res)); @@ -210,7 +215,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re } // What a page no longer draws must not stay reachable through an inherited or shared // /Resources either. - crate::interp::unlink_doomed(doc, &doomed)?; + crate::interp::unlink_doomed(doc, &doomed, &retired_properties)?; Ok(total) } @@ -266,6 +271,12 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result 0) || (layers && count(Hidden::HiddenLayers) > 0) { content_pass(doc, text, layers, true)?; } + // Every annotation the pages list now: whatever is no longer listed at the end (flattened + // widgets included) leaves the structure tree too. + let listed = |doc: &Document| -> std::collections::HashSet { + pdfcraft_model::pages(doc).iter().flat_map(|p| annots_of(doc, &p.dict)).filter_map(|a| a.as_ref()).collect() + }; + let listed_before = listed(doc); if which.contains(&Hidden::FormFields) && count(Hidden::FormFields) > 0 { // Fields without appearances get one first, so their values stay visible. for f in pdfcraft_forms::fields(doc) { @@ -286,16 +297,16 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result = pdfcraft_model::pages(doc) + .iter() + .flat_map(|p| annots_of(doc, &p.dict)) + .filter_map(|a| a.as_ref()) + .filter(|&r| doc.get(r).as_dict().is_some_and(drop_annot)) + .collect(); for p in pdfcraft_model::pages(doc) { let list = annots_of(doc, &p.dict); - let mut removed: Vec = Vec::new(); - for a in &list { - if let Some(r) = a.as_ref() - && doc.get(r).as_dict().is_some_and(drop_annot) - { - removed.push(r); - } - } let mut kept = Vec::new(); for a in list { let Some(r) = a.as_ref() else { @@ -335,6 +346,12 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result = listed_before.difference(&still).copied().collect(); + crate::tags::drop_object_refs(doc, &gone)?; let off = hidden_layers(doc); let mut cat = doc.get(root).as_dict().cloned().unwrap_or_default(); let mut names = sub(doc, &cat, b"Names"); diff --git a/crates/redact/src/tags.rs b/crates/redact/src/tags.rs index 02aae12aa3..b70cf23ab9 100644 --- a/crates/redact/src/tags.rs +++ b/crates/redact/src/tags.rs @@ -202,3 +202,111 @@ pub(crate) fn clean( } Ok(n) } + +/// Whether `o` is an object reference (`/Type /OBJR`, direct or indirect) to one of `gone`. +fn refers_to(doc: &Document, o: &Object, gone: &HashSet) -> bool { + doc.resolve(o) + .as_dict() + .is_some_and(|d| d.name(b"Type") == Some(b"OBJR") && d.get(b"Obj").and_then(Object::as_ref).is_some_and(|r| gone.contains(&r))) +} + +/// Drop the structure tree's object references (OBJR) to `gone`, annotations removed from their +/// pages (#2447). A tagged PDF's tree points at each link and comment it tags; left there, the +/// reference keeps the annotation, its URL or text included, in a full rewrite after the page +/// no longer lists it. Returns how many references were dropped. +pub(crate) fn drop_object_refs(doc: &mut Document, gone: &HashSet) -> Result { + drop_object_refs_within(doc, gone, MAX_STRUCT_REFS) +} + +/// The most structure-tree references [`drop_object_refs`] visits. +const MAX_STRUCT_REFS: usize = 2_000_000; + +/// [`drop_object_refs`], visiting at most `limit` references. A tree too large to walk in full +/// could keep a reference the walk never reached, so then the whole structure tree goes instead +/// (with `/MarkInfo`, which would claim tags there no longer are): fail-closed, at the cost of +/// the document's tags. +pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet, limit: usize) -> Result { + if gone.is_empty() { + return Ok(0); + } + let Some(root) = doc + .root() + .and_then(|r| doc.get(r).as_dict().cloned()) + .and_then(|c| c.get(b"StructTreeRoot").map(|s| doc.resolve(s)).and_then(|s| s.as_dict().cloned())) + else { + return Ok(0); + }; + let mut todo: Vec = root.get(b"K").cloned().into_iter().collect(); + let mut seen = HashSet::new(); + let mut hits: Vec = Vec::new(); + while let Some(k) = todo.pop() { + match k { + Object::Array(a) => todo.extend(a), + Object::Ref(r) => { + if !seen.insert(r) { + continue; + } + if seen.len() > limit { + let Some(catalog) = doc.root() else { return Ok(0) }; + doc.update_dict(catalog, |c| { + c.remove(b"StructTreeRoot"); + c.remove(b"MarkInfo"); + })?; + return Ok(0); + } + let object = doc.get(r); + if let Object::Array(a) = &*object { + todo.extend(a.iter().cloned()); + continue; + } + let Some(d) = object.as_dict() else { continue }; + if !d.contains(b"S") { + continue; + } + let kids: Vec = match d.get(b"K").map(|k| doc.resolve(k)) { + Some(o) => match &*o { + Object::Array(a) => a.clone(), + _ => d.get(b"K").cloned().into_iter().collect(), + }, + None => Vec::new(), + }; + if kids.iter().any(|x| refers_to(doc, x, gone)) { + hits.push(r); + } + todo.extend(kids); + } + _ => {} + } + } + let mut n = 0; + for r in hits { + let Some(d) = doc.get(r).as_dict().cloned() else { continue }; + let kids = d.get(b"K").map(|k| doc.resolve(k)); + let array = kids.as_ref().is_some_and(|k| matches!(&**k, Object::Array(_))); + let list: Vec = match kids.as_deref() { + Some(Object::Array(a)) => a.clone(), + Some(_) => d.get(b"K").cloned().into_iter().collect(), + None => Vec::new(), + }; + let before = list.len(); + let keep: Vec = list.into_iter().filter(|x| !refers_to(doc, x, gone)).collect(); + n += before - keep.len(); + doc.update_dict(r, |e| { + if array { + e.set(b"K".to_vec(), Object::Array(keep.clone())); + } else if let Some(k) = keep.first().cloned() { + e.set(b"K".to_vec(), k); + } else { + e.remove(b"K"); + } + // An element left with nothing to tag keeps no text describing what it tagged: + // a link's alternate text often repeats the URL, an annotation's its comment. + if keep.is_empty() { + for key in [&b"Alt"[..], b"ActualText", b"T", b"E"] { + e.remove(key); + } + } + })?; + } + Ok(n) +} diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index b9a230fc41..2dcc336024 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -79,6 +79,30 @@ fn glyphs_under_a_mark_go_and_the_rest_stays_put() { assert!(!content(&doc, 0).contains("1234")); } +/// #2130: a font with no /Widths is drawn with its embedded program's advances. Measured with a +/// guess from its name instead (Courier's 600 units for "Mono"), the end of the line seemed to +/// lie past a mark that covers all of it on screen, and was kept. +#[test] +fn a_whole_line_under_a_mark_goes_even_without_widths() { + let text = b"illicit little lilies"; + let font = "<< /Type /Font /Subtype /TrueType /BaseFont /HouseMono /Encoding /WinAnsiEncoding /FontDescriptor 6 0 R >>"; + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + stream("", [b"BT /F1 10 Tf 10 100 Td (".as_slice(), text, b") Tj ET"].concat()), + font.as_bytes().to_vec(), + b"<< /Type /FontDescriptor /FontName /HouseMono /Flags 32 /Ascent 900 /Descent -200 /FontFile2 7 0 R >>".to_vec(), + stream(&format!("/Length1 {}", pdfcraft_fonts::INTER_REGULAR.len()), pdfcraft_fonts::INTER_REGULAR), + ]); + // Inter draws these 21 narrow glyphs in about 60 pt; Courier's guess would need 126. + let line = [5.0, 95.0, 90.0, 112.0]; + mark(&mut doc, 0, &[line], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.glyphs, text.len(), "every glyph of the line goes"); + assert_eq!(under(&mut doc, 0, &[[0.0, 0.0, 300.0, 300.0]]), 0, "{}", content(&doc, 0)); +} + #[test] fn added_text_parameters_do_not_keep_redacted_text() { // An Edit ▸ Add content item: its stream dictionary keeps the source text under /PCAdded. @@ -375,6 +399,25 @@ fn named_image(doc: &Document, page: usize, name: &[u8]) -> Option> { } } +#[test] +fn redaction_clears_pixels_intersected_without_covering_their_centres() { + for (content, rect) in + [(&b"q 200 0 0 100 0 0 cm /Im Do Q"[..], [10.0, 10.0, 20.0, 90.0]), (&b"q 0 100 -200 0 200 0 cm /Im Do Q"[..], [10.0, 5.0, 190.0, 10.0])] + { + let mut doc = one_page( + content, + "/XObject << /Im 7 0 R >>", + vec![stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", [123, 234])], + ); + mark(&mut doc, 0, &[rect], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!(report.images_cleared, 1); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_image(&saved, 0, b"Im"), None); + assert_eq!(named_image(&saved, 0, b"PCRedacted1"), Some(vec![0, 234])); + } +} + /// Apply Redactions gives the page its own pruned /Resources, but the dictionary it drew through /// before (inherited from the /Pages node, or shared with another page) still named the removed /// image and form, so a full-rewrite save kept them. @@ -490,6 +533,59 @@ fn redacting_an_xfa_field_drops_the_xfa_packet_so_its_value_cannot_survive() { assert!(cat.get(b"NeedsRendering").is_none(), "/NeedsRendering is cleared"); } +#[test] +fn replies_to_a_redacted_comment_go_with_it() { + // #2659: a highlight under the mark has a reply placed elsewhere, and that reply a reply of + // its own. The replies' /IRT would keep the highlight (its comment and QuadPoints) in the file. + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>".to_vec(), + stream("", b"BT ET"), + b"<< /Type /Annot /Subtype /Highlight /Rect [20 20 60 30] /QuadPoints [20 30 60 30 20 20 60 20] /Contents (HIGHLIGHT-NOTE) >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [250 250 270 270] /IRT 5 0 R /Contents (FIRST-REPLY) >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [250 200 270 220] /IRT 6 0 R /Contents (SECOND-REPLY) >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [250 100 270 120] /Contents (UNRELATED) >>".to_vec(), + ]; + let mut doc = pdf(objs); + mark(&mut doc, 0, &[[10.0, 10.0, 70.0, 40.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.annotations, 3, "the highlight and both replies: {r:?}"); + let p = pdfcraft_model::pages(&doc).swap_remove(0); + assert_eq!(annots_of(&doc, &p.dict).len(), 1, "only the unrelated note stays"); + // A classic table, so the objects' strings are plain in the file. + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + for gone in [&b"HIGHLIGHT-NOTE"[..], b"FIRST-REPLY", b"SECOND-REPLY"] { + assert!(!bytes.windows(gone.len()).any(|w| w == gone), "{} survived the save", String::from_utf8_lossy(gone)); + } + assert!(bytes.windows(9).any(|w| w == b"UNRELATED")); +} + +/// The replies listed before the annotations they answer, deepest first, and a pop-up on a +/// reply: one pass in `/Annots` order would keep them, the repeated passes don't (review of +/// #2697). +#[test] +fn replies_listed_before_their_parent_and_their_pop_ups_go_too() { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Annots [7 0 R 9 0 R 6 0 R 5 0 R 8 0 R] >>".to_vec(), + stream("", b"BT ET"), + b"<< /Type /Annot /Subtype /Highlight /Rect [20 20 60 30] /QuadPoints [20 30 60 30 20 20 60 20] /Contents (HIGHLIGHT-NOTE) >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [250 250 270 270] /IRT 5 0 R /Contents (FIRST-REPLY) /Popup 9 0 R >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [250 200 270 220] /IRT 6 0 R /Contents (SECOND-REPLY) >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [250 100 270 120] /Contents (UNRELATED) >>".to_vec(), + b"<< /Type /Annot /Subtype /Popup /Rect [200 150 290 190] /Parent 6 0 R >>".to_vec(), + ]; + let mut doc = pdf(objs); + mark(&mut doc, 0, &[[10.0, 10.0, 70.0, 40.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.annotations, 3, "the highlight and both replies: {r:?}"); + let p = pdfcraft_model::pages(&doc).swap_remove(0); + let left = annots_of(&doc, &p.dict); + assert_eq!(left, vec![Object::Ref(ObjRef::new(8, 0))], "only the unrelated note stays, the reply's pop-up goes too"); +} + #[test] fn comments_links_and_fields_under_a_mark_go() { let mut doc = one_page(b"", "", vec![]); @@ -631,6 +727,140 @@ fn hidden_information_is_counted_and_removed() { assert!(shown, "the field's value stays visible as page content"); } +/// Issue #2447: in a tagged PDF the structure tree points at the link and the comment it tags +/// (`/OBJR`). Removing them must drop those references too, or the full rewrite keeps the +/// annotations, the URL and the comment text included, while the page no longer lists them. +#[test] +fn removed_links_and_comments_leave_the_structure_tree_and_the_saved_file() { + use crate::sanitize::{Hidden, remove_hidden}; + let objs: Vec> = vec![ + // 1 catalog, with a structure tree + b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 8 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + // 3 page: a tagged paragraph (MCID 0), a link and a comment, both tagged + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R 7 0 R] /StructParents 0 >>".to_vec(), + stream("", b"/P << /MCID 0 >> BDC BT /F1 10 Tf 10 230 Td (See the site) Tj ET EMC"), + FONT.replace("95 0 R", "13 0 R").into_bytes(), + // 6 link, 7 comment + b"<< /Type /Annot /Subtype /Link /Rect [10 228 80 240] /StructParent 1 /A << /S /URI /URI (https://secret.example/token=ZX81) >> >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [200 200 220 220] /StructParent 2 /Contents (COMMENTSECRETNOTE) >>".to_vec(), + // 8 struct tree root, 9 document element, 10 paragraph, 11 link element (direct OBJR), + // 12 annotation element (OBJR by reference, as K's only kid) + b"<< /Type /StructTreeRoot /K 9 0 R /ParentTree << /Nums [0 [10 0 R] 1 11 0 R 2 12 0 R] >> /ParentTreeNextKey 3 >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 8 0 R /K [10 0 R 11 0 R 12 0 R] >>".to_vec(), + b"<< /Type /StructElem /S /P /P 9 0 R /Pg 3 0 R /K 0 >>".to_vec(), + // the link element's alternate text repeats the URL + b"<< /Type /StructElem /S /Link /P 9 0 R /Pg 3 0 R /Alt (secret.example link) /K [<< /Type /OBJR /Obj 6 0 R /Pg 3 0 R >>] >>".to_vec(), + b"<< /Type /StructElem /S /Annot /P 9 0 R /Pg 3 0 R /K 14 0 R >>".to_vec(), + widths(), + b"<< /Type /OBJR /Obj 7 0 R /Pg 3 0 R >>".to_vec(), + ]; + let mut doc = pdf(objs); + let done = remove_hidden(&mut doc, &[Hidden::LinksActionsScripts, Hidden::Comments]).unwrap(); + assert!(done.contains(&(Hidden::LinksActionsScripts, 1)) && done.contains(&(Hidden::Comments, 1)), "{done:?}"); + // Without object streams, whose compression would hide the strings from the search. + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle); + assert!(has(b"(See the site)"), "the search sees the saved objects"); + assert!(!has(b"secret.example"), "the removed link's URL survived the save"); + assert!(!has(b"COMMENTSECRETNOTE"), "the removed comment's text survived the save"); + // The tagged paragraph and its content stay; the elements lose only the references. + let saved = Document::open(Arc::new(bytes)).unwrap(); + assert!(content(&saved, 0).contains("(See the site)")); + let objrs = saved.object_numbers().into_iter().filter(|&n| { + let o = saved.get(ObjRef::new(n, saved.generation(n))); + let direct = o.as_dict().is_some_and(|d| d.name(b"Type") == Some(b"OBJR")); + let nested = o.as_dict().and_then(|d| d.get(b"K")).is_some_and(|k| match &*saved.resolve(k) { + Object::Array(a) => a.iter().any(|x| saved.resolve(x).as_dict().is_some_and(|d| d.name(b"Type") == Some(b"OBJR"))), + _ => false, + }); + direct || nested + }); + assert_eq!(objrs.count(), 0, "no object reference to a removed annotation is left"); + let root = saved.get(saved.root().unwrap()).as_dict().cloned().unwrap(); + assert!(root.contains(b"StructTreeRoot"), "the structure tree itself stays"); +} + +/// #2532 review: a tagged form's widgets, flattened when form fields are removed, leave the +/// structure tree too (their field value with them), and a pop-up listed on another page than +/// its removed comment goes with it. +#[test] +fn flattened_widgets_and_far_pop_ups_leave_the_structure_tree() { + use crate::sanitize::{Hidden, remove_hidden}; + let objs: Vec> = vec![ + // 1 catalog with a form and a structure tree + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> /StructTreeRoot 8 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R 13 0 R] /Count 2 >>".to_vec(), + // 3 page 1: a text field's widget and a comment + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R 7 0 R] /StructParents 0 >>".to_vec(), + stream("", b"BT /F1 10 Tf 10 230 Td (Name:) Tj ET"), + FONT.replace("95 0 R", "12 0 R").into_bytes(), + // 6 the field and its widget, with an appearance + b"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (FIELDSECRETVALUE) /Rect [50 225 200 240] /StructParent 1 /AP << /N 11 0 R >> >>".to_vec(), + // 7 comment, its pop-up (15) listed on page 2 + b"<< /Type /Annot /Subtype /Text /Rect [200 200 220 220] /Contents (note) /Popup 15 0 R >>".to_vec(), + // 8 struct tree root, 9 document, 10 form element tagging the widget + b"<< /Type /StructTreeRoot /K 9 0 R /ParentTree << /Nums [1 10 0 R] >> /ParentTreeNextKey 2 >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 8 0 R /K [10 0 R] >>".to_vec(), + b"<< /Type /StructElem /S /Form /P 9 0 R /Pg 3 0 R /K [<< /Type /OBJR /Obj 6 0 R /Pg 3 0 R >>] >>".to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 150 15] /Resources << /Font << /F1 5 0 R >> >>", b"BT /F1 10 Tf 2 3 Td (FIELDSECRETVALUE) Tj ET"), + widths(), + // 13 page 2, listing the pop-up of page 1's comment + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Annots [15 0 R 16 0 R] >>".to_vec(), + b"<< >>".to_vec(), + b"<< /Type /Annot /Subtype /Popup /Rect [0 0 100 50] /Parent 7 0 R >>".to_vec(), + // 16 an unrelated link on page 2 stays + b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] >>".to_vec(), + ]; + let mut doc = pdf(objs); + remove_hidden(&mut doc, &[Hidden::FormFields, Hidden::Comments]).unwrap(); + let page2 = pdfcraft_model::pages(&doc)[1].dict.clone(); + let left: Vec = page2.get(b"Annots").map(|a| doc.resolve(a).as_array().map(|a| a.to_vec()).unwrap_or_default()).unwrap_or_default(); + assert_eq!(left, [Object::Ref(ObjRef::new(16, 0))], "the far pop-up went with its comment"); + let form = doc.get(ObjRef::new(10, 0)).as_dict().cloned().unwrap(); + let kids = form.get(b"K").map(|k| doc.resolve(k).as_array().map(Vec::len).unwrap_or(1)); + assert!(kids.unwrap_or(0) == 0, "the form element no longer references the flattened widget"); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + let saved = Document::open(Arc::new(bytes)).unwrap(); + let field_left = saved.object_numbers().into_iter().any(|n| { + saved.get(ObjRef::new(n, saved.generation(n))).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget") || d.contains(b"FT")) + }); + assert!(!field_left, "the flattened field didn't survive the save through the structure tree"); +} + +/// #2532 review: a structure tree larger than the walk's limit can't be searched in full, so a +/// reference the walk never reached could keep a removed annotation. Then the whole tree goes +/// (with `/MarkInfo`), rather than reporting success with references left behind; under the +/// limit only the reference goes. +#[test] +fn a_structure_tree_too_large_to_walk_is_removed_whole() { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] >>".to_vec(), + // 4: the removed link + b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (https://secret.example) >> >>".to_vec(), + // 5 root, 6 document, 7 section, 8 link element with the OBJR + b"<< /Type /StructTreeRoot /K 6 0 R >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 5 0 R /K [7 0 R] >>".to_vec(), + b"<< /Type /StructElem /S /Sect /P 6 0 R /K [8 0 R] >>".to_vec(), + b"<< /Type /StructElem /S /Link /P 7 0 R /K [<< /Type /OBJR /Obj 4 0 R >>] >>".to_vec(), + ]; + let gone: std::collections::HashSet = [ObjRef::new(4, 0)].into(); + let catalog = |doc: &Document| doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + + let mut doc = pdf(objs.clone()); + assert_eq!(crate::tags::drop_object_refs_within(&mut doc, &gone, 100).unwrap(), 1); + assert!(catalog(&doc).contains(b"StructTreeRoot") && catalog(&doc).contains(b"MarkInfo"), "under the limit the tree stays"); + + let mut doc = pdf(objs); + crate::tags::drop_object_refs_within(&mut doc, &gone, 2).unwrap(); + assert!(!catalog(&doc).contains(b"StructTreeRoot"), "a tree the walk couldn't finish goes whole"); + assert!(!catalog(&doc).contains(b"MarkInfo")); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + assert!(!bytes.windows(14).any(|w| w == b"secret.example"), "the removed link can't be reached any more"); +} + /// Issue #1325: removing comments keeps the annotations that are not comments: the multimedia /// and print-production ones (Watermark, PrinterMark, TrapNet, Screen, Movie, RichMedia, 3D). #[test] @@ -1056,6 +1286,100 @@ fn redacting_every_inheriting_page_retires_the_parent_resources() { } } +#[test] +fn replacement_text_and_property_resources_do_not_survive_redaction() { + let mut doc = one_page( + b"/Span /P0 BDC /Span <> BDC BT /F1 10 Tf 10 200 Td (SECRET)", + "/Properties << /P0 7 0 R >>", + vec![ + b"<< /MCID 0 /ActualText (LEAK_PROPERTY) /Alt (LEAK_ALT) /E (LEAK_EXPANSION) >>".to_vec(), + stream("", b"Tj ET EMC EMC /Span <> DP BT /F1 10 Tf 10 100 Td (Public) Tj ET"), + ], + ); + doc.update_dict(ObjRef::new(3, 0), |d| { + d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(ObjRef::new(8, 0))])); + }) + .unwrap(); + mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], ""); + apply(&mut doc, None).unwrap(); + assert_no_secret(&doc, b"LEAK_"); + let saved = reopen(&doc); + assert!(content(&saved, 0).contains("Public")); + let p = pdfcraft_model::pages(&saved).swap_remove(0); + assert!(!saved.resolve(p.dict.get(b"Resources").unwrap()).as_dict().unwrap().contains(b"Properties")); +} + +#[test] +fn inherited_form_properties_lose_replacement_text() { + let mut doc = one_page( + b"/Fm Do", + "/XObject << /Fm 7 0 R >> /Properties << /P0 8 0 R >>", + vec![ + stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300]", + b"/Span /P0 BDC BT /F1 10 Tf 10 200 Td (SECRET) Tj ET EMC BT /F1 10 Tf 10 100 Td (Public) Tj ET", + ), + b"<< /ActualText (LEAK_FORM_PROPERTY) >>".to_vec(), + ], + ); + mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], ""); + apply(&mut doc, None).unwrap(); + assert_no_secret(&doc, b"LEAK_"); + assert_no_secret(&doc, b"SECRET"); + let mut saved = reopen(&doc); + assert_eq!(under(&mut saved, 0, &[[0.0, 90.0, 100.0, 110.0]]), 6); +} + +#[test] +fn properties_used_by_unmodified_inheriting_forms_stay() { + let mut doc = one_page( + b"BT /F1 10 Tf 10 200 Td (SECRET) Tj ET /Fm Do", + "/XObject << /Fm 7 0 R >> /Properties << /P0 8 0 R >>", + vec![stream("/Type /XObject /Subtype /Form /BBox [0 0 10 10]", b"/Span /P0 BDC 0 0 10 10 re f EMC"), b"<< /ActualText (Public) >>".to_vec()], + ); + mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], ""); + apply(&mut doc, None).unwrap(); + let saved = reopen(&doc); + let page = pdfcraft_model::pages(&saved).swap_remove(0); + let resources = saved.resolve(page.dict.get(b"Resources").unwrap()); + let properties = saved.resolve(resources.as_dict().unwrap().get(b"Properties").unwrap()); + let property = saved.resolve(properties.as_dict().unwrap().get(b"P0").unwrap()); + assert_eq!(property.as_dict().unwrap().get(b"ActualText").unwrap().as_string().unwrap().to_text(), "Public"); +} + +#[test] +fn untouched_replacement_descriptions_survive_redaction_and_hidden_text_cleanup() { + for hidden in [false, true] { + let mode = if hidden { 3 } else { 0 }; + let data = format!( + "/Span <> BDC BT /F1 10 Tf {mode} Tr 10 200 Td (SECRET) Tj ET EMC \ + /Span /Public BDC BT /F1 10 Tf 0 Tr 10 100 Td (Public) Tj ET EMC" + ); + let mut doc = one_page( + data.as_bytes(), + "/Properties << /Public 7 0 R >>", + vec![b"<< /ActualText (PUBLIC_ACTUAL) /Alt (PUBLIC_ALT) /E (PUBLIC_EXPANSION) /T (PUBLIC_TITLE) >>".to_vec()], + ); + if hidden { + sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenText]).unwrap(); + } else { + mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], ""); + apply(&mut doc, None).unwrap(); + } + assert_no_secret(&doc, b"LEAK_"); + assert_no_secret(&doc, b"SECRET"); + let saved = reopen(&doc); + let page = pdfcraft_model::pages(&saved).swap_remove(0); + let resources = saved.resolve(page.dict.get(b"Resources").unwrap()); + let properties = saved.resolve(resources.as_dict().unwrap().get(b"Properties").unwrap()); + let property = saved.resolve(properties.as_dict().unwrap().get(b"Public").unwrap()); + let dict = property.as_dict().unwrap(); + for key in [&b"ActualText"[..], b"Alt", b"E", b"T"] { + assert!(dict.contains(key), "surviving description removed: {key:?}, hidden={hidden}"); + } + } +} + #[test] fn a_hostile_icc_component_count_is_refused_not_multiplied_into_a_crash() { // /N comes from the file. 2^60 × 8 bpc × width 2 overflows the row size; a negative /N wrapped @@ -1117,3 +1441,125 @@ fn masks_and_alternates_outside_redaction_stay() { assert!(image.dict.contains(b"SMask") && image.dict.contains(b"Alternates")); assert_eq!(image.decoded().unwrap(), b"PUBLIC_TOPBOTTOM"); } + +#[test] +fn stream_boundary_comment_stays_inert_after_unrelated_redaction() { + for tail in [b"BT /F1 10 Tf 10 200 Td (HIDDEN) Tj ET".as_slice(), b"BT /F1 10 Tf 10 200 Td (HIDDEN) Tj ET\nBT /F1 10 Tf 10 250 Td (LIVE) Tj ET"] { + let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td (REMOVE) Tj ET % note", "", vec![]); + let hidden = doc.add(Object::Stream(Stream::from_raw(Dict::new(), tail.to_vec()))); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(hidden)]))).unwrap(); + mark(&mut doc, 0, &[[9.0, 95.0, 50.0, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6); + let reopened = reopen(&doc); + let page = &pdfcraft_model::pages(&reopened)[0]; + let (_, data) = page_streams(&reopened, &page.dict, 0).unwrap(); + // Independent of Pieces::join: the renderer concatenates streams with spaces. + let ops = pdfcraft_content::parse(&data.join(&b' ')).ops; + assert!(!ops.iter().any(|o| o.operands.iter().any(|v| v.as_string().is_some_and(|s| s.bytes == b"HIDDEN"))), "hidden text became executable"); + let live = ops.iter().any(|o| o.operands.iter().any(|v| v.as_string().is_some_and(|s| s.bytes == b"LIVE"))); + assert_eq!(live, tail.contains(&b'\n')); + } +} + +#[test] +fn rewriting_a_piece_keeps_its_leading_comment_terminator() { + for eol in [b"\n".as_slice(), b"\r", b"\r\n"] { + let mut doc = one_page(b"q Q % note", "", vec![]); + let mut tail = eol.to_vec(); + tail.extend(b"BT /F1 10 Tf 10 100 Td (REMOVE) Tj ET BT /F1 10 Tf 10 200 Td (KEEP) Tj ET"); + let second = doc.add(Object::Stream(Stream::from_raw(Dict::new(), tail))); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(second)]))).unwrap(); + mark(&mut doc, 0, &[[9.0, 95.0, 50.0, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6); + let p = &pdfcraft_model::pages(&doc)[0]; + let (_, data) = page_streams(&doc, &p.dict, 0).unwrap(); + let ops = pdfcraft_content::parse(&data.join(&b' ')).ops; + assert_eq!(ops.iter().filter(|o| o.is("BT")).count(), 2, "leading EOL lost; first live operator swallowed"); + assert!(ops.iter().any(|o| o.operands.iter().any(|v| v.as_string().is_some_and(|s| s.bytes == b"KEEP")))); + } +} + +#[test] +fn named_hidden_text_descriptions_are_removed_from_saved_bytes() { + let mut doc = one_page( + b"/Span /P0 BDC BT /F1 10 Tf 3 Tr 10 200 Td (SECRET) Tj ET EMC", + "/Properties << /P0 7 0 R >>", + vec![b"<< /ActualText (LEAK_ACTUAL) /Alt (LEAK_ALT) /E (LEAK_EXPANSION) /T (LEAK_TITLE) >>".to_vec()], + ); + sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenText]).unwrap(); + assert_no_secret(&doc, b"SECRET"); + assert_no_secret(&doc, b"LEAK_"); + let saved = reopen(&doc); + let page = pdfcraft_model::pages(&saved).swap_remove(0); + assert!(!saved.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().contains(b"Properties")); +} + +#[test] +fn unused_descriptions_in_shared_or_inherited_resources_are_retired() { + for hidden in [false, true] { + for inherited in [false, true] { + let mode = if hidden { 3 } else { 0 }; + let data = format!("/Span /P0 BDC BT /F1 10 Tf {mode} Tr 10 200 Td (SECRET) Tj ET EMC /Fm Do"); + let mut doc = one_page( + data.as_bytes(), + "/Properties << /P0 7 0 R /Public 8 0 R >> /XObject << /Fm 9 0 R >>", + vec![ + b"<< /ActualText (LEAK_ACTUAL) /Alt (LEAK_ALT) /E (LEAK_EXPANSION) /T (LEAK_TITLE) >>".to_vec(), + b"<< /ActualText (PUBLIC_ACTUAL) /Alt (PUBLIC_ALT) /E (PUBLIC_EXPANSION) /T (PUBLIC_TITLE) >>".to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 120]", b"/Span /Public BDC BT /F1 10 Tf 0 Tr 10 100 Td (Public) Tj ET EMC"), + ], + ); + let first = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone(); + let resources = doc.add(first.get(b"Resources").unwrap().clone()); + let second_content = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"/Fm Do".to_vec()))); + let mut second = first; + second.set(b"Contents".to_vec(), Object::Ref(second_content)); + if inherited { + second.remove(b"Resources"); + } else { + second.set(b"Resources".to_vec(), Object::Ref(resources)); + } + let second = doc.add(Object::Dict(second)); + doc.update_dict(ObjRef::new(3, 0), |d| { + if inherited { + d.remove(b"Resources"); + } else { + d.set(b"Resources".to_vec(), Object::Ref(resources)); + } + }) + .unwrap(); + doc.update_dict(ObjRef::new(2, 0), |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(3, 0)), Object::Ref(second)])); + d.set(b"Count".to_vec(), Object::Int(2)); + if inherited { + d.set(b"Resources".to_vec(), Object::Ref(resources)); + } + }) + .unwrap(); + if hidden { + sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenText]).unwrap(); + } else { + mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], ""); + apply(&mut doc, Some(&[0])).unwrap(); + } + assert_no_secret(&doc, b"LEAK_"); + assert_no_secret(&doc, b"SECRET"); + let mut saved = reopen(&doc); + for page_index in 0..2 { + assert_eq!(under(&mut saved, page_index, &[[0.0, 90.0, 100.0, 110.0]]), 6); + let page = pdfcraft_model::pages(&saved).swap_remove(page_index); + let resources = saved.resolve(page.dict.get(b"Resources").unwrap()); + let properties = saved.resolve(resources.as_dict().unwrap().get(b"Properties").unwrap()); + assert!(!properties.as_dict().unwrap().contains(b"P0")); + let property = saved.resolve(properties.as_dict().unwrap().get(b"Public").unwrap()); + for key in [&b"ActualText"[..], b"Alt", b"E", b"T"] { + assert!(property.as_dict().unwrap().contains(key), "shared form lost {key:?}: hidden={hidden}, inherited={inherited}"); + } + } + } + } +} + +mod property_retirement; diff --git a/crates/redact/src/tests/property_retirement.rs b/crates/redact/src/tests/property_retirement.rs new file mode 100644 index 0000000000..ad5197637a --- /dev/null +++ b/crates/redact/src/tests/property_retirement.rs @@ -0,0 +1,212 @@ +use super::*; + +const SECRET: &[u8] = b"/Span /P0 BDC BT /F1 10 Tf 10 200 Td (SECRET) Tj ET EMC"; +const DESCRIPTION: &[u8] = b"<< /ActualText (LEAK_ACTUAL) /Alt (LEAK_ALT) /E (LEAK_EXPANSION) /T (LEAK_TITLE) >>"; +const PUBLIC: &[u8] = b"<< /ActualText (PUBLIC_ACTUAL) /Alt (PUBLIC_ALT) /E (PUBLIC_EXPANSION) /T (PUBLIC_TITLE) >>"; + +fn resources(doc: &Document, page: usize) -> Dict { + let page = pdfcraft_model::pages(doc).swap_remove(page); + doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone() +} + +fn property(doc: &Document, resources: &Dict, name: &[u8]) -> Option { + let properties = doc.resolve(resources.get(b"Properties")?); + doc.resolve(properties.as_dict()?.get(name)?).as_dict().cloned() +} + +fn assert_description(dict: &Dict) { + for key in [&b"ActualText"[..], b"Alt", b"E", b"T"] { + assert!(dict.contains(key), "surviving description lost {key:?}"); + } +} + +fn add_second_page(doc: &mut Document, data: &[u8], resources: Object) -> ObjRef { + let mut page = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone(); + let contents = doc.add(Object::Stream(Stream::from_raw(Dict::new(), data.to_vec()))); + page.set(b"Contents".to_vec(), Object::Ref(contents)); + page.set(b"Resources".to_vec(), resources); + let second = doc.add(Object::Dict(page)); + doc.update_dict(ObjRef::new(2, 0), |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(3, 0)), Object::Ref(second)])); + d.set(b"Count".to_vec(), Object::Int(2)); + }) + .unwrap(); + second +} + +fn redact_first(doc: &mut Document) -> Result { + mark(doc, 0, &[[5.0, 195.0, 60.0, 212.0]], ""); + apply(doc, Some(&[0])) +} + +#[test] +fn surviving_form_own_resources_do_not_retain_unused_shared_descriptions() { + for hidden in [false, true] { + let mode = if hidden { 3 } else { 0 }; + let data = format!("/Span /P0 BDC BT /F1 10 Tf {mode} Tr 10 200 Td (SECRET) Tj ET EMC /Fm Do"); + let mut doc = one_page( + data.as_bytes(), + "/Properties << /P0 7 0 R /Public 8 0 R >> /XObject << /Fm 9 0 R >>", + vec![ + DESCRIPTION.to_vec(), + PUBLIC.to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 120]", b"/Span /Public BDC BT /F1 10 Tf 0 Tr 10 100 Td (Public) Tj ET EMC"), + ], + ); + // The page and its unchanged Form both reference the same resource object. The + // dictionary names the Form itself, but the Form's content never invokes that name. + let shared = doc.add(Object::Dict(resources(&doc, 0))); + for owner in [ObjRef::new(3, 0), ObjRef::new(9, 0)] { + doc.update_dict(owner, |d| d.set(b"Resources".to_vec(), Object::Ref(shared))).unwrap(); + } + if hidden { + sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenText]).unwrap(); + } else { + redact_first(&mut doc).unwrap(); + } + assert_no_secret(&doc, b"LEAK_"); + assert_no_secret(&doc, b"SECRET"); + let mut saved = reopen(&doc); + assert_eq!(under(&mut saved, 0, &[[0.0, 90.0, 100.0, 110.0]]), 6); + let page_resources = resources(&saved, 0); + let xobjects = saved.resolve(page_resources.get(b"XObject").unwrap()); + let form = saved.resolve(xobjects.as_dict().unwrap().get(b"Fm").unwrap()); + let form_resources = saved.resolve(form.as_dict().unwrap().get(b"Resources").unwrap()); + let form_resources = form_resources.as_dict().unwrap(); + assert!(property(&saved, form_resources, b"P0").is_none()); + assert_description(&property(&saved, form_resources, b"Public").unwrap()); + } +} + +#[test] +fn unused_property_aliases_are_retired_by_value_not_resource_name() { + for direct in [false, true] { + let value = if direct { std::str::from_utf8(DESCRIPTION).unwrap() } else { "7 0 R" }; + let mut doc = one_page(SECRET, &format!("/Properties << /P0 {value} >>"), vec![DESCRIPTION.to_vec()]); + let mut second_resources = resources(&doc, 0); + let original_properties = doc.resolve(second_resources.get(b"Properties").unwrap()); + let mut alias = Dict::new(); + alias.set(b"Alias".to_vec(), original_properties.as_dict().unwrap().get(b"P0").unwrap().clone()); + second_resources.set(b"Properties".to_vec(), Object::Dict(alias)); + add_second_page(&mut doc, b"q Q", Object::Dict(second_resources)); + redact_first(&mut doc).unwrap(); + assert_no_secret(&doc, b"LEAK_"); + let saved = reopen(&doc); + assert!(property(&saved, &resources(&saved, 1), b"Alias").is_none(), "direct={direct}"); + } +} + +#[test] +fn unrelated_unused_property_with_the_same_name_is_not_changed() { + let mut doc = one_page(SECRET, "/Properties << /P0 7 0 R >>", vec![DESCRIPTION.to_vec(), PUBLIC.to_vec()]); + let mut second_resources = resources(&doc, 0); + let mut properties = Dict::new(); + properties.set(b"P0".to_vec(), Object::Ref(ObjRef::new(8, 0))); + second_resources.set(b"Properties".to_vec(), Object::Dict(properties)); + let second = add_second_page(&mut doc, b"q Q", Object::Dict(second_resources.clone())); + let before = doc.get(second); + redact_first(&mut doc).unwrap(); + assert_eq!(doc.get(second), before, "an unrelated page was rewritten"); + assert_no_secret(&doc, b"LEAK_"); + let saved = reopen(&doc); + assert_description(&property(&saved, &resources(&saved, 1), b"P0").unwrap()); +} + +#[test] +fn null_resources_forms_preserve_a_retired_property_still_used_by_nested_dp() { + let mut doc = one_page( + SECRET, + "/Properties << /P0 7 0 R >> /XObject << /Outer 8 0 R /Inner 9 0 R >>", + vec![ + PUBLIC.to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 120] /Resources null", b"/Inner Do"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 120]", b"/Span /P0 DP BT /F1 10 Tf 10 100 Td (Public) Tj ET"), + ], + ); + let shared = doc.add(Object::Dict(resources(&doc, 0))); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Resources".to_vec(), Object::Ref(shared))).unwrap(); + add_second_page(&mut doc, b"/Outer Do", Object::Ref(shared)); + redact_first(&mut doc).unwrap(); + assert_no_secret(&doc, b"SECRET"); + let mut saved = reopen(&doc); + assert!(property(&saved, &resources(&saved, 0), b"P0").is_none()); + assert_description(&property(&saved, &resources(&saved, 1), b"P0").unwrap()); + assert_eq!(under(&mut saved, 1, &[[0.0, 90.0, 100.0, 110.0]]), 6); +} + +#[test] +fn uninspectable_forms_with_candidate_properties_fail_closed() { + for own in [false, true] { + for malformed in [false, true] { + let filter = if malformed { "" } else { "/Filter /UnsupportedPropertyTest" }; + let data = if malformed { b")".as_slice() } else { b"opaque" }; + if malformed { + assert!(pdfcraft_content::parse(data).skipped > 0, "fixture must need parser recovery"); + } + let mut doc = one_page( + SECRET, + "/Properties << /P0 7 0 R >> /XObject << /Fm 8 0 R >>", + vec![DESCRIPTION.to_vec(), stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 100 120] {filter}"), data)], + ); + let shared = doc.add(Object::Dict(resources(&doc, 0))); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Resources".to_vec(), Object::Ref(shared))).unwrap(); + if own { + doc.update_dict(ObjRef::new(8, 0), |d| d.set(b"Resources".to_vec(), Object::Ref(shared))).unwrap(); + } + add_second_page(&mut doc, b"/Fm Do", Object::Ref(shared)); + assert_eq!(under(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]]), 6); + let error = redact_first(&mut doc).unwrap_err(); + assert!(matches!(error, RedactError::Cos(_)), "own Resources={own}, malformed={malformed}: {error:?}"); + } + } +} + +#[test] +fn hidden_text_in_nested_forms_retires_named_descriptions_and_keeps_public_text() { + let mut doc = one_page( + b"/Outer Do", + "/Properties << /P0 7 0 R /Public 8 0 R >> /XObject << /Outer 9 0 R /Inner 10 0 R >>", + vec![ + DESCRIPTION.to_vec(), + PUBLIC.to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"/Inner Do"), + stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300]", + b"/Span /P0 BDC BT /F1 10 Tf 3 Tr 10 200 Td (SECRET) Tj ET EMC \ + /Span /Public BDC BT /F1 10 Tf 0 Tr 10 100 Td (Public) Tj ET EMC", + ), + ], + ); + sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenText]).unwrap(); + assert_no_secret(&doc, b"SECRET"); + assert_no_secret(&doc, b"LEAK_"); + let mut saved = reopen(&doc); + assert_eq!(under(&mut saved, 0, &[[0.0, 90.0, 100.0, 110.0]]), 6); + // The rewritten inner Form owns the surviving /Public description after resources are + // localized; no page-level property lookup is sufficient to check it. + assert!(saved.scan_objects().any(|(_, object)| { + object.as_dict().and_then(|d| d.get(b"ActualText")).and_then(Object::as_string).is_some_and(|s| s.to_text() == "PUBLIC_ACTUAL") + })); +} + +#[test] +fn removing_hidden_layer_content_cleans_its_surviving_outer_description() { + let mut doc = one_page( + b"/Span /P0 BDC /OC /Off BDC BT /F1 10 Tf 10 200 Td (SECRET) Tj ET EMC EMC \ + /Span /Public BDC BT /F1 10 Tf 10 100 Td (Public) Tj ET EMC", + "/Properties << /P0 7 0 R /Public 8 0 R /Off 9 0 R >>", + vec![DESCRIPTION.to_vec(), PUBLIC.to_vec(), b"<< /Type /OCG /Name (Hidden test layer) >>".to_vec()], + ); + let mut defaults = Dict::new(); + defaults.set(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(9, 0))])); + let mut optional_content = Dict::new(); + optional_content.set(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(9, 0))])); + optional_content.set(b"D".to_vec(), Object::Dict(defaults)); + doc.update_dict(ObjRef::new(1, 0), |d| d.set(b"OCProperties".to_vec(), Object::Dict(optional_content))).unwrap(); + sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenLayers]).unwrap(); + assert_no_secret(&doc, b"SECRET"); + assert_no_secret(&doc, b"LEAK_"); + let mut saved = reopen(&doc); + assert_description(&property(&saved, &resources(&saved, 0), b"Public").unwrap()); + assert_eq!(under(&mut saved, 0, &[[0.0, 90.0, 100.0, 110.0]]), 6); +} diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 3c3988b7b5..0979f3d38a 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -56,6 +56,9 @@ pub struct DocInfo { pub keywords: Option, pub creator: Option, pub producer: Option, + /// Custom document properties: `/Info` entries with a key the spec does not name and a text + /// value, as (name, text), in the dictionary's order (at most 256). + pub custom: Vec<(String, String)>, pub pdf_version: String, pub file_size: usize, pub encrypted: bool, @@ -616,6 +619,23 @@ impl<'a> Inspector<'a> { info.keywords = self.text(d, b"Keywords"); info.creator = self.text(d, b"Creator"); info.producer = self.text(d, b"Producer"); + const STANDARD: [&[u8]; 9] = + [b"Title", b"Author", b"Subject", b"Keywords", b"Creator", b"Producer", b"CreationDate", b"ModDate", b"Trapped"]; + // The same rule as the engine's list after an edit (`pdfcraft_organize::custom_info`, + // trimmed and without empty values): text strings only, so a name value is not a + // custom property, and the cap counts listed entries. + info.custom = d + .iter() + .filter(|(k, _)| !STANDARD.contains(&k.as_slice())) + .filter_map(|(k, v)| match self.resolve(v) { + Object::String(bytes, _) => { + let v = text_string(bytes).trim_matches(|c: char| c == '\0' || c.is_whitespace()).to_string(); + (!v.is_empty()).then(|| (String::from_utf8_lossy(k).into_owned(), v)) + } + _ => None, + }) + .take(256) + .collect(); } let Ok(catalog) = self.doc.catalog() else { return }; info.tagged = catalog @@ -678,6 +698,12 @@ impl<'a> Inspector<'a> { } info.has_javascript |= names.get(b"JavaScript").is_ok(); } + if let Ok(action) = catalog.get(b"OpenAction") + && let Some(dict) = self.dict(action) + && dict.get(b"S").ok().and_then(|o| self.resolve(o).as_name().ok()) == Some(&b"JavaScript"[..]) + { + info.has_javascript = true; + } } // ── object helpers ────────────────────────────────────────────────────────────────────── @@ -2302,6 +2328,27 @@ trailer << /Root 1 0 R >> ); } + const CUSTOM_INFO: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R >> endobj +4 0 obj << /Title (Report) /Producer (Test) /Project (Apollo) /Revision 7 /Dept (R&D) /Trapped /False /Note >> endobj +trailer << /Root 1 0 R /Info 4 0 R >> +%%EOF"; + + #[test] + fn custom_document_properties_are_listed_apart_from_the_standard_ones() { + let info = inspect(Arc::new(CUSTOM_INFO.to_vec()), None).expect("opens"); + assert_eq!((info.title.as_deref(), info.producer.as_deref()), (Some("Report"), Some("Test"))); + let mut custom = info.custom.clone(); + custom.sort(); + // Revision is a number, not text, so it is not listed; Trapped is a standard entry. + assert_eq!( + custom, + [("Dept".to_string(), "R&D".to_string()), ("Note".to_string(), "部".to_string()), ("Project".to_string(), "Apollo".to_string())] + ); + } + #[test] fn radio_button_layer_groups_are_read() { let info = inspect(Arc::new(LAYERS.to_vec()), None).expect("opens"); @@ -2982,3 +3029,49 @@ trailer << /Size 7 /Root 1 0 R >> assert_eq!((f.substitute.as_deref(), f.substitution.as_deref()), (None, None), "the document's own glyphs are shown"); } } + +#[cfg(test)] +mod open_action_javascript_tests { + use std::sync::Arc; + + use super::inspect; + + /// One-page PDF whose catalog points its /OpenAction at object 4 (written by the caller). + fn pdf_with_open_action(action: &[u8]) -> Vec { + let objs: Vec<&[u8]> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /OpenAction 4 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>", + action, + ]; + 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 open_action_javascript_sets_has_javascript() { + let bytes = pdf_with_open_action(b"<< /S /JavaScript /JS (app.alert('hi')) >>"); + let info = inspect(Arc::new(bytes), None).expect("inspects"); + assert!(info.has_javascript, "an /OpenAction /JavaScript is JavaScript"); + } + + #[test] + fn open_action_goto_does_not_set_has_javascript() { + let bytes = pdf_with_open_action(b"<< /S /GoTo /D [3 0 R /Fit] >>"); + let info = inspect(Arc::new(bytes), None).expect("inspects"); + assert!(!info.has_javascript, "a /GoTo /OpenAction is not JavaScript"); + } +} diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 17afd82c78..382cae1b5a 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -2881,6 +2881,33 @@ trailer << /Root 1 0 R >> assert_eq!(px(20, 80), vec![255, 0, 0, 255], "checkbox must show the /Yes appearance, not the decoy stream"); } + /// Microsoft Reporting Services (bank statements, invoices) writes `stream \r\n`, a space + /// before the line break. The content stream was then taken to start at the space and the + /// page rendered blank, while Preview, Chrome and Poppler showed it. Vendored hayro-syntax + /// patch: spaces and tabs before the line break are skipped, with the right `/Length` and + /// with a wrong one. Spaces with no line break after them never start stream data. + #[test] + fn space_before_the_line_break_after_stream_still_renders() { + let pdf = |eol: &str, length: u32| { + format!( + "%PDF-1.3\r\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\r\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\r\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R >> endobj\r\n\ + 4 0 obj << /Length {length} >> stream{eol}0 0 0 rg 20 20 60 60 re f\r\nendstream\r\nendobj\r\n\ + trailer << /Root 1 0 R >>\r\n%%EOF" + ) + .into_bytes() + }; + for (eol, length) in [(" \r\n", 25), (" \n", 25), ("\t \r", 25), (" \r\n", 999), ("\r\n", 25)] { + let mut r = PageRenderer::new(Arc::new(pdf(eol, length)), RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(p.error.is_none(), "{eol:?}: {:?}", p.error); + let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec(); + assert_eq!(px(50, 50), vec![0, 0, 0, 255], "stream{eol:?} (/Length {length}) must draw the square"); + assert_eq!(px(5, 5), vec![255, 255, 255, 255], "outside the square stays white"); + } + } + /// An appearance whose /BBox is a line or a point, or whose /Matrix collapses it to one, has /// no mapping onto the annotation's /Rect: the scale (/Rect size over transformed box size) /// divided by zero, and the infinite or NaN matrix reached the device with everything the @@ -3462,6 +3489,141 @@ trailer << /Root 1 0 R >> assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255]); } + /// One page `w` × `h` points whose content `content` paints with pattern `/P1`: a 10 × 10 cell + /// of blue with `/XStep step /YStep step` and pattern matrix `matrix`. Its RGBA pixels. + fn tiling_page(w: usize, h: usize, content: &str, step: &str, matrix: &str) -> Vec { + let cell = "0 0 1 rg 0 0 10 10 re f"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}] /Contents 4 0 R /Resources << /Pattern << /P1 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 3 /BBox [0 0 10 10] /XStep {step} /YStep {step} /Matrix [{matrix}] /Length {} >> stream +{cell} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + cell.len() + ); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + page.rgba.to_vec() + } + + /// #3349: PowerPoint (PDFMaker) writes a picture fill as a tiling pattern drawn once, with + /// `/XStep 2147480100 /YStep 2147480100` and a matrix that flips y. Fitting such a step into + /// a cell pixmap shrank the picture below a pixel, so it vanished. The cell's `/BBox` is drawn + /// at full scale, once, filled or stroked: where it is it shows, and around it (left, right, + /// above, below) nothing repeats or smears. + #[test] + fn a_tiling_pattern_drawn_once_shows_its_cell() { + let at = |px: &[u8], w: usize, x: usize, y: usize| px[(y * w + x) * 4..][..3].to_vec(); + let (blue, white) = (vec![0, 0, 255], vec![255, 255, 255]); + // The cell at user x 10..20 and, flipped, y 10..20: device rows 20..30. + let once = tiling_page(40, 40, "/Pattern cs /P1 scn 0 0 40 40 re f", "2147480100", "1 0 0 -1 10 20"); + assert_eq!(at(&once, 40, 15, 25), blue, "the cell"); + for (x, y, side) in [(5, 25, "left"), (25, 25, "right"), (15, 15, "above"), (15, 35, "below")] { + assert_eq!(at(&once, 40, x, y), white, "nothing {side} of it"); + } + // Stroked: a wide line across the cell shows it too. + let stroked = tiling_page(40, 40, "/Pattern CS /P1 SCN 20 w 0 15 m 40 15 l S", "2147480100", "1 0 0 -1 10 20"); + assert_eq!(at(&stroked, 40, 15, 25), blue, "a stroke with the pattern"); + assert_eq!(at(&stroked, 40, 25, 25), white); + // An ordinary step still tiles. + let tiled = tiling_page(40, 40, "/Pattern cs /P1 scn 0 0 40 40 re f", "20", "1 0 0 1 0 0"); + assert_eq!(at(&tiled, 40, 25, 35), blue, "a step of 20 repeats the cell"); + } + + /// #3356 review: a `/BBox` too thin to divide by (not zero, which is refused, but 1e-40 + /// wide) makes the cell scale infinite; it keeps the old path, so no infinite or NaN + /// geometry reaches the cell renderer, the page renders and what follows still draws. + #[test] + fn a_tiling_cell_too_thin_to_scale_keeps_the_old_path() { + assert!(hayro::usable_cell_scale(2.0, 1.0)); + for (xs, ys) in [(f32::INFINITY, 1.0), (1.0, f32::NAN), (0.0, 1.0), (1.0, -2.0)] { + assert!(!hayro::usable_cell_scale(xs, ys), "{xs} {ys}"); + } + let thin = "0.0000000000000000000000000000000000000001"; + let content = "/Pattern cs /P1 scn 0 0 40 40 re f 1 0 0 rg 0 0 4 4 re f"; + let cell = "0 0 1 rg 0 0 10 10 re f"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /Pattern << /P1 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 3 /BBox [0 0 {thin} 10] /XStep 2147480100 /YStep 2147480100 /Length {} >> stream +{cell} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + cell.len() + ); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "what follows still draws"); + assert_eq!(&page.rgba[((20 * 40 + 20) * 4)..][..3], &[255, 255, 255], "the degenerate cell draws nothing"); + } + + /// #3349: a step too large for a cell pixmap is drawn as one cell only when the painted area + /// can't reach another: a page wide (or tall) enough to show the next cell, or the one before, + /// still tiles them. + #[test] + fn a_large_step_still_tiles_where_the_next_cell_shows() { + let at = |px: &[u8], w: usize, x: usize, y: usize| px[(y * w + x) * 4..][..3].to_vec(); + let blue = vec![0, 0, 255]; + // Steps of 3100 points: over 3000 pixels at this scale, so the cell is shrunk to fit, + // by 3000/3100; a pixel 4 points into a cell is blue either way. + let wide = "/Pattern cs /P1 scn 0 0 3200 20 re f"; + let next = tiling_page(3200, 20, wide, "3100", "1 0 0 1 0 0"); + assert_eq!(at(&next, 3200, 3104, 15), blue, "the cell to the right"); + let before = tiling_page(3200, 20, wide, "3100", "1 0 0 1 3100 0"); + assert_eq!(at(&before, 3200, 4, 15), blue, "the cell to the left"); + let tall = "/Pattern cs /P1 scn 0 0 20 3200 re f"; + let above = tiling_page(20, 3200, tall, "3100", "1 0 0 1 0 0"); + assert_eq!(at(&above, 20, 4, 3200 - 3104), blue, "the cell above"); + let below = tiling_page(20, 3200, tall, "3100", "1 0 0 1 0 3100"); + assert_eq!(at(&below, 20, 4, 3200 - 4), blue, "the cell below"); + // A stroke counts with its width: this line ends at x 3080, short of the next cell at + // 3100, but its square cap reaches 3110. + let stroked = tiling_page(3200, 20, "/Pattern CS /P1 SCN 60 w 2 J 0 10 m 3080 10 l S", "3100", "1 0 0 1 0 0"); + assert_eq!(at(&stroked, 3200, 3104, 15), blue, "the next cell, under the stroke's cap"); + // A miter reaches further than the line width (review on #3356): this join's vertex is + // at x 3085, 15 short of the next cell with a 10-wide line, but its miter tip, 45 long + // under the default limit of 10, reaches 3130. + let mitered = tiling_page(3200, 20, "/Pattern CS /P1 SCN 10 w 0 j 3000 0 m 3085 10 l 3000 20 l S", "3100", "1 0 0 1 0 0"); + // The tip narrows to a point, so this pixel is partly covered: blue, not white. + let tip = at(&mitered, 3200, 3104, 10); + assert!(tip[0] < 160 && tip[1] < 160 && tip[2] > 200, "the next cell, under the miter's tip: {tip:?}"); + // Under a skew, a direction between the axes stretches more than either axis (review on + // #3356). This join's vertex is at device x 2980 and its miter tip, along that direction, + // at 3107: the larger axis stretch (1.118) put the reach at 3092, short of the next cell; + // the most any direction stretches (1.461) puts it at 3126. + let skewed = tiling_page( + 3200, + 20, + "/Pattern CS /P1 SCN 1 0 1 0.5 0 0 cm 20 w 0 j 2783.936 -366.118 m 3033.6 -53.6 l 2721.082 -303.264 l S", + "3100", + "1 0 0 1 0 0", + ); + // The tip narrows to a point, so its pixels are partly covered. + let under_tip = (3100..3108).flat_map(|x| (10..20).map(move |y| (x, y))).map(|(x, y)| at(&skewed, 3200, x, y)); + assert!( + under_tip.clone().any(|px| px[0] < 200 && px[1] < 200 && px[2] > 240), + "the next cell, under the skewed miter's tip: {:?}", + under_tip.collect::>() + ); + } + /// Contributor-original JPEG 2000: one unsigned 8-bit pixel per component, one tile, /// no decomposition, reversible wavelet, and one empty packet per component. Empty /// coefficients decode to the midpoint (128) after the unsigned DC level shift. @@ -3503,6 +3665,60 @@ trailer << /Root 1 0 R >> pdf } + /// [`jpx_midpoint_pdf`]'s codestream (every sample 128) wrapped in a JP2 file whose header + /// has a colour specification naming enumerated colour space `enumerated`. + fn jp2_midpoint_pdf(components: u16, enumerated: u32) -> Vec { + let raw = jpx_midpoint_pdf(8, components); + let start = raw.windows(4).position(|w| w == [0xff, 0x4f, 0xff, 0x51]).unwrap(); + let end = raw.windows(2).rposition(|w| w == [0xff, 0xd9]).unwrap() + 2; + let codestream = &raw[start..end]; + let jp2_box = |kind: &[u8], body: &[u8]| [&((8 + body.len()) as u32).to_be_bytes()[..], kind, body].concat(); + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&1_u32.to_be_bytes()); // height + ihdr.extend_from_slice(&1_u32.to_be_bytes()); // width + ihdr.extend_from_slice(&components.to_be_bytes()); + ihdr.extend_from_slice(&[7, 7, 0, 0]); // 8 bits, wavelet, colour known, no IPR + let colr = [&[1, 0, 0][..], &enumerated.to_be_bytes()].concat(); + let jp2 = [ + vec![0, 0, 0, 12, b'j', b'P', b' ', b' ', 0x0d, 0x0a, 0x87, 0x0a], + jp2_box(b"ftyp", b"jp2 \0\0\0\0jp2 "), + jp2_box(b"jp2h", &[jp2_box(b"ihdr", &ihdr), jp2_box(b"colr", &colr)].concat()), + jp2_box(b"jp2c", codestream), + ] + .concat(); + let content = "q 20 0 0 20 5 5 cm /Im1 Do Q"; + let mut pdf = format!( + "%PDF-1.7\n\ +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj\n\ +4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ +5 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /Filter /JPXDecode /Length {} >> stream\n", + content.len(), + jp2.len() + ) + .into_bytes(); + pdf.extend_from_slice(&jp2); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF"); + pdf + } + + /// #3306: a JP2 image whose colour specification names an enumerated colour space the + /// decoder refuses (CIEJab in a veraPDF fixture, or YPbPr, or bilevel for one component) is + /// drawn as if it had none, by its component count, as MuPDF and Poppler draw it: the + /// midpoint image shows mid-grey instead of nothing. A space the decoder knows (sRGB) is + /// drawn as before. + #[test] + fn a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn() { + for (components, enumerated) in [(3, 19), (3, 22), (1, 15), (3, 16)] { + let mut r = PageRenderer::new(Arc::new(jp2_midpoint_pdf(components, enumerated)), RenderConfig::default()); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(page.error.is_none(), "{enumerated}: {:?}", page.error); + let px = &page.rgba[((40 - 15) * 40 + 15) * 4..][..3]; + assert!(px.iter().all(|&v| (120..=136).contains(&v)), "enum {enumerated}, {components} component(s): {px:?}"); + } + } + #[test] fn jpx_stream_bit_depth_boundaries_do_not_panic() { use hayro::hayro_syntax::bit_reader::BitReader; @@ -3631,6 +3847,46 @@ endstream endobj } } + /// #3308: a fax image whose `/Rows` says fewer rows than its `/Height` (a plotter driver wrote + /// `/Rows` equal to `/Columns`) is decoded to its height: only the first `/Rows` rows were + /// decoded and the rest drawn black, as MuPDF and Acrobat don't. `/BlackIs1` turns the fixture + /// white, so rows left undecoded show as black. Also through an `/Indexed` colour space, the + /// filter's other output path. + #[test] + fn group4_fax_with_rows_below_its_height_decodes_to_the_image_height() { + const ALL_BLACK_8X4: [u8; 6] = [0x26, 0xa2, 0xfe, 0x00, 0x20, 0x02]; + for (space, rows) in [("/DeviceGray", "/Rows 2"), ("[/Indexed /DeviceRGB 1 <000000FFFFFF>]", "/Rows 2"), ("/DeviceGray", "/Rows 4")] { + let content = "q 20 0 0 20 5 5 cm /Im1 Do Q"; + let mut pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /XObject /Subtype /Image /Width 8 /Height 4 /ColorSpace {space} /BitsPerComponent 1 /Filter /CCITTFaxDecode /DecodeParms << /K -1 /Columns 8 {rows} /BlackIs1 true >> /Length {} >> stream +", + content.len(), + ALL_BLACK_8X4.len() + ) + .into_bytes(); + pdf.extend_from_slice(&ALL_BLACK_8X4); + pdf.extend_from_slice( + b" +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + ); + let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(page.error.is_none(), "{space} {rows}: {:?}", page.error); + for y in [17, 22, 27, 32] { + assert_eq!(&page.rgba[((y * 40 + 15) * 4)..][..4], &[255, 255, 255, 255], "{space} {rows}: image row at y {y} is decoded"); + } + } + } + #[test] fn tiff_predictor_packed_rgb_rows_keep_their_byte_boundaries() { use hayro_syntax::object::{FromBytes, Object}; @@ -4586,7 +4842,11 @@ trailer << /Root 1 0 R >> let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() })); }); - let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("a self-painting form must not stall the renderer"); + // Only a guard against a hang: the shared budget is checked by the warnings below, which + // come out the same on every machine. The render decodes and reads 512 MiB of content, + // a third of a second on a desktop, and a busy FreeBSD VM in CI ran past the minute this + // used to allow. + let page = rx.recv_timeout(std::time::Duration::from_secs(600)).expect("a self-painting form must not stall the renderer"); assert!(page.error.is_none(), "{:?}", page.error); assert!(page.warnings.contains(&RenderWarning::ContentTruncated), "the skipped nested content is observable"); assert_eq!( @@ -4651,6 +4911,119 @@ trailer << /Root 1 0 R >> assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws"); } + /// A sampled function kept its samples in a hash map keyed by each point of its grid, built + /// again every time a shading using it was painted (vendored hayro-interpret patch 31). + /// InDesign pages paint 4096-sample gradient ramps dozens of times, and `bug1721218` in the + /// pdf.js corpus spent most of its 1.5 s hashing them. The samples are now one flat array + /// indexed by strides: this page paints a 2,097,152-sample ramp (the most a table may hold) + /// 30 times, which took 38 seconds through the hash map. + /// + /// The build each paint does is timed against reading the function's samples, the part of it + /// no table can skip, not against a deadline: through the hash map it took about a hundred + /// times as long as the read, through the flat array about as long. A deadline can't tell + /// them apart on every machine: the hash map's 30 paints took 14 s on one desktop, the flat + /// array's over 10 s on a busy FreeBSD VM in CI. No counter does either (both read every + /// sample on every build), so the test compares two times taken on this thread, the fastest + /// of several rounds of each: a stall from other work (other tests, swapping) would have to + /// slow every build and spare a read to fail it. + #[test] + fn shadings_with_large_sampled_functions_paint_quickly() { + use hayro::hayro_interpret::Function; + use hayro_syntax::bit_reader::BitReader; + use hayro_syntax::object::{FromBytes, Object}; + use std::time::{Duration, Instant}; + + const SAMPLES: usize = 1 << 21; + let samples = zlib_zeros(SAMPLES); + let function = format!( + "<< /FunctionType 0 /Domain [0 1] /Range [0 1] /Size [{SAMPLES}] /BitsPerSample 8 /Filter /FlateDecode /Length {} >>", + samples.len() + ); + // Black ramp on the left half, 30 times over; a red square after it. + let content = "q 0 0 20 40 re W n /Sh0 sh Q\n".repeat(30) + "1 0 0 rg 36 36 4 4 re f"; + let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /Shading << /Sh0 5 0 R >> >> >> endobj\n".to_vec(); + pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n", content.len()).as_bytes()); + pdf.extend_from_slice(b"5 0 obj << /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 40 0] /Function 6 0 R >> endobj\n"); + pdf.extend_from_slice(format!("6 0 obj {function} stream\n").as_bytes()); + pdf.extend_from_slice(&samples); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + let page = render_first_page(pdf); + assert_eq!(rgb_at(&page.rgba, 40, 5, 20), [0, 0, 0], "the ramp is drawn"); + assert_eq!(rgb_at(&page.rgba, 40, 30, 20), [255, 255, 255], "outside the clip"); + assert_eq!(rgb_at(&page.rgba, 40, 38, 2), [255, 0, 0], "the rest of the page draws"); + + // What each paint does: build the function from its stream. Timed against reading the + // stream's samples, the part of the build no table can skip. + let mut bytes = format!("{function} stream\n").into_bytes(); + bytes.extend_from_slice(&samples); + bytes.extend_from_slice(b"\nendstream"); + let object = Object::from_bytes(&bytes).expect("a stream object"); + let Object::Stream(stream) = &object else { panic!("the function is a stream") }; + let (mut build, mut read) = (Duration::MAX, Duration::MAX); + for _ in 0..8 { + let started = Instant::now(); + let function = Function::new(&object); + assert!(function.is_some(), "a sampled function"); + drop(function); + build = build.min(started.elapsed()); + + let started = Instant::now(); + let decoded = stream.decoded().expect("the samples decode"); + let mut reader = BitReader::new(&decoded); + let mut values = Vec::new(); + while values.len() < SAMPLES + && let Some(value) = reader.read(8) + { + values.push(value); + } + assert_eq!(std::hint::black_box(&values).len(), SAMPLES); + drop(values); + read = read.min(started.elapsed()); + } + assert!( + build < read * 10, + "building a {SAMPLES}-sample function took {build:?}, over ten times the {read:?} reading its samples takes (a hash map took a hundred times)" + ); + } + + /// Patch 31's flat table gives every sampled function the values the hash map gave: points + /// on the grid, multilinear interpolation between them (first input varying fastest), and no + /// value where a sample the interpolation needs is missing from a short stream. + #[test] + fn sampled_functions_interpolate_their_grid_as_before() { + use hayro::hayro_interpret::Function; + use hayro_syntax::object::{FromBytes, Object}; + + // Size [3 2], two outputs: out0 = 40 i + 60 j (linear), out1 = 100 i j (bilinear only). + let mut samples = Vec::new(); + for j in 0..2u8 { + for i in 0..3u8 { + samples.extend_from_slice(&[40 * i + 60 * j, 100 * i * j]); + } + } + let function = |data: &[u8]| { + let mut bytes = + format!("<< /FunctionType 0 /Domain [0 1 0 1] /Range [0 1 0 1] /Size [3 2] /BitsPerSample 8 /Length {} >> stream\n", data.len()) + .into_bytes(); + bytes.extend_from_slice(data); + bytes.extend_from_slice(b"\nendstream"); + bytes + }; + let whole = function(&samples); + let f = Function::new(&Object::from_bytes(&whole).expect("a stream object")).expect("a sampled function"); + for (x, y) in [(0.0, 0.0), (1.0, 1.0), (0.5, 0.0), (0.25, 0.5), (0.75, 0.25), (0.1, 0.9), (1.0, 0.3)] { + let (gi, gj) = (2.0 * x, y); + let got = f.eval([x, y].into_iter().collect()).expect("every sample is present"); + let want = [(40.0 * gi + 60.0 * gj) / 255.0, 100.0 * gi.min(1.0) * gj / 255.0 + 100.0 * (gi - 1.0).max(0.0) * gj / 255.0]; + assert!(got.len() == 2 && got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-5), "({x}, {y}): got {got:?}, want {want:?}"); + } + // Only the j = 0 row: points on it evaluate, points needing j = 1 have no value. + let short = function(&samples[..6]); + let f = Function::new(&Object::from_bytes(&short).expect("a stream object")).expect("a sampled function"); + assert_eq!(f.eval([0.5, 0.0].into_iter().collect()).map(|v| v.to_vec()), Some(vec![40.0 / 255.0, 0.0])); + assert_eq!(f.eval([0.5, 0.5].into_iter().collect()), None); + } + /// From `cargo xtask fuzz`: a mesh shading whose points lie far off the page (a 147-byte /// Coons patch stream decoded onto ±40000) was sampled pixel by pixel over its whole /// bounding box, up to 65536 × 65536 entries: a synthetic page passed 6 GB in six seconds. @@ -4743,6 +5116,54 @@ trailer << /Root 1 0 R >> assert!(middle != [255, 255, 255, 255] && middle[3] == 255, "a small patch is shaded: {middle:?}"); } + /// Coons and tensor-product patches put each corner's colour at its corner and blend them + /// in between. Patch 33 interpolates each point of a patch's 20 × 20 triangle grid once + /// instead of once per cell touching it, and moves vertices instead of copying them; these + /// colours (taken before that change) pin that every vertex still gets its own point's colour. + #[test] + fn patch_meshes_keep_their_corner_colours() { + // Points on a 4 × 4 grid over the 40 pt page, in stream order: the 12 boundary points + // (both patch types), then a tensor patch's four interior ones. + let grid = |x: u8, y: u8| [x * 85, y * 85]; + let boundary = [(0, 0), (0, 1), (0, 2), (0, 3), (1, 3), (2, 3), (3, 3), (3, 2), (3, 1), (3, 0), (2, 0), (1, 0)]; + let interior = [(1, 1), (1, 2), (2, 2), (2, 1)]; + let colours = [255u8, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 0]; + let patch = |points: &[(u8, u8)]| { + let mut data = vec![0u8]; + data.extend(points.iter().flat_map(|(x, y)| grid(*x, *y))); + data.extend_from_slice(&colours); + data + }; + let render = |shading_type: u8, data: Vec| { + let content = b"/Sh0 sh"; + let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /Shading << /Sh0 5 0 R >> >> >> endobj\n".to_vec(); + pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes()); + pdf.extend_from_slice(content); + pdf.extend_from_slice(format!("\nendstream endobj\n5 0 obj << /ShadingType {shading_type} /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 40 0 40 0 1 0 1 0 1] /Length {} >> stream\n", data.len()).as_bytes()); + pdf.extend_from_slice(&data); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + render_first_page(pdf) + }; + let coons = render(6, patch(&boundary)); + let tensor = render(7, patch(&[&boundary[..], &interior[..]].concat())); + // Near each corner (top-left, bottom-left, bottom-right, top-right in the raster), the + // centre, and a point between them. + let want = [ + ((5, 5), [32, 199, 28]), + ((5, 34), [217, 61, 5]), + ((34, 34), [217, 190, 32]), + ((34, 5), [32, 61, 190]), + ((20, 20), [127, 128, 64]), + ((12, 27), [172, 110, 25]), + ]; + for (what, page) in [("Coons", &coons), ("tensor", &tensor)] { + for ((x, y), colour) in want { + let got = rgb_at(&page.rgba, 40, x, y); + assert!(got.iter().zip(colour).all(|(g, w)| g.abs_diff(w) <= 1), "{what} patch at ({x}, {y}): got {got:?}, want {colour:?}"); + } + } + } + /// The other half of the mesh-shading patch: ten thousand triangles that each cover half /// of this 1000 × 1000 canvas walked their bounding boxes ten thousand times over (ten /// billion visits). The visit budget stops that work at eight per pixel plus four per @@ -5979,6 +6400,81 @@ endstream endobj assert_eq!(rgb_at(&out.rgba, w, 100, 35), [255, 0, 0]); } + /// The decoded-image cache keyed every decode by the size the image is drawn at, but only + /// a JPX stream decodes differently for a size: a scan was decoded again at every zoom step + /// and kept once per zoom level (vendored hayro-interpret patch 32). An image drawn at five + /// sizes is now decoded once, unless JPX decodes it or its soft mask. + #[test] + fn decoded_images_serve_every_size_they_are_drawn_at() { + use hayro::hayro_interpret::font::Glyph; + use hayro::hayro_interpret::hayro_syntax::Pdf; + use hayro::hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, Paint, PathDrawMode, SoftMask, TransformExt, interpret_page, + }; + use kurbo::{Affine, BezPath, Rect}; + use std::io::Write; + + /// Asks for every raster image at the size it is drawn at, as the renderer does, and + /// counts the decoded images it was handed. + #[derive(Default)] + struct Draws(usize); + impl<'a> Device<'a> for Draws { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'a>, _: &PathDrawMode) {} + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, _: &Glyph<'a>, _: Affine, _: Affine, _: &Paint<'a>, _: &GlyphDrawMode) {} + fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) { + let [a, b, c, d, ..] = transform.as_coeffs(); + let target = ((a.hypot(b) * f64::from(image.width())).ceil() as u32, (c.hypot(d) * f64::from(image.height())).ceil() as u32); + if let Image::Raster(r) = image { + r.with_rgba(|_, _| self.0 += 1, Some(target)); + } + } + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + + let flate = |data: &[u8]| { + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + z.write_all(data).unwrap(); + z.finish().unwrap() + }; + let pixels = flate(&(0..64 * 64 * 3).map(|i| (i % 251) as u8).collect::>()); + let image = |extra: &str| { + format!( + "<< /Type /XObject /Subtype /Image /Width 64 /Height 64 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode {extra}/Length {} >>", + pixels.len() + ) + }; + let (plain, jpx_masked) = (image(""), image("/SMask 7 0 R ")); + let jpx_mask = + "<< /Type /XObject /Subtype /Image /Width 64 /Height 64 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /JPXDecode /Length 4 >>"; + // `name` drawn at five sizes, from 10 to 40 pt square. + let decodes = |name: &str| { + let content = [(10, 0, 0), (20, 12, 0), (30, 34, 0), (40, 66, 0), (25, 100, 40)] + .map(|(s, x, y)| format!("q {s} 0 0 {s} {x} {y} cm /{name} Do Q")) + .join(" "); + let bytes = image_cache_pdf( + &content, + "<< /XObject << /A 5 0 R /B 6 0 R >> >>", + &[(&plain, &pixels), (&jpx_masked, &pixels), (jpx_mask, b"\0\0\0\0")], + ); + let parsed = Pdf::new(Arc::new(bytes)).expect("parses"); + let page = &parsed.pages()[0]; + let cache = InterpreterCache::new(); + let initial = page.initial_transform(true).to_kurbo(); + let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, 130.0, 70.0), &cache, page.xref(), InterpreterSettings::default()); + let mut device = Draws::default(); + interpret_page(page, &mut ctx, &mut device); + assert_eq!(device.0, 5, "every draw of /{name} was handed its pixels"); + cache.decoded_image_decodes() + }; + assert_eq!(decodes("A"), 1, "a Flate image decodes the same at every size"); + assert_eq!(decodes("B"), 5, "a JPX soft mask decodes for the size it is drawn at"); + } + /// Render `pdf`'s first page at 72 dpi with no time limit beyond the test's own. fn render_first_page(pdf: Vec) -> RenderedPage { let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); @@ -5993,6 +6489,8 @@ endstream endobj /// each of a 3304 × 4105 background's pixels, every page took four seconds and 430 MB to /// draw, again after each field was filled. This image (2048 × 2048, a 9,000-operator /// program) took about 40 seconds that way; from a 256-entry table it takes milliseconds. + /// The runs are counted (patch 34) rather than timed: a deadline also failed when a slow, + /// busy CI machine took longer than it allowed, and the count is the same on every machine. #[test] fn spot_colour_images_run_their_tint_transform_once_per_sample_value() { const SIDE: usize = 2048; @@ -6009,11 +6507,11 @@ endstream endobj let function = format!("<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] /Length {} >>", program.len()); let pdf = image_cache_pdf("q 130 0 0 70 0 0 cm /A Do Q", "<< /XObject << /A 5 0 R >> >>", &[(&image, &data), (&function, program.as_bytes())]); - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let _ = tx.send(render_first_page(pdf)); - }); - let out = rx.recv_timeout(std::time::Duration::from_secs(10)).expect("a spot-colour image must not run its tint transform for every pixel"); + // The count is per thread, and this render runs on this one. + let before = hayro::hayro_interpret::color::tint_transform_runs(); + let out = render_first_page(pdf); + let runs = hayro::hayro_interpret::color::tint_transform_runs() - before; + assert_eq!(runs, 256, "the tint transform runs once per possible sample value, not once for each of the {} pixels", SIDE * SIDE); let w = out.width as usize; assert_eq!(rgb_at(&out.rgba, w, 10, 35), [255, 0, 0], "tint 0"); assert_eq!(rgb_at(&out.rgba, w, 120, 35), [0, 255, 0], "tint 1"); diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index ae5ffa9a9c..6784f14eb0 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -1257,26 +1257,134 @@ fn collide(glyphs: &[&TextGlyph], height: &dyn Fn(usize) -> f32, h: f32, sc: &mu dense } -/// Reverses each right-to-left span of `idx` (visual → logical order). A space between two right-to-left glyphs -/// belongs to the span. -/// Reverse right-to-left runs of `idx` (visual → logical), and the word gaps between their glyphs -/// with them: `spaces[w]` is a gap left of `idx[w]` (#1062). +/// Simplified bidi class of a glyph or word gap (UAX #9: strong L/R, numbers, neutrals). +#[derive(Clone, Copy, PartialEq, Eq)] +enum Bidi { + L, + R, + Num, + N, +} + +fn bidi_class(text: &str) -> Bidi { + if text.chars().any(|c| is_rtl(c) && !c.is_numeric()) { + Bidi::R + } else if text.chars().any(char::is_numeric) { + Bidi::Num + } else if text.chars().any(char::is_alphabetic) { + Bidi::L + } else { + Bidi::N + } +} + +/// Reorders a line of `idx` from visual to logical order with a simplified bidi level pass (#2667), carrying the +/// word gaps along: `spaces[w]` is a gap left of `idx[w]` (#1062). The paragraph is right-to-left when it has +/// more right-to-left than left-to-right glyphs; numbers keep their left-to-right order, and neutrals +/// (punctuation, gaps) take the direction of their neighbours, or the paragraph's between opposite ones. fn reverse_rtl(glyphs: &[&TextGlyph], idx: &mut [usize], spaces: &mut [bool]) { - let rtl = |i: usize| glyphs[i].text.chars().any(is_rtl); + // Glyphs and word gaps (None) in visual order. + let mut items: Vec<(Option, Bidi)> = Vec::with_capacity(idx.len() * 2); + for (w, &i) in idx.iter().enumerate() { + if w > 0 && spaces.get(w).copied().unwrap_or(false) { + items.push((None, Bidi::N)); + } + items.push((Some(i), bidi_class(&glyphs[i].text))); + } + let texts: Vec<&str> = items.iter().map(|it| it.0.map_or("", |i| glyphs[i].text.as_str())).collect(); + let text = |k: usize| texts.get(k).copied().unwrap_or(""); + let count = |b: Bidi| items.iter().filter(|it| it.1 == b).count(); + let para = if count(Bidi::R) > count(Bidi::L) { Bidi::R } else { Bidi::L }; + let n = items.len(); + // W4: a single separator between two digits belongs to the number. + for k in 1..n.saturating_sub(1) { + let sep = matches!(text(k), "." | "," | ":" | "/" | "+" | "-" | "\u{066B}" | "\u{066C}"); + if sep && items[k - 1].1 == Bidi::Num && items[k + 1].1 == Bidi::Num { + items[k].1 = Bidi::Num; + } + } + // W5: terminators (percent, currency) next to a number belong to it. + let term = + |s: &str| !s.is_empty() && s.chars().all(|c| matches!(c, '%' | '‰' | '#' | '$' | '¢'..='¥' | '°' | '\u{066A}' | '\u{20A0}'..='\u{20CF}')); + for k in 1..n { + if items[k].1 == Bidi::N && items[k - 1].1 == Bidi::Num && term(text(k)) { + items[k].1 = Bidi::Num; + } + } + for k in (0..n.saturating_sub(1)).rev() { + if items[k].1 == Bidi::N && items[k + 1].1 == Bidi::Num && term(text(k)) { + items[k].1 = Bidi::Num; + } + } + // W7: a number after left-to-right text (or at the start of a left-to-right paragraph) is left-to-right. + let mut strong = para; + for it in &mut items { + match it.1 { + Bidi::L | Bidi::R => strong = it.1, + Bidi::Num if strong == Bidi::L => it.1 = Bidi::L, + _ => {} + } + } + // N1/N2: neutrals between same-direction text take it (numbers count as right-to-left), else the paragraph's. + let dir = |b: Bidi| if b == Bidi::L { Bidi::L } else { Bidi::R }; let mut k = 0; - while k < idx.len() { - if rtl(idx[k]) { + while k < n { + if items[k].1 != Bidi::N { + k += 1; + continue; + } + let mut e = k; + while e < n && items[e].1 == Bidi::N { + e += 1; + } + let before = if k == 0 { para } else { dir(items[k - 1].1) }; + let after = if e == n { para } else { dir(items[e].1) }; + let resolved = if before == after { before } else { para }; + for it in &mut items[k..e] { + it.1 = resolved; + } + k = e; + } + // Levels (I1/I2), then L2: reverse every run at or above each level, highest first. + let rtl_para = para == Bidi::R; + let levels: Vec = items + .iter() + .map(|it| match it.1 { + Bidi::R => 1, + Bidi::Num => 2, + _ if rtl_para => 2, + _ => 0, + }) + .collect(); + let mut order: Vec = (0..n).collect(); + for level in (1..=levels.iter().copied().max().unwrap_or(0)).rev() { + let mut k = 0; + while k < n { + if levels[order[k]] < level { + k += 1; + continue; + } let mut e = k; - while e + 1 < idx.len() && (rtl(idx[e + 1]) || (glyphs[idx[e + 1]].text.trim().is_empty() && e + 2 < idx.len() && rtl(idx[e + 2]))) { + while e < n && levels[order[e]] >= level { e += 1; } - idx[k..=e].reverse(); - if let Some(gaps) = spaces.get_mut(k + 1..=e) { - gaps.reverse(); + order[k..e].reverse(); + k = e; + } + } + let mut j = 0; + let mut gap = false; + for &o in &order { + match items[o].0 { + None => gap = true, + Some(i) => { + if let (Some(slot), Some(space)) = (idx.get_mut(j), spaces.get_mut(j)) { + *slot = i; + *space = gap && j > 0; + } + gap = false; + j += 1; } - k = e + 1; - } else { - k += 1; } } } @@ -1293,6 +1401,7 @@ fn union(a: &mut [f32; 4], b: &[f32; 4]) { /// /// Heuristics (documented so they can be tuned and tested; plan/architecture.md §8): /// 1. *Segments:* consecutive glyphs (content order) on one baseline with no column-sized gap. +/// A smaller glyph right next to the previous one (a sub- or superscript) stays in the segment. /// Segments on the same baseline that nearly touch are merged (text drawn out of order). /// 2. *Blocks:* segments stacked with line-sized gaps and overlapping horizontally. /// 3. *Block order:* repeatedly take the top-most remaining block, preferring a block to its left @@ -1345,7 +1454,10 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { let Some(p) = s.first.end.checked_sub(1) else { return false }; let ph = height(p).min(height(i)); let gap = g[0] - glyphs[p].rect[2]; - (cy(i) - cy(p)).abs() < ph * 0.5 && gap < ph * 3.0 && g[0] > glyphs[p].rect[0] - ph * 2.0 + // A sub- or superscript is a smaller glyph on a shifted baseline: its em box centre sits off the line by + // more than half its own height, but by less than half the height of the full-size glyph beside it. + let centre_limit = if gap.abs() < ph { height(p).max(height(i)) * 0.5 } else { ph * 0.5 }; + (cy(i) - cy(p)).abs() < centre_limit && gap < ph * 3.0 && g[0] > glyphs[p].rect[0] - ph * 2.0 }); let reset = cont && segs.last().is_some_and(|s| restarts_run(&glyphs, s.first.start, i, furthest_progress)); if cont @@ -1528,6 +1640,23 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { mod tests { use super::*; + fn em_box(text: &str, x0: f32, y0: f32, x1: f32, y1: f32) -> TextGlyph { + TextGlyph { text: text.into(), rect: [x0, y0, x1, y1], quad: [[x0, y0], [x1, y0], [x1, y1], [x0, y1]], direction: [1.0, 0.0] } + } + + #[test] + fn subscript_stays_in_its_word() { + // "CO2-E": an 8.1 pt "CO" and "-E", with a 4.7 pt "2" drawn 2.7 pt lower (#3174). + let glyphs = vec![ + em_box("C", 0.0, 92.5, 5.0, 100.6), + em_box("O", 5.0, 92.5, 11.0, 100.6), + em_box("2", 11.0, 97.9, 13.5, 102.6), + em_box("-", 13.5, 92.5, 16.0, 100.6), + em_box("E", 16.0, 92.5, 21.0, 100.6), + ]; + assert_eq!(layout(glyphs).plain_text(), "CO2-E"); + } + #[test] fn identity_v_columns_are_searchable_in_reading_order() { use lopdf::{Document, Object, Stream, dictionary}; @@ -1740,6 +1869,60 @@ mod tests { } } + /// One page per `(encoding, content)`, in Helvetica (`/F1`). + fn encoded_pages(pages: &[(lopdf::Object, &[u8])]) -> Pdf { + use lopdf::{Document, Object, Stream, dictionary}; + let mut doc = Document::with_version("1.7"); + let pages_id = doc.new_object_id(); + let mut kids = Vec::new(); + for (encoding, content) in pages { + let font = + doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica", "Encoding" => encoding.clone() }); + let content = doc.add_object(Stream::new(dictionary! {}, content.to_vec())); + let page = doc.add_object(dictionary! { "Type" => "Page", "Parent" => pages_id, "MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(300), Object::Integer(300)], "Resources" => dictionary! {"Font" => dictionary! {"F1" => font}}, "Contents" => content }); + kids.push(Object::Reference(page)); + } + let count = kids.len() as i64; + doc.objects.insert(pages_id, dictionary! {"Type" => "Pages", "Kids" => kids, "Count" => count}.into()); + let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages_id}); + doc.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).unwrap(); + Pdf::new(std::sync::Arc::new(bytes)).unwrap() + } + + /// The Annex D.2 alias codes extract as the ink they draw, not as formatting characters. + /// + /// WinAnsiEncoding gives 0xAD and 0xA0 the glyph names `hyphen` and `space`, the names it + /// also gives 0x2D and 0x20, and MacRomanEncoding gives 0xCA `space`. Reporting the Adobe + /// Glyph List values U+00AD and U+00A0 for them hid visible text: a soft hyphen is invisible, + /// so "well-known" written that way matched no search and no redaction pattern. + /// `XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 of them. + /// + /// A `/Differences` entry naming `sfthyphen` or `nbspace` is a deliberate statement about one + /// code and keeps its glyph list value. pdftotext draws the same line: on this fixture it + /// reads the base-encoded codes as "a-b a b" and the named ones as U+00AD. + #[test] + fn the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space() { + use lopdf::{Object, dictionary}; + let name = |n: &str| Object::Name(n.as_bytes().to_vec()); + let differences = Object::Dictionary(dictionary! { + "Type" => "Encoding", + "BaseEncoding" => "WinAnsiEncoding", + "Differences" => vec![Object::Integer(88), name("sfthyphen"), Object::Integer(89), name("nbspace")], + }); + let pdf = encoded_pages(&[ + (name("WinAnsiEncoding"), &b"BT /F1 20 Tf 1 0 0 1 20 150 Tm (a\xadb\xa0c) Tj ET"[..]), + (name("MacRomanEncoding"), &b"BT /F1 20 Tf 1 0 0 1 20 150 Tm (a\xcab) Tj ET"[..]), + (differences, &b"BT /F1 20 Tf 1 0 0 1 20 150 Tm (aXbYc) Tj ET"[..]), + ]); + let texts = + |page| extract_page(&pdf, page, &InterpreterSettings::default()).unwrap().glyphs.iter().map(|g| g.text.clone()).collect::>(); + assert_eq!(texts(0), ["a", "-", "b", " ", "c"], "WinAnsi 0xAD and 0xA0"); + assert_eq!(texts(1), ["a", " ", "b"], "MacRoman 0xCA"); + assert_eq!(texts(2), ["a", "\u{ad}", "b", "\u{a0}", "c"], "a named glyph keeps its glyph list value"); + } + #[test] fn narrow_ligatures_keep_every_letter() { // Split evenly, this "ffi" put its two "f"s so close that the second was dropped as a @@ -2038,6 +2221,26 @@ mod tests { assert_eq!(layout(v).plain_text(), "שלום עולם"); } + /// #2667: a number or punctuation on a right-to-left line must not split it into separately reversed words. + #[test] + fn rtl_line_with_number_and_punctuation_reads_in_logical_order() { + let line = |words: &[&str]| { + let mut v = Vec::new(); + let mut x = 10.0; + for w in words { + word(&mut v, w, x, 10.0, 6.0); + x += w.chars().count() as f32 * 6.0 + 6.0; + } + layout(v).plain_text() + }; + // Each line as drawn, left to right. + assert_eq!(line(&["םילקש", "120", "ריחמ"]), "מחיר 120 שקלים"); + assert_eq!(line(&[".הקידב", "ךמסמ", "והז", ":תירבע"]), "עברית: זהו מסמך בדיקה."); + assert_eq!(line(&["باتك", "12.5", "ددع"]), "عدد 12.5 كتاب"); + assert_eq!(line(&["םלוע", "םולש"]), "שלום עולם"); + assert_eq!(line(&["Total", "םולש", "price"]), "Total שלום price"); + } + /// Two lines, "ab cd" and "e fg": words stop at blank glyphs, word gaps and line ends. #[test] fn words_and_lines_around_a_glyph() { diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index 903673769a..78f2ad55df 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -468,7 +468,7 @@ impl SignatureInfo { } } -fn text(doc: &Document, d: &Dict, key: &[u8]) -> Option { +pub(crate) fn text(doc: &Document, d: &Dict, key: &[u8]) -> Option { let o = doc.resolve(d.get(key)?); let s = match &*o { Object::String(s) => s.to_text(), diff --git a/crates/sign/src/windows.rs b/crates/sign/src/windows.rs index 3974aff8ca..275bdb52c8 100644 --- a/crates/sign/src/windows.rs +++ b/crates/sign/src/windows.rs @@ -2,7 +2,7 @@ //! Private keys remain in CNG; Windows may ask permission to use a key. //! //! Certificates that can't sign through PdfCraft (no usable private key, a key type it doesn't -//! sign with) are reported in [`Listing::unusable`] with the reason rather than left out +//! sign with, a key usage or extended key usage that doesn't allow signing documents) are reported in [`Listing::unusable`] with the reason rather than left out //! silently, so a missing identity can be explained (issue #179). use std::sync::Arc; @@ -161,6 +161,13 @@ fn entries() -> Result { continue; } } + // A smart card holds encryption and logon certificates beside the signing one, all under + // the same name; a signature made with them doesn't validate, so they aren't offered. + // Checked after the key, so a certificate without one stays out of the list entirely. + if let Some(reason) = certificate.signing_problem_clause() { + skip(certificate.display_name(), reason, false); + continue; + } let public = certificate.public_key.clone(); let key = PrivateKey::external(public.clone(), Arc::new(WindowsKey { context: context.clone(), public })); let friendly_name = Some(certificate.display_name()); diff --git a/crates/sign/src/x509.rs b/crates/sign/src/x509.rs index 1a0ef58ade..6f4a731e7a 100644 --- a/crates/sign/src/x509.rs +++ b/crates/sign/src/x509.rs @@ -384,6 +384,15 @@ impl Certificate { } } + /// [`signing_problem`](Self::signing_problem) as a clause that follows the certificate's + /// name ("it is not issued for signing documents (…)"), for lists of digital IDs. + pub fn signing_problem_clause(&self) -> Option { + self.signing_problem().map(|d| match d.message.strip_prefix("The signer's certificate ") { + Some(rest) => format!("it {}", rest.strip_suffix('.').unwrap_or(rest)), + None => d.message, + }) + } + /// Issued by itself (subject = issuer and its own key verifies it). pub fn is_self_signed(&self) -> bool { self.issuer.raw == self.subject.raw && self.signed_by(&self.public_key) diff --git a/crates/sign/tests/chain.rs b/crates/sign/tests/chain.rs index ece529765a..59834104ee 100644 --- a/crates/sign/tests/chain.rs +++ b/crates/sign/tests/chain.rs @@ -281,6 +281,21 @@ fn a_trusted_signer_whose_certificate_may_not_sign_is_not_valid() { assert_eq!(status, Status::Valid, "{details:?}"); } +#[test] +fn a_smart_cards_certificates_that_may_not_sign_say_why_in_id_lists() { + // A DoD CAC's four certificates, all under the same name; only the signing one may sign. + let r = root(); + let clause = |exts: &[Vec]| signer_with("DOE.JANE.1234567890", &r, exts).cert.signing_problem_clause(); + let encryption = clause(&[key_usage(KEY_ENCIPHERMENT), extended_key_usage(&["1.3.6.1.5.5.7.3.4"])]); + assert_eq!(encryption.as_deref(), Some("it does not allow digital signatures (its key usage has neither digital signature nor non-repudiation)")); + let logon = clause(&[key_usage(DIGITAL_SIGNATURE), extended_key_usage(&["1.3.6.1.4.1.311.20.2.2", "1.3.6.1.5.5.7.3.2"])]); + assert_eq!(logon.as_deref(), Some("it is not issued for signing documents (its extended key usage has no document-signing purpose)")); + let card_auth = clause(&[key_usage(DIGITAL_SIGNATURE), extended_key_usage(&["2.16.840.1.101.3.6.8"])]); + assert_eq!(card_auth, logon); + let signing = clause(&[key_usage(DIGITAL_SIGNATURE | 0x40), extended_key_usage(&["1.3.6.1.5.5.7.3.4", "1.3.6.1.4.1.311.10.3.12"])]); + assert_eq!(signing, None); +} + #[test] fn unreadable_constraint_extensions_fail_closed() { // keyUsage present but not a BIT STRING: no usage at all, so no keyCertSign. diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index aa9fe54f66..42e9474f07 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -71,7 +71,7 @@ rfd = { workspace = true, features = ["file-handle-inner"] } wasm-bindgen-futures = "0.4" wasm-bindgen = "0.2" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "HtmlIFrameElement", "HtmlImageElement", "HtmlCollection", "Node", "EventTarget"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "HtmlIFrameElement", "HtmlImageElement", "HtmlCollection", "Node", "EventTarget", "Navigator"] } [dev-dependencies] pdfcraft-xfa = { workspace = true } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 953d415f32..3ad4317974 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -299,6 +299,74 @@ pub struct RasterMemory { pub pending_requests: usize, } +/// A historical snapshot opened from another document: View signed version opens the bytes +/// a signature covers, View revision an earlier incremental revision. Repeats reuse the tab, +/// and the tab and notice bar name what it is, instead of piling up identical tabs. +/// What a historical snapshot shows: the bytes a signature covers, or an earlier +/// incremental revision. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SnapshotKind { + SignedVersion, + Revision, +} + +#[derive(Clone, Debug)] +pub struct Snapshot { + pub kind: SnapshotKind, + /// The 1-based revision shown. + pub revision: usize, + /// The name of the document it was opened from. + pub from: String, +} + +/// A validation badge drawn over a signature widget: the widget rectangle (user space), the verdict it shows, and its hover text. +pub(crate) struct SignatureBadge { + pub rect: [f64; 4], + pub status: pdfcraft_engine::SignatureStatus, + pub summary: &'static str, +} + +/// The validation badges drawn over one page's signature widgets: signed, visible widgets +/// on this page with coordinates that survive the cast to screen space. Hostile files can +/// carry NaN, infinite or gigantic rectangles, which must never reach the painter. +pub(crate) fn signature_badges(signatures: &[pdfcraft_engine::SignatureInfo], page: usize) -> Vec { + signatures + .iter() + .filter(|s| s.signed && s.visible && s.page == Some(page)) + .filter_map(|s| { + s.rect.filter(|r| r.iter().all(|v| v.is_finite() && v.abs() < f32::MAX as f64)).map(|rect| SignatureBadge { + rect, + status: s.status, + summary: s.summary(), + }) + }) + .collect() +} + +/// The name shown for a document tab, the window title and the Combine picker. +/// A snapshot reads like its source document, plus the snapshot suffix that tells +/// the tabs apart. Without a title the file name (which already carries the suffix) +/// is enough. +pub(crate) fn tab_label(app: &PdfCraftApp, index: usize) -> Option { + let view = app.views.get(index)?; + let doc = app.session.get(view.id)?; + let Some(snap) = view.snapshot.as_ref() else { return Some(doc.display_name()) }; + Some(snapshot_tab_name(&doc.display_name(), &doc.name, snap)) +} + +/// A snapshot's tab name from its source title, file name and marker. Pure so the +/// tab strip, window title and picker cannot disagree with each other. +pub(crate) fn snapshot_tab_name(title: &str, file_name: &str, snapshot: &Snapshot) -> String { + if title == file_name { + return file_name.into(); + } + if snapshot.kind == SnapshotKind::SignedVersion { + format!("{title} (signed version)") + } else { + format!("{title} (revision {})", snapshot.revision) + } +} + pub struct DocView { pub id: DocId, pub zoom: f32, @@ -320,6 +388,11 @@ pub struct DocView { /// The file changed on disk while this document had unsaved changes: the notice bar asks /// before replacing it (#431). pub disk_changed: bool, + /// The snapshot notice bar's Close was clicked: the tab closes at the top of the next + /// frame, where no document borrow is held (the bar itself can't close it). + pub close_requested: bool, + /// A historical snapshot of another document (see [`Snapshot`]): `None` for real documents. + pub snapshot: Option, /// Two-page view: show the first page alone, as a cover (View ▸ Page display). pub cover: bool, /// Previous view / Next view: pages visited before (and after, once going back). @@ -350,6 +423,8 @@ pub struct DocView { /// This frame's consumers, with visible cells ahead of prefetch. thumb_demand: HashMap, frame_queue: Vec, + /// A page-list edit after layout invalidates this frame's scales and page indexes. + frame_geometry_changed: bool, /// Pages on screen this frame. Their rasters and tiles are always admitted: the viewport /// bounds them, and the byte and count limits apply to prefetch only. frame_visible: HashSet, @@ -371,6 +446,9 @@ pub struct DocView { pub(crate) text_failed: HashSet, pub find: Option, selection: Option, + /// The selection whose action bar was closed by its Copy button: the bar stays hidden + /// until the selection changes, as it does once a markup button consumes the selection. + actions_dismissed: Option, /// The drag in progress started a text selection: the wheel scrolls the page during it /// (#721), which egui's scroll area refuses while anything is dragged. selection_drag: bool, @@ -379,6 +457,9 @@ pub struct DocView { last_press_time: f64, last_press_pos: Option, last_queue: Vec, + /// Finished renders wait in the pool instead of reaching the view (tests): what the view + /// shows before a raster arrives can be looked at however fast the renderer is. + pub hold_rasters: bool, viewport_w: f32, viewport_h: f32, page_count: usize, @@ -403,6 +484,9 @@ pub struct DocView { /// (#759). The window outlasts a single frame so egui's internal easing tail is suppressed as /// it decays, instead of adding on top of the 1:1 motion. precise_scroll_until: f64, + /// Per-axis line-wheel state; horizontal input cannot extend a vertical burst. + wheel_boost_x: crate::wheel_boost::WheelBoost, + wheel_boost_y: crate::wheel_boost::WheelBoost, /// Touchpad momentum after the fingers lift (#531). fling: crate::fling::Fling, pub(crate) auto_scroll: crate::autoscroll::AutoScroll, @@ -478,6 +562,9 @@ pub struct DocView { pub pages_panel: Option, /// The Pages-panel gap under the pointer (where dropped files go), when it can take pages. pub panel_gap: Option, + /// The current page the Pages panel last followed: it scrolls to the current page's + /// thumbnail only when the page changes. + pub(crate) panel_followed: Option, /// How large the page grid draws its pages (1.0 = the usual thumbnails). grid_zoom: f32, /// A zoom asked for this frame (toolbar, pinch, keys); the grid applies it once it has laid @@ -506,6 +593,8 @@ pub struct DocView { pub(crate) last_scroll: Vec2, /// Side-by-side compare: scroll to here this frame, following the other half. pub(crate) sync_scroll: Option, + /// The page widget's id, by which the Hand tool finds its drag before the pages are laid out. + page_widget: Option, pan_hold_until: f64, /// Pages whose raster proved slow (see `SLOW_MILLIS`): only these get the zoom/pan /// settle holds. Fast pages render straight through, with no added latency. @@ -625,6 +714,8 @@ impl DocView { notice_dismissed: false, scripts_off_noted: false, disk_changed: false, + close_requested: false, + snapshot: None, cover: false, back: Vec::new(), forward: Vec::new(), @@ -640,6 +731,7 @@ impl DocView { thumbs: HashMap::new(), thumb_demand: HashMap::new(), frame_queue: Vec::new(), + frame_geometry_changed: false, frame_visible: HashSet::new(), stale_thumbs: HashSet::new(), stale_tiles: HashSet::new(), @@ -651,11 +743,13 @@ impl DocView { text_failed: HashSet::new(), find: None, selection: None, + actions_dismissed: None, selection_drag: false, clicks: 0, last_press_time: 0.0, last_press_pos: None, last_queue: Vec::new(), + hold_rasters: false, viewport_w: 800.0, viewport_h: 600.0, page_count: info.pages.len(), @@ -668,6 +762,8 @@ impl DocView { auto_ocr_tried: Default::default(), wheel: Default::default(), precise_scroll_until: 0.0, + wheel_boost_x: Default::default(), + wheel_boost_y: Default::default(), fling: Default::default(), auto_scroll: Default::default(), selected: BTreeSet::new(), @@ -689,6 +785,7 @@ impl DocView { grid_gap: None, pages_panel: None, panel_gap: None, + panel_followed: None, grid_zoom: 1.0, grid_zoom_request: None, grid_anchor: None, @@ -698,6 +795,7 @@ impl DocView { sharp_hold_until: 0.0, last_scroll: Vec2::ZERO, sync_scroll: None, + page_widget: None, pan_hold_until: 0.0, slow_pages: HashSet::new(), comments: Default::default(), @@ -726,6 +824,7 @@ impl DocView { /// adopt the new page geometry, keeping the reader's place where possible. pub fn document_changed(&mut self, info: &DocInfo) { self.invalidate_content(); + self.frame_geometry_changed = true; self.page_count = info.pages.len(); self.page_heights = info.pages.iter().map(|p| p.height).collect(); let last = self.page_count.saturating_sub(1); @@ -744,13 +843,15 @@ impl DocView { self.flash = None; // A thumbnail drag holds page indexes from before the change. self.panel_drag = None; + self.reset_coarse_state(); } /// The document was replaced by a newer version of its file (#431). The reader's place - /// stays: zoom, layout, rotation, page, scroll, view history and the find bar, and the page - /// images on screen until their replacements are drawn, so nothing flashes. Everything that - /// pointed into the old version (a selected comment, a field being typed in, text being - /// edited, a drag, page selection) starts afresh, so no stale index reaches the new one. + /// stays: zoom, layout, rotation, page, scroll, view history and the find bar, the snapshot + /// marker and its pending close, and the page images on screen until their replacements + /// are drawn, so nothing flashes. Everything that pointed into the old version (a selected + /// comment, a field being typed in, text being edited, a drag, page selection) starts + /// afresh, so no stale index reaches the new one. pub fn document_replaced(&mut self, info: &DocInfo) { let mut fresh = Self::new(self.id, info, ViewDefaults::default()); fresh.zoom = self.zoom; @@ -765,6 +866,8 @@ impl DocView { fresh.shown = self.shown; fresh.highlight_fields = self.highlight_fields; fresh.notice_dismissed = self.notice_dismissed; + fresh.snapshot = self.snapshot.clone(); + fresh.close_requested = self.close_requested; fresh.cover = self.cover; fresh.back = std::mem::take(&mut self.back); fresh.forward = std::mem::take(&mut self.forward); @@ -958,6 +1061,7 @@ impl DocView { pub(crate) fn clear_selection(&mut self) { self.selection = None; + self.actions_dismissed = None; } /// Select text on `page` from glyph `from` to glyph `to` (tests and automation). @@ -1058,6 +1162,7 @@ impl DocView { .unwrap_or(0.0); self.goto = Some((page, frac)); self.current = page; + self.reset_coarse_state(); } /// Screen position of the centre of glyph `glyph` on `page`, if that page is on screen and its @@ -1079,6 +1184,14 @@ impl DocView { self.thumbs.get(&page).map(|t| &t.tex) } + /// Drops wheel state after a discontinuous jump so the destination cannot + /// inherit momentum from the previous viewport. + fn reset_coarse_state(&mut self) { + self.wheel.clear_motion(); + self.wheel_boost_x.reset(); + self.wheel_boost_y.reset(); + } + pub fn go_to_page(&mut self, page: usize) { let page = page.min(self.page_count.saturating_sub(1)); if page != self.current { @@ -1092,7 +1205,7 @@ impl DocView { self.forward.clear(); } // Partial wheel motion belongs to the old place. - self.wheel.clear_motion(); + self.reset_coarse_state(); self.goto = Some((page, 0.0)); self.current = page; } @@ -1227,6 +1340,7 @@ impl DocView { let page = page.min(self.page_count.saturating_sub(1)); self.goto = Some((page, 0.0)); self.current = page; + self.reset_coarse_state(); true } @@ -1251,12 +1365,14 @@ impl DocView { pub fn rotate_view(&mut self, clockwise: bool) { self.rotation = (self.rotation + if clockwise { 90 } else { 270 }) % 360; self.goto = Some((self.current, 0.0)); + self.reset_coarse_state(); } /// Switch the zoom mode, keeping the current page in view. pub fn set_fit(&mut self, fit: Fit) { self.fit = fit; self.goto = Some((self.current, 0.0)); + self.reset_coarse_state(); } /// Switch the base page display (Single or Two-page), leaving continuous scrolling as it is. @@ -1268,6 +1384,7 @@ impl DocView { } self.layout = layout; self.goto = Some((self.current, 0.0)); + self.reset_coarse_state(); } /// Whether the view scrolls continuously (as opposed to one page or spread at a time). @@ -1281,6 +1398,7 @@ impl DocView { if self.continuous != continuous { self.continuous = continuous; self.goto = Some((self.current, 0.0)); + self.reset_coarse_state(); } } @@ -1296,6 +1414,7 @@ impl DocView { if self.cover != cover { self.cover = cover; self.goto = Some((self.current, 0.0)); + self.reset_coarse_state(); } } @@ -1426,6 +1545,13 @@ impl DocView { } } + /// A zoom typed as a percentage ("150", "150%", " 62.5 % ") as a zoom factor; `None` unless it is a + /// finite, positive number. `set_zoom` clamps it to the supported range. + pub fn parse_zoom_percent(typed: &str) -> Option { + let percent = typed.trim().trim_end_matches('%').trim_end().parse::().ok()?; + (percent.is_finite() && percent > 0.0).then_some(percent / 100.0) + } + /// Standard zoom steps (as in Acrobat's zoom menu). pub fn zoom_step(&mut self, up: bool) { const STEPS: [f32; 17] = [0.1, 0.25, 0.33, 0.5, 0.66, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0, 6.0, 8.0, 12.0, 16.0, 32.0]; @@ -1438,6 +1564,7 @@ impl DocView { /// Start collecting demand from every consumer before scheduling or uploading results. pub(crate) fn begin_render_frame(&mut self) { + self.frame_geometry_changed = false; self.pages_panel = None; self.panel_gap = None; self.thumb_demand.clear(); @@ -1653,12 +1780,23 @@ impl DocView { } pub(crate) fn finish_render_frame(&mut self, ctx: &egui::Context, info: &DocInfo, pool: &RenderPool) { + if self.frame_geometry_changed { + // Dialogs and edits can replace pages after the canvas collected demand. Its + // scales and indexes belong to the old pages, not to the new renderer (#3149). + self.begin_render_frame(); + self.last_queue.clear(); + pool.set_queue(Vec::new()); + ctx.request_repaint(); + return; + } let queue = self.prepare_render_queue(info, ctx.pixels_per_point()); if queue != self.last_queue { pool.set_queue(queue.clone()); self.last_queue = queue; } - self.receive(ctx, pool); + if !self.hold_rasters { + self.receive(ctx, pool); + } if !self.last_queue.is_empty() { ctx.request_repaint_after(std::time::Duration::from_millis(30)); } @@ -1717,6 +1855,12 @@ impl DocView { self.errors.insert(r.request.page, e); return 0; } + // Even current demand may have been sized with obsolete geometry or a different + // device limit. Check before cache eviction, conversion and every upload path. + let max_side = ctx.input(|i| i.max_texture_side); + if r.width == 0 || r.height == 0 || r.width as usize > max_side || r.height as usize > max_side { + return 0; + } // Remember heavy pages: only these get the zoom/pan settle holds, so fast pages // never pay added latency. Stale entries die with the tiles on edits. if r.millis >= SLOW_MILLIS { @@ -1917,6 +2061,14 @@ fn annotation_hover_text(a: &pdfcraft_render::Annotation) -> Option { Some(if body.is_empty() { who } else { format!("{who}\n{body}") }) } +/// The scroll offset that puts `point` (content coordinates) at `rel` in the viewport, kept within +/// the content as egui keeps it. egui clamps only after drawing, so an offset past the end showed +/// the page misplaced for a frame and then jumped (zooming out beside a page that fits). +fn anchored_scroll(point: Pos2, rel: Vec2, content: Vec2, viewport: Vec2) -> Vec2 { + let max = vec2((content.x - viewport.x).max(0.0), (content.y - viewport.y).max(0.0)); + vec2((point.x - rel.x).clamp(0.0, max.x), (point.y - rel.y).clamp(0.0, max.y)) +} + /// The request tag for a raster at `scale`: equal tags mean the same scale (to 1/65536). fn scale_tag(scale: f32) -> u64 { (f64::from(scale) * 65536.0).round() as u64 @@ -2075,11 +2227,20 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s if ctx.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::F))) { view.open_find(); } + let cmd = |k| KeyboardShortcut::new(Modifiers::COMMAND, k); + let pressed = |s: KeyboardShortcut| ctx.input_mut(|i| i.consume_shortcut(&s)); + // ⌘G / ⇧⌘G step through the matches from the find field too, as in Acrobat and Preview + // (#2669): ahead of the text-input check, but only while there is a search to step. + if view.find.is_some() { + if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::G)) { + view.find_step(false); + } else if pressed(cmd(Key::G)) { + view.find_step(true); + } + } if ctx.egui_wants_keyboard_input() { return; } - let cmd = |k| KeyboardShortcut::new(Modifiers::COMMAND, k); - let pressed = |s: KeyboardShortcut| ctx.input_mut(|i| i.consume_shortcut(&s)); // Rotation first: egui matches ⌘+ loosely with respect to Shift. if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::Plus)) || pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::Equals)) @@ -2120,11 +2281,6 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s view.fit = Fit::Width; view.goto = Some((view.current, 0.0)); } - if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::G)) { - view.find_step(false); - } else if pressed(cmd(Key::G)) { - view.find_step(true); - } if pressed(cmd(Key::OpenBracket)) { view.view_history(false); } @@ -2194,6 +2350,23 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s /// How far ↓ / ↑ scroll at Normal speed (Scroll speed halves or doubles it). Page Down / Page Up /// keep this much of the previous screen in view. const KEY_SCROLL_LINE: f32 = 40.0; +/// Suppression window for egui's eased copy of point input. +/// +/// egui 0.36.2 drains `WheelState::after_events` tail deltas by 90% per 0.1 s. +const SUPPRESS_SECS: f64 = 0.25; +/// Short rolling extension while egui still has an unprocessed wheel tail. +const SUPPRESS_TAIL_SECS: f64 = 0.05; + +// The viewer moves between docking and the central panel as modes change; keep its scroll +// identity tied to the document instead of either parent UI. +fn page_scroll( + ui: &mut egui::Ui, + scroll: egui::ScrollArea, + id: egui::Id, + draw: impl FnOnce(&mut egui::Ui, egui::Rect) -> R, +) -> egui::scroll_area::ScrollAreaOutput { + ui.scope_builder(egui::UiBuilder::new().id(id), |ui| scroll.show_viewport(ui, draw)).inner +} pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); @@ -2207,6 +2380,19 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { { f.in_panel = false; } + // The snapshot notice bar's Close was clicked: close here, where no document borrow is + // held. The tab is looked up by id: indices may have shifted since the click, and the + // flag rides its own view, so a stale flag can never close the wrong tab. + let pending = app.views.get(index).filter(|v| v.close_requested).map(|v| v.id).and_then(|id| app.views.iter().position(|v| v.id == id)); + if let Some(i) = pending { + app.views[i].close_requested = false; + if app.has_unsaved_work(i) { + app.close_request = Some(crate::CloseRequest::Tab(app.views[i].id)); + } else { + app.close_tab(i); + } + return; + } let Some(doc) = app.session.get(app.views[index].id) else { return }; let info = &doc.info; if info.pages.is_empty() { @@ -2236,6 +2422,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { Some(Notice::Repairs) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Advanced)), Some(Notice::Security) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Security)), Some(Notice::Unlock) => app.unlock_prompt = Some(crate::UnlockPrompt::new(doc, None)), + // Flags the view: the tab closes at the top of the next frame, where no document + // borrow is held. + Some(Notice::Close) => { + if let Some(view) = app.views.get_mut(index) { + view.close_requested = true; + } + } Some(Notice::Signatures) => app.right = Some(RightPanel::Signatures), Some(Notice::FieldHighlights(on)) => app.view_defaults.highlight_fields = on, Some(Notice::Console) => app.dialog = Some(crate::Dialog::JsConsole), @@ -2311,9 +2504,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // When not scrolling continuously, only one row is shown at a time: the current page, or the // current two-page spread (with the cover rule). These indices drive paging, which pages are // drawn, and the content height (#752). Empty for a document with no pages. - let row: Vec = if rects.is_empty() { - Vec::new() - } else { + let shown_row = |view: &DocView| -> Vec { + if rects.is_empty() { + return Vec::new(); + } let last = rects.len() - 1; let cur = view.current.min(last); match view.layout { @@ -2325,6 +2519,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } }; + let row = shown_row(view); let middle_gesture = view.auto_scroll.blocks_input(); // Not scrolling: when the whole page or spread fits, the wheel would do nothing, so it turns // pages instead; zoomed in far enough to pan, it pans, and at the edge it turns the page on @@ -2361,36 +2556,25 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ui.input_mut(|i| i.smooth_scroll_delta.y = 0.0); } } - // Touchpad scrolling 1:1 (#759). A precision touchpad sends pixel-precise `MouseWheel` - // events; on Windows they carry no `TouchPhase::Start`, so egui never takes its no-smoothing - // path and eases deltas of 8 px or more, leaving the page a frame behind the finger. Apply - // the precise delta ourselves this frame through the un-animated scroll path, and suppress - // egui's eased copy while it decays so it neither doubles nor trails the motion. A coarse - // mouse wheel keeps egui's easing (pleasant for chunky notches). + // Point input bypasses egui smoothing for 1:1 motion. Line input keeps egui's configured, + // smoothed base and adds only the accelerated surplus. While point suppression is active, + // reconstruct line and page input here so current coarse events survive without exposing an + // older eased tail. let now = ui.input(|i| i.time); - let mut precise_scroll = Vec2::ZERO; - if ui.rect_contains_pointer(avail) { - let mut coarse_wheel = false; - ui.input(|i| { - for e in &i.events { - if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = e { - if modifiers.command || modifiers.ctrl { - continue; // Zooming, not scrolling. - } - match unit { - // Shift scrolls sideways, matching egui's own wheel handling. - egui::MouseWheelUnit::Point if modifiers.shift => precise_scroll.x += delta.x + delta.y, - egui::MouseWheelUnit::Point => precise_scroll += *delta, - egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => coarse_wheel = true, - } - } - } - }); - if coarse_wheel { - view.precise_scroll_until = 0.0; - } else if precise_scroll != Vec2::ZERO { - view.precise_scroll_until = now + 0.25; - } + let input = if ui.rect_contains_pointer(avail) { + // Match the line distance used by egui's other scroll areas. + let live = ui.ctx().options(|o| o.input_options.line_scroll_speed); + let line_points = if live.is_finite() && live > 0.0 { live } else { crate::scroll_speed::EGUI_LINE_POINTS }; + ui.input(|i| crate::wheel_boost::accumulate(&i.events, line_points, single_paging)) + } else { + crate::wheel_boost::FrameInput::default() + }; + let mut precise_scroll = input.precise; + let mut line_base = input.line; + let mut boost_extra = crate::wheel_boost::boost_extra(input, &mut view.wheel_boost_x, &mut view.wheel_boost_y, now); + let has_page = input.page != Vec2::ZERO; + if now.is_finite() && precise_scroll != Vec2::ZERO && !has_page { + view.precise_scroll_until = now + SUPPRESS_SECS; } if edge_turned { // The turn leaves egui's eased copy of the notch to decay for a few frames; it must not @@ -2400,20 +2584,50 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // Paging owns the vertical axis in single-page view; leave it to the pager. // The rest of a gesture that turned the page is swallowed too, or it would pan the new page // away from the edge it was shown at. - if single_paging || (!view.continuous && view.wheel.gesture_turned()) { + let paging_owns_vertical = single_paging || (!view.continuous && view.wheel.gesture_turned()); + if paging_owns_vertical { precise_scroll.y = 0.0; + line_base.y = 0.0; + boost_extra.y = 0.0; } if now < view.precise_scroll_until { + if ui.input(|i| i.is_scrolling()) { + // Keep hiding egui's unprocessed copy until it has drained. The short rolling grace + // avoids a delayed duplicate without prolonging suppression after the tail is gone. + view.precise_scroll_until = view.precise_scroll_until.max(now + SUPPRESS_TAIL_SECS); + } ui.input_mut(|i| i.smooth_scroll_delta = Vec2::ZERO); + // The point path suppresses every eased wheel delta. Restore the line base through the + // same un-eased path as its surplus; paging-owned axes were cleared above. + boost_extra = crate::wheel_boost::cap_frame(boost_extra + line_base); + if has_page { + // Page units normally stay on egui's viewport-sized path. During suppression only, + // reproduce the current event directly so an older hidden tail stays hidden. + let page_scale = ui.input(|i| i.viewport_rect().height()); + let mut page_delta = + if page_scale.is_finite() && page_scale > 0.0 { crate::wheel_boost::cap_frame(input.page * page_scale) } else { Vec2::ZERO }; + if paging_owns_vertical { + page_delta.y = 0.0; + } + boost_extra = crate::wheel_boost::cap_frame(boost_extra + page_delta); + } } else { precise_scroll = Vec2::ZERO; + if line_base != Vec2::ZERO || has_page { + // Selection handling below uses zero to distinguish an inactive window. + view.precise_scroll_until = 0.0; + } + } + if has_page && paging_owns_vertical { + // Page input belongs to egui on the free axis; the pager still owns vertical motion. + ui.input_mut(|i| i.smooth_scroll_delta.y = 0.0); } // Touchpad momentum (#531): a swipe over the page lifted at speed glides on and slows to a // stop, through the same un-eased path. Never while the pager owns the wheel, a gesture or // dialog has the input, or animations are off; any click, key, zoom or other scrolling ends it. // Asked of the preference, not of egui's animation time, which test harnesses zero. let animates = app.animation_effects.animates(app.motion.reduced); - let may_glide = !single_paging && !middle_gesture && navigation_enabled && unobstructed && animates; + let may_glide = !paging_owns_vertical && !middle_gesture && navigation_enabled && unobstructed && animates; let over_page = ui.rect_contains_pointer(avail); ui.input(|i| view.fling.read(&i.events, now, may_glide && over_page)); let interrupted = ui.input(|i| i.pointer.any_pressed() || i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. }))); @@ -2424,6 +2638,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if view.fling.gliding() { ui.ctx().request_repaint(); } + // The wheel may have turned the page above: draw the row it turned to. Measured from the old + // row, the jump to the new page scrolled both out of view, a blank frame on every turn forward. + let row = shown_row(view); let visible_pages: Vec = if view.continuous { (0..rects.len()).collect() } else { row.clone() }; let (y_shift, content_h) = if view.continuous { (0.0, rects.iter().map(|r| r.bottom()).reduce(f32::max).map(|bottom| bottom + MARGIN).unwrap_or(0.0)) @@ -2456,20 +2673,52 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if !std::mem::replace(&mut view.shown, true) { scroll = scroll.scroll_offset(Vec2::ZERO); } + // The Hand tool pans here, before the pages are laid out, so the page moves with the pointer + // in the frame the pointer moves (#924). Scrolling by the drag from inside the area moved the + // page only after that frame was drawn, a frame behind. The frame egui tells the drag from a + // click, the page catches up with all the pointer has moved since the press. A jump asked for + // below still wins. + if app.quick_tool == QuickTool::Hand + && !middle_gesture + && let Some(id) = view.page_widget.filter(|id| ui.ctx().is_being_dragged(*id)) + { + let moved = if ui.ctx().drag_started_id() == Some(id) { + ui.input(|i| i.pointer.total_drag_delta()).unwrap_or(Vec2::ZERO) + } else { + ui.input(|i| i.pointer.delta()) + }; + // Within the content, as egui keeps it: past an end the page would show there for a + // frame and spring back. `max` is never NaN or below zero, so the clamp can't panic. + let max = vec2((content_w - avail.width()).max(0.0), (content_h - avail.height()).max(0.0)); + scroll = scroll.scroll_offset((view.last_scroll - moved).clamp(Vec2::ZERO, max)); + } if let Some(v) = view.sync_scroll.take() { scroll = scroll.scroll_offset(v); } if let Some((page, fx, fy, rel)) = view.zoom_anchor.take() { let r = rects[page.min(rects.len() - 1)]; let point = pos2(r.left() + fx * r.width(), r.top() - y_shift + fy * r.height()); - scroll = scroll.scroll_offset(vec2((point.x - rel.x).max(0.0), (point.y - rel.y).max(0.0))); - } else if edge_turned { - // The row above was laid out for the page before the turn: place the new one next frame. - ui.ctx().request_repaint(); + scroll = scroll.scroll_offset(anchored_scroll(point, rel, vec2(content_w, content_h), avail.size())); } else if let Some((page, frac)) = view.goto.take() { let page = page.min(rects.len() - 1); let r = rects[page]; - scroll = scroll.vertical_scroll_offset((r.top() - y_shift - GAP + frac * r.height()).max(0.0)); + // Clamped here: egui clamps only after drawing, so a page that fits was drawn MARGIN - GAP + // too high for a frame and then hopped into place. (`avail` is the viewport, as the + // scroll bars float.) + let max_y = (content_h - avail.height()).max(0.0); + scroll = scroll.vertical_scroll_offset((r.top() - y_shift - GAP + frac * r.height()).max(0.0).min(max_y)); + // Horizontally too, as Acrobat does (#2665): beside a much wider page the others sit off + // to one side. A page already shown whole, or one filling the window's width, keeps the + // scroll, so paging through a zoomed-in document stays on the same column. Otherwise a + // page that fits is centred, and a wider one scrolled just far enough to fill the window. + // The page counts with its side gutters, which keep its edge clear of the floating + // quick-action bar as the layout does. + let (x, w) = (view.last_scroll.x, avail.width()); + let (left, right) = (r.left() - SIDE, r.right() + SIDE); + let target = if right - left <= w { if left >= x && right <= x + w { x } else { r.center().x - w / 2.0 } } else { x.clamp(left, right - w) }; + if (target - x).abs() > 0.5 { + scroll = scroll.horizontal_scroll_offset(target.clamp(0.0, (content_w - w).max(0.0))); + } } let ppp = ui.ctx().pixels_per_point(); let scale = view.render_scale(ppp); @@ -2537,6 +2786,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { }; let mut open_signature = false; let mut hover_text: Option<(Pos2, String)> = None; + let mut badge_hover: Option<(Pos2, String)> = None; // A dialog, save prompt or palette covers the page. Hover tips are drawn above those // windows, so a comment under the pointer would otherwise float over the dialog. let page_covered = app.dialog.is_some() @@ -2583,7 +2833,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { scroll = scroll.vertical_scroll_offset((r.top() - y_shift + fy * r.height() - rel.y).clamp(0.0, max_y)); } } - let out = scroll.show_viewport(ui, |ui, viewport| { + let selection_wheel = view.selection_drag && navigation_enabled && unobstructed && !single_paging && ui.rect_contains_pointer(avail); + let out = page_scroll(ui, scroll, egui::Id::new(("page-view-scope", view.id)), |ui, viewport| { // egui's drag responses accept every pointer button. Keep a wheel gesture from also // selecting text, drawing a mark, or panning with the Hand tool. if middle_gesture { @@ -2593,21 +2844,24 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ui.set_opacity(opacity); } // Middle-button auto-scroll, the keyboard, precise touchpad input (#759) and its glide - // (#531), and a drawing's edge scroll; positive y moves the content down. All scroll - // without easing, unlike egui's wheel default. - let delta = auto_delta + edge_delta - vec2(0.0, key_scroll) + precise_scroll + glide; - if delta != Vec2::ZERO { + // (#531), accelerated wheel surplus (#1072), and a drawing's edge scroll; positive y + // moves the content down. All scroll without easing, unlike egui's wheel default. + // ScrollArea ignores wheel input during a widget drag. Defer the wheel contribution + // until after the text widgets when a selection owns that drag. + let wheel_delta = if selection_wheel { Vec2::ZERO } else { precise_scroll + boost_extra }; + let delta = auto_delta + edge_delta - vec2(0.0, key_scroll) + wheel_delta + glide; + if delta != Vec2::ZERO && delta.is_finite() { ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); } let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag()); + view.page_widget = Some(resp.id); // A drag lasts only while egui reports it, so the mark can't outlive one. if !resp.dragged_by(egui::PointerButton::Primary) { view.selection_drag = false; } - // The Hand tool pans: the content widget takes every drag, so scroll by its delta. + // The Hand tool pans by this widget's drag, before the pages are laid out (above). if hand { if resp.dragged() { - ui.scroll_with_delta(resp.drag_delta()); ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); } else if resp.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); @@ -2638,6 +2892,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { view.links.context = None; } for &i in &visible_pages { + badge_hover = None; let r = snap_to_pixels(rects[i].translate(origin.to_vec2()), ppp); if !r.intersects(visible.expand(400.0)) { continue; @@ -3093,6 +3348,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } if resp.drag_started() && press_here && !over_link { + // A drag replaces the selection without passing through none, so a + // re-drag over text whose bar Copy closed would keep the bar hidden. + view.actions_dismissed = None; view.selection_drag = true; let origin = ui.input(|inp| inp.pointer.press_origin()).unwrap_or(p); let (ox, oy) = xf.screen_to_view(origin); @@ -3177,6 +3435,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if tool == QuickTool::Select && !consumed { crate::image_pick::page_input(&resp, &xf, i, info, doc, pointer, view); } + crate::image_pick::drop_stale(doc, view); crate::image_pick::paint(painter, &xf, i, info, view); comments::page_after_text(&resp, &pcx, view); if tool == QuickTool::Redact && can_modify { @@ -3294,21 +3553,43 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { view.flash = None; } } + // Validation is viewer chrome over the signer's original artwork, which + // may still say "Signature Not Verified". These badges never enter document + // streams, image/text exports, vector printing or Print as Image. + for badge in signature_badges(&doc.signatures, i) { + let [x0, y0, x1, y1] = badge.rect; + let sr = xf.user_rect(info, i, [x0 as f32, y0 as f32, x1 as f32, y1 as f32]); + let (icon, color) = crate::sign_ui::signature_status_style(badge.status); + painter.rect_stroke(sr, CornerRadius::same(2), Stroke::new(1.5, color), egui::StrokeKind::Outside); + // A widget too small for the mark keeps its outline: the verdict still shows. + if sr.width() < 4.0 || sr.height() < 4.0 { + continue; + } + // Big enough to read at a glance, small enough to leave the signer's own + // text visible around it. + let side = (sr.width().min(sr.height()) * 0.8).clamp(24.0, 192.0); + let mark = Rect::from_center_size(sr.center(), vec2(side, side)); + icons::paint(ui, mark, icon, side * 0.64, color); + if let Some(p) = pointer.filter(|p| mark.expand(2.0).contains(*p)) { + badge_hover = Some((p, badge.summary.to_string())); + } + } + // Overlapping widgets paint in order, so the topmost badge owns the hover; an + // earlier link or annotation hover still wins over any badge. + if hover_text.is_none() { + hover_text = badge_hover.take(); + } } // ScrollArea ignores the wheel while any widget is dragged, so a text-selection drag // scrolls the page itself (#721). After the page loop, so it keeps scrolling once the // pointer leaves the selection's page for the gap or the next page; over the whole - // document area, like #759. A Line/Page event clears precise_scroll_until; a Point - // gesture's eased tail after its suppression deadline is not picked up. - if view.selection_drag - && navigation_enabled - && unobstructed - && !single_paging - && view.precise_scroll_until == 0.0 - && ui.rect_contains_pointer(avail) - { - let delta = ui.input_mut(|input| std::mem::take(&mut input.smooth_scroll_delta)); - if delta != Vec2::ZERO { + // document area, like #759. Combine direct and smoothed motion in one request so mixed + // Point and Line events retain their full contribution. + if selection_wheel { + let smooth = + if view.precise_scroll_until == 0.0 { ui.input_mut(|input| std::mem::take(&mut input.smooth_scroll_delta)) } else { Vec2::ZERO }; + let delta = precise_scroll + boost_extra + smooth; + if delta != Vec2::ZERO && delta.is_finite() { ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); } } @@ -3483,6 +3764,26 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { view.last_scroll = scroll_offset; let pan_held = now_settle < view.pan_hold_until; let (mut wanted, visible_now) = out.inner; + // Single-page and two-page views draw only the current row, so the next one was first asked + // for on the turn and showed blank paper until it rendered. Prefetch the rows either side, + // as continuous scrolling does for the pages just off screen. + if !view.continuous + && let (Some(&first), Some(&last)) = (visible_pages.first(), visible_pages.last()) + { + let n = if view.layout == PageLayout::Single { 1 } else { 2 }; + let max_side = ui.ctx().input(|inp| inp.max_texture_side) as f32; + for i in (last.saturating_add(1)..=last.saturating_add(n)).chain(first.saturating_sub(n)..first) { + let Some(p) = info.pages.get(i).filter(|_| !view.errors.contains_key(&i)) else { continue }; + // As drawn: the whole page at this scale, or the backdrop of a tiled page. + let side = p.width.max(p.height).max(1.0); + wanted.push(if side * scale > TILE_THRESHOLD.min(max_side) { + let bs = BASE_SIDE.min(max_side) / side; + (i, bs, scale_tag(bs), None) + } else { + (i, scale, tag, None) + }); + } + } let cur = view.current; wanted.sort_by_key(|w| (!visible_now.contains(&w.0), w.0.abs_diff(cur), w.3.is_some())); // Tiles of far-away pages are useless: free them. Tiles of an earlier zoom stay for a page @@ -3698,6 +3999,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if let Some((p, i)) = app.views[index].comments.default_request.take() { app.make_comment_default(p, i); } + if let Some((p, i, open)) = app.views[index].comments.attachment_request.take() { + let doc = app.views[index].id; + app.comment_attachment_action(doc, p, i, open); + } if let Some((name, w)) = field_props { app.open_field_props(&name, w); } @@ -3769,7 +4074,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if copy_selected { app.execute("edit.copy"); } - quick_bar(app, avail, ui); + // Read mode hides the tools (#2646). + if app.mode != crate::Mode::Read { + quick_bar(app, avail, ui); + } if let Some((source, file, destination)) = remote_open { app.open_remote_link(source, file, destination); } @@ -3796,7 +4104,13 @@ fn selection_actions( if ui.input(|input| input.pointer.is_decidedly_dragging()) { return false; } - let Some(selection) = view.selection else { return false }; + let Some(selection) = view.selection else { + view.actions_dismissed = None; + return false; + }; + if view.actions_dismissed == Some(selection) { + return false; + } let Some(text) = view.texts.get(&selection.page) else { return false }; let Some(xf) = view.page_xform(selection.page) else { return false }; let Some(last) = selection.rects(text).last().copied() else { return false }; @@ -3833,6 +4147,8 @@ fn selection_actions( }); let Some(action) = chosen else { return false }; if let SelectionAction::Copy = action { + // Copying closes the bar but keeps the words selected, for ⌘C or a second action. + view.actions_dismissed = Some(selection); return true; } let Some((page, quads)) = view.selection_quads(info) else { return false }; @@ -4104,10 +4420,13 @@ enum Notice { Console, /// Unlock… (enter the permissions password). Unlock, + /// Close this historical snapshot tab (View signed version / View revision). + Close, } -/// The notice bar above the pages: the signature status first (Acrobat's signature bar), then -/// a form's scripts having been turned off, security, forms and warnings. +/// The notice bar above the pages: a file changed on disk first, then what a historical +/// snapshot is, then the signature status bar, a form's scripts having been turned +/// off, security, forms and warnings. fn notices( view: &mut DocView, doc: &pdfcraft_engine::Document, @@ -4142,6 +4461,30 @@ fn notices( }); return reload.then_some(Notice::Reload); } + // A historical snapshot (View signed version / View revision): what it is, above the + // signature and security bars, so the tab is never mistaken for the live document. + if let Some(snap) = view.snapshot.as_ref() { + let mut close = false; + let text = if snap.kind == SnapshotKind::SignedVersion { + crate::i18n::fmt( + tl!("You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original."), + &[("from", &snap.from), ("rev", &snap.revision.to_string())], + ) + } else { + crate::i18n::fmt( + tl!("You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original."), + &[("rev", &snap.revision.to_string()), ("from", &snap.from)], + ) + }; + notice_bar(ui, t, "clock", t.accent_text, text, |ui| { + if crate::widgets::pill_button(ui, tl!("Close"), true).clicked() { + close = true; + } + }); + if close { + return Some(Notice::Close); + } + } if let Some((icon, color, template, arg)) = signed { let mut open = false; notice_bar(ui, t, icon, color, crate::i18n::fmt(tl!(template), &[("by", &arg)]), |ui| { @@ -4586,6 +4929,15 @@ pub(crate) fn drop_gap(cells: &[(usize, Rect)], p: Pos2) -> Option { Some(if p.x < r.center().x { *i } else { i + 1 }) } +/// Ctrl (Option on macOS) held when a page drag drops copies the dragged pages into the gap +/// instead of moving them, as Acrobat's Organize Pages does. +/// +/// The OS comes from the context, not `cfg!`: the wasm build runs on macOS too, where +/// `cfg!(target_os = "macos")` is false, so Option has to copy there as well (#3092 review). +fn copy_modifier(ctx: &egui::Context, m: &egui::Modifiers) -> bool { + if crate::commands::mac_shortcuts(ctx) { m.alt } else { m.ctrl } +} + /// A "+" on a gap of the page grid: insert files there. Returns whether it was clicked. fn gap_button(ui: &mut egui::Ui, gap: usize, at: Pos2, height: f32, label: String, t: &Tokens) -> bool { let resp = ui.interact(Rect::from_center_size(at, Vec2::splat(22.0)), ui.id().with(("org-gap", gap)), Sense::click()); @@ -4817,6 +5169,8 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool } // While dragging: the gap the pages would go to, drawn as a bar. if let (Some(pages), Some(p)) = (view.org_drag.clone(), ui.input(|i| i.pointer.hover_pos())) { + // Ctrl (Option on macOS) held over the drop copies the pages; see #2997. + let copying = copy_modifier(ui.ctx(), &ui.input(|i| i.modifiers)); if let Some(gap) = drop_gap(&cells, p) { let x = match cells.iter().find(|(i, _)| *i == gap) { Some((_, r)) => r.left() + 3.0, @@ -4824,8 +5178,18 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool }; let row = cells.iter().find(|(i, _)| *i == gap).or(cells.last()).map(|(_, r)| r.y_range()).unwrap_or(egui::Rangef::new(0.0, 0.0)); ui.painter().line_segment([pos2(x, row.min + 10.0), pos2(x, row.max - 10.0)], Stroke::new(3.0, t.accent)); + let mut text_at = p + vec2(14.0, 14.0); + if copying { + // A "+" by the pointer: this drop copies the pages, it doesn't move them. + let badge = p + vec2(12.0, 12.0); + ui.painter().circle(badge, 9.0, t.accent, Stroke::new(1.0, t.accent)); + let ink = Stroke::new(1.5, Color32::WHITE); + ui.painter().line_segment([badge - vec2(4.0, 0.0), badge + vec2(4.0, 0.0)], ink); + ui.painter().line_segment([badge - vec2(0.0, 4.0), badge + vec2(0.0, 4.0)], ink); + text_at.x += 18.0; + } ui.painter().text( - p + vec2(14.0, 14.0), + text_at, Align2::LEFT_TOP, if pages.len() == 1 { tl!("1 page").to_string() } else { crate::i18n::fmt(tl!("{n} pages"), &[("n", &pages.len().to_string())]) }, theme::medium(12.0), @@ -4835,13 +5199,19 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool if drop { view.org_drag = None; if let Some(gap) = drop_gap(&cells, p) { - // `to` counts positions without the moving pages. - let to = gap - pages.iter().filter(|x| **x < gap).count(); - let first = pages[0]; - let contiguous = pages.windows(2).all(|w| w[1] == w[0] + 1); - if !(contiguous && to == first) { - view.pending_edit = Some(Edit::MovePages { pages: pages.clone(), to }); - view.selected = (to..to + pages.len()).collect(); + if copying { + // The copies land at the gap as it is: nothing leaves its place. + view.pending_edit = Some(Edit::DuplicatePages { pages: pages.clone(), at: Some(gap) }); + view.selected = (gap..gap + pages.len()).collect(); + } else { + // `to` counts positions without the moving pages. + let to = gap - pages.iter().filter(|x| **x < gap).count(); + let first = pages[0]; + let contiguous = pages.windows(2).all(|w| w[1] == w[0] + 1); + if !(contiguous && to == first) { + view.pending_edit = Some(Edit::MovePages { pages: pages.clone(), to }); + view.selected = (to..to + pages.len()).collect(); + } } } } @@ -4937,6 +5307,19 @@ fn grayscale(mut img: egui::ColorImage) -> egui::ColorImage { #[cfg(test)] mod tests { + #[test] + fn zoom_anchor_scroll_stays_within_the_content() { + let (content, viewport) = (vec2(1000.0, 2000.0), vec2(800.0, 600.0)); + // Past the end: clamped to the maximum (200, 1400), not left for egui to snap back. + assert_eq!(anchored_scroll(pos2(900.0, 1900.0), vec2(0.0, 0.0), content, viewport), vec2(200.0, 1400.0)); + // Before the start. + assert_eq!(anchored_scroll(pos2(10.0, 10.0), vec2(300.0, 300.0), content, viewport), Vec2::ZERO); + // Inside: unchanged. + assert_eq!(anchored_scroll(pos2(150.0, 800.0), vec2(100.0, 100.0), content, viewport), vec2(50.0, 700.0)); + // Content smaller than the viewport: no scrolling. + assert_eq!(anchored_scroll(pos2(400.0, 400.0), Vec2::ZERO, vec2(500.0, 500.0), viewport), vec2(0.0, 0.0)); + } + #[test] fn page_size_hint_zone_is_the_bottom_left_quadrant() { let area = Rect::from_min_max(pos2(100.0, 50.0), pos2(500.0, 450.0)); @@ -4949,6 +5332,16 @@ mod tests { use super::*; + #[test] + fn typed_zoom_percentages_parse() { + for (typed, zoom) in [("150", 1.5), ("150%", 1.5), (" 62.5 % ", 0.625), ("6400%", 64.0)] { + assert_eq!(DocView::parse_zoom_percent(typed), Some(zoom), "{typed}"); + } + for typed in ["", "%", "abc", "0", "-50", "NaN", "inf", "1e40", "150%%x", "%150"] { + assert_eq!(DocView::parse_zoom_percent(typed), None, "{typed}"); + } + } + /// A rotated (landscape) page is drawn as wide as the cell allows and centred in the picture /// area, as Acrobat draws it, not as a small strip pinned to the top; a portrait page still /// fills the area's height; neither covers the checkbox corner or the label below. @@ -4999,6 +5392,84 @@ mod tests { assert_eq!(annotation_hover_text(&annotation(true)), None); } + fn signature(signed: bool, visible: bool, page: Option, rect: Option<[f64; 4]>) -> pdfcraft_engine::SignatureInfo { + pdfcraft_engine::SignatureInfo { + field: "Signature1".into(), + signed, + page, + rect, + visible, + signer: None, + certificate: None, + chain: Vec::new(), + date: None, + signing_time: None, + reason: None, + location: None, + contact: None, + sub_filter: None, + doc_timestamp: false, + certify: None, + revision: 1, + signed_len: 0, + digest: None, + algorithm: None, + timestamp: false, + timestamp_time: None, + status: pdfcraft_engine::SignatureStatus::Valid, + modification: pdfcraft_engine::sign::Modification::None, + details: Vec::new(), + } + } + + #[test] + fn signature_badges_keep_signed_visible_widgets_on_the_page() { + let rect = [10.0, 10.0, 100.0, 50.0]; + let signatures = + vec![signature(true, true, Some(0), Some(rect)), signature(true, true, Some(1), Some(rect)), signature(true, true, Some(0), Some(rect))]; + let badges = signature_badges(&signatures, 0); + assert_eq!(badges.len(), 2); + assert_eq!(badges[0].rect, rect); + } + + #[test] + fn signature_badges_skip_unsigned_invisible_and_unplaced_fields() { + let rect = [10.0, 10.0, 100.0, 50.0]; + let signatures = vec![ + signature(false, true, Some(0), Some(rect)), + signature(true, false, Some(0), Some(rect)), + signature(true, true, Some(0), None), + signature(true, true, Some(0), Some(rect)), + ]; + let badges = signature_badges(&signatures, 0); + assert_eq!(badges.len(), 1); + assert_eq!(badges[0].rect, rect); + } + + #[test] + fn signature_badges_skip_rectangles_that_cannot_reach_the_screen() { + let hostile = vec![ + [f64::NAN, 10.0, 100.0, 50.0], + [10.0, f64::INFINITY, 100.0, 50.0], + [10.0, 10.0, f64::NEG_INFINITY, 50.0], + [10.0, 10.0, 1e300, 50.0], + [10.0, 10.0, 100.0, 50.0], + ]; + let signatures: Vec<_> = hostile.into_iter().map(|rect| signature(true, true, Some(0), Some(rect))).collect(); + let badges = signature_badges(&signatures, 0); + assert_eq!(badges.len(), 1); + } + + #[test] + fn snapshot_tabs_read_like_their_source_plus_the_suffix() { + let snap = Snapshot { kind: SnapshotKind::SignedVersion, revision: 1, from: "report.pdf".into() }; + assert_eq!(snapshot_tab_name("Report", "report.pdf", &snap), "Report (signed version)"); + let rev = Snapshot { kind: SnapshotKind::Revision, revision: 2, from: "report.pdf".into() }; + assert_eq!(snapshot_tab_name("Report", "report.pdf", &rev), "Report (revision 2)"); + assert_eq!(snapshot_tab_name("report.pdf", "report.pdf", &snap), "report.pdf"); + assert_eq!(snapshot_tab_name("report (revision 2).pdf", "report (revision 2).pdf", &rev), "report (revision 2).pdf"); + } + #[test] fn grayscale_converts_colours_to_their_luminance() { let img = egui::ColorImage::new([2, 1], vec![Color32::from_rgb(255, 0, 0), Color32::from_rgba_premultiplied(0, 51, 102, 128)]); @@ -5110,6 +5581,7 @@ trailer << /Root 1 0 R >> fn explicit_search_jumps_record_history_and_keep_match_destination() { for step in [None, Some(true), Some(false)] { let mut v = history_search_view(); + v.page_input = "draft in progress".into(); v.goto_point = Some((3, Some(0.8), Some(0.9), Vec2::ZERO)); if let Some(forward) = step { v.find_step(forward); @@ -5118,7 +5590,11 @@ trailer << /Root 1 0 R >> } assert_eq!(v.back, vec![0], "explicit search jump must remember its origin"); assert_eq!(v.goto, Some((3, 0.5)), "keep the match offset instead of the page top"); - assert_eq!(v.page_input, "4"); + assert_eq!(v.current, 3); + assert_eq!(v.current_page_label(None), "4"); + // Chrome refreshes the idle page box; navigation must not overwrite a draft + // that may still be focused. DocView does not own the editor's focus state. + assert_eq!(v.page_input, "draft in progress"); assert!(v.goto_point.is_none(), "an earlier link destination cannot override the search offset"); v.go_to_match(1); assert_eq!(v.back, vec![0], "same-page matches do not duplicate history"); @@ -5285,6 +5761,81 @@ trailer << /Root 1 0 R >> } } + /// #3149: the replacement dialog commits after the canvas collected this frame's demand. + #[test] + fn replacing_a_page_after_layout_never_uploads_an_oversized_texture() { + let ctx = egui::Context::default(); + ctx.input_mut(|i| i.max_texture_side = 2048); + let mut app = PdfCraftApp::new(); + let original = app.session.create_blank(200.0, 300.0, 1).unwrap(); + let replacement = app.session.create_blank(400.0, 500.0, 1).unwrap(); + app.open_bytes("original.pdf", None, original.as_ref().clone()).unwrap(); + let request = RenderRequest { scale: 4.7, tag: scale_tag(4.7), ..Default::default() }; + app.views[0].frame_queue = vec![request]; + app.views[0].frame_visible.insert(0); + assert!(app.apply_edit(Edit::ReplacePages { pages: vec![0], name: "replacement.pdf".into(), bytes: replacement, src_pages: vec![0] })); + let doc = app.session.get(app.views[0].id).unwrap(); + assert_eq!((doc.info.pages[0].width, doc.info.pages[0].height), (400.0, 500.0)); + let edited_bytes = doc.bytes.clone(); + let pool = RenderPool::new_inline(doc.display.clone(), Default::default()); + app.views[0].finish_render_frame(&ctx, &doc.info, &pool); + assert!(app.views[0].last_queue.is_empty(), "old geometry must not reach the replacement renderer"); + + // The next layout can render a bounded backdrop and sharp tiles at the same zoom. + let backdrop = RenderRequest { scale: 2048.0 / 500.0, tag: scale_tag(2048.0 / 500.0), ..request }; + let tile = RenderRequest { tile: Some(Tile { x: 0, y: 0, w: 1024, h: 1024 }), ..request }; + for _ in 0..2 { + let view = &mut app.views[0]; + view.begin_render_frame(); + view.frame_visible.insert(0); + view.frame_queue = vec![backdrop, tile]; + view.finish_render_frame(&ctx, &doc.info, &pool); + } + let view = &app.views[0]; + assert_eq!(view.pages.get(&0).unwrap().tex.size(), [1639, 2048]); + assert_eq!(view.tiles.get(&(0, tile.tag, 0, 0)).unwrap().size(), [1024, 1024]); + assert!(view.errors.is_empty()); + assert_eq!(doc.bytes, edited_bytes, "rendering preserves the unsaved edit"); + assert!(doc.dirty); + assert_eq!(doc.can_undo(), Some("Replace page")); + } + + #[test] + fn oversized_raster_results_do_not_replace_cached_textures() { + let ctx = egui::Context::default(); + ctx.input_mut(|i| i.max_texture_side = 64); + for request in [ + RenderRequest::default(), + RenderRequest { tag: THUMB_TAG, ..Default::default() }, + RenderRequest { tag: GRID_TAG, ..Default::default() }, + RenderRequest { tag: PRINT_TAG, ..Default::default() }, + RenderRequest { tile: Some(Tile { x: 0, y: 0, w: 64, h: 32 }), ..Default::default() }, + ] { + let mut view = DocView::new(DocId(1), &thumbnail_info(1), ViewDefaults::default()); + view.last_queue = vec![request]; + let texture = |v: &DocView| { + v.pages + .values() + .map(|p| &p.tex) + .chain(v.thumbs.values().map(|p| &p.tex)) + .chain(v.grid_pages.values().map(|(_, t)| t)) + .chain(v.print_pages.values().map(|(_, t)| t)) + .chain(v.tiles.values()) + .map(|t| (t.id(), t.size())) + .collect::>() + }; + assert_eq!(view.receive_result(&ctx, solid_result(request, 64, 32)), 64 * 32 * 4); + let cached = texture(&view); + assert_eq!(cached.len(), 1); + for (w, h) in [(65, 32), (32, 65), (65, 65), (0, 32), (32, 0)] { + assert_eq!(view.receive_result(&ctx, solid_result(request, w, h)), 0, "{request:?}: {w}x{h}"); + assert_eq!(texture(&view), cached, "an invalid result must keep the previous raster"); + } + assert_eq!(view.receive_result(&ctx, solid_result(request, 32, 64)), 32 * 64 * 4); + assert_eq!(texture(&view)[0].1, [32, 64], "valid results still make progress"); + } + } + #[test] fn thumbnails_release_on_scroll_panel_close_and_request_again_after_eviction() { let ctx = egui::Context::default(); @@ -5346,6 +5897,30 @@ trailer << /Root 1 0 R >> assert_eq!(v.pages.get(&0).unwrap().tag, STALE_TAG); } + /// #1858: a released drawing stays painted from its points until a raster that has it + /// arrives. A page raster that lands before the drawing's edit is applied (in full screen the + /// edit waits for the next frame, and a render already under way can finish first) is of the + /// document without it, and must not end the preview. + #[test] + fn a_raster_from_before_a_drawing_is_committed_keeps_it_drawn() { + let ctx = egui::Context::default(); + let info = thumbnail_info(1); + let mut v = DocView::new(DocId(1), &info, ViewDefaults::default()); + let req = RenderRequest { scale: 1.0, tag: 1000, ..Default::default() }; + v.comments.settle(0.0, 0, crate::comments::CommentTool::Ink, &[[10.0, 10.0], [40.0, 30.0]]); + v.last_queue = vec![req]; + assert!(v.receive_result(&ctx, solid_result(req, 10, 10)) > 0, "the raster is taken"); + assert_eq!(v.comments.settling_len(), 1, "a raster from before the commit doesn't have the stroke"); + v.comments.drawings_committed(); + v.last_queue = vec![req]; + assert!(v.receive_result(&ctx, solid_result(req, 10, 10)) > 0); + assert_eq!(v.comments.settling_len(), 0, "a raster after the commit draws it"); + // An edit that fails leaves nothing to wait for. + v.comments.settle(0.0, 0, crate::comments::CommentTool::Ink, &[[10.0, 10.0], [40.0, 30.0]]); + v.comments.drawings_refused(); + assert_eq!(v.comments.settling_len(), 0, "a refused drawing isn't left painted"); + } + #[test] fn page_and_tile_admission_accounts_for_bytes_before_count_limits() { let mut info = thumbnail_info(40); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index e8bf3b48dd..e01d752c40 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -77,9 +77,9 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { // Tabs shrink to share the strip (names end in "…") and scroll only once they reach // their narrowest; then arrows either side say so and step through them. let mut natural: Vec = Vec::with_capacity(app.views.len() + 1); - for v in &app.views { - if let Some(doc) = app.session.get(v.id) { - natural.push(tab_natural_width(ui, &t, &doc.display_name())); + for i in 0..app.views.len() { + if let Some(name) = crate::canvas::tab_label(app, i) { + natural.push(tab_natural_width(ui, &t, &name)); } } if app.combine_tab.open { @@ -125,7 +125,8 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let mut dragged = None; for i in 0..app.views.len() { let Some(doc) = app.session.get(app.views[i].id) else { continue }; - let (name, dirty) = (doc.display_name(), doc.dirty); + let Some(name) = crate::canvas::tab_label(app, i) else { continue }; + let dirty = doc.dirty; // A tab showing the document's title names its file on hover. let file = (name != doc.name).then_some(doc.name.as_str()); let response = tab( @@ -532,88 +533,100 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); } +/// Keep menus usable when their contents are taller than the window. +pub(crate) fn scroll_menu(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) { + let height = (ui.ctx().content_rect().height() - 24.0).max(40.0); + egui::ScrollArea::vertical().max_height(height).auto_shrink([true, true]).show(ui, add); +} + fn main_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let resp = widgets::ghost_button(ui, "panel-left", tl!("Menu")); egui::Popup::menu(&resp).show(|ui| { - ui.set_min_width(230.0); - ui.menu_button(tl!("File"), |ui| crate::commands::registry_menu(app, ui, "File")); - ui.menu_button(tl!("Edit"), |ui| crate::commands::registry_menu(app, ui, "Edit")); - ui.menu_button(tl!("Pages"), |ui| crate::commands::registry_menu(app, ui, "Pages")); - ui.menu_button(tl!("View"), |ui| { - if let Some(i) = app.active { - let v = &mut app.views[i]; - let ctx = ui.ctx().clone(); - let key = |s| crate::commands::shortcut_label(&ctx, s); - use crate::commands::{ACTUAL_SIZE, FIT_WIDTH, NEXT_VIEW, PAGE_LEVEL, PREV_VIEW, ROTATE_CCW, ROTATE_CW, ZOOM_IN, ZOOM_OUT}; - ui.label(egui::RichText::new(tl!("Zoom")).color(t.text_faint).small()); - if widgets::menu_item(ui, tl!("Actual size"), &key(ACTUAL_SIZE)).clicked() { - v.set_zoom(1.0); - } - if widgets::menu_item(ui, tl!("Zoom to page level"), &key(PAGE_LEVEL)).clicked() { - v.fit = Fit::Page; - } - if widgets::menu_item(ui, tl!("Fit to width"), &key(FIT_WIDTH)).clicked() { - v.fit = Fit::Width; - } - if widgets::menu_item(ui, tl!("Fit to height"), "").clicked() { - v.fit = Fit::Height; - v.goto = Some((v.current, 0.0)); - } - if widgets::menu_item(ui, tl!("Fit visible"), &crate::commands::command_shortcut_label(&ctx, "view.fit_visible")).clicked() { - ui.close(); - app.execute("view.fit_visible"); - return; - } - if widgets::menu_item(ui, tl!("Zoom in"), &key(ZOOM_IN)).clicked() { - v.zoom_step(true); - } - if widgets::menu_item(ui, tl!("Zoom out"), &key(ZOOM_OUT)).clicked() { - v.zoom_step(false); - } - if widgets::menu_item(ui, tl!("Rotate view clockwise"), &key(ROTATE_CW)).clicked() { - v.rotate_view(true); - } - if widgets::menu_item(ui, tl!("Rotate view counterclockwise"), &key(ROTATE_CCW)).clicked() { - v.rotate_view(false); - } - ui.separator(); - ui.label(egui::RichText::new(tl!("Page navigation")).color(t.text_faint).small()); - if ui.add_enabled(!v.back.is_empty(), egui::Button::new(tl!("Previous view")).shortcut_text(key(PREV_VIEW))).clicked() { - v.view_history(false); - } - if ui.add_enabled(!v.forward.is_empty(), egui::Button::new(tl!("Next view")).shortcut_text(key(NEXT_VIEW))).clicked() { - v.view_history(true); - } - ui.separator(); - if let Some(id) = page_display_menu(ui, &app.views[i]) { - app.execute(id); - } - ui.separator(); - } - crate::commands::registry_menu(app, ui, "View"); - ui.menu_button(tl!("Display theme"), |ui| theme_menu(app, ui)); - ui.menu_button(tl!("Side panels"), |ui| { - for (p, label) in [ - (RightPanel::Comments, tl!("Comments")), - (RightPanel::Bookmarks, tl!("Bookmarks")), - (RightPanel::Pages, tl!("Pages")), - (RightPanel::Fields, tl!("Fields")), - (RightPanel::Layers, tl!("Layers")), - (RightPanel::Attachments, tl!("Attachments")), - (RightPanel::Signatures, tl!("Signatures")), - (RightPanel::Accessibility, tl!("Accessibility Checker")), - (RightPanel::Tags, tl!("Tags")), - (RightPanel::Search, tl!("Search")), - (RightPanel::Compare, tl!("Compare")), - ] { - if ui.add(widgets::radio(app.right == Some(p), tl!(label))).clicked() { - app.choose_right_panel(Some(p)); + scroll_menu(ui, |ui| { + ui.set_min_width(230.0); + ui.menu_button(tl!("File"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "File"))); + ui.menu_button(tl!("Edit"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Edit"))); + ui.menu_button(tl!("Pages"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Pages"))); + ui.menu_button(tl!("View"), |ui| { + scroll_menu(ui, |ui| { + if let Some(i) = app.active { + let v = &mut app.views[i]; + let ctx = ui.ctx().clone(); + let key = |s| crate::commands::shortcut_label(&ctx, s); + use crate::commands::{ACTUAL_SIZE, FIT_WIDTH, NEXT_VIEW, PAGE_LEVEL, PREV_VIEW, ROTATE_CCW, ROTATE_CW, ZOOM_IN, ZOOM_OUT}; + ui.label(egui::RichText::new(tl!("Zoom")).color(t.text_faint).small()); + if widgets::menu_item(ui, tl!("Actual size"), &key(ACTUAL_SIZE)).clicked() { + v.set_zoom(1.0); + } + if widgets::menu_item(ui, tl!("Zoom to page level"), &key(PAGE_LEVEL)).clicked() { + v.fit = Fit::Page; + } + if widgets::menu_item(ui, tl!("Fit to width"), &key(FIT_WIDTH)).clicked() { + v.fit = Fit::Width; + } + if widgets::menu_item(ui, tl!("Fit to height"), "").clicked() { + v.fit = Fit::Height; + v.goto = Some((v.current, 0.0)); + } + if widgets::menu_item(ui, tl!("Fit visible"), &crate::commands::command_shortcut_label(&ctx, "view.fit_visible")).clicked() { + ui.close(); + app.execute("view.fit_visible"); + return; + } + if widgets::menu_item(ui, tl!("Zoom in"), &key(ZOOM_IN)).clicked() { + v.zoom_step(true); + } + if widgets::menu_item(ui, tl!("Zoom out"), &key(ZOOM_OUT)).clicked() { + v.zoom_step(false); + } + if widgets::menu_item(ui, tl!("Rotate view clockwise"), &key(ROTATE_CW)).clicked() { + v.rotate_view(true); + } + if widgets::menu_item(ui, tl!("Rotate view counterclockwise"), &key(ROTATE_CCW)).clicked() { + v.rotate_view(false); + } + ui.separator(); + ui.label(egui::RichText::new(tl!("Page navigation")).color(t.text_faint).small()); + if ui.add_enabled(!v.back.is_empty(), egui::Button::new(tl!("Previous view")).shortcut_text(key(PREV_VIEW))).clicked() { + v.view_history(false); + } + if ui.add_enabled(!v.forward.is_empty(), egui::Button::new(tl!("Next view")).shortcut_text(key(NEXT_VIEW))).clicked() { + v.view_history(true); + } + ui.separator(); + if let Some(id) = page_display_menu(ui, &app.views[i]) { + app.execute(id); + } + ui.separator(); } - } + crate::commands::registry_menu(app, ui, "View"); + ui.menu_button(tl!("Display theme"), |ui| theme_menu(app, ui)); + ui.menu_button(tl!("Side panels"), |ui| { + scroll_menu(ui, |ui| { + for (p, label) in [ + (RightPanel::Comments, tl!("Comments")), + (RightPanel::Bookmarks, tl!("Bookmarks")), + (RightPanel::Pages, tl!("Pages")), + (RightPanel::Fields, tl!("Fields")), + (RightPanel::Layers, tl!("Layers")), + (RightPanel::Attachments, tl!("Attachments")), + (RightPanel::Signatures, tl!("Signatures")), + (RightPanel::Accessibility, tl!("Accessibility Checker")), + (RightPanel::Tags, tl!("Tags")), + (RightPanel::Search, tl!("Search")), + (RightPanel::Compare, tl!("Compare")), + ] { + if ui.add(widgets::radio(app.right == Some(p), tl!(label))).clicked() { + app.choose_right_panel(Some(p)); + } + } + }) + }); + }) }); - }); - ui.menu_button(tl!("Help"), |ui| crate::commands::registry_menu(app, ui, "Help")); + ui.menu_button(tl!("Help"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Help"))); + }) }); } @@ -693,10 +706,36 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width }; view.goto = Some((view.current, 0.0)); } - let zoom = format!("{:.0}%", view.zoom * 100.0); - ui.allocate_ui_with_layout(vec2(40.0, 16.0), Layout::top_down(Align::Center), |ui| { - ui.label(egui::RichText::new(zoom).font(theme::medium(11.0)).color(t.text_muted)); - }); + let id = egui::Id::new("zoom-input"); + let draft = id.with("draft"); + let mut typed = ui.data(|d| d.get_temp::(draft)).unwrap_or_else(|| format!("{:.0}%", view.zoom * 100.0)); + let edit = egui::TextEdit::singleline(&mut typed) + .id(id) + .desired_width(40.0) + .horizontal_align(Align::Center) + .font(theme::medium(11.0)) + .text_color(t.text_muted) + .frame(egui::Frame::NONE) + .margin(vec2(2.0, 2.0)); + let r = ui.add(edit).on_hover_text(tl!("Zoom — type a percentage and press Enter")); + ui.ctx().accesskit_node_builder(id, |node| node.set_label(tl!("Zoom"))); + if r.gained_focus() { + let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(typed.chars().count())); + state.cursor.set_char_range(Some(all)); + state.store(ui.ctx(), id); + } + if r.has_focus() { + ui.data_mut(|d| d.insert_temp(draft, typed.clone())); + } else { + ui.data_mut(|d| d.remove::(draft)); + if r.lost_focus() + && ui.input(|i| i.key_pressed(egui::Key::Enter)) + && let Some(zoom) = DocView::parse_zoom_percent(&typed) + { + view.set_zoom(zoom); + } + } ui.add_space(6.0); if icons::button(ui, "chevron-down", 30.0, false, tl!("Next page")).clicked() { view.step_page(true); diff --git a/crates/ui-egui/src/combine_ui.rs b/crates/ui-egui/src/combine_ui.rs index 467a040b97..6704ce2276 100644 --- a/crates/ui-egui/src/combine_ui.rs +++ b/crates/ui-egui/src/combine_ui.rs @@ -733,7 +733,7 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let edge = ui.max_rect().right(); add_menu(app, ui); let open_docs: Vec<(usize, String)> = - app.views.iter().enumerate().filter_map(|(i, v)| app.session.get(v.id).map(|d| (i, d.display_name()))).collect(); + app.views.iter().enumerate().filter_map(|(i, _)| crate::canvas::tab_label(app, i).map(|name| (i, name))).collect(); let response = ui.add_enabled_ui(!open_docs.is_empty(), |ui| widgets::icon_pill(ui, "file-plus", tl!("Add open documents"), false)).inner; response.clone().on_disabled_hover_text(tl!("No documents are open")); egui::Popup::menu(&response).show(|ui| { diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index c46d93d624..b769c0c00c 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -513,7 +513,7 @@ impl PdfCraftApp { } "sign.validate" => { let certs = self.session.trusted_certificates().to_vec(); - self.session.set_trusted_certificates(certs); + self.apply_trusted_certificates(certs); self.choose_right_panel(Some(RightPanel::Signatures)); } "sign.panel" => self.choose_right_panel(Some(RightPanel::Signatures)), @@ -627,7 +627,13 @@ impl PdfCraftApp { "create.clipboard" => self.create_from_clipboard(), "optimize.reduce" => self.reduce_file_size(), "page.duplicate" => { - self.apply_edit(Edit::DuplicatePages { pages: targets }); + self.apply_edit(Edit::DuplicatePages { pages: targets, at: None }); + } + // Two or more selected pages reverse among themselves; otherwise the whole document. + "page.reverse" => { + let all = active.and_then(|i| self.views.get(i)).map_or(0, |v| v.page_count()); + let pages = if targets.len() >= 2 { targets } else { (0..all).collect() }; + self.apply_edit(Edit::ReversePages { pages }); } "ocr.enhance_camera" => { // The corners start on the document's edges when they can be found, else @@ -773,17 +779,19 @@ pub(crate) fn registry_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui, menu: &str let mut open: Option = None; let mut clear = false; ui.menu_button(label, |ui| { - for r in &app.recent { - if ui.button(&r.name).on_hover_text(&r.path).clicked() { - open = Some(r.path.clone()); + crate::chrome::scroll_menu(ui, |ui| { + for r in &app.recent { + if ui.button(&r.name).on_hover_text(&r.path).clicked() { + open = Some(r.path.clone()); + ui.close(); + } + } + ui.separator(); + if ui.button(tl!("Clear Recent Files")).clicked() { + clear = true; ui.close(); } - } - ui.separator(); - if ui.button(tl!("Clear Recent Files")).clicked() { - clear = true; - ui.close(); - } + }) }); if let Some(p) = open { app.open_recent(&p); diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 5a708c14f6..68b1a72a0c 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -420,6 +420,10 @@ pub(crate) struct Settling { pub points: Vec<[f64; 2]>, /// Frame time (seconds) at release; a raster that never arrives must not leave a ghost. pub since: f64, + /// Its annotation is in the document, so a raster of the page from now on draws it. Until + /// then a raster is of the document without it (in full screen the edit is applied the frame + /// after the release, and a raster already on its way can arrive in between). + pub committed: bool, } /// Longest a released drawing stays painted without its page raster, and how many are kept. @@ -428,7 +432,7 @@ const SETTLE_MAX: usize = 16; impl CommentView { /// Remember a released drag drawing so it stays on screen until its page is re-rendered. - fn settle(&mut self, now: f64, page: usize, tool: CommentTool, points: &[[f64; 2]]) { + pub(crate) fn settle(&mut self, now: f64, page: usize, tool: CommentTool, points: &[[f64; 2]]) { if !matches!(tool, CommentTool::Ink | CommentTool::Line | CommentTool::Arrow | CommentTool::Rectangle | CommentTool::Oval) { return; } @@ -436,7 +440,7 @@ impl CommentView { if self.settling.len() >= SETTLE_MAX { self.settling.remove(0); } - self.settling.push(Settling { page, tool, points: points.to_vec(), since: now }); + self.settling.push(Settling { page, tool, points: points.to_vec(), since: now, committed: false }); } /// How many released drawings are still painted from their points (waiting for a raster). @@ -444,9 +448,22 @@ impl CommentView { self.settling.len() } - /// A fresh raster of `page` is on screen: it draws the committed annotation itself. + /// A fresh raster of `page` is on screen: it draws the committed annotations itself. One + /// still waiting for its edit isn't in that raster, and stays drawn from its points. pub(crate) fn page_rendered(&mut self, page: usize) { - self.settling.retain(|s| s.page != page); + self.settling.retain(|s| s.page != page || !s.committed); + } + + /// The edit adding the released drawings was applied: rasters from now on draw them. + pub(crate) fn drawings_committed(&mut self) { + for s in &mut self.settling { + s.committed = true; + } + } + + /// The edit adding the released drawings failed: nothing will draw them. + pub(crate) fn drawings_refused(&mut self) { + self.settling.retain(|s| s.committed); } } @@ -487,6 +504,9 @@ pub struct CommentView { pub props_request: Option<(usize, usize)>, /// Make Current Properties Default was asked for: (page, index). pub default_request: Option<(usize, usize)>, + /// A file attachment comment's file was asked for: (page, index, open it in a new tab + /// rather than save it). + pub attachment_request: Option<(usize, usize, bool)>, /// Replace Text: the struck-out text's quads while the replacement is typed. pub replace: Option>, /// Attach file: where the icon goes (page, top-left in user space), for the app to pick a file. @@ -1578,6 +1598,23 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool } } +/// Save attachment… and, for a PDF, Open in a new tab, first in a file attachment comment's +/// menus as in the Attachments panel: the comment is where the reader finds its file. Reading +/// the file changes nothing, so a read-only document offers them too. +pub(crate) fn attachment_items(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo, a: &Annotation) { + let source = pdfcraft_render::AttachmentSource::Annotation { page: a.page, index: a.index }; + let Some(file) = info.attachments.iter().find(|f| f.source == source) else { return }; + if ui.button(tl!("Save attachment…")).clicked() { + view.comments.attachment_request = Some((a.page, a.index, false)); + ui.close(); + } + if file.name.to_lowercase().ends_with(".pdf") && ui.button(tl!("Open in a new tab")).clicked() { + view.comments.attachment_request = Some((a.page, a.index, true)); + ui.close(); + } + ui.separator(); +} + /// Items of the canvas context menu for the selected comment (or the page). pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo, prefs: &CommentPrefs, allowed: bool) -> Option { let mut action = None; @@ -1585,6 +1622,7 @@ pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo match selected { Some(a) => { let (page, index) = (a.page, a.index); + attachment_items(ui, view, info, a); if ui.add_enabled(allowed, egui::Button::new(tl!("Edit text…"))).clicked() { view.comments.composer = Some(Composer { page, diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index c5fbbd0ce3..2ac1853f4e 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -255,7 +255,7 @@ pub(crate) fn show( continue; } let thread = replies_of(a); - if let Some(e) = card(ui, t, a, &thread, view, prefs, allowed, nav) { + if let Some(e) = card(ui, t, a, &thread, info, view, prefs, allowed, nav) { edit = Some(e); } } @@ -268,6 +268,7 @@ fn card( t: &Tokens, a: &Annotation, thread: &[&Annotation], + info: &DocInfo, view: &mut DocView, prefs: &CommentPrefs, allowed: bool, @@ -447,6 +448,7 @@ fn card( .layout(Layout::top_down_justified(Align::Min)) .show(|ui| { ui.set_min_width(160.0); + crate::comments::attachment_items(ui, view, info, a); if ui.button(tl_ctx!("comment menu", "Edit")).clicked() { view.comments.editing = Some((a.page, a.index, a.contents.clone().unwrap_or_default())); ui.close(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 742641330c..a4ab35d969 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -410,12 +410,23 @@ impl Control { let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 200) as usize; let modifiers = modifiers(p.get("modifiers"))?; let which = pointer_button(p, "ui.drag")?; + // The events carry the modifiers and so does the input state: gestures that read + // `ui.input(|i| i.modifiers)` (a Ctrl-drag copy, an Alt-drag column select) see them. + let held = |mut events: Vec| { + if modifiers != egui::Modifiers::NONE { + events.insert(0, egui::Event::ModifiersChanged(modifiers)); + } + events + }; let button = |pos, pressed| egui::Event::PointerButton { pos, button: which, pressed, modifiers }; - let mut frames = vec![vec![egui::Event::PointerMoved(from)], vec![button(from, true)]]; + let mut frames = vec![held(vec![egui::Event::PointerMoved(from)]), held(vec![button(from, true)])]; for k in 1..=steps { - frames.push(vec![egui::Event::PointerMoved(from + (to - from) * (k as f32 / steps as f32))]); + frames.push(held(vec![egui::Event::PointerMoved(from + (to - from) * (k as f32 / steps as f32))])); + } + frames.push(held(vec![button(to, false)])); + if modifiers != egui::Modifiers::NONE { + frames.push(vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)]); } - frames.push(vec![button(to, false)]); let n = self.inject(frames); Ok(Handled::AfterFrames(n, json!({ "dragged": [[from.x, from.y], [to.x, to.y]] }))) } @@ -793,14 +804,27 @@ struct Limits { max_unauthenticated: usize, /// How long a write may block on a client that never reads its replies. write_timeout: std::time::Duration, + /// How long a connection refused after a reply is kept half-open, discarding input, so the + /// reply is not lost to a reset (see [`close_after_reply`]). + linger: std::time::Duration, + /// How many refused connections may linger at once. A lingering connection has given back + /// its place among [`Self::max_unauthenticated`], so this is what bounds their threads; + /// beyond it a refused connection is closed at once. + max_lingering: usize, } #[cfg(not(target_arch = "wasm32"))] -const LIMITS: Limits = - Limits { auth_deadline: std::time::Duration::from_secs(10), max_unauthenticated: 8, write_timeout: std::time::Duration::from_secs(30) }; +const LIMITS: Limits = Limits { + auth_deadline: std::time::Duration::from_secs(10), + max_unauthenticated: 8, + write_timeout: std::time::Duration::from_secs(30), + linger: std::time::Duration::from_secs(1), + max_lingering: 8, +}; -/// One of the [`Limits::max_unauthenticated`] places, given back when dropped: when the -/// connection authenticates, and on every way its thread can end. +/// One of a limited number of places, given back when dropped: a place among +/// [`Limits::max_unauthenticated`] (given back when the connection authenticates or is refused, +/// and on every way its thread can end), or among [`Limits::max_lingering`]. #[cfg(not(target_arch = "wasm32"))] struct Slot(std::sync::Arc); @@ -850,6 +874,38 @@ impl std::io::Read for Reading { } } +/// Close a connection whose last reply has just been written without losing that reply. +/// +/// Closing a socket with input still unread makes the kernel send a reset instead of a FIN, and +/// the reset can discard the reply before the client reads it (Linux does): a client still +/// sending when it is refused would never learn why. So send FIN first, then read and discard +/// whatever arrives until the client closes its side or `linger` passes, and only then close. +/// +/// Only [`Limits::max_lingering`] connections linger at once: `place` is one of those places, +/// taken by [`refuse`] before the reply was written, and held for the drain only. Without one +/// the connection is closed at once after the FIN, accepting the risk of a reset rather than +/// holding another thread. +#[cfg(not(target_arch = "wasm32"))] +fn close_after_reply(reading: &mut Reading, place: Option, linger: std::time::Duration) { + use std::io::Read; + let _ = reading.stream.shutdown(std::net::Shutdown::Write); + let Some(_place) = place else { return }; + reading.until = Some(std::time::Instant::now() + linger); + let mut sink = [0u8; 1024]; + // Each read is bounded by the time left, and none is made once `linger` has passed. + while matches!(reading.read(&mut sink), Ok(n) if n > 0) {} +} + +/// A connection is being refused before `auth`: give back its place among those waiting to +/// authenticate, so it does not keep a real client out while it lingers, and take a place to +/// linger in if one is free. Done before the reply is written, so a client that has read the +/// reply sees the places as they will be. +#[cfg(not(target_arch = "wasm32"))] +fn refuse(slot: &mut Option, lingering: &std::sync::Arc, limits: Limits) -> Option { + drop(slot.take()); + Slot::take(lingering, limits.max_lingering) +} + /// Listen on `127.0.0.1` (random port) and forward authenticated requests to `client`. /// /// Protocol: newline-delimited JSON-RPC 2.0. The first request on a connection must be @@ -868,6 +924,7 @@ fn serve_with(client: ControlClient, limits: Limits) -> std::io::Result std::io::Result std::io::Result { let secs = limits.auth_deadline.as_secs_f64(); let e = rpc_error(Value::Null, -32004, &format!("not authenticated within {secs} s")); - let _ = writeln!(write, "{e}").and_then(|_| write.flush()); + let place = refuse(&mut slot, &lingering, limits); + if writeln!(write, "{e}").and_then(|_| write.flush()).is_ok() { + close_after_reply(reader.get_mut(), place, limits.linger); + } break; } // End of stream or a failed read ends the connection, as before. Ok(LineRead::End) | Err(_) => break, Ok(LineRead::TooLong) => { let e = rpc_error(Value::Null, -32600, &format!("request line longer than {max} bytes before auth")); - let _ = writeln!(write, "{e}").and_then(|_| write.flush()); + let place = refuse(&mut slot, &lingering, limits); + if writeln!(write, "{e}").and_then(|_| write.flush()).is_ok() { + close_after_reply(reader.get_mut(), place, limits.linger); + } break; } } @@ -945,7 +1009,13 @@ fn serve_with(client: ControlClient, limits: Limits) -> std::io::Result = (0..limits.max_unauthenticated).map(|_| connect(ep.port)).collect(); + for s in &others { + // A refused one has already been closed, so its write may fail; its reply says why. + let _ = writeln!(s.try_clone().unwrap(), "{auth}"); + let reply = next_line(&mut BufReader::new(s.try_clone().unwrap())).expect("a reply"); + assert_eq!(reply["result"]["ok"], true, "admitted while the refused one lingers: {reply}"); + } + assert!(started.elapsed() < limits.linger, "checked while it lingered: {:?}", started.elapsed()); + drop(refused); + } + + /// Only [`super::Limits::max_lingering`] refused connections linger at once: one more still + /// gets its reply, and is then closed at once instead of lingering. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn only_so_many_refused_connections_linger() { + use std::io::{BufReader, Write}; + // A linger far longer than the checks below take, even on a loaded machine. + let limits = super::Limits { linger: std::time::Duration::from_secs(5), max_lingering: 1, ..TEST_LIMITS }; + let (_control, client) = super::attach(&egui::Context::default()); + let ep = super::serve_with(client, limits).unwrap(); + let bad = r#"{"jsonrpc": "2.0", "id": 1, "method": "page_count"}"#; + // The first refused connection takes the only lingering place and keeps it. + let first = connect(ep.port); + writeln!(first.try_clone().unwrap(), "{bad}").unwrap(); + let mut r1 = BufReader::new(first.try_clone().unwrap()); + assert_eq!(next_line(&mut r1).expect("refused with a reason")["error"]["code"], -32001); + // The second still gets its reason (it has nothing unread, so no reset)... + let second = connect(ep.port); + let mut w2 = second.try_clone().unwrap(); + writeln!(w2, "{bad}").unwrap(); + let mut r2 = BufReader::new(second.try_clone().unwrap()); + assert_eq!(next_line(&mut r2).expect("refused with a reason")["error"]["code"], -32001); + // ...and is closed outright, not lingering: writing to it soon fails, where a lingering + // connection would read and discard for the whole of `linger`. + let started = std::time::Instant::now(); + let closed = loop { + if w2.write_all(b"x").is_err() { + break true; + } + if started.elapsed() > limits.linger { + break false; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + }; + assert!(closed && started.elapsed() < limits.linger / 2, "it lingered: {:?}", started.elapsed()); + drop(first); + } + /// #1806: only so many connections may wait to authenticate at once. One more is refused /// on the spot; once a waiting one authenticates or times out, its place is free again. #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2d2407629c..981db214b2 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -354,17 +354,20 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { let ends = doc.revision_ends(); if ends.len() > 1 { ui.label(egui::RichText::new(tl!("Revisions")).color(t.text_muted)); - ui.horizontal_wrapped(|ui| { - ui.label(ends.len().to_string()); - for n in (1..ends.len()).rev().take(12) { - if ui - .small_button(crate::i18n::fmt(tl!("View revision {n}"), &[("n", &n.to_string())])) - .on_hover_text(tl!("Open the file as it was saved then")) - .clicked() - { - open_revision = Some(n); + // Reserve the full wrapped height before the grid starts the next row. + ui.vertical(|ui| { + ui.horizontal_wrapped(|ui| { + ui.label(ends.len().to_string()); + for n in (1..ends.len()).rev().take(12) { + if ui + .small_button(crate::i18n::fmt(tl!("View revision {n}"), &[("n", &n.to_string())])) + .on_hover_text(tl!("Open the file as it was saved then")) + .clicked() + { + open_revision = Some(n); + } } - } + }); }); ui.end_row(); } @@ -738,8 +741,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { match action { Some(crate::sign_ui::CertAction::Trust(c)) => app.trust_certificate(*c), Some(crate::sign_ui::CertAction::Export(c)) => { - let pem = crate::sign_ui::certificate_pem(&c); - app.write_files(&[(format!("{}.cer", c.display_name()), std::sync::Arc::new(pem.into_bytes()))], "Export certificate"); + app.export_certificate(&c); } None => {} } diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index d8881ba47d..66ad98ffe6 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -1329,7 +1329,12 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - ed.max_width = ed.rect.width(); let scale = (ed.rect.width() / (ed.source_rect[2] - ed.source_rect[0]).abs().max(0.01)).max(0.01); ed.size = (ed.look.size as f32 * scale).clamp(1.0, 300.0); - let font = editor_font(&ed.look, ed.size); + // egui draws the caret at least one row of this font tall. Each row already has its source + // row's height, so the font stays below every row (half the region's smallest size): on a + // 12 pt row under a 24 pt title the caret is as tall as the row, not the title. + let smallest = + ed.source.as_ref().and_then(|(block, lines)| block.lines.iter().filter_map(|i| lines.get(*i)).map(|l| l.size as f32).reduce(f32::min)); + let font = editor_font(&ed.look, smallest.map_or(ed.size, |s| (s * scale * 0.5).clamp(1.0, 300.0).min(ed.size))); let text_color = color32(ed.look.color); let mut done = None; egui::Area::new(egui::Id::new("edit-text-line")).order(egui::Order::Foreground).fixed_pos(Pos2::new(ed.rect.left(), ed.rect.top())).show( @@ -1475,6 +1480,21 @@ mod tests { (app, ed) } + #[test] + fn a_loaded_font_is_applied_to_the_paragraph_being_edited() { + // Loading a font used to only register it on the page: the paragraph kept its source + // face until the font was found again in the document-font menu (#2591). + let (mut app, ed) = preview_app(); + app.views[0].line_editor = Some(ed); + let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf").to_vec(); + app.use_files(crate::files::FilePurpose::TextFont { page: 0 }, vec![("Inter-Regular.ttf".into(), inter)]); + let doc = app.session.get(app.views[0].id).unwrap(); + let lines = doc.text_lines(0); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::(), "Preview text"); + assert!(lines.iter().all(|l| l.base_font == "Inter-Regular"), "{:?}", lines.iter().map(|l| &l.base_font).collect::>()); + assert!(app.views[0].line_editor.is_some(), "the paragraph stays open for editing"); + } + #[test] fn text_background_is_cached_without_editing_and_released_on_cancel() { let (app, ed) = preview_app(); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index be1347d68d..e554d7a1b0 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -24,6 +24,7 @@ pub enum CloseRequest { pub struct PendingOverwrite { pub id: pdfcraft_engine::DocId, pub dest: String, + target: SaveTarget, /// What was to run once the document is written (close its tab, quit). after: Box, } @@ -52,7 +53,16 @@ impl PdfCraftApp { let Some(doc) = self.session.get(id) else { return true }; let info = &doc.info; let view = &mut self.views[i]; - if !matches!(edit, Edit::AddAnnotation(_)) { + // A picture picked with the Select tool may be the one this edit removed or moved: + // drop its highlight now, not on the next frame (which only comes with the next input). + view.picked_image = None; + if let Some(ctx) = &self.ctx { + ctx.request_repaint(); + } + if matches!(edit, Edit::AddAnnotation(_)) { + // A released drawing's raster can come now: it will have the annotation. + view.comments.drawings_committed(); + } else { // Only a new drawing waits on its raster; any other edit may remove or move it. view.comments.settling.clear(); } @@ -123,6 +133,11 @@ impl PdfCraftApp { let to = to.min(info.pages.len() - n); view.select_pages(&(to..to + n).collect::>()); } + Edit::DuplicatePages { pages, at: Some(at) } => { + let n = pages.iter().collect::>().len(); + let at = at.min(info.pages.len().saturating_sub(n)); + view.select_pages(&(at..at + n).collect::>()); + } Edit::InsertBlankPage { at, .. } => view.select_pages(&[at.min(info.pages.len() - 1)]), Edit::DeletePages { .. } => view.select_pages(&[]), // A stroke drawn with the pen stays unselected, so the selection box and @@ -163,6 +178,10 @@ impl PdfCraftApp { true } Err(e) => { + if let Some(view) = self.views.get_mut(i) { + // A drawing whose annotation wasn't added must not stay painted. + view.comments.drawings_refused(); + } let message = if matches!(edit, Edit::MoveObjects { .. }) { crate::object_ui::move_error(&e) } else { e.to_string() }; self.notify_fmt("{label} failed: {e}", &[("label", &crate::i18n::edit_label(&label)), ("e", &message)]); false @@ -614,12 +633,12 @@ impl PdfCraftApp { let dialog = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(name); // The bytes are taken once the user has chosen, so edits made meanwhile are saved too. self.ask_one(crate::pickers::Ask::Save(dialog), None, move |app, dest| { - app.save_doc_to_then(id, &dest.to_string_lossy(), Box::new(after)); + app.save_doc_to_then(id, &dest.to_string_lossy(), target, Box::new(after)); }); return false; } }; - self.save_doc_to_then(id, &destination, Box::new(after)) + self.save_doc_to_then(id, &destination, target, Box::new(after)) } #[cfg(target_arch = "wasm32")] { @@ -674,14 +693,14 @@ impl PdfCraftApp { /// `pending_overwrite` behind [`Dialog::OverwriteChanged`](crate::Dialog::OverwriteChanged). /// Returns `true` if written now. #[cfg(not(target_arch = "wasm32"))] - fn save_doc_to_then(&mut self, id: pdfcraft_engine::DocId, dest: &str, after: Box) -> bool { + fn save_doc_to_then(&mut self, id: pdfcraft_engine::DocId, dest: &str, target: SaveTarget, after: Box) -> bool { let own = self.session.get(id).and_then(|d| d.path.as_deref()).is_some_and(|p| crate::same_file(p, dest)); if own && self.watch.disk_differs(id, dest) { - self.pending_overwrite = Some(PendingOverwrite { id, dest: dest.to_string(), after }); + self.pending_overwrite = Some(PendingOverwrite { id, dest: dest.to_string(), target, after }); self.dialog = Some(crate::Dialog::OverwriteChanged); return false; } - let saved = self.save_doc_to(id, dest); + let saved = self.save_doc_to(id, dest, target); if saved { after(self); } @@ -692,7 +711,7 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] pub(crate) fn overwrite_changed_file(&mut self) { let Some(p) = self.pending_overwrite.take() else { return }; - if self.save_doc_to(p.id, &p.dest) { + if self.save_doc_to(p.id, &p.dest, p.target) { (p.after)(self); } } @@ -708,17 +727,27 @@ impl PdfCraftApp { /// Write document `id` to `dest` and make it the document's file. Returns `true` if written. #[cfg(not(target_arch = "wasm32"))] - fn save_doc_to(&mut self, id: pdfcraft_engine::DocId, dest: &str) -> bool { + fn save_doc_to(&mut self, id: pdfcraft_engine::DocId, dest: &str, target: SaveTarget) -> bool { // Save As answers on a later frame: commit what was typed meanwhile, too (#166), even // if another tab is active by then. if !self.commit_typing_in(id) || !self.flatten_fill_sign_for_save(id) { return false; } - let Some(name) = self.session.get(id).map(|d| d.name.clone()) else { + let Some((name, full)) = self.session.get(id).map(|d| { + // Save As to another file is a clean copy, without the earlier revisions that still + // hold deleted pages or replaced text (#2165). A signed document stays incremental so + // its signatures keep covering the file. + let full = target == SaveTarget::As + && !d.path.as_deref().is_some_and(|p| crate::same_file(p, dest)) + && d.read_only_reason.is_none() + && !d.is_signed(); + (d.name.clone(), full) + }) else { self.notify_tr("The document was closed before it could be saved."); return false; }; - let bytes = match self.session.save_bytes(id).and_then(|b| self.session.check_save(id, &b).map(|()| b)) { + let bytes = if full { self.session.save_full_bytes(id) } else { self.session.save_bytes(id) }; + let bytes = match bytes.and_then(|b| self.session.check_save(id, &b).map(|()| b)) { Ok(b) => b, Err(e) => { self.notify_fmt("Couldn't save {name}: {e}", &[("name", &name), ("e", &e.to_string())]); @@ -972,13 +1001,15 @@ fn is_form_edit(edit: &Edit) -> bool { /// classic Outlook (`Temporary Internet Files\Content.Outlook\` on older Windows), and the /// `Microsoft.OutlookForWindows` package folder for the new Outlook. Saving there either fails, /// because Outlook can still hold the file, or appears to work while the edits stay out of the -/// email and are deleted with the cache. Matched on either separator and in any case, so it can -/// be tested on every platform. +/// email and are deleted with the cache. Apple Mail opens one from `Mail Downloads//` in +/// its sandbox container, where other apps can't write, so saving there fails (#2751). Matched on +/// either separator and in any case, so it can be tested on every platform. pub(crate) fn is_mail_temp_copy(path: &str) -> bool { let p = path.replace('\\', "/").to_ascii_lowercase(); p.contains("/inetcache/content.outlook/") || p.contains("/temporary internet files/content.outlook/") || p.contains("/microsoft.outlookforwindows") + || p.contains("/library/containers/com.apple.mail/data/library/mail downloads/") } /// Write via a temporary file in the same directory and rename over the target, so a crash or @@ -1226,8 +1257,8 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - /// Windows refuses to replace a read-only file, so the rename fails: the staging file must not - /// be left behind. + /// A read-only target is refused before anything is staged, so no staging file is left + /// behind. #[cfg(windows)] #[test] fn a_failed_save_removes_the_staging_file() { @@ -1238,7 +1269,7 @@ mod tests { perms.set_readonly(true); std::fs::set_permissions(&target, perms.clone()).unwrap(); let e = write_atomically_with(&target.to_string_lossy(), b"NEW", [7]).unwrap_err(); - assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied, "the rename failed: {e}"); + assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied, "a read-only target is refused: {e}"); assert_eq!(read(&target), "OLD"); assert_eq!(listing(&dir), ["out.pdf"], "the staging file was removed"); #[allow(clippy::permissions_set_readonly_false)] @@ -1296,6 +1327,16 @@ mod tests { } } + #[test] + fn apple_mail_attachment_copies_are_recognised() { + for p in [ + "/Users/me/Library/Containers/com.apple.mail/Data/Library/Mail Downloads/6F1C2A9E-3B4D-4E5F-8A7B-9C0D1E2F3A4B/booking.pdf", + "/Users/me/library/containers/COM.APPLE.MAIL/data/library/mail downloads/6F1C2A9E/Booking (2).pdf", + ] { + assert!(is_mail_temp_copy(p), "{p}"); + } + } + #[test] fn ordinary_paths_are_not_mail_copies() { for p in [ @@ -1303,6 +1344,8 @@ mod tests { r"C:\Users\me\OneDrive - Contoso\Content.Outlook notes\Quote.pdf", r"C:\Users\me\AppData\Local\Microsoft\Windows\INetCache\IE\Quote.pdf", "/home/me/Documents/Quote.pdf", + "/Users/me/Downloads/Mail Downloads/booking.pdf", + "/Users/me/Library/Containers/com.apple.Notes/Data/Library/Mail Downloads/booking.pdf", "Quote.pdf", ] { assert!(!is_mail_temp_copy(p), "{p}"); diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index 23754e13ef..03c773807c 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -195,9 +195,9 @@ pub(crate) fn file_stem_from_user(name: &str) -> String { if reserved { format!("_{stem}") } else { stem.to_string() } } -/// A safe native Save suggestion for a PDF-supplied attachment name. -/// This changes only the suggestion, never the stored name or the selected destination. -#[cfg(any(test, not(target_arch = "wasm32")))] +/// A plain file name for a PDF-supplied attachment name: the Save suggestion, and the tab name +/// (which later names files written from that tab). It never changes the stored name or a +/// destination the user selected. pub(crate) fn attachment_file_name(name: &str) -> String { let cleaned: String = name .chars() @@ -217,6 +217,14 @@ pub(crate) fn attachment_file_name(name: &str) -> String { if reserved { format!("_{name}") } else { name.to_string() } } +/// Whether `name` names a file directly inside a folder: not empty, `.` or `..`, with no `/` or +/// `\\`, and no drive (`C:`). On Windows any `:` is refused, which also rules out alternate data +/// streams. +pub(crate) fn is_plain_file_name(name: &str) -> bool { + let drive = matches!(name.as_bytes(), [letter, b':', ..] if letter.is_ascii_alphabetic()); + !(name.is_empty() || name == "." || name == ".." || name.contains(['/', '\\']) || drive || (cfg!(windows) && name.contains(':'))) +} + /// Pages ▸ Rotate Pages. #[derive(Clone, Debug, PartialEq)] pub struct RotateDraft { @@ -468,13 +476,26 @@ impl PdfCraftApp { match purpose { FilePurpose::TextFont { page } => { let Some((_, bytes)) = files.into_iter().next() else { return }; - if self.apply_edit(Edit::RegisterTextFont { page, bytes: Arc::new(bytes) }) - && let Some((index, id)) = self.active_ids() - && let Some(doc) = self.session.get(id) - && let Some(ed) = self.views[index].line_editor.as_mut() - && let Some(ctx) = self.ctx.as_ref() + let bytes = Arc::new(bytes); + if !self.apply_edit(Edit::RegisterTextFont { page, bytes: bytes.clone() }) { + return; + } + let Some((index, id)) = self.active_ids() else { return }; + let Some(mut ed) = self.views[index].line_editor.clone().filter(|ed| ed.page == page) else { return }; + // The font just loaded is the one the user wants: apply it to the paragraph, as + // choosing it from the document-font menu does, instead of only registering it (#2591). + let resource = self.session.get(id).and_then(|doc| { + doc.text_editor_fonts(page).into_iter().find(|f| f.bytes == *bytes && !f.resource.starts_with("PCApproved")).map(|f| f.resource) + }); + let ctx = self.ctx.clone(); + if resource.is_some() { + ed.font_resource = resource; + self.apply_line_format(index, ed, ctx.as_ref()); + } else if let Some(doc) = self.session.get(id) + && let Some(ctx) = ctx { - ed.fonts = crate::edit_text_ui::install_source_fonts(ctx, doc, page); + ed.fonts = crate::edit_text_ui::install_source_fonts(&ctx, doc, page); + self.views[index].line_editor = Some(ed); } } FilePurpose::Combine => self.stage_combine(files), @@ -667,7 +688,12 @@ impl PdfCraftApp { /// [`Self::write_files`], then `after` once every file is written: now, or on a later frame /// when the user chooses the folder. `after` never runs when writing fails or is cancelled. + /// Every name must be a plain file name ([`is_plain_file_name`]); otherwise nothing is written. pub(crate) fn write_files_then(&mut self, named: Vec<(String, Arc>)>, title: &str, after: impl FnOnce(&mut Self) + Send + 'static) { + if let Some((name, _)) = named.iter().find(|(name, _)| !is_plain_file_name(name)) { + self.notify_fmt("Couldn't write {name}: {e}", &[("name", name), ("e", tl!("the name is not a plain file name"))]); + return; + } #[cfg(not(target_arch = "wasm32"))] { let write = move |app: &mut Self, dir: std::path::PathBuf| { @@ -996,7 +1022,7 @@ impl PdfCraftApp { #[cfg(test)] mod stem_tests { - use super::{attachment_file_name, file_stem_from_user}; + use super::{attachment_file_name, file_stem_from_user, is_plain_file_name}; #[test] fn user_file_names_stay_inside_the_folder_and_writable_everywhere() { @@ -1049,4 +1075,36 @@ mod stem_tests { assert_eq!(attachment_file_name(&once), once); } } + + #[test] + fn write_files_refuses_names_that_leave_the_folder() { + let dir = std::env::temp_dir().join(format!("pdfcraft-write-files-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let out = dir.join("a").join("out"); + std::fs::create_dir_all(&out).unwrap(); + let absolute = dir.join("abs.pdf").to_string_lossy().into_owned(); + let mut app = crate::PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.export_dir_override = Some(out.to_string_lossy().into_owned()); + for bad in ["../x.pdf", "..", ".", "", "sub/x.pdf", "sub\\x.pdf", "C:x.pdf", absolute.as_str()] { + assert!(!is_plain_file_name(bad), "{bad}"); + // A plain name first: nothing is written when any name in the batch is refused. + app.write_files(&[("ok.pdf".into(), std::sync::Arc::new(b"%PDF".to_vec())), (bad.into(), std::sync::Arc::new(b"%PDF".to_vec()))], "t"); + assert!(app.toast.as_ref().is_some_and(|t| t.0.contains("not a plain file name")), "{bad}"); + } + let names: Vec<_> = walk(&dir); + assert!(names.is_empty(), "{names:?}"); + app.write_files(&[("x.pdf".into(), std::sync::Arc::new(b"%PDF".to_vec()))], "t"); + assert_eq!(walk(&dir), vec![out.join("x.pdf")]); + let _ = std::fs::remove_dir_all(&dir); + } + + fn walk(dir: &std::path::Path) -> Vec { + let mut out = Vec::new(); + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { out.extend(walk(&path)) } else { out.push(path) } + } + out + } } diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 01bb1679e4..8697887e10 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -162,6 +162,7 @@ Next page الصفحة التالية Previous page الصفحة السابقة Current page — type a page number or label (such as iv) and press Enter الصفحة الحالية — اكتب رقم صفحة أو تسميتها (مثل iv) ثم اضغط Enter + Zoom — type a percentage and press Enter التكبير — اكتب نسبة مئوية واضغط Enter Welcome to PdfCraft مرحبًا بك في PdfCraft An open-source PDF workbench — local, private, and scriptable. منضدة عمل مفتوحة المصدر لملفات PDF — محلية وخاصة وقابلة للبرمجة. Join the ArtCraft community انضم إلى مجتمع ArtCraft @@ -1703,6 +1704,7 @@ Couldn't use {name}: {e} تعذّر استخدام {name}: {e} Couldn't extract pages: {e} تعذّر استخراج الصفحات: {e} Couldn't write {name}: {e} تعذّرت كتابة {name}: {e} + the name is not a plain file name الاسم ليس اسم ملف بسيطًا Wrote 1 file to {dir} كُتب ملف واحد في {dir} Wrote {n} files to {dir} كُتب {n} ملفات في {dir} No pages match those choices لا صفحات تطابق هذه الاختيارات @@ -2402,6 +2404,9 @@ animation effects Off معطّلة Move tag earlier نقل العلامة للأعلى Move tag later نقل العلامة للأسفل +# Historical snapshot notice bar (View signed version / View revision). + You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. أنت تعرض النسخة الموقعة من “{from}” (المراجعة {rev}). هذه لقطة: التغييرات هنا لن تؤثر على الأصل. + You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. أنت تعرض المراجعة {rev} من “{from}”. هذه لقطة: التغييرات هنا لن تؤثر على الأصل. # Completion: the rows the newer catalogs (lt, cs, es …) grew — certificates, # digital-signature verification detail, accessibility checks, calendar weekdays # and the newer plural counts — same clean-room Arabic as the rest of the file. diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 8f2fea70e0..813a2c7ae8 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1123,6 +1123,7 @@ Next page Следваща страница Previous page Предишна страница Current page — type a page number or label (such as iv) and press Enter Текуща страница — въведете номер или етикет на страница (например iv) и натиснете Enter + Zoom — type a percentage and press Enter Мащаб — въведете процент и натиснете Enter Open a PDF ({key}) Отвори PDF файл ({key}) Dark gray theme Тъмносива тема Light theme Светла тема @@ -1664,6 +1665,7 @@ Couldn't use {name}: {e} {name} не можа да бъде използван: {e} Couldn't extract pages: {e} Страниците не можаха да бъдат извлечени: {e} Couldn't write {name}: {e} {name} не можа да бъде записан: {e} + the name is not a plain file name името не е обикновено име на файл Wrote 1 file to {dir} Записан е 1 файл в {dir} Wrote {n} files to {dir} Записани са {n} файла в {dir} No pages match those choices Няма страници, отговарящи на този избор @@ -2343,6 +2345,10 @@ animation effects Off Изключени This document is trying to open a local file: Този документ се опитва да отвори локален файл: Move tag earlier Преместване на етикета по-рано Move tag later Преместване на етикета по-късно + +# Historical snapshot notice bar (View signed version / View revision). + You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Разглеждате подписаната версия на „{from}“ (редакция {rev}). Това е моментна снимка: промените тук няма да засегнат оригинала. + You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Разглеждате редакция {rev} на „{from}“. Това е моментна снимка: промените тук няма да засегнат оригинала. Turn upside down (180°) Обърни наопаки (180°) 4 x 6 in label (101.6 x 152.4 mm) Етикет 4 × 6 in (101,6 × 152,4 mm) Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Изрязва всяка страница до отпечатаната ѝ област, завърта я изправено и я побира в листа без разтягане. Един етикет на лист. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index bad5c497e8..9936325523 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -279,6 +279,7 @@ key Space Mezerník Next page Další stránka Previous page Předchozí stránka Current page — type a page number or label (such as iv) and press Enter Aktuální stránka — zadejte číslo nebo označení stránky (třeba iv) a stiskněte Enter + Zoom — type a percentage and press Enter Lupa — zadejte procenta a stiskněte Enter Welcome to PdfCraft Vítá vás PdfCraft An open-source PDF workbench — local, private, and scriptable. Open-source dílna pro PDF — lokální, soukromá a skriptovatelná. Join the ArtCraft community Připojte se ke komunitě ArtCraft @@ -1755,6 +1756,7 @@ key Space Mezerník Couldn't use {name}: {e} {name} nelze použít: {e} Couldn't extract pages: {e} Stránky nelze extrahovat: {e} Couldn't write {name}: {e} {name} nelze zapsat: {e} + the name is not a plain file name název není prostý název souboru Wrote 1 file to {dir} Zapsán 1 soubor do {dir} Wrote {n} files to {dir} Zapsáno souborů: {n} (složka {dir}) No pages match those choices Těmto volbám neodpovídají žádné stránky @@ -2454,6 +2456,10 @@ animation effects Off Vypnuto This document is trying to open a local file: Tento dokument se pokouší otevřít místní soubor: Move tag earlier Přesunout značku dříve Move tag later Přesunout značku později + +# Historical snapshot notice bar (View signed version / View revision). + You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Prohlížíte si podepsanou verzi dokumentu „{from}“ (revize {rev}). Je to snímek: změny zde neovlivní originál. + You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Prohlížíte si revizi {rev} dokumentu „{from}“. Je to snímek: změny zde neovlivní originál. Turn upside down (180°) Otočit vzhůru nohama (180°) 4 x 6 in label (101.6 x 152.4 mm) Štítek 4 × 6 in (101,6 × 152,4 mm) Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Ořízne každou stránku na potištěnou oblast, otočí ji svisle a přizpůsobí listu bez deformace. Jeden štítek na list. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b5baed7ba1..4d9207eb9d 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -135,6 +135,7 @@ Next page Nächste Seite Previous page Vorherige Seite Current page — type a page number or label (such as iv) and press Enter Aktuelle Seite – gib eine Seitenzahl oder -beschriftung ein (z. B. iv) und drücke die Eingabetaste + Zoom — type a percentage and press Enter Zoom – gib einen Prozentwert ein und drücke die Eingabetaste Welcome to PdfCraft Willkommen bei PdfCraft An open-source PDF workbench — local, private, and scriptable. Eine Open-Source-PDF-Werkbank – lokal, privat und skriptfähig. Join the ArtCraft community Tritt der ArtCraft-Community bei @@ -1637,6 +1638,7 @@ Couldn't use {name}: {e} {name} konnte nicht verwendet werden: {e} Couldn't extract pages: {e} Seiten konnten nicht extrahiert werden: {e} Couldn't write {name}: {e} {name} konnte nicht geschrieben werden: {e} + the name is not a plain file name der Name ist kein einfacher Dateiname Wrote 1 file to {dir} 1 Datei nach {dir} geschrieben Wrote {n} files to {dir} {n} Dateien nach {dir} geschrieben No pages match those choices Keine Seiten entsprechen dieser Auswahl @@ -2381,6 +2383,10 @@ animation effects Off Aus This document is trying to open a local file: Dieses Dokument versucht, eine lokale Datei zu öffnen: Move tag earlier Tag früher verschieben Move tag later Tag später verschieben + +# Historical snapshot notice bar (View signed version / View revision). + You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Sie sehen die signierte Version von „{from}“ (Revision {rev}). Es handelt sich um eine Momentaufnahme: Änderungen hier wirken sich nicht auf das Original aus. + You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Sie sehen Revision {rev} von „{from}“. Es handelt sich um eine Momentaufnahme: Änderungen hier wirken sich nicht auf das Original aus. Turn upside down (180°) Auf den Kopf drehen (180°) 4 x 6 in label (101.6 x 152.4 mm) Etikett 4 × 6 Zoll (101,6 × 152,4 mm) Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Schneidet jede Seite auf ihren bedruckten Bereich zu, richtet sie auf und passt sie ohne Verzerrung an das Blatt an. Ein Etikett pro Blatt. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 57de6eef07..06fe2b540b 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -282,6 +282,7 @@ Flatten Fill & Sign when saving Ενσωμάτωση συμπληρώσεων και υπογραφών κατά την αποθήκευση Text, marks and signatures become part of the page. Other comments stay editable. Το κείμενο, τα σημάδια και οι υπογραφές γίνονται μέρος της σελίδας. Τα υπόλοιπα σχόλια παραμένουν επεξεργάσιμα. Current page — type a page number or label (such as iv) and press Enter Τρέχουσα σελίδα — πληκτρολογήστε αριθμό ή ετικέτα σελίδας (π.χ. iv) και πατήστε Enter + Zoom — type a percentage and press Enter Ζουμ — πληκτρολογήστε ένα ποσοστό και πατήστε Enter Files you open in PdfCraft appear here. Drop a PDF anywhere to open it. Τα αρχεία που ανοίγετε στο PdfCraft εμφανίζονται εδώ. Σύρετε ένα PDF οπουδήποτε για να το ανοίξετε. Scripts run in a sandbox without file or network access. With JavaScript off, Acrobat's standard format, validate and calculate functions still work. Τα σενάρια εκτελούνται σε απομονωμένο περιβάλλον χωρίς πρόσβαση σε αρχεία ή δίκτυο. Με απενεργοποιημένη τη JavaScript, οι τυπικές λειτουργίες μορφοποίησης, επικύρωσης και υπολογισμού του Acrobat εξακολουθούν να λειτουργούν. Move up Μετακίνηση πάνω @@ -1215,6 +1216,7 @@ Couldn't use {name}: {e} Δεν ήταν δυνατή η χρήση του {name}: {e} Couldn't extract pages: {e} Δεν ήταν δυνατή η εξαγωγή σελίδων: {e} Couldn't write {name}: {e} Δεν ήταν δυνατή η εγγραφή του {name}: {e} + the name is not a plain file name το όνομα δεν είναι απλό όνομα αρχείου Wrote 1 file to {dir} Γράφτηκε 1 αρχείο στον φάκελο {dir} Wrote {n} files to {dir} Γράφτηκαν {n} αρχεία στον φάκελο {dir} No pages match those choices Καμία σελίδα δεν αντιστοιχεί σε αυτές τις επιλογές diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 548daf0caa..79c668992c 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -158,6 +158,7 @@ Next page Página siguiente Previous page Página anterior Current page — type a page number or label (such as iv) and press Enter Página actual: escribe un número o una etiqueta de página (por ejemplo, iv) y pulsa Intro + Zoom — type a percentage and press Enter Zoom: escribe un porcentaje y pulsa Intro Welcome to PdfCraft Te damos la bienvenida a PdfCraft An open-source PDF workbench — local, private, and scriptable. Un banco de trabajo PDF de código abierto: local, privado y automatizable. Join the ArtCraft community Únete a la comunidad de ArtCraft @@ -1714,6 +1715,7 @@ action wizard Start Iniciar Couldn't use {name}: {e} No se pudo usar {name}: {e} Couldn't extract pages: {e} No se pudieron extraer las páginas: {e} Couldn't write {name}: {e} No se pudo escribir {name}: {e} + the name is not a plain file name el nombre no es un nombre de archivo simple Wrote 1 file to {dir} Se ha escrito 1 archivo en {dir} Wrote {n} files to {dir} Se han escrito {n} archivos en {dir} No pages match those choices Ninguna página coincide con esas opciones @@ -2568,3 +2570,7 @@ animation effects Off Desactivados Make properties default Usar como propiedades predeterminadas Pages selected Páginas seleccionadas Page properties Propiedades de página + +# Historical snapshot notice bar (View signed version / View revision). + You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Estás viendo la versión firmada de «{from}» (revisión {rev}). Es una instantánea: los cambios aquí no afectarán al original. + You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Estás viendo la revisión {rev} de «{from}». Es una instantánea: los cambios aquí no afectarán al original. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index c082789bc1..9bdcf0bc8a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -135,6 +135,7 @@ Next page Page suivante Previous page Page précédente Current page — type a page number or label (such as iv) and press Enter Page actuelle — saisissez un numéro ou une étiquette de page (comme iv) et appuyez sur Entrée + Zoom — type a percentage and press Enter Zoom — saisissez un pourcentage et appuyez sur Entrée Welcome to PdfCraft Bienvenue dans PdfCraft An open-source PDF workbench — local, private, and scriptable. Un atelier PDF open source — local, privé et automatisable. Join the ArtCraft community Rejoindre la communauté ArtCraft @@ -1632,6 +1633,7 @@ Couldn't use {name}: {e} Impossible d'utiliser {name} : {e} Couldn't extract pages: {e} Impossible d'extraire les pages : {e} Couldn't write {name}: {e} Impossible d'écrire {name} : {e} + the name is not a plain file name le nom n'est pas un simple nom de fichier Wrote 1 file to {dir} 1 fichier écrit dans {dir} Wrote {n} files to {dir} {n} fichiers écrits dans {dir} No pages match those choices Aucune page ne correspond à ces choix @@ -2380,6 +2382,10 @@ animation effects Off Désactivés This document is trying to open a local file: Ce document tente d’ouvrir un fichier local : Move tag earlier Déplacer la balise plus tôt Move tag later Déplacer la balise plus tard + +# Historical snapshot notice bar (View signed version / View revision). + You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Vous consultez la version signée de «{from}» (révision {rev}). Il s'agit d'un instantané : les modifications effectuées ici n'affecteront pas l'original. + You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Vous consultez la révision {rev} de «{from}». Il s'agit d'un instantané : les modifications effectuées ici n'affecteront pas l'original. Turn upside down (180°) Retourner (180°) 4 x 6 in label (101.6 x 152.4 mm) Étiquette 4 × 6 po (101,6 × 152,4 mm) Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Rogne chaque page à sa zone imprimée, la redresse et l’ajuste à la feuille sans la déformer. Une étiquette par feuille. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index b47da5b412..d537677c7d 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -133,6 +133,7 @@ Next page Következő oldal Previous page Előző oldal Current page — type a page number or label (such as iv) and press Enter Aktuális oldal — írj be egy oldalszámot vagy címkét (például iv), majd nyomd meg az Entert + Zoom — type a percentage and press Enter Nagyítás – írjon be egy százalékértéket, és nyomja meg az Entert Welcome to PdfCraft Üdvözöljük a PdfCraftban An open-source PDF workbench — local, private, and scriptable. Nyílt forráskódú PDF-munkapad — helyi, privát és parancsfájlokkal vezérelhető. Join the ArtCraft community Csatlakozz az ArtCraft közösséghez @@ -1622,6 +1623,7 @@ Couldn't use {name}: {e} {name} használata nem sikerült: {e} Couldn't extract pages: {e} Az oldalakat nem sikerült kinyerni: {e} Couldn't write {name}: {e} {name} írása nem sikerült: {e} + the name is not a plain file name a név nem egyszerű fájlnév Wrote 1 file to {dir} 1 fájl ide írva: {dir} Wrote {n} files to {dir} {n} fájl ide írva: {dir} No pages match those choices Egy oldal sem felel meg ezeknek a választásoknak @@ -2379,6 +2381,10 @@ animation effects Off Ki This document is trying to open a local file: Ez a dokumentum egy helyi fájlt próbál megnyitni: Move tag earlier Címke korábbra helyezése Move tag later Címke későbbre helyezése + +# Historical snapshot notice bar (View signed version / View revision). + You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. A(z) „{from}“ aláírt változatát (a {rev}. változatot) tekinti meg. Ez egy pillanatkép: az itteni módosítások nem érintik az eredetit. + You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. A(z) „{from}“ {rev}. változatát tekinti meg. Ez egy pillanatkép: az itteni módosítások nem érintik az eredetit. Turn upside down (180°) Fejjel lefelé fordítás (180°) 4 x 6 in label (101.6 x 152.4 mm) 4 × 6 hüvelykes címke (101,6 × 152,4 mm) Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Minden oldalt a nyomtatott területére vág, függőlegesre fordítja, és torzítás nélkül a laphoz igazítja. Laponként egy címke. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 9d54a4bead..35cba7a410 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -159,6 +159,7 @@ Next page Pagina successiva Previous page Pagina precedente Current page — type a page number or label (such as iv) and press Enter Pagina corrente: digita un numero o un'etichetta di pagina (ad esempio iv) e premi Invio + Zoom — type a percentage and press Enter Zoom: digita una percentuale e premi Invio Welcome to PdfCraft Benvenuto in PdfCraft An open-source PDF workbench — local, private, and scriptable. Un banco di lavoro PDF open source: locale, riservato e programmabile. Join the ArtCraft community Unisciti alla community di ArtCraft @@ -1709,6 +1710,7 @@ Couldn't use {name}: {e} Impossibile usare {name}: {e} Couldn't extract pages: {e} Impossibile estrarre le pagine: {e} Couldn't write {name}: {e} Impossibile scrivere {name}: {e} + the name is not a plain file name il nome non è un semplice nome file Wrote 1 file to {dir} Scritto 1 file in {dir} Wrote {n} files to {dir} Scritti {n} file in {dir} No pages match those choices Nessuna pagina corrisponde a queste scelte @@ -2389,6 +2391,10 @@ animation effects Off Disattivati This document is trying to open a local file: Questo documento sta tentando di aprire un file locale: Move tag earlier Sposta tag prima Move tag later Sposta tag dopo + +# Historical snapshot notice bar (View signed version / View revision). + You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Stai visualizzando la versione firmata di “{from}” (revisione {rev}). È un'istantanea: le modifiche qui non influiscono sull'originale. + You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Stai visualizzando la revisione {rev} di “{from}”. È un'istantanea: le modifiche qui non influiscono sull'originale. Turn upside down (180°) Capovolgi (ruota di 180°) 4 x 6 in label (101.6 x 152.4 mm) Etichetta 4×6 - 101,6 × 152,4 mm Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Ritaglia l'area stampata di ogni pagina, la raddrizza e la adatta al foglio senza deformarla. Un'etichetta per foglio. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 461e074935..e845a736da 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -162,6 +162,7 @@ Next page 次のページ Previous page 前のページ Current page — type a page number or label (such as iv) and press Enter 現在のページ:ページ番号またはラベル(例:iv)を入力して Enter + Zoom — type a percentage and press Enter ズーム — パーセンテージを入力して Enter キーを押します Welcome to PdfCraft PdfCraft へようこそ An open-source PDF workbench — local, private, and scriptable. ローカルで処理し、プライバシーを守る、スクリプト対応のオープンソース PDF ツール。 Join the ArtCraft community ArtCraft コミュニティに参加 @@ -1713,6 +1714,7 @@ Couldn't use {name}: {e} {name} を使用できませんでした: {e} Couldn't extract pages: {e} ページを抽出できませんでした: {e} Couldn't write {name}: {e} {name} に書き込めませんでした: {e} + the name is not a plain file name 名前が単独のファイル名ではありません Wrote 1 file to {dir} {dir} に 1 ファイルを書き込みました Wrote {n} files to {dir} {dir} に {n} ファイルを書き込みました No pages match those choices 条件に一致するページはありません @@ -2380,6 +2382,10 @@ animation effects Off オフ This document is trying to open a local file: この文書はローカルファイルを開こうとしています: Move tag earlier タグを前へ移動 Move tag later タグを後へ移動 + +# Historical snapshot notice bar (View signed version / View revision). + You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. 「{from}」の署名済みバージョン(リビジョン {rev})を表示しています。これはスナップショットであり、ここでの変更は元の文書に影響しません。 + You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. 「{from}」のリビジョン {rev} を表示しています。これはスナップショットであり、ここでの変更は元の文書に影響しません。 Turn upside down (180°) 上下反転 (180°) 4 x 6 in label (101.6 x 152.4 mm) 4 × 6 インチ ラベル (101.6 × 152.4 mm) Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. 各ページを印刷領域で切り抜き、正しい向きに回転して、伸縮させずに用紙に合わせます。1 枚に 1 ラベル。 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index af140d3a07..9acb4ec1be 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -123,6 +123,7 @@ Next page Келесі бет Previous page Алдыңғы бет Current page — type a page number or label (such as iv) and press Enter Ағымдағы бет — бет нөмірін немесе белгісін (мысалы, iv) теріңіз де, Enter пернесін басыңыз + Zoom — type a percentage and press Enter Масштаб — пайызды енгізіп, Enter пернесін басыңыз Welcome to PdfCraft PdfCraft-ге қош келдіңіз An open-source PDF workbench — local, private, and scriptable. Ашық бастапқы кодты PDF жұмыс орны — жергілікті, жеке әрі скрипттерді қолдайтын. Join the ArtCraft community ArtCraft қауымдастығына қосылыңыз @@ -1634,6 +1635,7 @@ Couldn't use {name}: {e} {name} файлын пайдалану мүмкін болмады: {e} Couldn't extract pages: {e} Беттерді шығарып алу мүмкін болмады: {e} Couldn't write {name}: {e} {name} файлына жазу мүмкін болмады: {e} + the name is not a plain file name атау қарапайым файл атауы емес Wrote 1 file to {dir} 1 файл {dir} ішіне жазылды Wrote {n} files to {dir} {n} файл {dir} ішіне жазылды No pages match those choices Ешбір бет бұл таңдауларға сәйкес келмейді @@ -2378,6 +2380,10 @@ animation effects Off Өшірулі This document is trying to open a local file: Бұл құжат жергілікті файлды ашуға әрекеттеніп жатыр: Move tag earlier Тегті ертерек жылжыту Move tag later Тегті кейінірек жылжыту + +# Historical snapshot notice bar (View signed version / View revision). + You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Сіз „{from}“ құжатының қол қойылған нұсқасын ({rev} нұсқа) қарап отырсыз. Бұл сақталған көшірме: мұндағы өзгерістер түпнұсқаға әсер етпейді. + You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Сіз „{from}“ құжатының {rev} нұсқасын қарап отырсыз. Бұл сақталған көшірме: мұндағы өзгерістер түпнұсқаға әсер етпейді. Turn upside down (180°) Төңкеру (180°) 4 x 6 in label (101.6 x 152.4 mm) 4 × 6 дюйм жапсырма (101,6 × 152,4 мм) Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Әр бетті басылған аймағына дейін қиып, тік бұрып, созбай параққа сыйғызады. Әр параққа бір жапсырма. diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 247b6cff95..d1dfc33044 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -431,6 +431,7 @@ Current page Dabartinis puslapis Current page ({n}) Dabartinis puslapis ({n}) Current page — type a page number or label (such as iv) and press Enter Dabartinis puslapis — įveskite puslapio numerį arba žymą (pvz., iv) ir paspauskite „Enter“ + Zoom — type a percentage and press Enter Mastelis – įveskite procentus ir paspauskite Enter Custom Pasirinktinis Custom scale: Pasirinktinis mastelis: Custom text: Pasirinktinis tekstas: @@ -1965,6 +1966,7 @@ comment menu Edit Redaguoti Couldn't use the photo: {e} Nepavyko panaudoti nuotraukos: {e} Couldn't use {name}: {e} Nepavyko panaudoti {name}: {e} Couldn't write {name}: {e} Nepavyko įrašyti {name}: {e} + the name is not a plain file name vardas nėra paprastas failo vardas Couldn't write {path}: {e} Nepavyko įrašyti {path}: {e} Create link Kurti saitą Created 1 link Sukurtas 1 saitas @@ -2624,6 +2626,10 @@ key Space Tarpas This document is trying to open a local file: Šis dokumentas bando atverti vietinį failą: Move tag earlier Perkelti žymą anksčiau Move tag later Perkelti žymą vėliau + +# Historical snapshot notice bar (View signed version / View revision). + You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Jūs peržiūrite pasirašytą „{from}“ versiją ({rev} versija). Tai momentinė kopija: čia atlikti pakeitimai originalo nepaveiks. + You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Jūs peržiūrite „{from}“ {rev} versiją. Tai momentinė kopija: čia atlikti pakeitimai originalo nepaveiks. Turn upside down (180°) Apversti aukštyn kojomis (180°) 4 x 6 in label (101.6 x 152.4 mm) 4 × 6 col. etiketė (101,6 × 152,4 mm) Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Apkerpa kiekvieną puslapį iki spausdinamos srities, pasuka tiesiai ir pritaiko lapui neiškraipydama. Viena etiketė lape. @@ -2672,3 +2678,10 @@ key Space Tarpas Make properties default Nustatyti ypatybes kaip numatytąsias Pages selected Pasirinkti puslapiai Page properties Puslapio ypatybės + +# AI-assisted additions for concurrently merged UI features (2026-10-15). +# These additions have not had separate native-speaker review. + Mouse wheel scroll speed Pelės ratuko slinkimo greitis + The password must have at least 6 characters. Slaptažodį turi sudaryti bent 6 simboliai. + This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Šis failas diske pasikeitė. Įkelkite jį iš naujo, kad pamatytumėte naują versiją; neįrašyti pakeitimai bus prarasti. +theme Appearance Išvaizda diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 5bb2889c9d..20c0fd0467 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -390,9 +390,23 @@ fn first_supported(list: &str) -> Option { list.split(['(', ')', ',', '"', '\n']).map(str::trim).filter(|s| !s.is_empty()).find_map(lang_from_tag) } +/// The first supported language among BCP 47 tags in preference order, as the browser lists them +/// in `navigator.languages` (#2954). Only the first few tags are looked at: the list is short in +/// practice, and a page can't make Auto walk an unbounded one. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +fn first_supported_tag>(tags: impl IntoIterator) -> Option { + tags.into_iter().take(32).find_map(|tag| lang_from_tag(tag.as_ref().trim())) +} + +/// The browser's preferred languages, as the desktop builds use the system's: `navigator.languages` +/// in order, then `navigator.language` (kept within the 32 tags `first_supported_tag` reads). +/// English when the browser offers none we have a catalog for. #[cfg(target_arch = "wasm32")] fn detect_system_lang() -> Lang { - Lang::EN + let Some(navigator) = web_sys::window().map(|w| w.navigator()) else { return Lang::EN }; + let mut tags: Vec = navigator.languages().iter().take(31).filter_map(|v| v.as_string()).collect(); + tags.extend(navigator.language()); + first_supported_tag(&tags).unwrap_or(Lang::EN) } thread_local! { @@ -411,7 +425,38 @@ pub fn set_current(lang: Lang) { /// `label` as it reads inside a sentence ("add a text box"): lowercased, except in languages /// that capitalise nouns (German), where lowercasing would misspell it. pub fn in_sentence(label: &str) -> std::borrow::Cow<'_, str> { - if current().code() == "de" { std::borrow::Cow::Borrowed(label) } else { std::borrow::Cow::Owned(label.to_lowercase()) } + match current().code() { + "de" => std::borrow::Cow::Borrowed(label), + "tr" => std::borrow::Cow::Owned(label.chars().flat_map(lower_turkish).collect()), + _ => std::borrow::Cow::Owned(label.to_lowercase()), + } +} + +/// The translation of `english` in upper case, for section titles. Rust's case mapping knows no +/// language, and Turkish has a dotted and a dotless i (`i` ↔ `İ`, `ı` ↔ `I`): "Gelişmiş" must +/// become "GELİŞMİŞ", not "GELIŞMIŞ". A label without a Turkish row is still English and keeps +/// the English rule. +pub fn upper_label(english: &str) -> String { + let lang = current(); + let shown = tr(lang, english); + if lang.code() == "tr" && has(lang, english) { shown.chars().flat_map(upper_turkish).collect() } else { shown.to_uppercase() } +} + +fn upper_turkish(c: char) -> std::char::ToUppercase { + match c { + 'i' => 'İ'.to_uppercase(), + 'ı' => 'I'.to_uppercase(), + c => c.to_uppercase(), + } +} + +fn lower_turkish(c: char) -> std::char::ToLowercase { + match c { + // `İ`.to_lowercase() is "i" plus a combining dot above. + 'İ' => 'i'.to_lowercase(), + 'I' => 'ı'.to_lowercase(), + c => c.to_lowercase(), + } } pub fn current() -> Lang { @@ -619,6 +664,21 @@ mod tests { assert_eq!(first_supported("("), None); } + /// #2954: the web build's Auto language follows the browser's preferred languages in order, + /// skipping ones without a catalog, and is English only when none is supported. + #[test] + fn browser_language_list_is_followed_in_order() { + assert_eq!(first_supported_tag(["it-IT", "en-US"]), Lang::from_code("it")); + assert_eq!(first_supported_tag(["xx-YY", "ja", "fr-FR"]), Some(JA())); + assert_eq!(first_supported_tag(["zh-TW"]), Lang::from_code("zh-hant")); + assert_eq!(first_supported_tag(["en-GB", "fr-FR"]), Some(Lang::EN)); + assert_eq!(first_supported_tag([" pt-PT "]), Lang::from_code("pt-br")); + assert_eq!(first_supported_tag(["", "xx"]), None); + assert_eq!(first_supported_tag(Vec::::new()), None); + let long = std::iter::repeat_n("xx", 10_000).chain(["fr"]); + assert_eq!(first_supported_tag(long), None, "only the first tags are read"); + } + #[test] fn windows_display_language_resolves_chinese_scripts() { for tag in ["zh-CN", "zh-SG", "zh-Hans", "zh-Hans-CN"] { @@ -1961,6 +2021,20 @@ mod tests { set_current(Lang::EN); } + #[test] + fn turkish_keeps_its_dotted_and_dotless_i_when_changing_case() { + set_current(Lang::from_code("tr").expect("tr registered")); + assert_eq!(upper_label("Advanced"), "GELİŞMİŞ"); + assert_eq!(upper_label("Signatures"), "İMZALAR"); + // A label with no Turkish row is still English and keeps the English rule. + assert_eq!(upper_label("Untranslated title"), "UNTRANSLATED TITLE"); + assert_eq!(in_sentence("İmza Alanı"), "imza alanı"); + assert_eq!(in_sentence("Işık"), "ışık"); + set_current(Lang::EN); + assert_eq!(upper_label("Advanced"), "ADVANCED"); + assert_eq!(in_sentence("Image"), "image"); + } + #[test] fn german_certificate_subject_has_a_distinct_context() { let de = Lang::from_code("de").expect("de registered"); diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3dbb247056..c432a6a92e 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -158,6 +158,7 @@ Next page Następna strona Previous page Poprzednia strona Current page — type a page number or label (such as iv) and press Enter Bieżąca strona — wpisz numer lub etykietę strony (np. iv) i naciśnij Enter + Zoom — type a percentage and press Enter Powiększenie — wpisz wartość procentową i naciśnij Enter Welcome to PdfCraft Witamy w PdfCraft An open-source PDF workbench — local, private, and scriptable. Otwarty zestaw narzędzi do PDF — lokalny, prywatny i obsługujący skrypty. Join the ArtCraft community Dołącz do społeczności ArtCraft @@ -1696,6 +1697,7 @@ Couldn't use {name}: {e} Nie można użyć {name}: {e} Couldn't extract pages: {e} Nie można wyodrębnić stron: {e} Couldn't write {name}: {e} Nie można zapisać {name}: {e} + the name is not a plain file name nazwa nie jest zwykłą nazwą pliku Wrote 1 file to {dir} Zapisano 1 plik w {dir} Wrote {n} files to {dir} Liczba plików zapisanych w {dir}: {n} No pages match those choices Żadna strona nie pasuje do wybranych opcji diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3e55173169..89ff80f9dd 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1067,6 +1067,7 @@ Next page Próxima página Previous page Página anterior Current page — type a page number or label (such as iv) and press Enter Página atual — digite o número da página ou rótulo (como iv) e pressione Enter + Zoom — type a percentage and press Enter Zoom — digite uma porcentagem e pressione Enter Open a PDF ({key}) Abrir um PDF ({key}) Dark gray theme Tema cinza escuro Light theme Tema claro @@ -1538,6 +1539,7 @@ Couldn't use {name}: {e} Não foi possível usar {name}: {e} Couldn't extract pages: {e} Não foi possível extrair páginas: {e} Couldn't write {name}: {e} Não foi possível gravar {name}: {e} + the name is not a plain file name o nome não é um nome de arquivo simples Wrote 1 file to {dir} 1 arquivo gravado em {dir} Wrote {n} files to {dir} {n} arquivos gravados em {dir} No pages match those choices Nenhuma página corresponde a essas opções @@ -2394,6 +2396,10 @@ animation effects Off Desativados This document is trying to open a local file: Este documento está tentando abrir um arquivo local: Move tag earlier Mover tag para antes Move tag later Mover tag para depois + +# Historical snapshot notice bar (View signed version / View revision). + You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Você está vendo a versão assinada de “{from}” (revisão {rev}). É um instantâneo: as alterações aqui não afetarão o original. + You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Você está vendo a revisão {rev} de “{from}”. É um instantâneo: as alterações aqui não afetarão o original. Turn upside down (180°) Virar de cabeça para baixo (180°) 4 x 6 in label (101.6 x 152.4 mm) Etiqueta 4 × 6 pol (101,6 × 152,4 mm) Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Recorta cada página na área impressa, deixa-a na vertical e ajusta-a à folha sem distorcer. Uma etiqueta por folha. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 1ac9c10ba1..73f21aa0d8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -155,6 +155,7 @@ Next page Следующая страница Previous page Предыдущая страница Current page — type a page number or label (such as iv) and press Enter Текущая страница — введите номер или метку страницы (например, iv) и нажмите Enter + Zoom — type a percentage and press Enter Масштаб — введите процент и нажмите Enter Welcome to PdfCraft Добро пожаловать в PdfCraft An open-source PDF workbench — local, private, and scriptable. Открытый PDF-редактор — локальный, приватный и программируемый. Join the ArtCraft community Присоединяйтесь к сообществу ArtCraft @@ -1704,6 +1705,7 @@ Couldn't use {name}: {e} Не удалось использовать {name}: {e} Couldn't extract pages: {e} Не удалось извлечь страницы: {e} Couldn't write {name}: {e} Не удалось записать {name}: {e} + the name is not a plain file name имя не является простым именем файла Wrote 1 file to {dir} Записан 1 файл в {dir} Wrote {n} files to {dir} Записано {n} файлов в {dir} No pages match those choices Страницы не соответствуют этим выбору @@ -2293,6 +2295,10 @@ animation effects Off Выкл This document is trying to open a local file: Этот документ пытается открыть локальный файл: Move tag earlier Переместить тег раньше Move tag later Переместить тег позже + +# Historical snapshot notice bar (View signed version / View revision). + You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Вы просматриваете подписанную версию документа «{from}» (ревизия {rev}). Это снимок: изменения здесь не затронут оригинал. + You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Вы просматриваете ревизию {rev} документа «{from}». Это снимок: изменения здесь не затронут оригинал. Turn upside down (180°) Перевернуть (180°) 4 x 6 in label (101.6 x 152.4 mm) Этикетка 4 × 6 дюйма (101,6 × 152,4 мм) Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Обрезает каждую страницу по напечатанной области, разворачивает её прямо и вписывает в лист без искажений. Одна этикетка на лист. diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 4165909995..dd169bc961 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -479,6 +479,7 @@ Couldn't take the snapshot: {e} Posnetka ni bilo mogoče usvariti: {e} Couldn't use {name}: {e} Datoteke {name} ni bilo mogoče uporabiti: {e} Couldn't write {name}: {e} Datoteke {name} ni bilo mogoče zapisati: {e} + the name is not a plain file name ime ni preprosto ime datoteke Couldn't write {path}: {e} Poti {path} ni bilo mogoče zapisati: {e} Counterclockwise 90 degrees 90 stopinj v levo Country/Region Država/regija @@ -518,6 +519,7 @@ Current page Trenutna stran Current page ({n}) Trenutna stran ({n}) Current page — type a page number or label (such as iv) and press Enter Trenutna stran — vnesite številko ali oznako strani (npr. iv) in pritisnite Enter + Zoom — type a percentage and press Enter Povečava – vnesite odstotek in pritisnite Enter Custom Po meri Custom scale: Merilo po meri: Custom stamps arrive on the web with file pickers for images Lastni žigi bodo v spletni različici na voljo z izbirniki slikovnih datotek @@ -2385,6 +2387,10 @@ animation effects Off Izklopljeni This document is trying to open a local file: Ta dokument poskuša odpreti lokalno datoteko: Move tag earlier Premakni oznako prej Move tag later Premakni oznako pozneje + +# Historical snapshot notice bar (View signed version / View revision). + You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Ogledate si podpisano različico dokumenta „{from}“ (revizija {rev}). To je posnetek: spremembe tukaj ne bodo vplivale na izvirnik. + You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Ogledate si revizijo {rev} dokumenta „{from}“. To je posnetek: spremembe tukaj ne bodo vplivale na izvirnik. Turn upside down (180°) Obrni na glavo (180°) 4 x 6 in label (101.6 x 152.4 mm) Nalepka 4 × 6 in (101,6 × 152,4 mm) Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Vsako stran obreže na natisnjeno območje, jo poravna pokonci in prilagodi listu brez raztegovanja. Ena nalepka na list. diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 07b716eac1..47aee36f8b 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -139,6 +139,7 @@ comment menu Edit సవరించు Next page తదుపరి పేజీ Previous page మునుపటి పేజీ Current page — type a page number or label (such as iv) and press Enter ప్రస్తుత పేజీ — పేజీ సంఖ్యను లేదా లేబుల్‌ను (iv వంటిది) టైప్ చేసి Enter నొక్కండి + Zoom — type a percentage and press Enter జూమ్ — శాతాన్ని టైప్ చేసి Enter నొక్కండి Welcome to PdfCraft PdfCraftకు స్వాగతం An open-source PDF workbench — local, private, and scriptable. ఓపెన్-సోర్స్ PDF వర్క్‌బెంచ్ — మీ కంప్యూటర్‌లోనే నడుస్తుంది, గోప్యంగా ఉంటుంది, స్క్రిప్ట్‌లతో ఆటోమేట్ చేయవచ్చు. Join the ArtCraft community ArtCraft కమ్యూనిటీలో చేరండి @@ -1623,6 +1624,7 @@ action wizard Start ప్రారంభించు Couldn't use {name}: {e} {name}ను ఉపయోగించడం సాధ్యం కాలేదు: {e} Couldn't extract pages: {e} పేజీలను వేరు తీయడం సాధ్యం కాలేదు: {e} Couldn't write {name}: {e} {name}ను రాయడం సాధ్యం కాలేదు: {e} + the name is not a plain file name పేరు సాధారణ ఫైల్ పేరు కాదు Wrote 1 file to {dir} {dir}లో 1 ఫైల్ సేవ్ చేయబడింది Wrote {n} files to {dir} {dir}లో {n} ఫైల్‌లు సేవ్ చేయబడ్డాయి No pages match those choices ఆ ఎంపికలకు సరిపోయే పేజీలు ఏవీ లేవు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 9fbd4dc966..95ca0b608c 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -120,6 +120,7 @@ Next page Sonraki sayfa Previous page Önceki sayfa Current page — type a page number or label (such as iv) and press Enter Geçerli sayfa — bir sayfa numarası veya etiketi (örneğin iv) yazıp Enter'a basın + Zoom — type a percentage and press Enter Yakınlaştırma — bir yüzde yazıp Enter tuşuna basın Welcome to PdfCraft PdfCraft'a Hoş Geldiniz An open-source PDF workbench — local, private, and scriptable. Açık kaynaklı bir PDF çalışma alanı — yerel, gizli ve komut dosyalarıyla yönetilebilir. Join the ArtCraft community ArtCraft topluluğuna katılın @@ -1603,6 +1604,7 @@ Couldn't use {name}: {e} {name} kullanılamadı: {e} Couldn't extract pages: {e} Sayfalar ayıklanamadı: {e} Couldn't write {name}: {e} {name} yazılamadı: {e} + the name is not a plain file name ad, basit bir dosya adı değil Wrote 1 file to {dir} 1 dosya {dir} konumuna yazıldı Wrote {n} files to {dir} {n} dosya {dir} konumuna yazıldı No pages match those choices Bu seçimlerle eşleşen sayfa yok @@ -2376,6 +2378,10 @@ animation effects Off Kapalı This document is trying to open a local file: Bu belge yerel bir dosya açmaya çalışıyor: Move tag earlier Etiketi daha erken taşı Move tag later Etiketi daha geç taşı + +# Historical snapshot notice bar (View signed version / View revision). + You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. “{from}” belgesinin imzalanmış sürümünü (düzeltme {rev}) görüntülüyorsunuz. Bu bir anlık görüntüdür: buradaki değişiklikler özgün belgeyi etkilemez. + You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. “{from}” belgesinin {rev}. düzeltmesini görüntülüyorsunuz. Bu bir anlık görüntüdür: buradaki değişiklikler özgün belgeyi etkilemez. Turn upside down (180°) Baş aşağı çevir (180°) 4 x 6 in label (101.6 x 152.4 mm) 4 × 6 inç etiket (101,6 × 152,4 mm) Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Her sayfayı basılı alanına göre kırpar, dik çevirir ve germeden sayfaya sığdırır. Sayfa başına bir etiket. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 53bc832f55..7993a06185 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -160,6 +160,7 @@ Next page Наступна сторінка Previous page Попередня сторінка Current page — type a page number or label (such as iv) and press Enter Поточна сторінка — введіть номер або позначку сторінки (наприклад iv) і натисніть Enter + Zoom — type a percentage and press Enter Масштаб — введіть відсоток і натисніть Enter Welcome to PdfCraft Вітаємо в PdfCraft An open-source PDF workbench — local, private, and scriptable. Відкритий інструментарій для PDF — локальний, приватний і з підтримкою сценаріїв. Join the ArtCraft community Приєднатися до спільноти ArtCraft @@ -1701,6 +1702,7 @@ Couldn't use {name}: {e} Не вдалося використати {name}: {e} Couldn't extract pages: {e} Не вдалося видобути сторінки: {e} Couldn't write {name}: {e} Не вдалося записати {name}: {e} + the name is not a plain file name назва не є простою назвою файлу Wrote 1 file to {dir} Записано 1 файл у {dir} Wrote {n} files to {dir} Записано файлів ({n}) у {dir} No pages match those choices Жодна сторінка не відповідає вибраним параметрам @@ -2402,6 +2404,10 @@ animation effects Off Вимкнено This document is trying to open a local file: Цей документ намагається відкрити локальний файл: Move tag earlier Перемістити тег раніше Move tag later Перемістити тег пізніше + +# Historical snapshot notice bar (View signed version / View revision). + You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Ви переглядаєте підписану версію документа «{from}» (редакція {rev}). Це знімок: зміни тут не вплинуть на оригінал. + You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Ви переглядаєте редакцію {rev} документа «{from}». Це знімок: зміни тут не вплинуть на оригінал. Turn upside down (180°) Перевернути (180°) 4 x 6 in label (101.6 x 152.4 mm) Етикетка 4 × 6 дюйма (101,6 × 152,4 мм) Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Обрізає кожну сторінку за надрукованою областю, розвертає її прямо та вписує в аркуш без спотворень. Одна етикетка на аркуш. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index a01b47f8a1..bae599bf6a 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1,7 +1,7 @@ # Vietnamese (vi) catalog. Columns, separated by a TAB: context source translation # # Clean-room translations from PdfCraft's English UI labels. Reviewed by a native -# Vietnamese-speaking contributor. PhotoCraft/VectorCraft Vietnamese catalogs were used only as +# Vietnamese-speaking contributor for the original catalog; later additions are marked below. PhotoCraft/VectorCraft Vietnamese catalogs were used only as # translation memory when the English source and its meaning in context matched. Product and # format names (PdfCraft, PDF, PDF/A, OCR, XFA, Word…) remain names where appropriate. # Plural rule: `plural_none` — every @plural row lists exactly one form. @@ -15,6 +15,7 @@ Zoom Thu phóng Actual size Kích thước thực Zoom to page level Thu phóng vừa trang + Zoom — type a percentage and press Enter Thu phóng — nhập tỷ lệ phần trăm rồi nhấn Enter Fit to width Vừa chiều rộng Display theme Chủ đề hiển thị Side panels Bảng bên @@ -1955,6 +1956,7 @@ comment menu Edit Sửa Couldn't use the photo: {e} Không thể dùng ảnh: {e} Couldn't use {name}: {e} Không thể dùng {name}: {e} Couldn't write {name}: {e} Không thể ghi {name}: {e} + the name is not a plain file name tên không phải là tên tệp đơn thuần Couldn't write {path}: {e} Không thể ghi {path}: {e} Create link Tạo liên kết Created 1 link Đã tạo 1 liên kết @@ -2615,3 +2617,73 @@ key Space Space Redact Image Xóa nội dung nhạy cảm của hình ảnh Create Link… Tạo liên kết… Recognize Text… Nhận dạng văn bản… + +# AI-assisted additions for concurrently merged UI features (2026-10-15). +# These additions have not had separate native-speaker review. + Characters Ký tự + Characters without spaces Ký tự không tính dấu cách + Counting words… {n} pages read Đang đếm từ… đã đọc {n} trang + Crop Image Xén hình ảnh + Current page only Chỉ trang hiện tại + DN: Tên phân biệt: + Date: Ngày: + Digitally signed by Được ký số bởi + Exclude image/OCR text Loại trừ văn bản trong ảnh/OCR + Exclude tagged code blocks Loại trừ khối mã đã gắn thẻ + Exclude tagged tables Loại trừ bảng đã gắn thẻ + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. Việc loại trừ dựa trên thẻ PDF. Bảng và mã chưa gắn thẻ vẫn được tính. Loại trừ hình ảnh cũng loại bỏ văn bản ẩn. + Fill colour: Màu tô: + Font size Cỡ chữ + Full Screen Toàn màn hình + Location: Vị trí: + Make properties default Đặt thuộc tính làm mặc định + Mark as artifact Đánh dấu là thành phần trang trí + Mark content as artifact Đánh dấu nội dung là thành phần trang trí + Merge image fragments Gộp các mảnh hình ảnh + Mobile Di động + New file only Chỉ tệp mới + New file: {name} Tệp mới: {name} + Next tab Thẻ tiếp theo + No printers were found. Check that the printer is installed and the print spooler is running, then open Print again. Không tìm thấy máy in. Kiểm tra xem máy in đã được cài đặt và dịch vụ hàng đợi in đang chạy, rồi mở lại hộp thoại In. + Not now Để sau + Old file: {name} Tệp cũ: {name} + Opacity: Độ mờ: + Open in Full Screen mode? Mở ở chế độ toàn màn hình? + Optimizing… merging image fragments Đang tối ưu hóa… gộp các mảnh hình ảnh + Outline colour: Màu đường viền: + Page properties Thuộc tính trang + Pages counted Số trang đã đếm + Pages selected Số trang đã chọn + Previous tab Thẻ trước + Print as image In dưới dạng hình ảnh + Reason: Lý do: + Redaction mark appearance Hình thức dấu xóa nội dung nhạy cảm + Remove Crop Bỏ xén + Remove this leaf tag from reading order and mark its direct content as an artifact Xóa thẻ lá này khỏi thứ tự đọc và đánh dấu nội dung trực tiếp của nó là thành phần trang trí + Resolution: Độ phân giải: + Save anyway Vẫn lưu + Save over the changed file? Lưu đè lên tệp đã thay đổi? + Scroll speed (mouse wheel and arrow keys) Tốc độ cuộn (con lăn chuột và phím mũi tên) + Set page transitions Đặt hiệu ứng chuyển trang + Shown while the pointer is over a mark. Hiển thị khi con trỏ nằm trên một dấu. + Side by side Cạnh nhau + Some content could not be classified safely and was kept in the count. Một số nội dung không thể được phân loại an toàn và vẫn được tính. + Some pages contain no countable text. Scanned images need OCR first. Một số trang không có văn bản có thể đếm. Ảnh quét cần được nhận dạng văn bản (OCR) trước. + Standard Tiêu chuẩn + The labels are in English: the signature's font can't draw them in this language. Các nhãn được hiển thị bằng tiếng Anh: phông chữ của chữ ký không thể hiển thị chúng bằng ngôn ngữ này. + The password must have at least 8 characters. Mật khẩu phải có ít nhất 8 ký tự. + This document asks to open in Full Screen mode: the page fills the screen and nothing else shows. Press Esc at any time to leave it. Tài liệu này yêu cầu mở ở chế độ toàn màn hình: trang chiếm toàn bộ màn hình và không hiển thị nội dung nào khác. Nhấn Esc bất kỳ lúc nào để thoát. + This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Tệp này đã thay đổi trên đĩa. Tải lại sẽ hiển thị phiên bản mới và bỏ các thay đổi chưa lưu của bạn; Lưu sẽ thay thế tệp bằng phiên bản này. + Trim the picture with its handles; Enter or Done applies Xén hình bằng các tay nắm; nhấn Enter hoặc Xong để áp dụng + Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Dùng khi máy in bỏ sót văn bản, chuyển sắc hoặc độ trong suốt. Bản in không còn chứa văn bản có thể tìm kiếm; với In thang độ xám, hình ảnh sẽ có màu xám. + What takes up the space in this file, by kind of content. Dung lượng mà từng loại nội dung chiếm trong tệp này. + Word count Số từ + Word counting stopped unexpectedly. Việc đếm từ đã dừng đột ngột. + Word count… Đếm từ… + Words Từ + “{name}” changed on disk after it was opened or last saved. Saving now replaces that newer version with this one. “{name}” đã thay đổi trên đĩa sau khi được mở hoặc lưu lần cuối. Lưu lúc này sẽ thay thế phiên bản mới hơn đó bằng phiên bản này. +@id file.word_count Đếm từ… + +# Historical snapshot notice bar (View signed version / View revision). + You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Bạn đang xem phiên bản đã ký của “{from}” (bản sửa đổi {rev}). Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc. + You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Bạn đang xem bản sửa đổi {rev} của “{from}”. Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 3274ff4731..1ddecc655f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1130,6 +1130,7 @@ Next page 下一页 Previous page 上一页 Current page — type a page number or label (such as iv) and press Enter 当前页 — 输入页码或标签(如 iv)后按 Enter + Zoom — type a percentage and press Enter 缩放 — 输入百分比并按 Enter Open a PDF ({key}) 打开 PDF ({key}) Dark gray theme 深灰色主题 Light theme 浅色主题 @@ -1671,6 +1672,7 @@ Couldn't use {name}: {e} 无法使用{name}:{e} Couldn't extract pages: {e} 无法提取页面:{e} Couldn't write {name}: {e} 无法写入{name}:{e} + the name is not a plain file name 名称不是单纯的文件名 Wrote 1 file to {dir} 已将 1 个文件写入{dir} Wrote {n} files to {dir} 已将{n}个文件写入{dir} No pages match those choices 所选条件没有匹配的页面 @@ -2344,6 +2346,10 @@ animation effects Off 关 This document is trying to open a local file: 此文档正尝试打开本地文件: Move tag earlier 将标签前移 Move tag later 将标签后移 + +# Historical snapshot notice bar (View signed version / View revision). + You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. 您正在查看“{from}”的已签名版本(修订版本 {rev})。这是快照:在此处所做的更改不会影响原始文档。 + You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. 您正在查看“{from}”的修订版本 {rev}。这是快照:在此处所做的更改不会影响原始文档。 Turn upside down (180°) 上下颠倒 (180°) 4 x 6 in label (101.6 x 152.4 mm) 4 × 6 英寸标签 (101.6 × 152.4 毫米) Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. 将每页裁切到其打印区域,转正后不拉伸地适合纸张。每张纸一个标签。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 0ea252e56c..bb5ec3649b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -109,7 +109,7 @@ Sanitize 清理 Mark all 全部標記 Save changes to “{name}” before closing? 在關閉前儲存對「{name}」的更改? - Your changes will be lost if you don't save them. 如果不儲存,更改將會丟失。 + Your changes will be lost if you don't save them. 如果不儲存,更改將會遺失。 Password required 需要密碼 “{name}” is protected. Enter a password to open it. 「{name}」受保護。請輸入密碼以開啟。 Password 密碼 @@ -190,7 +190,7 @@ Discard User Data 捨棄使用者資料 Clean Up 清理 Color Images 彩色圖片 - Grayscale Images 灰度圖片 + Grayscale Images 灰階圖片 Bicubic downsampling to 雙三次降取樣至 for images above (高於此解析度的圖片) Compression 壓縮 @@ -303,8 +303,8 @@ Negative number style: 負數樣式: Format: 格式: Custom: 自訂: - Zip Code 郵政編碼 - Zip Code + 4 郵政編碼 + 4 + Zip Code 郵遞區號 + Zip Code + 4 郵遞區號 + 4 Phone Number 電話號碼 Social Security Number 社會安全號碼 Arbitrary Mask 任意掩碼 @@ -503,7 +503,7 @@ Continue 繼續 Configure a Digital ID for Signing 設定簽署用的數位 ID Use a Digital ID from a file 使用檔案中的數位 ID - Create a new Digital ID (self-signed, saved to a password-protected file) 建立新數位 ID(自簽名,儲存到受密碼保護的檔案) + Create a new Digital ID (self-signed, saved to a password-protected file) 建立新數位 ID(自我簽署,儲存到受密碼保護的檔案) Name 名稱 Organizational Unit 組織單位 Organization Name 組織名稱 @@ -511,7 +511,7 @@ Country/Region 國家/地區 Key Algorithm 金鑰演算法 Confirm Password 確認密碼 - Valid for 5 years, for digital signatures. Self-signed IDs are usually not trusted by others. 有效期 5 年,用於數位簽章。自簽名 ID 通常不受他人信任。 + Valid for 5 years, for digital signatures. Self-signed IDs are usually not trusted by others. 有效期 5 年,用於數位簽章。自我簽署 ID 通常不受他人信任。 File (.p12, .pfx) 檔案 (.p12, .pfx) Digital ID 數位 ID Browse… 瀏覽… @@ -566,7 +566,7 @@ Public key 公鑰 Key usage 金鑰用途 Not present 不存在 - Self-signed 自簽名 + Self-signed 自我簽署 Edit Action 編輯操作 New Action 新建操作 Action name: 操作名稱: @@ -772,9 +772,9 @@ Social Security Numbers 社會安全號碼 Dates 日期 Text in images isn't found; recognise text (OCR) first. 圖片中的文字無法找到;請先識別文字(OCR)。 - No matches. 無匹配。 + No matches. 無相符。 Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth. 此處未顯示部分書籤:面板列出的書籤數量和巢狀深度有限。 - {n} match(es) marked for redaction. 已標記 {n} 個匹配以待遮蔽內容。 + {n} match(es) marked for redaction. 已標記 {n} 個相符以待遮蔽內容。 Multiple words or phrases 多個單字或片語 One word or phrase per line 每行一個單字或片語 Import list… 匯入清單… @@ -787,7 +787,6 @@ Redaction code: 遮蔽代碼: Font: 字型: Font size: 字型大小: - Font size: 字號: Font size 字號 Auto-size text to fit redaction region 自動調整文字大小以適應遮蔽內容區域 Font colour: 字型顏色: @@ -816,7 +815,7 @@ {name} (default) {name}(預設) Copies: 份數: Collate 逐份列印 - Print in grayscale 灰度列印 + Print in grayscale 灰階列印 Two-sided: 雙面: Off 關 Flip on long edge 沿長邊翻轉 @@ -1075,6 +1074,7 @@ Next page 下一頁 Previous page 上一頁 Current page — type a page number or label (such as iv) and press Enter 目前頁面:輸入頁碼或頁面標籤(例如 iv),再按 Enter + Zoom — type a percentage and press Enter 縮放 — 輸入百分比並按 Enter Open a PDF ({key}) 開啟 PDF({key}) Dark gray theme 深灰色主題 Light theme 淺色主題 @@ -1175,7 +1175,7 @@ Add a comment 新增註解 No comments yet. 尚無註解。 Pick a tool in the quick bar, or type above. 在快捷欄中選擇工具,或在上方輸入。 - No comments match. 沒有匹配的註解。 + No comments match. 沒有相符的註解。 Unknown author 未知作者 No colour 無顏色 Remove checkmark 刪除勾選標記 @@ -1232,13 +1232,13 @@ Open in a new tab 在新索引標籤中開啟 This document has no pages. 本文件沒有頁面。 Find text 尋找文字 - No matches 無匹配 + No matches 無相符 Searching… {s}/{p} 正在搜尋… {s}/{p} {c} of {n}{more} {c} / {n}{more} Previous ({key}) 上一個 ({key}) Next ({key}) 下一個 ({key}) Find options 尋找選項 - Whole words only 僅全字匹配 + Whole words only 僅全字相符 Case-sensitive 區分大小寫 Close (Esc) 關閉 (Esc) Align 對齊 @@ -1303,7 +1303,7 @@ · password-protected · 受密碼保護 Recover 恢復 Discard 放棄 - Next / previous match 下一個/上一個符合項目 + Next / previous match 下一個/上一個相符項目 Copy selected text 複製所選文字 Select a word 選取一個詞 Clear selection / close find 清除選取/關閉尋找 @@ -1322,7 +1322,6 @@ Sort comments by: 註解排序依據: Create PDF Comment Summary 建立 PDF 註解摘要 Revert to the previously saved version of “{name}”? Changes since then can't be undone afterwards. 還原到「{name}」的上次儲存版本?此後的更改將無法復原。 - Revert to the previously saved version of “{name}”? Changes since then can't be undone afterwards. 還原到“{name}”的上次儲存版本?此後的更改將無法復原。 Save over the changed file? 覆寫已變更的檔案? “{name}” changed on disk after it was opened or last saved. Saving now replaces that newer version with this one. 「{name}」在開啟或上次儲存後已在磁碟上變更。現在儲存會以此版本取代那個較新的版本。 Save anyway 仍要儲存 @@ -1617,9 +1616,10 @@ Couldn't use {name}: {e} 無法使用{name}:{e} Couldn't extract pages: {e} 無法提取頁面:{e} Couldn't write {name}: {e} 無法寫入{name}:{e} + the name is not a plain file name 名稱不是單純的檔案名稱 Wrote 1 file to {dir} 已將 1 個檔案寫入{dir} Wrote {n} files to {dir} 已將{n}個檔案寫入{dir} - No pages match those choices 所選條件沒有匹配的頁面 + No pages match those choices 所選條件沒有相符的頁面 Couldn't split: {e} 無法拆分:{e} Couldn't summarize comments: {e} 無法彙總註解:{e} Couldn't open the result: {e} 無法開啟結果:{e} @@ -1694,8 +1694,8 @@ What word or phrase would you like to search for? 您想搜尋哪個詞或短語? Searching… {s} of {p} pages 正在搜尋… {s}/{p} 頁 (searching… {s} of {p} pages) (正在搜尋… {s}/{p} 頁) - 1 instance 1 個匹配 - {n} instances {n} 個匹配 + 1 instance 1 個相符 + {n} instances {n} 個相符 Go to this result 轉到此結果 Highlight 突出顯示 Draw 繪製 @@ -1744,7 +1744,7 @@ Tool 工具 Command 指令 Search tools and commands… 搜尋工具與指令… - No matching tools 找不到符合的工具 + No matching tools 找不到相符的工具 All tools 所有工具 Read 閱讀 Convert 轉換 diff --git a/crates/ui-egui/src/image_pick.rs b/crates/ui-egui/src/image_pick.rs index a05aefe2d2..53e4bdf158 100644 --- a/crates/ui-egui/src/image_pick.rs +++ b/crates/ui-egui/src/image_pick.rs @@ -9,12 +9,14 @@ use crate::PdfCraftApp; use crate::canvas::{DocView, PageXform}; use pdfcraft_render::DocInfo; -/// The selected picture: page, index in `page_images` and its box in user space. +/// The selected picture: page, index in `page_images` and its box in user space, and the +/// document's edit generation when it was picked (any later edit may have removed or renumbered it). #[derive(Clone, Copy, Debug, PartialEq)] pub struct PickedImage { pub page: usize, pub index: usize, pub rect: [f64; 4], + pub generation: u64, } const BLUE: Color32 = Color32::from_rgb(0x3A, 0x7B, 0xF0); @@ -43,8 +45,18 @@ pub(crate) fn page_input( let (vx, vy) = xf.screen_to_view(p); let u = info_page.view_to_user(vx, vy); let at = [f64::from(u[0]), f64::from(u[1])]; + let generation = doc.edit_generation(); view.picked_image = - doc.page_image_at(page, at).and_then(|index| doc.page_images(page).get(index).map(|i| PickedImage { page, index, rect: i.rect })); + doc.page_image_at(page, at).and_then(|index| doc.page_images(page).get(index).map(|i| PickedImage { page, index, rect: i.rect, generation })); +} + +/// Forget the selected picture once the document has been edited since it was picked: Edit text & +/// images (or Undo) may have deleted or moved it, and its highlight would stay behind on an empty +/// spot of the page. +pub(crate) fn drop_stale(doc: &pdfcraft_engine::Document, view: &mut DocView) { + if view.picked_image.is_some_and(|p| p.generation != doc.edit_generation()) { + view.picked_image = None; + } } /// The selected picture's highlight on page `page`. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ad03936a38..a6c4f34561 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -139,9 +139,12 @@ mod tab_context; pub mod theme; pub mod ui_scale; pub mod updates; +mod wheel_boost; mod wheel_pager; mod widgets; pub mod window_controls; +#[cfg(not(target_arch = "wasm32"))] +pub mod window_state; #[cfg(all(test, not(target_arch = "wasm32")))] mod open_worker_tests; @@ -744,6 +747,9 @@ pub struct PdfCraftApp { dialog_epoch: u64, /// The egui context, for commands that change window or theme state. ctx: Option, + /// The window's size and place kept across a minimized close (desktop). + #[cfg(not(target_arch = "wasm32"))] + pub window_state: window_state::WindowState, styled: bool, fonts_ready: bool, /// The CJK fallback order the installed fonts follow (see `theme::installed_font_definitions`). @@ -1033,6 +1039,8 @@ impl PdfCraftApp { dialog_seen: None, dialog_epoch: 0, ctx: None, + #[cfg(not(target_arch = "wasm32"))] + window_state: Default::default(), styled: false, fonts_ready: false, fonts_cjk: theme::CjkPreference::Default, @@ -1520,7 +1528,8 @@ impl PdfCraftApp { match (data, open) { (Err(e), _) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]), (Ok(bytes), true) => { - if let Err(e) = self.open_bytes(&att.name, None, bytes) { + // The tab name later names files written from the tab (Split, Extract pages). + if let Err(e) = self.open_bytes(&crate::files::attachment_file_name(&att.name), None, bytes) { self.notify_fmt("Couldn't open {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]); } } @@ -1540,6 +1549,15 @@ impl PdfCraftApp { } } + /// [`Self::attachment_action`] for the file of the attachment comment at (page, index). + pub fn comment_attachment_action(&mut self, doc: DocId, page: usize, index: usize, open: bool) { + let source = pdfcraft_render::AttachmentSource::Annotation { page, index }; + // The menus offer the items only for a listed file, so a miss is a comment gone since. + if let Some(i) = self.session.get(doc).and_then(|d| d.info.attachments.iter().position(|a| a.source == source)) { + self.attachment_action(doc, i, open); + } + } + /// Whether the app draws the window's title bar, buttons and resize edges: the desktop leaves /// the frame to the app and Use system title bar is off. pub fn draws_window_frame(&self) -> bool { @@ -2152,6 +2170,11 @@ impl PdfCraftApp { } else if matches!(self.quick_tool, QuickTool::EditText | QuickTool::AddText) { self.put_down_edit_text(); } + // Read mode hides the quick toolbar, so no other tool may stay active there. Opening a + // document with Read as the default mode comes here without `switch_mode`. + if mode == Mode::Read { + self.quick_tool = QuickTool::Select; + } } /// Back to the Select tool from an editing tool. A paragraph still open in the editor is @@ -2169,6 +2192,7 @@ impl PdfCraftApp { /// Switches workspace because the user chose to (mode bar, Read mode command). Entering Read /// mode closes the right panel, as Acrobat's Read mode hides its panes, and leaving it reopens /// that panel. The rail stays in Read mode, and its buttons open panels there too (#932). + /// Read mode hides the quick toolbar, so entering it also puts the current tool down. pub(crate) fn switch_mode(&mut self, mode: Mode, select_tools: bool) { let was_read = self.mode == Mode::Read; if select_tools { @@ -2178,6 +2202,12 @@ impl PdfCraftApp { } if mode == Mode::Read && !was_read { self.right_before_read = self.right.take(); + // An editing tool is put down so its open paragraph is applied, as `select_mode` does. + if matches!(self.quick_tool, QuickTool::EditText | QuickTool::AddText) { + self.put_down_edit_text(); + } else { + self.quick_tool = QuickTool::Select; + } } else if mode != Mode::Read && was_read { let before = self.right_before_read.take(); if self.right.is_none() { @@ -2437,7 +2467,7 @@ impl PdfCraftApp { } if let Ok(pems) = serde_json::from_value::>(v["trusted"].clone()) { let certs = pems.iter().filter_map(|p| pdfcraft_engine::sign::x509::load_certificates(p.as_bytes()).ok()).flatten().collect(); - self.session.set_trusted_certificates(certs); + self.apply_trusted_certificates(certs); } } @@ -2555,7 +2585,7 @@ impl PdfCraftApp { self.palette_query = value.to_string(); } ("page", Some(v)) => v.go_to_page(value.parse::().map_err(|e| e.to_string())?.saturating_sub(1)), - ("zoom", Some(v)) => v.set_zoom(value.trim_end_matches('%').parse::().map_err(|e| e.to_string())? / 100.0), + ("zoom", Some(v)) => v.set_zoom(DocView::parse_zoom_percent(value).ok_or("zoom must be a percentage above 0")?), ("layout", Some(v)) => { let (layout, continuous) = canvas::parse_display_token(value).ok_or("layout must be single, continuous, two-up or two-page")?; v.set_display(layout, continuous); @@ -2910,11 +2940,19 @@ impl eframe::App for PdfCraftApp { } fn save(&mut self, storage: &mut dyn eframe::Storage) { + // eframe has just stored the window as it is now: not while it is minimized. + #[cfg(not(target_arch = "wasm32"))] + { + let minimized = self.ctx.as_ref().is_some_and(|c| c.input(|i| i.viewport().minimized == Some(true))); + self.window_state.keep(storage, minimized); + } storage.set_string("pdfcraft", self.persist()); } fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.ctx = Some(ctx.clone()); + #[cfg(not(target_arch = "wasm32"))] + self.window_state.repair(ctx); self.dialog_epoch(); // Notices raised outside `ui` (opened files, OS events, the control channel) translate too. let lang = i18n::Lang::from_pref(&self.language); @@ -3086,8 +3124,7 @@ impl eframe::App for PdfCraftApp { // The window shows the active document's name (or title, if it asks for that). let title = self .active - .and_then(|i| self.session.get(self.views[i].id)) - .map(|d| d.display_name()) + .and_then(|i| crate::canvas::tab_label(self, i)) .or_else(|| self.combine_showing().then(|| tl!("Combine files").to_owned())) .map_or_else(|| "PdfCraft".to_owned(), |name| format!("{name} — PdfCraft")); if title != self.window_title { diff --git a/crates/ui-egui/src/marks_ui.rs b/crates/ui-egui/src/marks_ui.rs index 4a60c40a6e..d91a69f937 100644 --- a/crates/ui-egui/src/marks_ui.rs +++ b/crates/ui-egui/src/marks_ui.rs @@ -28,8 +28,10 @@ pub struct PageRange { } impl Default for PageRange { + /// From the first page to the last: `to` starts past any page count, and the range field + /// shows (and clamps it to) the document's last page. fn default() -> Self { - Self { all: true, from: 1, to: 1, subset: Subset::All } + Self { all: true, from: 1, to: usize::MAX, subset: Subset::All } } } @@ -53,9 +55,6 @@ impl PageRange { widgets::radio_value(ui, &mut self.all, false, tl!("Pages from")); ui.add_enabled(!self.all, egui::DragValue::new(&mut self.from).range(1..=count.max(1))); ui.label(tl!("to")); - if self.to == 1 && self.from == 1 { - self.to = count.max(1); - } ui.add_enabled(!self.all, egui::DragValue::new(&mut self.to).range(1..=count.max(1))); ui.label(tl!("Subset")); let label = match self.subset { @@ -519,6 +518,11 @@ mod tests { assert_eq!(r.pages(6), [1, 3, 5]); let r = PageRange { all: true, subset: Subset::Odd, ..PageRange::default() }; assert_eq!(r.pages(5), [0, 2, 4]); + // Not all pages: by default up to the last one; a single page is a range too. + let r = PageRange { all: false, ..PageRange::default() }; + assert_eq!(r.pages(4), [0, 1, 2, 3]); + let r = PageRange { all: false, from: 1, to: 1, subset: Subset::All }; + assert_eq!(r.pages(38), [0]); } #[test] diff --git a/crates/ui-egui/src/open_worker_tests.rs b/crates/ui-egui/src/open_worker_tests.rs index 75ce823141..ebdd45054b 100644 --- a/crates/ui-egui/src/open_worker_tests.rs +++ b/crates/ui-egui/src/open_worker_tests.rs @@ -608,3 +608,65 @@ fn tool_picker_keeps_picked_recipient_when_other_open_is_ready() { } } } + +#[test] +#[allow(clippy::expect_used, clippy::unwrap_used)] +fn inline_tool_open_keeps_its_command_for_pdf_conversion_and_password_retry() { + for (name, bytes, encrypted) in + [("plain.pdf", fixture(1), false), ("converted.txt", b"Text to export".to_vec(), false), ("locked.pdf", encrypted_tool_fixture(), true)] + { + let path = std::env::temp_dir().join(format!("pdfcraft-inline-tool-{}-{name}", std::process::id())); + std::fs::write(&path, bytes).expect("write selected file"); + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").expect("language preference"); + assert!(app.ctx.is_none(), "this exercises the no-context inline path"); + app.open_path_then(&path.to_string_lossy(), Some("export.text")); + if encrypted { + assert!(app.dialog.is_none()); + assert_eq!(app.password_prompt.as_ref().expect("password prompt").then, Some("export.text")); + app.submit_password(Some("wrong".into())); + assert!(app.password_prompt.as_ref().expect("wrong-password prompt").error.is_some()); + assert!(app.dialog.is_none()); + app.submit_password(Some("tool-password".into())); + } + assert_eq!(app.views.len(), 1); + assert_eq!(app.active, Some(0)); + assert!(matches!(app.dialog, Some(crate::Dialog::Export(_))), "the inline {name} retains its export command"); + let _ = std::fs::remove_file(path); + } +} + +#[test] +#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] +fn startup_files_wait_for_absolute_link_consent_and_preparation() { + let (mut h, _) = harness(); + let source = h.state().views[0].id; + let path = std::env::temp_dir().join(format!("pdfcraft-open-consented-startup-{}.pdf", std::process::id())); + std::fs::write(&path, fixture(2)).expect("write linked PDF"); + h.state_mut().open_remote_link(source, path.to_string_lossy().into_owned(), pdfcraft_render::RemoteDestination::Default); + h.state().startup_inbox.lock().expect("startup queue").push(("startup.pdf".into(), fixture(1))); + h.step(); + assert!(h.state().pending_remote_link.is_some()); + assert_eq!(h.state().startup_inbox.lock().expect("startup queue").len(), 1); + assert_eq!(h.state().views.len(), 1, "startup cannot replace the consent interaction"); + + let (gate, started, release, finished) = open_worker::prepare_gate(); + h.state_mut().open_prepare_gate = Some(gate); + h.state_mut().resolve_pending_remote_link(true); + started.recv_timeout(Duration::from_secs(5)).expect("worker reached prepare barrier"); + h.step(); + let queued = h.state().startup_inbox.lock().expect("startup queue").len(); + let visible = h.state().views.len(); + // Always release before asserting: a failure must not strand the worker at the barrier. + release.send(()).expect("release prepare barrier"); + finished.recv_timeout(Duration::from_secs(5)).expect("worker finished preparation"); + assert_eq!(queued, 1, "startup bytes wait while the approved linked PDF prepares"); + assert_eq!(visible, 1); + h.step(); + let app = h.state(); + assert_eq!(app.views.len(), 3); + assert_eq!(app.session.get(app.views[1].id).expect("linked document").path.as_deref(), path.to_str()); + assert_eq!(app.session.get(app.views[2].id).expect("startup document").name, "startup.pdf"); + assert_eq!(app.active, Some(1), "the user's approved navigation keeps focus"); + let _ = std::fs::remove_file(path); +} diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 92071c2493..0f2c601497 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -418,17 +418,7 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { } changed |= crate::edit_text_ui::extras_panel(ui, &mut ed.extras); if changed { - let edit = pdfcraft_engine::Edit::EditTextBlock { page: ed.page, block: ed.block, text: ed.text.clone(), style: ed.style() }; - if app.apply_edit(edit) { - ed.applied(); - if let Some(doc) = app.session.get(app.views[i].id) - && let Some(block) = doc.text_blocks(ed.page).get(ed.block) - { - ed.refresh_source(block, doc.text_lines(ed.page)); - ed.fonts = crate::edit_text_ui::install_source_fonts(ui.ctx(), doc, ed.page); - } - } - app.views[i].line_editor = Some(ed); + app.apply_line_format(i, ed, Some(ui.ctx())); } ui.add_space(6.0); ui.separator(); @@ -482,6 +472,25 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { ui.separator(); } +impl PdfCraftApp { + /// Apply the open paragraph's formatting as one change; the paragraph stays open. + pub(crate) fn apply_line_format(&mut self, index: usize, mut ed: crate::edit_text_ui::LineEditor, ctx: Option<&egui::Context>) { + let edit = pdfcraft_engine::Edit::EditTextBlock { page: ed.page, block: ed.block, text: ed.text.clone(), style: ed.style() }; + if self.apply_edit(edit) { + ed.applied(); + if let Some(doc) = self.session.get(self.views[index].id) + && let Some(block) = doc.text_blocks(ed.page).get(ed.block) + { + ed.refresh_source(block, doc.text_lines(ed.page)); + if let Some(ctx) = ctx { + ed.fonts = crate::edit_text_ui::install_source_fonts(ctx, doc, ed.page); + } + } + } + self.views[index].line_editor = Some(ed); + } +} + // ───────────────────────────────────────────────────────────────────────────── right panels pub(crate) enum Nav { @@ -778,20 +787,22 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { app.cert_viewer = Some(crate::sign_ui::CertViewer { chain, selected: 0, tab: crate::sign_ui::CertTab::Summary }); app.dialog = Some(crate::Dialog::CertificateViewer); } - Some(crate::sign_ui::PanelAction::ViewSigned(len)) => app.view_signed_version(len), + Some(crate::sign_ui::PanelAction::ViewSigned { len, revision }) => app.view_signed_version(len, revision), Some(crate::sign_ui::PanelAction::Sign(field)) => { let page = app.views[index].current; app.start_signing(page, None, Some(field), None); } Some(crate::sign_ui::PanelAction::ExportCertificate(c)) => { - let pem = crate::sign_ui::certificate_pem(&c); - app.write_files(&[(format!("{}.cer", c.display_name()), std::sync::Arc::new(pem.into_bytes()))], "Export certificate"); + app.export_certificate(&c); } None => {} } if let Some((p, i)) = app.views.get_mut(index).and_then(|v| v.comments.default_request.take()) { app.make_comment_default(p, i); } + if let Some((p, i, open)) = app.views.get_mut(index).and_then(|v| v.comments.attachment_request.take()) { + app.comment_attachment_action(id, p, i, open); + } if let Some((p, i)) = app.views.get_mut(index).and_then(|v| v.comments.props_request.take()) { app.open_comment_props(p, i); } @@ -1053,6 +1064,11 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie { delete_pages(view, info.pages.len()); } + // A new current page scrolls its thumbnail into view, as little as it takes (Acrobat), but + // not while thumbnails are dragged: then the change counts as seen. A wheel turned or a + // scroll bar dragged by the user already wins over a scroll to a place in egui. + let follow = view.panel_followed != Some(view.current) && view.panel_drag.is_none(); + view.panel_followed = Some(view.current); let w = (ui.available_width() - 40.0).min(150.0); let count = info.pages.len(); let mut rows: Vec = Vec::with_capacity(info.pages.len()); @@ -1097,7 +1113,14 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie Stroke::new(if selected { 2.0 } else { 1.0 }, if selected { t.accent } else { t.border }), egui::StrokeKind::Outside, ); - ui.label(egui::RichText::new(&p.label).font(theme::medium(12.0)).color(if selected || picked { t.accent_text } else { t.text_muted })); + let label = ui.label(egui::RichText::new(&p.label).font(theme::medium(12.0)).color(if selected || picked { + t.accent_text + } else { + t.text_muted + })); + if follow && selected { + ui.scroll_to_rect(rect.union(label.rect), None); + } let resp = theme::hand(resp); if resp.clicked() { let m = ui.input(|i| i.modifiers); @@ -1105,6 +1128,8 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie view.click_page(i, m, true); } else { view.clear_page_selection(Some(i)); + // A page clicked here is in view already: the panel stays put. + view.panel_followed = Some(i); *nav = Some(Nav::Page(i)); } } diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index 386c42e872..a46611274d 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -734,6 +734,7 @@ pub struct FieldDraft { pub tab: FieldTab, pub name: String, pub tooltip: String, + pub caption: String, pub read_only: bool, pub required: bool, pub multiline: bool, @@ -805,6 +806,7 @@ impl FieldDraft { tab: FieldTab::General, name: f.name.rsplit('.').next().unwrap_or(&f.name).to_string(), tooltip: f.tooltip.clone().unwrap_or_default(), + caption: f.widgets.get(widget).and_then(|w| w.caption.clone()).unwrap_or_default(), read_only: f.read_only(), required: f.has(pdfcraft_engine::field_flags::REQUIRED), multiline: f.has(pdfcraft_engine::field_flags::MULTILINE), @@ -848,7 +850,7 @@ impl FieldDraft { fn tabs(&self) -> Vec<(FieldTab, &'static str)> { let mut t = vec![(FieldTab::General, "General"), (FieldTab::Appearance, "Appearance"), (FieldTab::Position, "Position")]; - if !matches!(self.kind, FormFieldKind::PushButton | FormFieldKind::Signature) { + if self.kind != FormFieldKind::Signature { t.push((FieldTab::Options, "Options")); } if matches!(self.kind, FormFieldKind::Text | FormFieldKind::Combo) { @@ -865,6 +867,7 @@ impl FieldDraft { let p = FieldProps { name: (self.name.trim() != o.name).then(|| self.name.trim().to_string()), tooltip: (self.tooltip != o.tooltip).then(|| self.tooltip.clone()), + caption: (self.caption != o.caption).then(|| (self.widget, self.caption.clone())), read_only: ch(self.read_only, o.read_only), required: ch(self.required, o.required), multiline: ch(self.multiline, o.multiline), @@ -1087,6 +1090,12 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke FieldTab::Calculate => calculate_tab(ui, d, t), FieldTab::Actions => actions_tab(ui, d, t), FieldTab::Options => match d.kind { + FormFieldKind::PushButton => { + ui.horizontal(|ui| { + let l = ui.label(tl!("Label")); + ui.add(crate::widgets::line(&mut d.caption).desired_width(340.0)).labelled_by(l.id); + }); + } FormFieldKind::Text => { use pdfcraft_engine::field_flags as ff; egui::Grid::new("text-options").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { diff --git a/crates/ui-egui/src/print_ui.rs b/crates/ui-egui/src/print_ui.rs index 4e0f065885..f4e039d512 100644 --- a/crates/ui-egui/src/print_ui.rs +++ b/crates/ui-egui/src/print_ui.rs @@ -1103,7 +1103,7 @@ pub(crate) fn body( ); // Advanced (Acrobat's Advanced Print Setup): print as image. Folded unless it is on. ui.add_space(6.0); - egui::CollapsingHeader::new(egui::RichText::new(tl!("Advanced").to_uppercase()).font(theme::semibold(10.5)).color(t.text_faint)) + egui::CollapsingHeader::new(egui::RichText::new(crate::i18n::upper_label("Advanced")).font(theme::semibold(10.5)).color(t.text_faint)) .id_salt("print-advanced") .default_open(d.as_image) .show(ui, |ui| { diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs index 4f1f1d8e8e..3d5f78ad1b 100644 --- a/crates/ui-egui/src/recovery.rs +++ b/crates/ui-egui/src/recovery.rs @@ -9,6 +9,9 @@ //! Encrypted documents are stored encrypted (the working file is), so recovery never writes //! plaintext of a protected document to disk. The web build has no recovery store yet. //! +//! On Unix the folder is owner-only (`0o700`), so other accounts on the machine can't read the +//! snapshots of documents that aren't encrypted. +//! //! A running instance holds an exclusively locked `instance--.lock` in the folder and //! names it in the snapshots it writes, so a second instance can tell live autosaves from a //! crash's leftovers and neither offers nor discards them (#2166). The OS releases the lock @@ -75,12 +78,31 @@ impl RecoveryStore { Self { dir: dir.into(), lock: Arc::default() } } + /// Create the folder if it is missing. On Unix only the owner may open it: snapshots are + /// unsaved copies of open documents, and with the default mode other accounts on the + /// machine could read them. A folder an earlier version made is tightened too. A volume + /// that can't change modes (FAT, some network shares) works as before, with one warning in + /// the log. + fn create_dir(&self) -> std::io::Result<()> { + std::fs::create_dir_all(&self.dir)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let open_to_others = std::fs::metadata(&self.dir).is_ok_and(|m| m.permissions().mode() & 0o077 != 0); + if open_to_others && let Err(e) = std::fs::set_permissions(&self.dir, std::fs::Permissions::from_mode(0o700)) { + static WARNED: std::sync::Once = std::sync::Once::new(); + WARNED.call_once(|| log::warn!("recovery folder {} can't be made owner-only: {e}", self.dir.display())); + } + } + Ok(()) + } + /// Mark the folder as in use by a running instance (see the module doc). Without a lock /// (the folder can't be written, or the platform has no file locks) snapshots are written /// and offered as before. pub fn claim(&self) { self.lock.get_or_init(|| { - if let Err(e) = std::fs::create_dir_all(&self.dir) { + if let Err(e) = self.create_dir() { log::warn!("recovery folder {} can't be made: {e}", self.dir.display()); return None; } @@ -166,7 +188,7 @@ impl RecoveryStore { /// Write (or replace) an entry. The PDF is written before its metadata, both atomically, so /// a listed entry always has complete bytes. pub fn write(&self, meta: &RecoveryMeta, bytes: &[u8]) -> std::io::Result<()> { - std::fs::create_dir_all(&self.dir)?; + self.create_dir()?; crate::editing::write_atomically(&self.dir.join(format!("{}.pdf", meta.key)).to_string_lossy(), bytes)?; let json = serde_json::to_vec_pretty(meta).map_err(std::io::Error::other)?; crate::editing::write_atomically(&self.dir.join(format!("{}.json", meta.key)).to_string_lossy(), &json) diff --git a/crates/ui-egui/src/scroll_speed.rs b/crates/ui-egui/src/scroll_speed.rs index a2d857c741..cc3a7e86e5 100644 --- a/crates/ui-egui/src/scroll_speed.rs +++ b/crates/ui-egui/src/scroll_speed.rs @@ -18,7 +18,7 @@ pub enum ScrollSpeed { } /// egui's own `line_scroll_speed`, which its wheel zoom rate is tuned against. -const EGUI_LINE_POINTS: f32 = if cfg!(target_arch = "wasm32") { 8.0 } else { 40.0 }; +pub(crate) const EGUI_LINE_POINTS: f32 = if cfg!(target_arch = "wasm32") { 8.0 } else { 40.0 }; /// Points one wheel line scrolls at Normal speed. const NORMAL_LINE_POINTS: f32 = if cfg!(target_arch = "wasm32") { diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs index 29147a9927..3cb67bb8ee 100644 --- a/crates/ui-egui/src/sign_ui.rs +++ b/crates/ui-egui/src/sign_ui.rs @@ -5,10 +5,10 @@ use std::path::PathBuf; use egui::{Align, Color32, CornerRadius, Layout, Pos2, Rect, Stroke, vec2}; -use pdfcraft_engine::sign::{self, Appearance, AppearanceLabels, Certificate, DigitalId, Modification, Name, PrivateKey}; +use pdfcraft_engine::sign::{self, Appearance, AppearanceLabels, Certificate, DigitalId, Name, PrivateKey}; use pdfcraft_engine::{SignOptions, SignatureImage, SignatureInfo, SignatureStatus}; -use crate::canvas::{DocView, PageXform}; +use crate::canvas::{DocView, PageXform, Snapshot, SnapshotKind}; use crate::fill_sign::SavedSig; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, icons, widgets}; @@ -266,6 +266,12 @@ fn entry_unusable(u: &sign::windows::Unusable) -> DigitalIdEntry { } impl PdfCraftApp { + /// Export certificate: write `c` as PEM into a chosen folder, named after its subject. + pub(crate) fn export_certificate(&mut self, c: &Certificate) { + let name = format!("{}.cer", crate::files::attachment_file_name(&c.display_name())); + self.write_files(&[(name, std::sync::Arc::new(certificate_pem(c).into_bytes()))], "Export certificate"); + } + /// Start signing: the rectangle (or field) is known; show Sign with a Digital ID. pub fn start_signing(&mut self, page: usize, rect: Option<[f64; 4]>, field: Option, certify: Option) { self.refresh_os_key_store_ids(); @@ -528,36 +534,100 @@ impl PdfCraftApp { if !certs.iter().any(|c| c.raw == cert.raw) { certs.push(cert); } + self.apply_trusted_certificates(certs); + } + + /// Replace the trust store and redraw from the rebuilt display copy. One helper + /// so no caller can revalidate without invalidating: trust changes verdicts, + /// verdicts choose what the display copy shows, and the old rasters would + /// otherwise linger while the badge already follows the new verdict. + /// Trust alone is not an edit: nothing becomes dirty. + pub fn apply_trusted_certificates(&mut self, certs: Vec) { self.session.set_trusted_certificates(certs); + for view in &mut self.views { + view.invalidate_content(); + } + } + + /// A file name without its PDF extension, for snapshot tab names. + fn file_stem(name: &str) -> String { + name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_string() } /// Open saved revision `n` (1 = the oldest) of the active document as a new document. + /// A repeat reuses the tab: every click used to open another identical one. pub fn open_revision(&mut self, n: usize) { let Some((_, id)) = self.active_ids() else { return }; + let Some(doc) = self.session.get(id) else { return }; + // The revision's byte range, as `Session::open_revision` reads it. An unknown `n` + // skips the lookup: the whole file could otherwise match the latest revision's tab + // instead of erroring. + let ends = doc.revision_ends(); + if let Some(&end) = n.checked_sub(1).and_then(|k| ends.get(k)) { + let end = end.min(doc.bytes.len()); + if let Some(index) = self.find_snapshot(SnapshotKind::Revision, &doc.name, &doc.bytes[..end]) { + self.active = Some(index); + return; + } + } + let from = doc.name.clone(); match self.session.open_revision(id, n) { Ok(new) => { // The reading-colours preference is app-wide: the newly opened revision starts with it. self.session.set_reading_colors(new, self.reading_colors); let Some(doc) = self.session.get(new) else { return }; - self.views.push(DocView::new(new, &doc.info, self.view_defaults)); + let mut view = DocView::new(new, &doc.info, self.view_defaults); + view.snapshot = Some(Snapshot { kind: SnapshotKind::Revision, revision: n, from }); + self.views.push(view); self.active = Some(self.views.len() - 1); } Err(e) => self.notify_fmt("Couldn't open revision {n}: {e}", &[("n", &n.to_string()), ("e", &e.to_string())]), } } + /// A tab already showing these exact snapshot bytes: its index, so a repeat + /// switches to it instead of opening another identical tab. An exact match on + /// the source document wins, so the banner names the file opened from; a + /// bytes-only match still reuses the tab (incremental saves append, so one + /// file's revision can be byte-identical to another tab's, and a snapshot's + /// own repeats name the snapshot itself). + fn find_snapshot(&self, kind: SnapshotKind, from: &str, bytes: &[u8]) -> Option { + let mut fallback = None; + for (i, v) in self.views.iter().enumerate() { + let same_kind = v.snapshot.as_ref().is_some_and(|s| s.kind == kind); + let same_bytes = same_kind && self.session.get(v.id).is_some_and(|d| d.bytes.as_slice() == bytes); + if !same_bytes { + continue; + } + if v.snapshot.as_ref().is_some_and(|s| s.from == from) { + return Some(i); + } + fallback.get_or_insert(i); + } + fallback + } + /// Open the signed version of a signature as a new document (View signed version). - pub fn view_signed_version(&mut self, len: usize) { + /// A repeat reuses the tab: every click used to open another identical one. + pub fn view_signed_version(&mut self, len: usize, revision: usize) { let Some((_, id)) = self.active_ids() else { return }; let Some(doc) = self.session.get(id) else { return }; - let bytes = doc.bytes[..len.min(doc.bytes.len())].to_vec(); - let name = format!("{} (signed version)", doc.name.trim_end_matches(".pdf")); + let end = len.min(doc.bytes.len()); + if let Some(index) = self.find_snapshot(SnapshotKind::SignedVersion, &doc.name, &doc.bytes[..end]) { + self.active = Some(index); + return; + } + let from = doc.name.clone(); + let bytes = doc.bytes[..end].to_vec(); + let name = format!("{} (signed version)", Self::file_stem(&from)); match self.session.open(format!("{name}.pdf"), None, std::sync::Arc::new(bytes), doc.password.clone().as_deref()) { Ok(new) => { // The reading-colours preference is app-wide: the newly opened copy starts with it. self.session.set_reading_colors(new, self.reading_colors); let Some(doc) = self.session.get(new) else { return }; - self.views.push(DocView::new(new, &doc.info, self.view_defaults)); + let mut view = DocView::new(new, &doc.info, self.view_defaults); + view.snapshot = Some(Snapshot { kind: SnapshotKind::SignedVersion, revision, from }); + self.views.push(view); self.active = Some(self.views.len() - 1); } Err(e) => self.notify_fmt("Couldn't open the signed version: {e}", &[("e", &e.to_string())]), @@ -1084,11 +1154,16 @@ fn status_icon(s: &SignatureInfo) -> (&'static str, Color32) { if !s.signed { return ("pen-line", Color32::from_gray(0x80)); } - match (s.status, &s.modification) { - (SignatureStatus::Valid, Modification::None) => ("circle-check", Color32::from_rgb(0x2D, 0x9D, 0x78)), - (SignatureStatus::Valid, _) => ("circle-check", Color32::from_rgb(0x2D, 0x9D, 0x78)), - (SignatureStatus::Unknown, _) => ("triangle-alert", Color32::from_rgb(0xE6, 0x86, 0x19)), - (SignatureStatus::Invalid, _) => ("circle-x", Color32::from_rgb(0xD7, 0x37, 0x3F)), + signature_status_style(s.status) +} + +/// One look per signature verdict, shared by the Signatures panel and the page badges so +/// the two can't drift apart. +pub(crate) fn signature_status_style(status: SignatureStatus) -> (&'static str, Color32) { + match status { + SignatureStatus::Valid => ("circle-check", Color32::from_rgb(0x2D, 0x9D, 0x78)), + SignatureStatus::Unknown => ("triangle-alert", Color32::from_rgb(0xE6, 0x86, 0x19)), + SignatureStatus::Invalid => ("circle-x", Color32::from_rgb(0xD7, 0x37, 0x3F)), } } @@ -1098,7 +1173,10 @@ pub enum PanelAction { Validate, GoTo(usize), Trust(Box), - ViewSigned(usize), + ViewSigned { + len: usize, + revision: usize, + }, Sign(String), ExportCertificate(Box), /// Certificate Viewer for the signer's chain (signer first). @@ -1231,7 +1309,7 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, sigs: &[SignatureInfo], expan action = Some(PanelAction::Trust(Box::new(c.clone()))); } if ui.button(tl!("View signed version")).clicked() { - action = Some(PanelAction::ViewSigned(s.signed_len)); + action = Some(PanelAction::ViewSigned { len: s.signed_len, revision: s.revision }); } if let Some(c) = &s.certificate && ui.button(tl!("Show certificate…")).clicked() @@ -1424,6 +1502,30 @@ mod tests { Ok(PathBuf::from(&app.digital_ids[i].path)) } + #[test] + fn an_exported_certificate_is_written_inside_the_chosen_folder() { + let dir = scratch(); + let out = dir.join("out"); + std::fs::create_dir_all(&out).unwrap(); + let key = PrivateKey::generate_p256().unwrap(); + let cert = Certificate::self_signed(&Name::build("../x", "", "", "", ""), &key, sign::Time::from_unix(0), 1, &[1]).unwrap(); + let mut app = PdfCraftApp::new(); + app.export_dir_override = Some(out.to_string_lossy().into_owned()); + app.export_certificate(&cert); + let written: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect(); + assert_eq!(written, vec![std::ffi::OsString::from("out")]); + let pem = std::fs::read_to_string(out.join(".._x.cer")).unwrap(); + assert!(pem.starts_with("-----BEGIN CERTIFICATE-----"), "{pem}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn signature_status_style_gives_each_verdict_its_panel_icon() { + assert_eq!(signature_status_style(SignatureStatus::Valid).0, "circle-check"); + assert_eq!(signature_status_style(SignatureStatus::Unknown).0, "triangle-alert"); + assert_eq!(signature_status_style(SignatureStatus::Invalid).0, "circle-x"); + } + #[test] fn a_new_digital_id_skips_names_already_taken_without_writing_through_them() { let dir = scratch(); diff --git a/crates/ui-egui/src/tab_context.rs b/crates/ui-egui/src/tab_context.rs index aa16ef7105..7cc502722b 100644 --- a/crates/ui-egui/src/tab_context.rs +++ b/crates/ui-egui/src/tab_context.rs @@ -55,9 +55,8 @@ fn reveal(path: &std::path::Path) -> Result<(), String> { return Err("The file no longer exists".into()); } let platform = std::env::consts::OS; - let run = |program: &str, args: &[std::ffi::OsString]| { - std::process::Command::new(program).args(args).output().map(|o| o.status.success()).map_err(|e| e.to_string()) - }; + let run = + |program: &str, args: &[std::ffi::OsString]| reveal_process(program, args).output().map(|o| o.status.success()).map_err(|e| e.to_string()); if matches!(platform, "linux" | "freebsd") { // `dbus-send --print-reply` waits up to 2 s for the file manager (then xdg-open may run), // so it runs off the UI thread; a failure there is only logged. @@ -71,6 +70,24 @@ fn reveal(path: &std::path::Path) -> Result<(), String> { reveal_via(platform, &path, run) } +/// The process that runs `program` with `args`. Windows: `reveal_command` already quotes what +/// Explorer needs quoted, and Rust's own quoting of an argument with a space breaks `/select,` +/// (#2579), so the arguments go on the command line as written. +#[cfg(not(target_arch = "wasm32"))] +fn reveal_process(program: &str, args: &[std::ffi::OsString]) -> std::process::Command { + let mut command = std::process::Command::new(program); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + for arg in args { + command.raw_arg(arg); + } + } + #[cfg(not(windows))] + command.args(args); + command +} + #[cfg(not(target_arch = "wasm32"))] fn reveal_via( platform: &str, @@ -95,9 +112,16 @@ fn reveal_via( fn reveal_command(platform: &str, path: &std::path::Path) -> Result<(&'static str, Vec), String> { match platform { "windows" => { - // Explorer uses /select,, rather than a separate option value. - let mut selection = std::ffi::OsString::from("/select,"); + // Explorer reads `/select,""`: the path quoted after the comma (#2579). Quoting + // the whole argument instead ("/select,C:\a b.pdf", what an ordinary argument with a + // space becomes) opens Documents. A Windows path can't contain `"`, so the quotes + // can't be closed early; one that does is refused. + if path.as_os_str().as_encoded_bytes().contains(&b'"') { + return Err("The file name can't be shown in File Explorer".into()); + } + let mut selection = std::ffi::OsString::from("/select,\""); selection.push(path); + selection.push("\""); Ok(("explorer.exe", vec![selection])) } "macos" => Ok(("open", vec!["-R".into(), path.as_os_str().into()])), @@ -221,6 +245,19 @@ mod tests { assert!(reveal_via("linux", path, |_, _| Err("command missing".into())).is_err()); } + /// #2579 (review of #2625): the `/select,"…"` argument reaches the process exactly as written. + /// `cmd /c echo` prints its command line back; with Rust's own quoting (`args`) it would come + /// back wrapped in quotes with the inner ones escaped, which Explorer reads as no selection. + #[cfg(windows)] + #[test] + fn the_reveal_argument_reaches_the_process_as_written() { + let (_, args) = reveal_command("windows", std::path::Path::new(r"C:\Reports\annual report.pdf")).unwrap(); + let mut echo: Vec = ["/d", "/c", "echo"].map(std::ffi::OsString::from).to_vec(); + echo.extend(args); + let out = reveal_process("cmd", &echo).output().unwrap(); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim_end(), r#"/select,"C:\Reports\annual report.pdf""#); + } + #[test] fn reveal_arguments_preserve_spaces_and_unicode() { let path = std::path::Path::new("/tmp/年度 report.pdf"); @@ -229,10 +266,13 @@ mod tests { assert_eq!(args, [std::ffi::OsString::from("-R"), path.as_os_str().into()]); let (exe, args) = reveal_command("windows", path).unwrap(); assert_eq!(exe, "explorer.exe"); - assert_eq!(args, [std::ffi::OsString::from("/select,/tmp/年度 report.pdf")]); + assert_eq!(args, [std::ffi::OsString::from("/select,\"/tmp/年度 report.pdf\"")]); + // #2579: the path is quoted after the comma, never the whole argument, which Explorer + // reads as no selection and answers by opening Documents. let special = std::path::Path::new(r"C:\Reports\年度 report, #1 & (draft).pdf"); let (_, windows) = reveal_command("windows", special).unwrap(); - assert_eq!(windows, [std::ffi::OsString::from(r"/select,C:\Reports\年度 report, #1 & (draft).pdf")]); + assert_eq!(windows, [std::ffi::OsString::from(r#"/select,"C:\Reports\年度 report, #1 & (draft).pdf""#)]); + assert!(reveal_command("windows", std::path::Path::new(r#"C:\a" b.pdf"#)).is_err(), "a quote can't end the path early"); let (exe, args) = reveal_command("linux", path).unwrap(); assert_eq!(exe, "dbus-send"); assert_eq!(args[6], "array:string:file:///tmp/%E5%B9%B4%E5%BA%A6%20report.pdf"); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index d28a7b6580..3f43a03dcc 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -367,6 +367,9 @@ fn font_definitions_for_platform(prefer_hans: bool, platform: FontPolicyPlatform add(&mut fonts, "JetBrainsMono", include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf")); fonts.families.entry(FontFamily::Proportional).or_default().insert(0, "Inter".to_owned()); fonts.families.entry(FontFamily::Monospace).or_default().insert(0, "JetBrainsMono".to_owned()); + // Keep missing Latin/Cyrillic letters in the app's own faces (for example Bulgarian ѝ, + // absent from JetBrains Mono), before egui's defaults or optional system/CJK fallbacks. + fonts.families.entry(FontFamily::Monospace).or_default().insert(1, "Inter".to_owned()); // The same static bytes pdfcraft-fonts uses for Japanese/Chinese text in PDFs: one copy, not two. for face in pdfcraft_fonts::ui_cjk_fonts(prefer_hans) { let name = face.name(); diff --git a/crates/ui-egui/src/wheel_boost.rs b/crates/ui-egui/src/wheel_boost.rs new file mode 100644 index 0000000000..b9d474565e --- /dev/null +++ b/crates/ui-egui/src/wheel_boost.rs @@ -0,0 +1,395 @@ +//! Bounded acceleration for coarse wheel input in the document viewport. +//! +//! Line-unit events describe notches, not motion over time. A fixed distance per +//! notch makes a fast spin feel slow, so this module derives a bounded multiplier +//! from arrival time and direction. The state is constant-size and leaves isolated +//! notches unchanged. + +use egui::Vec2; + +/// Idle time after which a burst is forgotten, in seconds. +const RESET_GAP_SECS: f64 = 0.3; +/// Notch spacing at or below which input counts as a continuous spin, in seconds. +const FAST_GAP_SECS: f64 = 0.08; +/// Multiplier gained per fast consecutive notch. +const BOOST_PER_TICK: f32 = 0.4; +/// Hard limit for accelerated motion. +const MAX_BOOST: f32 = 6.0; +/// Ticks that affect the output. +const MAX_TICKS: u32 = 13; +/// Extra ticks retained beyond the ceiling. They absorb a few slow events +/// before the visible multiplier starts to fall. +const HYSTERESIS_TICKS: u32 = 4; +/// Maximum scroll applied from one frame of coarse input, in points per axis. +/// Each event is clamped first; this caps the sum. +pub(crate) const MAX_FRAME_POINTS: f32 = 8192.0; +/// Per-event clamp, in native units (lines or pages). Keeps one hostile event +/// bounded before points conversion. +pub(crate) const MAX_EVENT_UNITS: f32 = 10.0; + +/// Constant-size arrival-rate state for line wheel events. +#[derive(Debug, Default)] +pub(crate) struct WheelBoost { + last: f64, + burst: u32, + dir: i8, +} + +impl WheelBoost { + /// Returns the multiplier for one bucket of motion carrying `motion`. + /// + /// `motion` is signed in native units; only its sign matters. Zero means + /// no motion. Non-finite time returns 1.0 and leaves state unchanged; + /// backwards time resets the burst and returns 1.0. + pub(crate) fn multiplier(&mut self, now: f64, motion: f32) -> f32 { + if !now.is_finite() || !motion.is_finite() || motion == 0.0 { + return 1.0; + } + let dir = if motion > 0.0 { 1 } else { -1 }; + let idle = now - self.last; + if !idle.is_finite() || idle < 0.0 { + self.last = now; + self.burst = 0; + self.dir = dir; + return 1.0; + } + if idle > RESET_GAP_SECS || dir != self.dir { + self.burst = 0; + } else if idle <= FAST_GAP_SECS { + self.burst = self.burst.saturating_add(1).min(MAX_TICKS + HYSTERESIS_TICKS); + } else { + // Slow input in the same direction decays rather than extending the burst. + self.burst = self.burst.saturating_sub(1); + } + self.last = now; + self.dir = dir; + let ticks = self.burst.min(MAX_TICKS); + // MAX_TICKS is well within f32's exact integer range. + (1.0 + ticks as f32 * BOOST_PER_TICK).min(MAX_BOOST) + } + + /// Drops burst state. Call after a programmatic scroll jump. + pub(crate) fn reset(&mut self) { + *self = Self::default(); + } +} + +/// Caps one frame of accumulated coarse scroll, per axis. A non-finite axis +/// maps to zero; the other axis is preserved. +pub(crate) fn cap_frame(delta: Vec2) -> Vec2 { + Vec2::new( + if delta.x.is_finite() { delta.x.clamp(-MAX_FRAME_POINTS, MAX_FRAME_POINTS) } else { 0.0 }, + if delta.y.is_finite() { delta.y.clamp(-MAX_FRAME_POINTS, MAX_FRAME_POINTS) } else { 0.0 }, + ) +} + +/// One frame of wheel input, normalized in a single pass. Line motion is in +/// points; page motion remains in native units until the viewport is known. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct FrameInput { + pub precise: Vec2, + pub line: Vec2, + pub page: Vec2, +} + +/// Folds raw wheel events into frame totals without consulting time or UI state. +/// Invalid scales contribute no line motion. +pub(crate) fn accumulate(events: &[egui::Event], line_points: f32, single_paging: bool) -> FrameInput { + let line_points = sanitize_scale(line_points); + let mut out = FrameInput::default(); + for event in events { + let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event else { continue }; + if modifiers.command || modifiers.ctrl { + continue; // Zooming, not scrolling. + } + if !delta.is_finite() { + continue; + } + match unit { + egui::MouseWheelUnit::Point if modifiers.shift => { + // Shift scrolls sideways, matching egui's own wheel handling. + let sum = delta.x + delta.y; + if sum.is_finite() { + out.precise.x += sum; + } + } + egui::MouseWheelUnit::Point => out.precise += *delta, + egui::MouseWheelUnit::Line if modifiers.shift => { + let sum = delta.x + delta.y; + if sum.is_finite() { + out.line.x += sum.clamp(-MAX_EVENT_UNITS, MAX_EVENT_UNITS) * line_points; + } + } + egui::MouseWheelUnit::Line => { + out.line.x += delta.x.clamp(-MAX_EVENT_UNITS, MAX_EVENT_UNITS) * line_points; + if !single_paging { + out.line.y += delta.y.clamp(-MAX_EVENT_UNITS, MAX_EVENT_UNITS) * line_points; + } + } + egui::MouseWheelUnit::Page if modifiers.shift => { + let sum = delta.x + delta.y; + if sum.is_finite() { + out.page.x += sum.clamp(-MAX_EVENT_UNITS, MAX_EVENT_UNITS); + } + } + egui::MouseWheelUnit::Page => { + out.page.x += delta.x.clamp(-MAX_EVENT_UNITS, MAX_EVENT_UNITS); + out.page.y += delta.y.clamp(-MAX_EVENT_UNITS, MAX_EVENT_UNITS); + } + } + } + // Bound batched events before acceleration is applied. + out.precise = cap_frame(out.precise); + out.line = cap_frame(out.line); + out.page = cap_frame(out.page); + out +} + +/// Converts the frame total to the extra scroll delta beyond egui's share. +/// +/// egui applies the base distance through its own smoothing. This function +/// returns only the accelerated surplus, so an isolated notch adds nothing. +/// Each axis advances once per frame; events batched into one frame cannot +/// accelerate one another. Output is frame-capped. +pub(crate) fn boost_extra(input: FrameInput, boost_x: &mut WheelBoost, boost_y: &mut WheelBoost, now: f64) -> Vec2 { + let mut extra = Vec2::ZERO; + if input.line.x != 0.0 && input.line.x.is_finite() { + extra.x += input.line.x * (boost_x.multiplier(now, input.line.x) - 1.0); + } + if input.line.y != 0.0 && input.line.y.is_finite() { + extra.y += input.line.y * (boost_y.multiplier(now, input.line.y) - 1.0); + } + cap_frame(extra) +} + +/// Maps a non-finite or non-positive points-per-unit scale to zero. +fn sanitize_scale(points: f32) -> f32 { + if points.is_finite() && points > 0.0 { points } else { 0.0 } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn single_and_slow_notches_stay_unboosted() { + let mut b = WheelBoost::default(); + assert_eq!(b.multiplier(0.0, -1.0), 1.0); + assert_eq!(b.multiplier(0.5, -1.0), 1.0); + assert_eq!(b.multiplier(1.0, -1.0), 1.0); + } + + #[test] + fn fast_spin_accelerates_then_caps() { + let mut b = WheelBoost::default(); + assert_eq!(b.multiplier(1.0, -1.0), 1.0); + let mut last = 1.0; + for (i, t) in (1..=20).map(|i| 1.0 + f64::from(i) * 0.02).enumerate() { + last = b.multiplier(t, -1.0); + if i < 2 { + assert!(last >= 1.0, "early burst must not jump, got {last}"); + } + } + assert!(last > 2.0, "a sustained fast spin must accelerate, got {last}"); + assert!(last <= MAX_BOOST, "boost must be capped, got {last}"); + assert_eq!(last, MAX_BOOST, "a long spin should reach the ceiling"); + for i in 1..=20 { + let m = b.multiplier(1.5 + f64::from(i) * 0.02, -1.0); + assert!(m <= MAX_BOOST, "boost must stay capped, got {m}"); + } + } + + #[test] + fn reversal_and_idle_reset_the_burst() { + let mut b = WheelBoost::default(); + b.multiplier(1.0, -1.0); + for i in 1..=6 { + b.multiplier(1.0 + f64::from(i) * 0.02, -1.0); + } + let boosted = b.multiplier(1.0 + 7.0 * 0.02, -1.0); + assert!(boosted > 1.0, "precondition: burst is boosted, got {boosted}"); + assert_eq!(b.multiplier(1.0 + 8.0 * 0.02, 1.0), 1.0); + for i in 9..=14 { + b.multiplier(1.0 + f64::from(i) * 0.02, 1.0); + } + assert_eq!(b.multiplier(5.0, 1.0), 1.0); + } + + #[test] + fn invalid_input_never_boosts_or_corrupts() { + let mut b = WheelBoost::default(); + for bad_now in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert_eq!(b.multiplier(bad_now, -1.0), 1.0); + } + for bad_motion in [0.0, -0.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + assert_eq!(b.multiplier(1.0, bad_motion), 1.0); + } + assert_eq!(b.multiplier(2.0, -1.0), 1.0); + b.reset(); + assert_eq!(b.multiplier(3.0, -1.0), 1.0); + } + + #[test] + fn same_timestamp_events_count_as_a_burst() { + let mut b = WheelBoost::default(); + assert_eq!(b.multiplier(1.0, -1.0), 1.0); + for _ in 0..6 { + b.multiplier(1.0, -1.0); + } + assert!(b.multiplier(1.0, -1.0) > 1.0); + } + + #[test] + fn fractional_notches_share_the_burst() { + let mut b = WheelBoost::default(); + b.multiplier(1.0, -0.25); + for i in 1..=8 { + b.multiplier(1.0 + f64::from(i) * 0.02, -0.25); + } + assert!(b.multiplier(1.0 + 9.0 * 0.02, -0.25) > 1.0); + } + + #[test] + fn sleep_wake_resets_the_burst() { + let mut b = WheelBoost::default(); + b.multiplier(1.0, -1.0); + for i in 1..=6 { + b.multiplier(1.0 + f64::from(i) * 0.02, -1.0); + } + assert_eq!(b.multiplier(3600.0, -1.0), 1.0); + } + + #[test] + fn backwards_time_resets_without_corrupting() { + let mut b = WheelBoost::default(); + b.multiplier(1.0, -1.0); + b.multiplier(1.02, -1.0); + assert_eq!(b.multiplier(0.5, -1.0), 1.0); + assert_eq!(b.multiplier(0.52, -1.0), 1.4); + } + + #[test] + fn unhurried_spin_decays() { + let mut b = WheelBoost::default(); + b.multiplier(1.0, -1.0); + for i in 1..=8 { + b.multiplier(1.0 + f64::from(i) * 0.02, -1.0); + } + let peak = b.multiplier(1.0 + 9.0 * 0.02, -1.0); + assert!(peak > 1.0); + // Same direction but slower than the fast gap: each event sheds one tick. + let mut prev = peak; + for i in 10..=13 { + let m = b.multiplier(1.0 + f64::from(i) * 0.15, -1.0); + assert!(m <= prev, "decay must not grow: {m} > {prev}"); + prev = m; + } + } + + #[test] + fn hysteresis_holds_the_ceiling_briefly() { + let mut b = WheelBoost::default(); + let mut t = 1.0; + b.multiplier(t, -1.0); + for _ in 0..20 { + t += 0.02; + b.multiplier(t, -1.0); + } + assert_eq!(b.multiplier(t + 0.02, -1.0), MAX_BOOST); + // A couple of unhurried events consume hysteresis before output falls. + t += 0.15; + assert_eq!(b.multiplier(t, -1.0), MAX_BOOST); + t += 0.15; + assert_eq!(b.multiplier(t, -1.0), MAX_BOOST); + } + + #[test] + fn frame_cap_bounds_each_axis() { + use egui::vec2; + assert_eq!(cap_frame(vec2(40.0, -40.0)), vec2(40.0, -40.0)); + assert_eq!(cap_frame(vec2(1e9, -1e9)), vec2(MAX_FRAME_POINTS, -MAX_FRAME_POINTS)); + assert_eq!(cap_frame(vec2(f32::NAN, 40.0)), vec2(0.0, 40.0)); + assert_eq!(cap_frame(vec2(40.0, f32::INFINITY)), vec2(40.0, 0.0)); + } + + fn wheel(unit: egui::MouseWheelUnit, dx: f32, dy: f32) -> egui::Event { + egui::Event::MouseWheel { unit, delta: egui::vec2(dx, dy), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE } + } + + fn shifted(unit: egui::MouseWheelUnit, dx: f32, dy: f32) -> egui::Event { + egui::Event::MouseWheel { unit, delta: egui::vec2(dx, dy), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::SHIFT } + } + + #[test] + fn isolated_notch_adds_no_extra() { + use egui::MouseWheelUnit::Line; + for base in [50.0, 100.0, 200.0, 40.0, 8.0] { + let input = accumulate(&[wheel(Line, 0.0, -1.0)], base, false); + assert_eq!(input.line, egui::vec2(0.0, -base)); + let mut bx = WheelBoost::default(); + let mut by = WheelBoost::default(); + assert_eq!(boost_extra(input, &mut bx, &mut by, 1.0), egui::Vec2::ZERO); + } + } + + #[test] + fn fast_spin_adds_a_bounded_extra() { + use egui::MouseWheelUnit::Line; + let mut bx = WheelBoost::default(); + let mut by = WheelBoost::default(); + let mut peak = 0.0f32; + for i in 0..20 { + let input = accumulate(&[wheel(Line, 0.0, -1.0)], 100.0, false); + let extra = boost_extra(input, &mut bx, &mut by, 1.0 + f64::from(i) * 0.02); + assert!(extra.y <= 0.0, "extra follows the spin direction, got {extra:?}"); + peak = peak.min(extra.y); + } + assert!(peak < 0.0, "a sustained spin must add an extra"); + assert!(peak >= -MAX_FRAME_POINTS, "extra stays frame-capped, got {peak}"); + } + + #[test] + fn point_and_page_events_stay_out_of_the_line_boost() { + use egui::{MouseWheelUnit::Line, MouseWheelUnit::Page, MouseWheelUnit::Point}; + let input = accumulate(&[wheel(Page, 0.0, -1.0), wheel(Point, 0.0, -30.0)], 100.0, false); + assert_eq!(input.precise, egui::vec2(0.0, -30.0)); + assert_eq!(input.page, egui::vec2(0.0, -1.0)); + assert_eq!(input.line, egui::Vec2::ZERO); + let input = accumulate(&[wheel(Line, 0.0, -1.0), wheel(Page, 0.0, -1.0)], 100.0, false); + assert_eq!(input.line, egui::vec2(0.0, -100.0)); + assert_eq!(input.page, egui::vec2(0.0, -1.0)); + let mut bx = WheelBoost::default(); + let mut by = WheelBoost::default(); + assert_eq!(boost_extra(input, &mut bx, &mut by, 1.0), egui::Vec2::ZERO); + } + + #[test] + fn single_paging_drops_vertical_keeps_horizontal() { + use egui::MouseWheelUnit::Line; + let input = accumulate(&[wheel(Line, 5.0, -1.0)], 100.0, true); + assert_eq!(input.line, egui::vec2(500.0, 0.0)); + } + + #[test] + fn shift_merges_into_horizontal() { + use egui::MouseWheelUnit::{Line, Page}; + let input = accumulate(&[shifted(Line, 1.0, -2.0)], 100.0, false); + assert_eq!(input.line, egui::vec2(-100.0, 0.0)); + let input = accumulate(&[shifted(Page, 1.0, -2.0)], 100.0, false); + assert_eq!(input.page, egui::vec2(-1.0, 0.0)); + } + + #[test] + fn invalid_scales_and_events_contribute_nothing() { + use egui::MouseWheelUnit::Line; + for bad in [f32::NAN, 0.0, -10.0, f32::INFINITY] { + let input = accumulate(&[wheel(Line, 0.0, -1.0)], bad, false); + assert_eq!(input.line, egui::Vec2::ZERO, "line scale {bad}"); + } + let zoom = + egui::Event::MouseWheel { unit: Line, delta: egui::vec2(0.0, -1.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::COMMAND }; + let key = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + let input = accumulate(&[zoom, key, wheel(Line, 0.0, f32::NAN)], 100.0, false); + assert_eq!(input, FrameInput::default()); + } +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 4f5aaa79b6..c39f4cf05e 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -272,7 +272,7 @@ pub fn section_title(ui: &mut egui::Ui, text: &str) { let t = Tokens::get(ui.ctx()); ui.add_space(10.0); // Section titles across every panel go through here, so one translation point covers them. - ui.label(egui::RichText::new(tl!(text).to_uppercase()).font(theme::semibold(10.5)).color(t.text_faint).extra_letter_spacing(0.6)); + ui.label(egui::RichText::new(crate::i18n::upper_label(text)).font(theme::semibold(10.5)).color(t.text_faint).extra_letter_spacing(0.6)); ui.add_space(2.0); } diff --git a/crates/ui-egui/src/window_state.rs b/crates/ui-egui/src/window_state.rs new file mode 100644 index 0000000000..0475604a48 --- /dev/null +++ b/crates/ui-egui/src/window_state.rs @@ -0,0 +1,137 @@ +//! The window's saved size and place across starts. eframe stores them just before +//! `App::save`, from the window as it is at that moment. A minimized window has neither: Windows +//! reports it as 0 × 0 at (-32000, -32000), and the next start then opened at egui's 64-point +//! floor in the screen's corner. So while the window is minimized the last settings it was shown +//! with are put back, and a window that opens at that floor is given a usable size. + +/// eframe's storage key for the window settings. +const WINDOW_KEY: &str = "window"; +/// The size egui opens a stored size below (its floor for "ridiculously small" windows), in points. +const EGUI_FLOOR: f32 = 64.0; +/// The size a window that opened at [`EGUI_FLOOR`] is given (apps/pdfcraft opens a first start at +/// this size too), at most nine tenths of the monitor. +const USABLE: egui::Vec2 = egui::vec2(1440.0, 920.0); + +#[derive(Debug, Default)] +pub struct WindowState { + /// The settings eframe last stored for a shown (not minimized) window. + shown: Option, + /// The size at start has been checked ([`Self::repair`]). + checked: bool, +} + +impl WindowState { + /// The settings the window started with: what to put back if it is minimized before eframe + /// stores a shown one. + pub fn started_with(storage: Option<&dyn eframe::Storage>) -> Self { + Self { shown: storage.and_then(|s| s.get_string(WINDOW_KEY)), checked: false } + } + + /// After eframe has stored the window: remember it while the window is shown, and while it + /// is minimized put the last shown settings back (or none, so the next start opens at the + /// default size). + pub fn keep(&mut self, storage: &mut dyn eframe::Storage, minimized: bool) { + if !minimized { + self.shown = storage.get_string(WINDOW_KEY); + } else if let Some(shown) = &self.shown { + storage.set_string(WINDOW_KEY, shown.clone()); + } else { + storage.remove_string(WINDOW_KEY); + } + } + + /// Once the window's size is known: a shown window at egui's floor in both directions came + /// from settings stored while it was minimized (before this fix), so give it a usable size. + pub fn repair(&mut self, ctx: &egui::Context) { + if self.checked { + return; + } + let (rect, minimized, monitor) = ctx.input(|i| (i.viewport().inner_rect, i.viewport().minimized, i.viewport().monitor_size)); + let Some(rect) = rect else { return }; + if minimized == Some(true) { + return; + } + self.checked = true; + if rect.width() <= EGUI_FLOOR + 0.5 && rect.height() <= EGUI_FLOOR + 0.5 { + // The stored settings are the broken ones: never put them back. + self.shown = None; + let size = monitor.map_or(USABLE, |m| USABLE.min(m * 0.9)); + ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use eframe::Storage as _; + + #[derive(Default)] + struct Store(std::collections::HashMap); + + impl eframe::Storage for Store { + fn get_string(&self, key: &str) -> Option { + self.0.get(key).cloned() + } + fn set_string(&mut self, key: &str, value: String) { + self.0.insert(key.to_string(), value); + } + fn remove_string(&mut self, key: &str) { + self.0.remove(key); + } + fn flush(&mut self) {} + } + + const SHOWN: &str = "(outer_position_pixels:Some((x:200.0,y:200.0)),inner_size_points:Some((x:1320.0,y:940.0)))"; + const MINIMIZED: &str = "(outer_position_pixels:Some((x:-32000.0,y:-32000.0)),inner_size_points:Some((x:0.0,y:0.0)))"; + + /// Closing PdfCraft while it was minimized stored a 0 × 0 window off screen. + #[test] + fn a_minimized_window_keeps_the_last_shown_settings() { + let mut store = Store::default(); + store.set_string(WINDOW_KEY, SHOWN.into()); + let mut state = WindowState::started_with(Some(&store)); + // eframe stores the minimized window, then the app saves. + store.set_string(WINDOW_KEY, MINIMIZED.into()); + state.keep(&mut store, true); + assert_eq!(store.get_string(WINDOW_KEY).as_deref(), Some(SHOWN)); + // Shown again and resized: that is what is kept from then on. + let moved = SHOWN.replace("200.0", "300.0"); + store.set_string(WINDOW_KEY, moved.clone()); + state.keep(&mut store, false); + store.set_string(WINDOW_KEY, MINIMIZED.into()); + state.keep(&mut store, true); + assert_eq!(store.get_string(WINDOW_KEY), Some(moved)); + } + + #[test] + fn with_nothing_shown_yet_a_minimized_save_stores_no_window() { + let mut store = Store::default(); + let mut state = WindowState::started_with(Some(&store)); + store.set_string(WINDOW_KEY, MINIMIZED.into()); + state.keep(&mut store, true); + assert_eq!(store.get_string(WINDOW_KEY), None, "the next start opens at the default size"); + } + + fn repaired(size: egui::Vec2, minimized: Option) -> Vec { + let ctx = egui::Context::default(); + let mut input = egui::RawInput::default(); + let info = input.viewports.entry(egui::ViewportId::ROOT).or_default(); + info.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)); + info.minimized = minimized; + info.monitor_size = Some(egui::vec2(2674.0, 1504.0)); + let mut state = WindowState { shown: Some(MINIMIZED.into()), checked: false }; + let out = ctx.run_logic(&input, |ctx| state.repair(ctx)); + let commands = out.viewport_commands.get(&egui::ViewportId::ROOT).cloned().unwrap_or_default(); + // Repaired: the settings it started with are not put back by a minimized close. + assert_eq!(state.shown.is_none(), !commands.is_empty()); + commands + } + + #[test] + fn a_window_at_eguis_floor_gets_a_usable_size() { + assert_eq!(repaired(egui::vec2(64.0, 64.0), Some(false)), [egui::ViewportCommand::InnerSize(USABLE)]); + assert!(repaired(egui::vec2(820.0, 520.0), Some(false)).is_empty(), "a small window someone chose stays"); + assert!(repaired(egui::vec2(64.0, 64.0), Some(true)).is_empty(), "not while minimized"); + } +} diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index 914bc281b8..3d53f1e3bb 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -7,6 +7,8 @@ use egui_kittest::kittest::Queryable; use pdfcraft_render::Annotation; use pdfcraft_ui_egui::{PdfCraftApp, QuickTool}; +mod support; + /// Two 300×200 pages of Helvetica text. const TEXT_FIXTURE: &[u8] = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj @@ -364,6 +366,40 @@ fn selected_text_shows_actions_and_underlines_without_changing_tools() { assert_eq!(h.state().quick_tool, QuickTool::Select); } +#[test] +fn selected_text_copy_action_closes_the_bar_and_keeps_the_selection() { + let mut h = harness(|_| {}); + h.state_mut().views[0].select_text(0, 4, 8); + h.run_steps(3); + h.get_by_label("Copy").click(); + h.run_steps(3); + assert!(h.query_by_label("Add comment").is_none(), "the action bar closes after Copy"); + assert!(h.state().views[0].selected_text().is_some_and(|t| !t.is_empty()), "the words stay selected"); + // A new selection brings the bar back. + h.state_mut().views[0].select_text(0, 2, 6); + h.run_steps(3); + h.get_by_label("Add comment"); +} + +#[test] +fn dragging_over_copied_text_again_brings_the_action_bar_back() { + let mut h = harness(|_| {}); + let (a, b) = { + let v = &h.state().views[0]; + (v.glyph_screen_pos(0, 4).expect("text layer"), v.glyph_screen_pos(0, 8).expect("glyph")) + }; + drag(&mut h, a, b); + let copied = h.state().views[0].selected_text(); + assert!(copied.is_some(), "the drag selects text"); + h.get_by_label("Copy").click(); + h.run_steps(3); + assert!(h.query_by_label("Add comment").is_none(), "the action bar closes after Copy"); + // The same drag again selects the same words, which is a new selection: the bar is back. + drag(&mut h, a, b); + assert_eq!(h.state().views[0].selected_text(), copied, "the drag selects the same words"); + assert!(h.query_by_label("Add comment").is_some(), "the action bar is back after a new drag"); +} + #[test] fn selected_text_replace_action_opens_replacement_composer() { let mut h = harness(|_| {}); @@ -406,8 +442,11 @@ fn shapes_and_ink_are_drawn_by_dragging() { assert!((r[0] - 40.0).abs() < 3.0 && (r[1] - 40.0).abs() < 3.0 && (r[2] - 140.0).abs() < 3.0 && (r[3] - 100.0).abs() < 3.0, "{r:?}"); } -/// Press, drag and release a stroke, running only one frame after the release. +/// Press, drag and release a stroke, running only one frame after the release. Finished +/// renders are held back from the view (`hold_rasters`), so the raster the commit asks for can't +/// arrive before the test looks, however fast it renders; a test lets it through by clearing it. fn stroke_without_settling(h: &mut Harness<'static, PdfCraftApp>, a: (f32, f32), b: (f32, f32)) { + h.state_mut().views[0].hold_rasters = true; let (from, to) = (at(h, a.0, a.1), at(h, b.0, b.1)); h.hover_at(from); h.run_steps(1); @@ -429,7 +468,10 @@ fn a_released_pen_stroke_stays_drawn_until_the_page_raster_has_it() { stroke_without_settling(&mut h, (40.0, 100.0), (140.0, 60.0)); assert_eq!(comments(&h).len(), 1, "the stroke is committed"); assert_eq!(h.state().views[0].comments.settling_len(), 1, "still drawn from its points"); - settle(&mut h); + h.run_steps(5); + assert_eq!(h.state().views[0].comments.settling_len(), 1, "drawn from its points for as long as its raster takes"); + h.state_mut().views[0].hold_rasters = false; + support::wait_until(&mut h, "the page raster with the stroke", |app| app.views[0].comments.settling_len() == 0); assert_eq!(h.state().views[0].comments.settling_len(), 0, "the raster draws it now"); } @@ -1205,6 +1247,91 @@ fn attaching_a_file_as_a_comment() { assert_eq!(s.quick_tool, QuickTool::Select); } +/// One 300×200 page with a paperclip FileAttachment comment at (100, 100)–(120, 124) carrying +/// `inner.pdf`, and no /Contents: the card shows only "Attachment" (#2565). The cross-reference +/// table keeps the reader from mistaking the embedded PDF's objects for the outer ones. +fn attachment_comment_fixture() -> Vec { + let inner = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF"; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R] >>".into(), + "<< /Type /Annot /Subtype /FileAttachment /Rect [100 100 120 124] /Name /Paperclip /T (Reviewer) /M (D:20261014105200) /FS 5 0 R >>".into(), + "<< /Type /Filespec /F (inner.pdf) /UF (inner.pdf) /EF << /F 6 0 R >> >>".into(), + format!("<< /Type /EmbeddedFile /Length {} >>\nstream\n{inner}\nendstream", inner.len()), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn attachment_harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("annexed.pdf", None, attachment_comment_fixture()).expect("opens"); + app.set_option("left", "closed").unwrap(); + setup(&mut app); + app + }); + h.run_steps(4); + settle(&mut h); + h +} + +fn open_tab_names(h: &Harness<'static, PdfCraftApp>) -> Vec { + let s = h.state(); + s.views.iter().filter_map(|v| s.session.get(v.id)).map(|d| d.name.clone()).collect() +} + +#[test] +fn an_attachment_comment_opens_its_file_from_the_page_menu() { + let mut h = attachment_harness(|app| { + app.set_option("panel", "comments").unwrap(); + app.set_option("quick", "select").unwrap(); + }); + assert_eq!(comments(&h)[0].subtype, "FileAttachment"); + click_pt(&mut h, (110.0, 112.0)); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the attachment comment is selected"); + let p = at(&h, 110.0, 112.0); + h.hover_at(p); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + h.get_by_label("Copy text"); + // The Attachments panel is closed, so these items can only come from the comment's menu. + h.get_by_label("Save attachment…"); + h.get_by_label("Open in a new tab").click(); + h.run_steps(4); + assert_eq!(open_tab_names(&h), ["annexed.pdf", "inner.pdf"]); +} + +#[test] +fn an_attachment_comment_opens_its_file_from_the_card_menu() { + let mut h = attachment_harness(|app| app.set_option("panel", "comments").unwrap()); + h.state_mut().views[0].comments.selected = Some((0, 0)); + h.run_steps(2); + h.get_by_label("More").click(); + h.run_steps(2); + h.get_by_label("Copy text"); + h.get_by_label("Save attachment…"); + h.get_by_label("Open in a new tab").click(); + h.run_steps(4); + assert_eq!(open_tab_names(&h), ["annexed.pdf", "inner.pdf"]); +} + /// Drag with the middle button (the mouse wheel) in small steps. fn middle_drag(h: &mut Harness<'static, PdfCraftApp>, from: Pos2, to: Pos2) { let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: egui::Modifiers::NONE }; diff --git a/crates/ui-egui/tests/control.rs b/crates/ui-egui/tests/control.rs index 847ffcc754..e6af166d77 100644 --- a/crates/ui-egui/tests/control.rs +++ b/crates/ui-egui/tests/control.rs @@ -5,10 +5,23 @@ use std::sync::{Arc, Mutex}; use egui_kittest::Harness; use egui_kittest::kittest::{NodeT, Queryable}; +use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind}; use pdfcraft_ui_egui::PdfCraftApp; use pdfcraft_ui_egui::control::{ControlClient, Reply}; use serde_json::{Value, json}; +/// The text each page shows, in order, as the app's session holds it. +fn page_texts(app: &PdfCraftApp) -> Vec { + let doc = app.session.get(app.views[0].id).unwrap(); + let mut r = PageRenderer::new(doc.bytes.clone(), Default::default()); + (0..r.page_count()) + .map(|p| { + let out = r.render(RenderRequest { page: p, kind: RequestKind::Text, scale: 1.0, ..Default::default() }); + out.text.map(|t| t.plain_text().trim().to_string()).unwrap_or_default() + }) + .collect() +} + fn fixture(n: usize) -> Vec { let mut objs: Vec = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()]; let kids: Vec = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect(); @@ -1034,6 +1047,45 @@ fn state_reports_the_selected_pages() { assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["active"]["selected_pages"], json!([])); } +#[test] +fn ctrl_dragging_a_thumbnail_through_the_control_channel_copies_the_page() { + let (mut h, c) = harness_pages(3); + ok(&mut h, &c, "ui.set", json!({ "key": "tool", "value": "organize" })); + h.run_steps(3); + // The grid cell of a page: the node labelled exactly "Page N" (the checkbox says "Select page N"). + let cell = |h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, page: usize| -> [f64; 4] { + let nodes = ok(h, c, "ui.inspect", json!({ "query": &format!("page {page}") })); + let want = format!("Page {page}"); + let r = nodes["widgets"] + .as_array() + .unwrap() + .iter() + .find(|w| w["label"].as_str() == Some(want.as_str())) + .and_then(|w| w["rect"].as_array().cloned()) + .expect("the page's grid cell"); + let f = |i: usize| r[i].as_f64().unwrap(); + [f(0), f(1), f(2), f(3)] + }; + let (a, b) = (cell(&mut h, &c, 1), cell(&mut h, &c, 3)); + // The gesture reads the OS the context reports: Option on macOS, Ctrl elsewhere. + let copy = if h.ctx.os() == egui::os::OperatingSystem::Mac { "alt" } else { "ctrl" }; + ok( + &mut h, + &c, + "ui.drag", + json!({ + "from": [(a[0] + a[2]) / 2.0, (a[1] + a[3]) / 2.0], + "to": [b[2] - 4.0, (b[1] + b[3]) / 2.0], + "modifiers": [copy], + }), + ); + h.run_steps(3); + let st = ok(&mut h, &c, "ui.state", json!({})); + assert_eq!(st["documents"][0]["pages"], 4, "the drag copied the page instead of moving it: {st}"); + assert_eq!(st["active"]["selected_pages"], json!([4]), "{st}"); + assert_eq!(page_texts(h.state()), ["Page 1", "Page 2", "Page 3", "Page 1"], "the copy landed at the drop gap, after page 3"); +} + #[test] fn organize_overview_is_reachable_through_the_control_channel() { let (mut h, c) = harness(); @@ -1603,7 +1655,15 @@ fn page_size_shows_while_the_pointer_is_in_the_bottom_left_quadrant() { #[test] fn navigation_history_commands_and_rail_are_per_tab_and_non_editing() { + for os in [egui::os::OperatingSystem::Nix, egui::os::OperatingSystem::Mac, egui::os::OperatingSystem::Windows] { + navigation_history_for_os(os); + } +} + +fn navigation_history_for_os(os: egui::os::OperatingSystem) { let (mut h, c) = harness(); + h.ctx.set_os(os); + h.run_steps(2); let enabled = |list: Value, id: &str| list["commands"].as_array().unwrap().iter().find(|cmd| cmd["id"] == id).unwrap()["enabled"].as_bool().unwrap(); assert!(!enabled(ok(&mut h, &c, "ui.commands", json!({})), "view.previous_view")); @@ -1617,7 +1677,8 @@ fn navigation_history_commands_and_rail_are_per_tab_and_non_editing() { ok(&mut h, &c, "ui.set", json!({"key": "page", "value": "4"})); assert!(enabled(ok(&mut h, &c, "ui.commands", json!({})), "view.previous_view")); // The real rail button is accessible and invokes the same history, not the adjacent page. - ok(&mut h, &c, "ui.click", json!({"label": &format!("Previous view ({})", if cfg!(target_os = "macos") { "⌘[" } else { "Ctrl+[" })})); + let shortcut = if h.ctx.os() == egui::os::OperatingSystem::Mac { "⌘[" } else { "Ctrl+[" }; + ok(&mut h, &c, "ui.click", json!({"label": &format!("Previous view ({shortcut})")})); assert_eq!(h.state().views[0].current, 0); assert!(enabled(ok(&mut h, &c, "ui.commands", json!({})), "view.next_view")); ok(&mut h, &c, "ui.command", json!({"id": "view.next_view"})); diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 65b90dad55..2ef52a4403 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -351,6 +351,21 @@ fn shift_click_selects_a_range_and_moves_it() { assert_eq!(page_texts(h.state()), ["Page 3", "Page 4", "Page 1", "Page 2"]); } +#[test] +fn reverse_pages_reverses_the_selection_or_else_the_whole_document() { + let mut h = organize(4); + assert!(h.state_mut().execute("page.reverse")); + h.run_steps(3); + assert_eq!(page_texts(h.state()), ["Page 4", "Page 3", "Page 2", "Page 1"]); + h.get_by_label("Page 1").click(); + h.run_steps(1); + h.get_by_label("Page 3").click_modifiers(Modifiers::COMMAND); + h.run_steps(2); + assert!(h.state_mut().execute("page.reverse")); + h.run_steps(3); + assert_eq!(page_texts(h.state()), ["Page 2", "Page 3", "Page 4", "Page 1"]); +} + #[test] fn command_click_toggles_and_rotation_applies_to_selection() { let mut h = organize(3); @@ -520,6 +535,27 @@ fn save_writes_an_incremental_update_and_clears_dirty() { let _ = std::fs::remove_file(out); } +#[test] +fn save_as_to_a_new_file_drops_earlier_revisions() { + // #2165: Save As appended a revision, so a deleted page stayed in the new file's first one. + let out = temp_path("save-as-clean.pdf"); + let mut h = organize(3); + h.get_by_label("Page 2").hover(); + h.run_steps(2); + h.get_by_label("Delete page 2").click(); + h.run_steps(3); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + assert!(h.state_mut().save_active(pdfcraft_ui_egui::SaveTarget::As)); + let saved = std::fs::read(&out).expect("file written"); + let count = |needle: &[u8]| saved.windows(needle.len()).filter(|w| *w == needle).count(); + assert_eq!(count(b"%%EOF"), 1, "one revision only"); + assert_eq!((count(b"(Page 1)"), count(b"(Page 2)")), (1, 0), "the deleted page's content is gone"); + assert!(!dirty(&h)); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes.as_slice(), saved.as_slice()); + assert_eq!(page_texts(h.state()), ["Page 1", "Page 3"]); + let _ = std::fs::remove_file(out); +} + #[test] fn closing_a_dirty_tab_asks_and_cancel_keeps_it() { let mut h = organize(2); @@ -1777,9 +1813,12 @@ fn files_dropped_beside_the_pages_panel_still_open_as_documents() { h.hover_at(egui::pos2(thumb.left() - 300.0, thumb.center().y)); h.run_steps(2); drop_files(&mut h, vec![Dropped("other.pdf", fixture(1))]); - h.run_steps(3); + // Native opening prepares the document on a worker; three UI frames need not give it + // any CPU time. Wait for the result, as the other dropped-document regression does. + support::wait_until(&mut h, "document dropped beside the Pages panel", |app| app.views.len() == 2 && app.password_prompt.is_none()); assert_eq!(h.state().views.len(), 2); assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 2"]); + assert_eq!(texts_of(h.state(), 1), ["Page 1"], "the dropped PDF opens as its own document"); } fn grid_zoom(h: &Harness<'static, PdfCraftApp>) -> f32 { @@ -2351,6 +2390,81 @@ fn dragging_thumbnails_reorders_pages() { assert_eq!(sel, vec![2], "the moved page stays selected"); } +/// A page drag with the copy modifier held — Ctrl (Option on macOS) — instead of none. +/// Modifiers are input state: `ModifiersChanged` once, as the column-select drag does. +fn copy_drag(h: &mut Harness<'static, PdfCraftApp>, from: egui::Pos2, to: egui::Pos2) { + page_drag(h, from, to, copy_modifiers(h)); +} + +/// The copy modifier of the OS the app reports: Option on macOS, Ctrl elsewhere. That is the OS +/// the gesture itself reads, so a Mac browser — a wasm build, where `cfg!(target_os = "macos")` is +/// false — is covered by asking the context rather than the target. +fn copy_modifiers(h: &Harness<'static, PdfCraftApp>) -> Modifiers { + if h.ctx.os() == egui::os::OperatingSystem::Mac { Modifiers::ALT } else { Modifiers::CTRL } +} + +/// Drag the page under `from` to `to` with `modifiers` held for the whole gesture. +fn page_drag(h: &mut Harness<'static, PdfCraftApp>, from: egui::Pos2, to: egui::Pos2, modifiers: Modifiers) { + h.event(egui::Event::ModifiersChanged(modifiers)); + h.run_steps(1); + h.hover_at(from); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers }); + h.run_steps(1); + for k in 1..=5 { + h.hover_at(from + (to - from) * (k as f32 / 5.0)); + h.run_steps(1); + } + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.run_steps(4); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(1); +} + +#[test] +fn dragging_with_the_copy_modifier_copies_the_pages_into_the_gap() { + let mut h = organize(3); + let before = page_texts(h.state()); + let grab = |h: &Harness<'static, PdfCraftApp>, label: &str| h.get_by_label(label).rect(); + let (from, to) = (grab(&h, "Page 1").center(), grab(&h, "Page 3").right_center() - egui::vec2(10.0, 0.0)); + copy_drag(&mut h, from, to); + // The original stays first; a copy of page 1 lands after page 3 (#2997). + let after = page_texts(h.state()); + assert_eq!(after, vec![before[0].clone(), before[1].clone(), before[2].clone(), before[0].clone()], "{after:?}"); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Duplicate page")); + let sel: Vec = h.state().views[0].selected.iter().copied().collect(); + assert_eq!(sel, vec![3], "the copy is selected"); +} + +/// macOS is not only `cfg!(target_os = "macos")`: the wasm build runs in a Mac browser, where the +/// cfg is false but the context says Mac. The gesture must follow the context, or Option-drag +/// there moves the pages instead of copying them (#3092 review). +#[test] +fn option_copies_and_ctrl_moves_when_the_context_reports_macos() { + let grab = |h: &Harness<'static, PdfCraftApp>, label: &str| h.get_by_label(label).rect(); + let points = |h: &Harness<'static, PdfCraftApp>| (grab(h, "Page 1").center(), grab(h, "Page 3").right_center() - egui::vec2(10.0, 0.0)); + + // Ctrl is not the copy modifier on macOS, so that drag moves the page. + let mut h = organize(3); + h.ctx.set_os(egui::os::OperatingSystem::Mac); + let before = page_texts(h.state()); + let (from, to) = points(&h); + page_drag(&mut h, from, to, Modifiers::CTRL); + assert_eq!(page_texts(h.state()), vec![before[1].clone(), before[2].clone(), before[0].clone()], "Ctrl moves on macOS"); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Move page")); + + // Option is, so the same drag drops a copy at the gap. + let mut h = organize(3); + h.ctx.set_os(egui::os::OperatingSystem::Mac); + let before = page_texts(h.state()); + let (from, to) = points(&h); + page_drag(&mut h, from, to, Modifiers::ALT); + assert_eq!(page_texts(h.state()), vec![before[0].clone(), before[1].clone(), before[2].clone(), before[0].clone()], "Option copies on macOS"); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Duplicate page")); + let sel: Vec = h.state().views[0].selected.iter().copied().collect(); + assert_eq!(sel, vec![3], "the copy is selected"); +} + #[test] fn copying_cutting_and_pasting_pages() { let mut h = organize(4); @@ -3793,6 +3907,58 @@ fn one_editor_contains_mixed_styles_and_visual_rows() { assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().text_blocks(0)[0].rows.len(), 3); } +#[test] +fn the_caret_on_a_small_row_is_as_tall_as_that_row() { + // It was one row of the region's first (18 pt) size tall, reaching into the row below. + let bytes = source_font_fixture_with_text( + "BT 0 0.2 0.5 rg /F1 1 Tf 18 0 0 18 20 240 Tm (Heading) Tj ET \ + BT 0 g /F2 12 Tf 20 215 Td (Plain ) Tj /F1 12 Tf 0 0.2 0.5 rg (bold) Tj /F2 12 Tf 0 g ( words) Tj ET \ + BT /F2 10 Tf 20 195 Td (Last row) Tj ET", + ); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("mixed.pdf", None, bytes).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let page = h.state().views[0].page_screen_rect(0).unwrap(); + let k = page.width() / 300.0; + let screen = |x: f32, y: f32| egui::pos2(page.left() + x * k, page.top() + (300.0 - y) * k); + let click = |h: &mut Harness<'static, PdfCraftApp>, at: egui::Pos2| { + h.hover_at(at); + h.run_steps(1); + h.drag_at(at); + h.run_steps(1); + h.drop_at(at); + h.run_steps(4); + }; + click(&mut h, screen(30.0, 245.0)); + assert!(h.state().views[0].line_editor.is_some(), "the region opens"); + let row = h.state().session.get(h.state().views[0].id).unwrap().text_lines(0).into_iter().find(|l| l.text == "Last row").unwrap().rect; + click(&mut h, screen(40.0, ((row[1] + row[3]) / 2.0) as f32)); + let row_height = (row[3] - row[1]) as f32 * k; + let (left, right) = (screen(row[0] as f32, 0.0).x - 2.0, screen(row[2] as f32, 0.0).x + 2.0); + let middle = screen(0.0, ((row[1] + row[3]) / 2.0) as f32).y; + // The caret: a vertical segment within the row's span, through its middle. + let caret = h + .output() + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::LineSegment { points: [a, b], .. } + if (a.x - b.x).abs() < 0.01 && (left..right).contains(&a.x) && a.y.min(b.y) < middle && a.y.max(b.y) > middle => + { + Some((b.y - a.y).abs()) + } + _ => None, + }) + .expect("a caret on the last row"); + assert!(caret > row_height * 0.8 && caret < row_height + 4.0, "caret {caret} px on a {row_height} px row"); +} + /// Opt-in private-document regression. This never captures images or saves the source file. /// Run with PDFCRAFT_TEXT_REGRESSION_PDF=/path/to/document.pdf and --ignored. #[test] @@ -4222,9 +4388,25 @@ fn image_pixels_follow_the_frame_while_a_corner_is_dragged() { .filter(|b| b.width() < page.width() - 1.0) .collect() }; + // The dragged picture is cut from the page raster, which a render thread delivers after + // however many frames it takes (more on a slow runner): wait for it on screen, by state. + let raster_shown = |h: &Harness<'static, PdfCraftApp>| { + h.output().shapes.iter().any(|s| { + matches!(&s.shape, egui::Shape::Mesh(m) if m.texture_id != egui::TextureId::default() && m.calc_bounds().width() >= page.width() - 1.0) + }) + }; + let wait_for_raster = |h: &mut Harness<'static, PdfCraftApp>| { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while h.state().render_pending() || !raster_shown(h) { + assert!(std::time::Instant::now() < deadline, "timed out waiting for the page raster"); + h.step(); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + }; let start = frame(&h).expect("the selected image has a frame"); let (grip, fixed) = (start.right_top(), start.left_bottom()); for factor in [0.6, 1.3] { + wait_for_raster(&mut h); let to = fixed + (grip - fixed) * factor; h.hover_at(grip); h.run_steps(1); @@ -4670,3 +4852,32 @@ fn leaving_organize_goes_back_to_the_workspace_panel() { h.run_steps(3); assert_eq!(h.state().left, LeftPanel::AllTools); } + +/// The Read mode command (not the mode bar) applies a paragraph still open in the editor, as +/// putting the Edit text tool down does; left open, it would stay drawn in Read mode with no tool. +#[test] +fn read_mode_shortcut_applies_the_open_paragraph() { + use pdfcraft_ui_egui::{Mode, QuickTool}; + let mut h = harness(1, |_| {}); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + // The line "Page 1" sits at (20, 150) on a 200 × 300 page, 24 pt. + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let at = egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height()); + h.hover_at(at); + h.run_steps(1); + h.drag_at(at); + h.run_steps(1); + h.drop_at(at); + h.run_steps(3); + assert!(h.state().views[0].line_editor.is_some(), "the line opens for editing"); + h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Chapter One".into(); + h.run_steps(1); + h.state_mut().run_command("view.read_mode"); + h.run_steps(3); + let s = h.state(); + assert_eq!(s.mode, Mode::Read); + assert_eq!(s.quick_tool, QuickTool::Select); + assert!(s.views[0].line_editor.is_none(), "no editor stays open in Read mode"); + assert_eq!(s.session.get(s.views[0].id).unwrap().text_lines(0)[0].text, "Chapter One", "the edit is applied"); +} diff --git a/crates/ui-egui/tests/fonts.rs b/crates/ui-egui/tests/fonts.rs index 258abe89c4..9cfa36c8ab 100644 --- a/crates/ui-egui/tests/fonts.rs +++ b/crates/ui-egui/tests/fonts.rs @@ -291,12 +291,16 @@ fn ui_fonts_work_without_craft_fonts() { } /// Latin- and Cyrillic-script catalogs (Czech, Brazilian Portuguese, German, Spanish, French, Russian, Bulgarian, Hungarian, Ukrainian, Italian, Slovene) are drawn entirely by the app's own faces -/// (Inter, JetBrains Mono): no letter falls through to egui's defaults or a CJK fallback. (egui's +/// (Inter, JetBrains Mono with Inter fallback): no letter falls through to egui's defaults or a CJK fallback. (egui's /// `has_glyphs` can't answer this: with only the primary face it is also the replacement face.) #[test] fn primary_ui_fonts_cover_latin_and_cyrillic_catalogs() { use skrifa::MetadataProvider as _; let defs = theme::font_definitions(); + assert_eq!(&defs.families[&FontFamily::Monospace][..2], ["JetBrainsMono", "Inter"]); + let fallback = &defs.font_data["Inter"]; + let fallback_font = skrifa::FontRef::from_index(&fallback.font, fallback.index).unwrap(); + let fallback_charmap = fallback_font.charmap(); for (code, catalog) in [ ("cs", include_str!("../src/i18n/cs.tsv")), ("el", include_str!("../src/i18n/el.tsv")), @@ -325,7 +329,8 @@ fn primary_ui_fonts_cover_latin_and_cyrillic_catalogs() { let data = &defs.font_data[name]; let font = skrifa::FontRef::from_index(&data.font, data.index).unwrap(); let charmap = font.charmap(); - let mut missing: Vec = text.chars().filter(|c| charmap.map(*c).is_none()).collect(); + let mut missing: Vec = + text.chars().filter(|c| charmap.map(*c).is_none() && (name != "JetBrainsMono" || fallback_charmap.map(*c).is_none())).collect(); missing.sort_unstable(); missing.dedup(); assert!(missing.is_empty(), "{code}: {name} lacks {missing:?}"); diff --git a/crates/ui-egui/tests/pickers.rs b/crates/ui-egui/tests/pickers.rs index e7db8cffa6..bf9df10b39 100644 --- a/crates/ui-egui/tests/pickers.rs +++ b/crates/ui-egui/tests/pickers.rs @@ -748,20 +748,26 @@ fn a_background_startup_forms_script_notice_waits_until_its_tab_is_selected() { /// Embedded bytes and the PDF's original /UF name stay independent of the Save suggestion. fn attachment_fixture() -> Vec { - let name = "invoice\u{202e}fdp.exe"; + attachment_pdf("invoice\u{202e}fdp.exe", b"hello notes") +} + +/// A one-page PDF with one embedded file whose /UF name is `name`. +fn attachment_pdf(name: &str, data: &[u8]) -> Vec { let uf = std::iter::once(0xfeffu16).chain(name.encode_utf16()).map(|c| format!("{c:04X}")).collect::(); - let objs = [ - "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(notes.txt) 5 0 R] >> >> >>".to_string(), - "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), - "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> >>".to_string(), - "<< /Type /EmbeddedFile /Length 11 >>\nstream\nhello notes\nendstream".to_string(), - format!("<< /Type /Filespec /F (notes.txt) /UF <{uf}> /EF << /F 4 0 R >> >>"), + let objs: [Vec; 5] = [ + b"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(notes.txt) 5 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 200 200] /Resources << >> >>".to_vec(), + [format!("<< /Type /EmbeddedFile /Length {} >>\nstream\n", data.len()).as_bytes(), data, b"\nendstream"].concat(), + format!("<< /Type /Filespec /F (notes.txt) /UF <{uf}> /EF << /F 4 0 R >> >>").into_bytes(), ]; 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()); + 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(b"xref\n0 6\n0000000000 65535 f \n"); @@ -824,3 +830,42 @@ fn attachment_save_preserves_bytes_metadata_and_selected_destination() { assert_eq!(doc.info.attachments[index].name, raw_name); assert_eq!(doc.bytes.as_ref(), &original); } + +/// Every file under `dir`, recursively, sorted. +fn files_under(dir: &std::path::Path) -> Vec { + let mut out = Vec::new(); + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { out.extend(files_under(&path)) } else { out.push(path) } + } + out.sort(); + out +} + +#[test] +fn files_written_from_an_attachment_tab_stay_inside_the_chosen_folder() { + let dir = AttachmentDir::new(); + let absolute = dir.0.join("elsewhere").join("x.pdf").to_string_lossy().into_owned(); + for name in ["../../x.pdf", absolute.as_str()] { + let out = dir.0.join("a").join("b").join("out"); + std::fs::create_dir_all(&out).unwrap(); + let mut h = harness(&[]); + h.state_mut().open_bytes("outer.pdf", None, attachment_pdf(name, &fixture(2))).unwrap(); + h.run_steps(2); + let id = h.state().views[0].id; + h.state_mut().attachment_action(id, 0, true); + h.run_steps(2); + assert_eq!(h.state().views.len(), 2, "{name}: the attachment opens as a tab"); + h.state_mut().export_dir_override = Some(out.to_string_lossy().into_owned()); + h.state_mut().split_active(&pdfcraft_ui_egui::SplitPlan::By(pdfcraft_engine::SplitBy::PageCount(1))); + let written = files_under(&dir.0); + assert_eq!(written.len(), 2, "{name}: two split parts, got {written:?}"); + assert!(written.iter().all(|p| p.parent() == Some(out.as_path())), "{name}: {written:?}"); + // Extract writes page 1 under the name Split gave it. + h.state_mut().extract_draft = pdfcraft_ui_egui::ExtractDraft { separate: true, ..Default::default() }; + h.state_mut().extract_selection(); + assert!(h.state().toast.as_ref().is_some_and(|t| t.0.starts_with("Wrote 1 file")), "{name}: {:?}", h.state().toast); + assert_eq!(files_under(&dir.0), written, "{name}"); + std::fs::remove_dir_all(dir.0.join("a")).unwrap(); + } +} diff --git a/crates/ui-egui/tests/prepare.rs b/crates/ui-egui/tests/prepare.rs index eb25e42a03..a2b173c7a1 100644 --- a/crates/ui-egui/tests/prepare.rs +++ b/crates/ui-egui/tests/prepare.rs @@ -250,6 +250,62 @@ fn options_tab_sets_the_check_box_style() { assert_eq!(s.session.get(s.views[0].id).unwrap().field_check_style("agree"), Some(CheckStyle::Cross)); } +#[test] +fn button_label_is_editable_without_renaming_the_field() { + use pdfcraft_engine::{Edit, NewField}; + let mut h = harness(); + // The byte comparisons below must not vary with the save's /ModDate. + let session = &mut h.state_mut().session; + *session = std::mem::take(session).with_clock(|| 1_700_000_000); + h.state_mut().apply_edit(Edit::AddField { + page: 0, + rect: [40.0, 300.0, 190.0, 330.0], + kind: NewField::Button { caption: String::new() }, + name: Some("clear_button".into()), + }); + h.state_mut().open_field_props("clear_button", 0); + h.run_steps(2); + h.get_by_label("Options").click(); + h.run_steps(2); + h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Label").click(); + h.run_steps(1); + h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Label").type_text("Clear Form"); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + + let app = h.state(); + let id = app.views[0].id; + let field = app.session.get(id).unwrap().form.iter().find(|f| f.name == "clear_button").unwrap(); + let bytes = app.session.save_bytes(id).unwrap(); + let doc = pdfcraft_cos::Document::open(bytes).unwrap(); + let widget = doc.get(field.widgets[0].obj); + let mk = doc.resolve(widget.as_dict().unwrap().get(b"MK").unwrap()); + assert_eq!(doc.resolve(mk.as_dict().unwrap().get(b"CA").unwrap()).as_string().unwrap().to_text(), "Clear Form"); + assert_eq!(app.session.get(id).unwrap().can_undo(), Some("Change field properties")); + + // Read the existing label back, and leave both Cancel and an unchanged OK harmless. + let before = app.session.save_bytes(id).unwrap(); + h.state_mut().open_field_props("clear_button", 0); + h.run_steps(2); + h.get_by_label("Options").click(); + h.run_steps(2); + assert_eq!(h.state().field_props.as_ref().unwrap().caption, "Clear Form"); + h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Label").click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Label").type_text("Discard this"); + h.run_steps(2); + h.get_by_label("Cancel").click(); + h.run_steps(2); + assert_eq!(h.state().session.save_bytes(id).unwrap(), before); + h.state_mut().open_field_props("clear_button", 0); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(2); + assert_eq!(h.state().session.save_bytes(id).unwrap(), before); +} + #[test] fn dropdown_alignment_is_editable_and_preserved() { choice_alignment(pdfcraft_engine::NewField::Combo { options: vec!["One".into(), "Two".into()], editable: false }, "Center", 1); diff --git a/crates/ui-egui/tests/print.rs b/crates/ui-egui/tests/print.rs index 48f12feab8..f3d10238cd 100644 --- a/crates/ui-egui/tests/print.rs +++ b/crates/ui-egui/tests/print.rs @@ -244,14 +244,16 @@ fn print_dialog_shows_the_printer_driver_options() { h.get_by_label("Printer properties"); h.get_by_label("Media"); h.get_by_label("Paper tray"); - assert_eq!(h.state().print_draft.job("form.pdf").options, [("InputSlot".to_string(), "Tray2".to_string())]); + // The selected sheet's media is always sent, independently of changed driver properties. + let media = vec![("media".to_string(), "Letter".to_string())]; + assert_eq!(h.state().print_draft.job("form.pdf").options, [("InputSlot".to_string(), "Tray2".to_string()), media[0].clone()]); h.get_by_label("Reset to printer defaults").click(); h.run_steps(2); - assert!(h.state().print_draft.job("form.pdf").options.is_empty(), "defaults are not sent"); + assert_eq!(h.state().print_draft.job("form.pdf").options, media, "driver defaults are not sent; selected paper is retained"); // Options read for another printer never go with this one's job. h.state_mut().print_draft.driver_choices.insert("InputSlot".into(), "Tray2".into()); h.state_mut().print_draft.printer = Some("Other_Queue".into()); - assert!(h.state().print_draft.job("form.pdf").options.is_empty()); + assert_eq!(h.state().print_draft.job("form.pdf").options, media, "another queue must not inherit driver options"); } /// The desktop app lists printers in the background (PowerShell on Windows takes a second or diff --git a/crates/ui-egui/tests/reading_colors.rs b/crates/ui-egui/tests/reading_colors.rs index 69d7b41784..6dd8e11f1b 100644 --- a/crates/ui-egui/tests/reading_colors.rs +++ b/crates/ui-egui/tests/reading_colors.rs @@ -132,7 +132,7 @@ fn reading_colours_seed_on_open() { assert_ne!(revision, id); assert_eq!(h.state().session.reading_colors(revision), Some(ReadingColors::YELLOW_ON_BLACK)); let len = h.state().session.get(id).unwrap().bytes.len(); - h.state_mut().view_signed_version(len); + h.state_mut().view_signed_version(len, 1); let signed = h.state().views.last().unwrap().id; assert_ne!(signed, id); assert_eq!(h.state().session.reading_colors(signed), Some(ReadingColors::YELLOW_ON_BLACK)); diff --git a/crates/ui-egui/tests/recovery.rs b/crates/ui-egui/tests/recovery.rs index 807261c8c6..a259ca3ad9 100644 --- a/crates/ui-egui/tests/recovery.rs +++ b/crates/ui-egui/tests/recovery.rs @@ -411,3 +411,23 @@ fn a_running_instances_autosaves_are_not_offered_to_a_second_instance() { let fourth = harness(instance()); assert_eq!(fourth.state().recoverable.len(), 1, "a name outside the folder is not looked at, and the snapshot is offered"); } + +/// Snapshots are unsaved copies of open documents, so on Unix only the owner may open the +/// folder: a new one is made that way, and one an earlier version left with the default mode is +/// tightened when an instance starts. +#[cfg(unix)] +#[test] +fn the_recovery_folder_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + let mode = |s: &RecoveryStore| std::fs::metadata(s.dir()).unwrap().permissions().mode() & 0o777; + let fresh = store("owner-only"); + crashed_session(&fresh, fixture(2), None, None); + assert_eq!(files_in(&fresh), 2); + assert_eq!(mode(&fresh), 0o700); + + let earlier = store("owner-only-earlier"); + std::fs::create_dir_all(earlier.dir()).unwrap(); + std::fs::set_permissions(earlier.dir(), std::fs::Permissions::from_mode(0o755)).unwrap(); + earlier.claim(); + assert_eq!(mode(&earlier), 0o700); +} diff --git a/crates/ui-egui/tests/signing.rs b/crates/ui-egui/tests/signing.rs index 7b321d4dac..c7fd4c42fa 100644 --- a/crates/ui-egui/tests/signing.rs +++ b/crates/ui-egui/tests/signing.rs @@ -152,11 +152,16 @@ fn drawing_a_signature_creating_an_id_signing_and_trusting() { h.get_by_label_contains("Signed by Grace Hopper").click(); h.run_steps(2); h.get_by_label("Signature validity is UNKNOWN:"); + // Seed a find so the trust path proves it invalidates the view: trust rebuilds + // the display copy, and the next frame must draw from it without any zoom change. + h.state_mut().views[0].find = Some(pdfcraft_ui_egui::canvas::Find { matches: vec![(0, 0..5)], ..Default::default() }); h.get_by_label("Add to trusted certificates").click(); h.run_steps(3); h.get_by_label("Signed and all signatures are valid."); let s = h.state(); assert_eq!(s.session.get(s.views[0].id).unwrap().signatures[0].status, SignatureStatus::Valid); + assert!(s.views[0].find.as_ref().is_some_and(|f| f.matches.is_empty()), "trust invalidates the view without zooming"); + assert!(!s.session.get(s.views[0].id).unwrap().dirty, "trust alone is not an edit"); // Show certificate: the Certificate Viewer. h.get_by_label("Show certificate…").click(); h.run_steps(3); @@ -182,8 +187,60 @@ fn drawing_a_signature_creating_an_id_signing_and_trusting() { assert_eq!(s.views.len(), 2); let v = s.session.get(s.views[1].id).unwrap(); assert!(v.name.contains("signed version") && v.signatures.iter().any(|x| x.signed)); + assert!(s.views[0].snapshot.is_none()); + assert!(s.views[1].snapshot.as_ref().is_some_and(|snap| snap.kind == pdfcraft_ui_egui::canvas::SnapshotKind::SignedVersion)); + // A repeat click reuses the snapshot tab instead of opening another identical one. + h.get_by_label("View signed version").click(); + h.run_steps(3); + let s = h.state(); + assert_eq!((s.views.len(), s.active), (2, Some(1))); + // A repeat from the original tab reuses it too. + h.state_mut().active = Some(0); + h.run_steps(1); + h.get_by_label("View signed version").click(); + h.run_steps(3); + let s = h.state(); + assert_eq!((s.views.len(), s.active), (2, Some(1))); + // The snapshot tab says what it is. + h.get_by_label_contains("You are viewing the signed version of"); + // Closing a clean snapshot closes the tab with no save prompt. The bar's own + // Close is addressed from its message, because the tab chrome has a Close button + // with the same label. + let label = h.get_by_label_contains("You are viewing the signed version of"); + let label_row = label.rect().y_range(); + let mut node = label; + let mut close = None; + for _ in 0..4 { + if let Some(pill) = node.query_by_label("Close") { + close = Some(pill); + break; + } + let Some(up) = node.parent() else { break }; + node = up; + } + let close = close.expect("the notice bar holds its own Close"); + // The bar's Close sits in the bar's row and the tab chrome's sits above it. Without + // this the walk could reach the chrome's button, which closes the same tab, and the + // test would pass with the bar's own button never pressed. + assert!(label_row.contains(close.rect().center().y), "the Close found is the bar's own"); + close.click(); + h.run_steps(2); + assert_eq!(h.state().views.len(), 1); + assert!(h.state().close_request.is_none()); + // Closing a dirty snapshot asks about saving instead of closing at once. + h.get_by_label("View signed version").click(); + h.run_steps(3); + let id = h.state().views[1].id; + h.state_mut().session.apply(id, pdfcraft_engine::Edit::SetInfo { key: "Title".into(), value: "Dirty".into() }).unwrap(); + h.state_mut().views[1].close_requested = true; + h.run_steps(2); + assert_eq!(h.state().views.len(), 2); + h.get_by_label("Save changes to “contract_signed (signed version).pdf” before closing?"); + h.get_by_label("Don't save").click(); + h.run_steps(2); + assert_eq!(h.state().views.len(), 1); // Trusted certificates and the ID list persist. - let saved = s.persist(); + let saved = h.state().persist(); let mut fresh = PdfCraftApp::new(); fresh.set_option("language", "en").unwrap(); fresh.restore(&saved); @@ -228,6 +285,38 @@ fn certifying_without_a_visible_signature() { h.get_by_label_contains("Certified by Test Signer EC"); } +#[test] +fn save_as_keeps_a_signed_document_incremental() { + // #2165: Save As to a new file rewrites unsigned documents in full; a signed one is appended + // to, or its signature would no longer cover the file. + let p12 = concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12"); + let digital_id = pdfcraft_engine::sign::pkcs12::open(&std::fs::read(p12).unwrap(), "test").unwrap(); + let mut s = pdfcraft_engine::Session::new(); + let id = s.open("contract.pdf", None, std::sync::Arc::new(FIXTURE.to_vec()), None).unwrap(); + let opts = pdfcraft_engine::SignOptions { page: 0, rect: Some([20.0, 20.0, 180.0, 60.0]), ..Default::default() }; + let signed = s.sign(id, &digital_id, opts).unwrap(); + let out = dir().join("copy.pdf"); + let (bytes, cert) = (signed.to_vec(), digital_id.certificate.clone()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("contract.pdf", None, bytes).expect("opens"); + app.session.set_trusted_certificates(vec![cert]); + app + }); + h.run_steps(4); + let status = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().signatures.iter().find(|x| x.signed).map(|x| x.status) + }; + assert_eq!(status(&h), Some(SignatureStatus::Valid)); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + assert!(h.state_mut().save_active(pdfcraft_ui_egui::SaveTarget::As), "a signed document can be saved as a copy"); + let saved = std::fs::read(&out).expect("file written"); + assert!(saved.starts_with(&signed), "the signed revision is kept as it was"); + assert_eq!(status(&h), Some(SignatureStatus::Valid)); +} + /// The fixture with an empty signature field across the bottom of the page. const WITH_FIELD: &[u8] = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >> endobj diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 1b6276d7c4..b86f7699f0 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -246,6 +246,39 @@ fn read_mode_closes_the_panes_and_leaving_it_brings_them_back() { assert_eq!(h.state().right, Some(RightPanel::Pages)); } +/// Read mode hides the quick toolbar too, and puts down the tool that was in use (#2646). +#[test] +fn read_mode_hides_the_quick_toolbar() { + use pdfcraft_ui_egui::{Mode, QuickTool}; + let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); + h.get_by_label("Hand (H)").click(); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Hand); + h.get_by_label("Read").click(); + h.run_steps(3); + assert!(h.query_by_label("Select (V)").is_none(), "no quick toolbar in Read mode"); + assert_eq!(h.state().quick_tool, QuickTool::Select); + h.state_mut().run_command("view.read_mode"); + h.run_steps(3); + assert_eq!(h.state().mode, Mode::AllTools); + h.get_by_label("Select (V)"); +} + +/// Opening a document with Read as the default mode puts down the tool in use, as entering Read +/// mode from the mode bar does: the hidden toolbar would leave no way to see or change it. +#[test] +fn opening_into_read_mode_puts_the_tool_down() { + use pdfcraft_ui_egui::{Mode, QuickTool}; + let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); + assert_eq!(h.state().mode, Mode::AllTools); + h.state_mut().set_option("quick", "hand").unwrap(); + h.state_mut().set_option("default-mode", "read").unwrap(); + h.state_mut().open_bytes("again.pdf", None, FIXTURE.to_vec()).unwrap(); + h.run_steps(3); + assert_eq!(h.state().mode, Mode::Read, "the new document opens in the default mode"); + assert_eq!(h.state().quick_tool, QuickTool::Select, "no tool stays active behind the hidden toolbar"); +} + /// A pane closed in Read mode stays closed on the way out: leaving Read mode doesn't bring back the /// pane saved on the way in, and closing Comments keeps its close preference. #[test] @@ -839,6 +872,9 @@ fn files_and_quit_from_the_operating_system() { let queue = std::rc::Rc::new(std::cell::RefCell::new(vec![OsEvent::Open(vec![path.to_string_lossy().into_owned()])])); let q = queue.clone(); let mut h = harness(move |app| app.os_events = Some(Box::new(move || q.borrow_mut().drain(..).collect()))); + // The file is read on a worker thread, after however many frames that takes: it must still + // be there until the tab has opened. + support::wait_until(&mut h, "the file the operating system opened", |app| !app.views.is_empty()); std::fs::remove_file(&path).ok(); assert_eq!(h.state().views.len(), 1, "the file opens in a tab"); h.get_by_label_contains(&name); diff --git a/crates/ui-egui/tests/ui_group/marks.rs b/crates/ui-egui/tests/ui_group/marks.rs index 0a0e45b152..1f1515ad60 100644 --- a/crates/ui-egui/tests/ui_group/marks.rs +++ b/crates/ui-egui/tests/ui_group/marks.rs @@ -96,3 +96,37 @@ fn backgrounds_and_watermarks_from_a_file() { let s = h.state(); assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Add background")); } + +/// Pages from 1 to 1 stays one page: the dialog used to turn "to" back into the last page on +/// every frame while both were 1, so only page 1 could not be chosen alone. The field starts on +/// the last page. +#[test] +fn page_range_from_one_to_one_selects_only_the_first_page() { + let mut h = harness(); + h.state_mut().execute("edit.background"); + h.run_steps(2); + assert_eq!(h.state().marks_draft.range.pages(3), [0, 1, 2]); + h.get_by_label("Pages from").click(); + h.run_steps(2); + let range = h.state().marks_draft.range; + assert_eq!((range.from, range.to), (1, 3), "from the first page to the last"); + h.state_mut().marks_draft.range.to = 1; + h.run_steps(3); + let range = h.state().marks_draft.range; + assert_eq!((range.from, range.to), (1, 1), "the range stays as typed"); + h.get_by_label("Applies to 1 page"); + h.get_by_label("OK").click(); + h.run_steps(3); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.marks(), [MarkKind::Background]); + // Rendered: page 1 has the (default cream) background, page 2 is still blank. + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), pdfcraft_render::RenderConfig::default()); + let corner = |r: &mut pdfcraft_render::PageRenderer, page: usize| { + let p = r.render(pdfcraft_render::RenderRequest { page, scale: 1.0, ..Default::default() }); + assert!(p.error.is_none(), "{:?}", p.error); + p.rgba[..4].to_vec() + }; + assert_eq!(corner(&mut r, 0), vec![255, 255, 217, 255], "page 1 has the background"); + assert_eq!(corner(&mut r, 1), vec![255, 255, 255, 255], "page 2 does not"); +} diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs index 65f4b31324..9cf99021a3 100644 --- a/crates/ui-egui/tests/view.rs +++ b/crates/ui-egui/tests/view.rs @@ -185,6 +185,53 @@ fn single_page_layout_shows_one_page_at_a_time() { assert!(rect(&h, 0).is_none() && rect(&h, 1).is_some(), "only page 2"); } +#[test] +fn single_page_view_prefetches_the_next_page() { + // Only the shown page is drawn, but the next one renders ahead of the turn: otherwise + // turning forward shows blank paper until it arrives. + let mut h = harness(&[("layout", "single"), ("zoom", "50")]); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while h.state().render_pending() { + assert!(std::time::Instant::now() < deadline, "renders settle"); + h.run_steps(2); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(rect(&h, 1).is_none(), "page 2 is not shown"); + assert!(h.state().views[0].raster_memory().page_count >= 2, "page 2 is rendered ahead of the turn"); +} + +#[test] +fn single_page_wheel_turn_draws_the_new_page_at_once() { + // The frame that turns the page draws the new one. Its jump used to be measured from the + // old page's place, which scrolled both out of view: a blank frame on every turn forward. + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "single")]); + h.key_press_modifiers(Modifiers::COMMAND, Key::Num0); // fit page + h.run_steps(4); + let vp = h.state().views[0].viewport_rect(); + h.hover_at(vp.center()); + h.run_steps(2); + h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move)); + h.step(); + assert_eq!(h.state().views[0].current, 1, "one notch turns one page"); + let r = rect(&h, 1).expect("page 2 drawn in the frame that turns to it"); + assert!(r.min.y >= vp.min.y && r.max.y <= vp.max.y, "page 2 fits on screen: {r:?} in {vp:?}"); +} + +#[test] +fn single_page_jump_draws_a_fitting_page_where_it_stays() { + // A page that fits has nothing to scroll, but the jump to it asked for the gap above it: + // drawn that much too high for a frame, it then hopped into place. + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "single")]); + h.key_press_modifiers(Modifiers::COMMAND, Key::Num0); // fit page + h.run_steps(4); + h.state_mut().set_option("page", "2").unwrap(); + h.step(); + let first = rect(&h, 1).expect("page 2 drawn in the frame after the jump"); + h.run_steps(4); + let settled = rect(&h, 1).expect("page 2"); + assert!((first.min.y - settled.min.y).abs() < 0.5, "page 2 drawn where it settles: {first:?} then {settled:?}"); +} + #[test] fn single_page_wheel_pans_instead_when_zoomed_in() { // The zoomed-in guard: with room to pan, the wheel pans and must not turn pages. @@ -345,8 +392,78 @@ fn precise_touchpad_wheel_scrolls_one_to_one() { assert!(drift.abs() < 2.0, "no easing tail after the swipe: drifted {drift}"); } +#[test] +fn rapid_wheel_notches_accelerate_and_follow_touchpad_input() { + use pdfcraft_ui_egui::scroll_speed::ScrollSpeed; + + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "continuous"), ("zoom", "300")]); + h.state_mut().set_option("animation_effects", "off").unwrap(); + let at = h.state().views[0].viewport_rect().center(); + h.hover_at(at); + h.run_steps(2); + + h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move)); + let single = scroll_over(&mut h, 2); + let base = ScrollSpeed::default().line_points(); + assert!((single - base).abs() < 1.0, "one notch keeps the configured base: moved {single}, base {base}"); + + // Let the isolated notch expire, then spin five notches one frame apart. The burst must move + // farther than five base steps without adding work on idle frames. + h.run_steps(30); + let mut burst = 0.0; + for _ in 0..5 { + h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move)); + burst += scroll_over(&mut h, 2); + } + assert!(burst > 7.0 * base, "a rapid five-notch spin accelerates: moved {burst}, base {base}"); + + // A line notch inside the precise path's suppression window keeps its base share (#1072): + // suppressing egui's touchpad tail must not swallow mixed-device input. + h.run_steps(30); + h.event(wheel(MouseWheelUnit::Point, -20.0, TouchPhase::Move)); + scroll_over(&mut h, 2); + // Arrive near the old suppression deadline: the line's new easing tail must extend it. + h.run_steps(11); + h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move)); + let after_touchpad = scroll_over(&mut h, 2); + assert!(after_touchpad >= base, "a notch after point input is not dropped: moved {after_touchpad}, base {base}"); + let delayed = scroll_over(&mut h, 20); + assert!(delayed.abs() < 2.0, "the reconstructed notch has no delayed easing copy: moved {delayed}"); + + h.event(wheel(MouseWheelUnit::Point, -20.0, TouchPhase::Move)); + scroll_over(&mut h, 2); + h.event(wheel(MouseWheelUnit::Page, -1.0, TouchPhase::Move)); + let page_after_touchpad = scroll_over(&mut h, 2); + assert!(page_after_touchpad > base, "a page event after point input is preserved: moved {page_after_touchpad}"); + let delayed_page = scroll_over(&mut h, 20); + assert!(delayed_page.abs() < 2.0, "the reconstructed page has no delayed easing copy: moved {delayed_page}"); +} + +#[test] +fn changing_continuous_layout_resets_wheel_acceleration() { + use pdfcraft_ui_egui::scroll_speed::ScrollSpeed; + + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "continuous"), ("zoom", "300")]); + h.state_mut().set_option("animation_effects", "off").unwrap(); + let at = h.state().views[0].viewport_rect().center(); + h.hover_at(at); + h.run_steps(2); + for _ in 0..5 { + h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move)); + scroll_over(&mut h, 2); + } + + h.state_mut().views[0].set_continuous(false); + h.run_steps(3); + h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move)); + let moved = scroll_over(&mut h, 2); + let base = ScrollSpeed::default().line_points(); + assert!((moved - base).abs() < 1.0, "a layout jump clears the old burst: moved {moved}, base {base}"); +} + /// Steps `frames` frames and returns how far the document scrolled down over them, from the /// pages on screen in each pair of consecutive frames (a page can leave the screen on the way). +/// Used by wheel and non-macOS glide tests below. fn scroll_over(h: &mut Harness<'static, PdfCraftApp>, frames: usize) -> f32 { let tops = |h: &Harness<'static, PdfCraftApp>| (0..3).map(|p| rect(h, p).map(|r| r.min.y)).collect::>(); let mut moved = 0.0; @@ -363,6 +480,8 @@ fn scroll_over(h: &mut Harness<'static, PdfCraftApp>, frames: usize) -> f32 { /// A two-finger swipe down the page, 25 points a frame for six frames, lifted the frame after /// (the axis stop Wayland sends), at 300 % so the document is long enough to glide in: how far /// it scrolled by the frame after the lift (the page is drawn a frame behind its scroll). +/// Same gate as its callers below. +#[cfg(not(target_os = "macos"))] fn swipe_and_lift(h: &mut Harness<'static, PdfCraftApp>) -> f32 { let at = h.state().views[0].viewport_rect().center(); h.hover_at(at); @@ -613,6 +732,35 @@ fn default_zoom_is_used_for_new_documents() { assert_eq!(fit_zoom(&fresh, 0), (Fit::None, 1.5)); } +#[test] +fn the_rail_zoom_field_sets_a_typed_zoom() { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + use pdfcraft_ui_egui::canvas::Fit; + let mut h = harness(&[]); + let type_zoom = |h: &mut Harness<'static, PdfCraftApp>, typed: &str, key: Key| { + h.get_by_role_and_label(Role::TextInput, "Zoom").click(); + h.run_steps(2); + h.get_by_role_and_label(Role::TextInput, "Zoom").type_text(typed); + h.run_steps(1); + h.key_press(key); + h.run_steps(3); + }; + type_zoom(&mut h, "150", Key::Enter); + assert_eq!((h.state().views[0].fit, h.state().views[0].zoom), (Fit::None, 1.5)); + type_zoom(&mut h, "62.5 %", Key::Enter); + assert_eq!(h.state().views[0].zoom, 0.625); + for (typed, key) in [("abc", Key::Enter), ("0", Key::Enter), ("300", Key::Escape)] { + type_zoom(&mut h, typed, key); + assert_eq!(h.state().views[0].zoom, 0.625, "{typed}"); + } + type_zoom(&mut h, "9000", Key::Enter); + assert_eq!(h.state().views[0].zoom, 64.0); + h.state_mut().views[0].set_zoom(2.0); + h.run_steps(2); + assert_eq!(h.get_by_role_and_label(Role::TextInput, "Zoom").value().as_deref(), Some("200%")); +} + #[test] fn preferences_set_the_default_zoom() { use egui_kittest::kittest::Queryable; @@ -1042,6 +1190,47 @@ fn the_hand_tool_pans_by_dragging() { assert!(h.state().views[0].selected_text().is_none(), "no text selection with the hand"); } +#[test] +fn the_hand_tool_moves_the_page_with_the_pointer() { + // With the Hand tool the page stays under the pointer, point for point, in the frame the + // pointer moves (#924, #1992). The drag used to reach the scroll offset only after the frame + // was drawn (and, before the page view stopped animating its scrolls, eased in over several + // frames), so the page trailed behind the pointer; the points moved before egui tells a drag + // from a click were lost on top. + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "continuous"), ("zoom", "600"), ("quick", "hand")]); + let start = h.state().views[0].viewport_rect().center(); + h.hover_at(start); + h.run_steps(2); + let page = |h: &Harness<'static, PdfCraftApp>| rect(h, 0).expect("page 1 on screen").min; + let grabbed = page(&h); + let click_dist = h.ctx.options(|o| o.input_options.max_click_dist); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + h.event(button(start, true)); + h.step(); + // A slow start, two points a frame, then quick strokes both ways. Once the pointer is further + // than a click may wander, the page has moved exactly as far as it, in every frame. + let mut pointer = start; + let strokes = [egui::vec2(-2.0, -2.0); 5].into_iter().chain([egui::vec2(-40.0, -150.0), egui::vec2(25.0, 60.0)]); + for stroke in strokes { + pointer += stroke; + h.event(egui::Event::PointerMoved(pointer)); + h.step(); + if (pointer - start).length() > click_dist { + assert_eq!(page(&h) - grabbed, pointer - start, "the page keeps up with the pointer in the same frame"); + } + } + // Held still, it stays: nothing is left to ease in. + h.run_steps(30); + assert_eq!(page(&h) - grabbed, pointer - start, "nothing moves the page once the pointer stops"); + // Let go mid-stroke, it stops with the pointer: the Hand tool doesn't fling the page on. + pointer += egui::vec2(0.0, -30.0); + h.event(egui::Event::PointerMoved(pointer)); + h.step(); + h.event(button(pointer, false)); + h.run_steps(30); + assert_eq!(page(&h) - grabbed, pointer - start, "the page stays where it was let go"); +} + #[test] fn required_fields_get_a_red_border_when_highlighting() { let _gpu = gpu(); @@ -1377,3 +1566,82 @@ fn two_up_scroll_end_reaches_an_unpaired_final_page() { fn two_up_scroll_end_with_cover_reaches_a_taller_left_page() { assert_final_spread_bottom_reachable(&[400, 400, 400, 400, 100], true); } + +#[test] +fn switching_modes_keeps_the_current_page_at_fixed_zoom() { + use egui_kittest::kittest::Queryable; + let kids = (3..33).map(|i| format!("{i} 0 R")).collect::>().join(" "); + let mut pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [{kids}] /Count 30 /MediaBox [0 0 300 400] >> endobj\n" + ); + for i in 3..33 { + pdf.push_str(&format!("{i} 0 obj << /Type /Page /Parent 2 0 R >> endobj\n")); + } + pdf.push_str("trailer << /Root 1 0 R >>\n%%EOF"); + let other = pdf.clone().into_bytes(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("pages.pdf", None, pdf.into_bytes()).unwrap(); + app.set_option("zoom", "100").unwrap(); + app + }); + h.run_steps(8); + h.state_mut().views[0].go_to_page(17); + h.run_steps(8); + assert_eq!(h.state().views[0].current, 17); + h.hover_at(h.state().views[0].viewport_rect().center()); + h.event(wheel(MouseWheelUnit::Point, -35.0, TouchPhase::Move)); + h.run_steps(8); + let before = rect(&h, 17).unwrap().top() - h.state().views[0].viewport_rect().top(); + h.get_by_label("Read").click(); + h.run_steps(8); + assert_eq!(h.state().views[0].current, 17, "switching to Read should keep the current page"); + let after = rect(&h, 17).unwrap().top() - h.state().views[0].viewport_rect().top(); + assert!((before - after).abs() < 1.0, "the position within the page moved: {before} to {after}"); + h.state_mut().views[0].go_to_page(5); + h.run_steps(8); + assert_eq!(h.state().views[0].current, 5); + h.get_by_label("All tools").click(); + h.run_steps(8); + assert_eq!(h.state().views[0].current, 5, "switching to All tools should keep the current page"); + h.state_mut().open_bytes("other.pdf", None, other).unwrap(); + h.state_mut().set_option("zoom", "100").unwrap(); + h.run_steps(8); + h.state_mut().views[1].go_to_page(9); + h.run_steps(8); + assert_eq!(h.state().views[1].current, 9); + h.get_by_label("pages.pdf").click(); + h.run_steps(8); + assert_eq!(h.state().views[0].current, 5, "the first tab keeps its own position"); + h.get_by_label("other.pdf").click(); + h.run_steps(8); + assert_eq!(h.state().views[1].current, 9, "the second tab keeps its own position"); +} + +#[test] +fn long_view_menu_fits_a_short_window() { + use egui_kittest::kittest::Queryable; + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 400.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("pages.pdf", None, PAGES.to_vec()).unwrap(); + app + }); + h.run_steps(8); + h.get_by_label("Menu").click(); + h.run_steps(2); + h.get_by_label("View ⏵").hover(); + h.run_steps(4); + let before = h.get_by_label("Side panels ⏵").rect().bottom(); + h.get_by_label("Fit to height ").hover(); + h.event(wheel(MouseWheelUnit::Point, -1000.0, TouchPhase::Move)); + h.run_steps(6); + let after = h.get_by_label("Side panels ⏵").rect().bottom(); + assert!(after < before && after <= 400.0, "menu did not scroll into view: {before} -> {after}"); + h.get_by_label("Side panels ⏵").hover(); + h.run_steps(4); + h.get_all_by_label("Bookmarks").last().unwrap().click(); + h.run_steps(4); + assert_eq!(h.state().right, Some(pdfcraft_ui_egui::RightPanel::Bookmarks)); +} diff --git a/crates/ui-egui/tests/viewer.rs b/crates/ui-egui/tests/viewer.rs index 077553c71d..b154694854 100644 --- a/crates/ui-egui/tests/viewer.rs +++ b/crates/ui-egui/tests/viewer.rs @@ -175,6 +175,36 @@ fn find_shortcuts_advance_and_retreat_matches() { assert_eq!(h.state().views[0].find.as_ref().unwrap().current, Some((start + n - 1) % n)); } +/// #2669: ⌘G and ⇧⌘G step through the matches while the find field still has the focus. +#[test] +fn find_shortcuts_work_from_the_find_field() { + let mut h = harness(); + h.state_mut().active = Some(0); + h.run_steps(2); + h.state_mut().views[0].open_find(); + h.state_mut().views[0].find.as_mut().unwrap().query = "page".into(); + h.state_mut().views[0].rerun_find(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + while std::time::Instant::now() < deadline { + h.run_steps(2); + std::thread::sleep(std::time::Duration::from_millis(5)); + if h.state().views[0].find.as_ref().unwrap().matches.len() >= 15 { + break; + } + } + let n = h.state().views[0].find.as_ref().unwrap().matches.len(); + assert_eq!(n, 15); + assert!(h.ctx.egui_wants_keyboard_input(), "the find field has the focus"); + let start = h.state().views[0].find.as_ref().unwrap().current.unwrap(); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::G); + h.run_steps(2); + assert_eq!(h.state().views[0].find.as_ref().unwrap().current, Some((start + 1) % n)); + h.key_press_modifiers(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, egui::Key::G); + h.run_steps(2); + assert_eq!(h.state().views[0].find.as_ref().unwrap().current, Some(start)); + assert!(h.ctx.egui_wants_keyboard_input(), "and keeps it"); +} + #[test] fn marking_while_searching_does_not_jitter_or_reset_search_state() { let mut h = harness(); @@ -857,6 +887,113 @@ fn an_earlier_revision_opens_from_document_properties() { let doc = s.session.get(s.views[s.active.unwrap()].id).unwrap(); assert_eq!(doc.name, "updated (revision 1).pdf"); assert_eq!(doc.info_value("Title"), None, "as it was before the update"); + // A repeat reuses the revision tab instead of opening another identical one. + let views = s.views.len(); + let ids: Vec<_> = h.state().views.iter().map(|v| v.id).collect(); + let original = { + let s = h.state(); + ids.iter().position(|id| s.session.get(*id).is_some_and(|d| d.name == "updated.pdf")).unwrap() + }; + h.state_mut().active = Some(original); + h.state_mut().open_revision(1); + h.run_steps(1); + let s = h.state(); + assert_eq!(s.views.len(), views); + assert!( + s.views[s.active.unwrap()] + .snapshot + .as_ref() + .is_some_and(|snap| snap.kind == pdfcraft_ui_egui::canvas::SnapshotKind::Revision && snap.revision == 1) + ); + // An unknown revision errors without opening a tab. + h.state_mut().active = Some(original); + h.state_mut().open_revision(99); + h.run_steps(1); + let s = h.state(); + assert_eq!(s.views.len(), views); + assert!(h.state().toast.as_ref().is_some_and(|t| t.0.starts_with("Couldn't open revision 99")), "unknown revisions error without opening a tab"); + // Another tab over the same original shares the snapshot: b.pdf's first revision is + // byte-identical to updated.pdf's (incremental saves append), so it reuses the tab. + let ids: Vec<_> = h.state().views.iter().map(|v| v.id).collect(); + let other = { + let s = h.state(); + ids.iter().position(|id| s.session.get(*id).is_some_and(|d| d.name == "b.pdf")).unwrap() + }; + h.state_mut().active = Some(other); + h.state_mut().open_revision(1); + h.run_steps(1); + let s = h.state(); + assert_eq!(s.views.len(), views); + assert!( + s.views[s.active.unwrap()] + .snapshot + .as_ref() + .is_some_and(|snap| snap.kind == pdfcraft_ui_egui::canvas::SnapshotKind::Revision && snap.revision == 1) + ); +} + +#[test] +fn wrapped_revision_buttons_stay_above_binding_and_open_the_saved_revision() { + use pdfcraft_engine::{Edit, Session}; + let mut bytes = std::sync::Arc::new(fixture(1)); + // Reopen each save so the next edit appends to the preceding saved revision. + for revision in 2..=13 { + let mut source = Session::new().with_clock(|| 1_700_000_000); + let id = source.open("revisions.pdf", None, bytes, None).unwrap(); + source.apply(id, Edit::SetInfo { key: "Title".into(), value: format!("Revision {revision}") }).unwrap(); + bytes = source.save_bytes(id).unwrap(); + } + for width in [1400.0, 720.0] { + let pdf = bytes.clone(); + let mut h = Harness::builder().with_size(egui::vec2(width, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("revisions.pdf", None, pdf.to_vec()).unwrap(); + app + }); + let s = h.state(); + let doc = s.session.get(s.views[s.active.unwrap()].id).unwrap(); + assert_eq!(doc.revision_ends().len(), 13, "the fixture contains thirteen saved revisions"); + h.state_mut().dialog = Some(Dialog::Properties(pdfcraft_ui_egui::PropsTab::Advanced)); + // Let the grid and the centered dialog settle before measuring or clicking. + h.run_steps(6); + let buttons: Vec<_> = (1..13).map(|n| (n, h.get_by_label(&format!("View revision {n}")).rect())).collect(); + let first_row = h.get_by_label("View revision 12").rect(); + let lower_revision = buttons + .iter() + .find(|(n, rect)| *n > 1 && rect.top() > first_row.bottom()) + .map(|(n, _)| *n) + .expect("revision buttons wrap to a lower row"); + let binding = h.get_by_label("Binding").rect(); + let left = h.get_by_label("Left Edge").rect(); + let right = h.get_by_label("Right Edge").rect(); + // Properties requests a fixed width. Measure its card in each viewport. + let card = h.ctx.data(|d| d.get_temp::(egui::Id::new("dialog").with("card"))).expect("Properties is shown"); + for (n, rect) in &buttons { + for (label, below) in [("Binding", binding), ("Left Edge", left), ("Right Edge", right)] { + assert!(rect.bottom() <= below.top(), "revision {n} overlaps {label} at viewport width {width}: {rect:?}, {below:?}"); + } + } + for (n, rect) in &buttons { + assert!( + rect.left() >= card.left() && rect.right() <= card.right(), + "revision {n} escapes Properties horizontally at viewport width {width}: {rect:?}, measured card {card:?}" + ); + } + h.get_by_label(&format!("View revision {lower_revision}")).scroll_to_me(); + h.run_steps(3); + h.get_by_label(&format!("View revision {lower_revision}")).click(); + h.run_steps(3); + let s = h.state(); + assert!(s.dialog.is_none(), "opening a revision closes Properties"); + let view = &s.views[s.active.unwrap()]; + let doc = s.session.get(view.id).unwrap(); + assert_eq!(doc.name, format!("revisions (revision {lower_revision}).pdf")); + assert_eq!(doc.info_value("Title"), Some(format!("Revision {lower_revision}"))); + let snapshot = view.snapshot.as_ref().expect("the saved revision opens as a snapshot"); + assert_eq!(snapshot.kind, pdfcraft_ui_egui::canvas::SnapshotKind::Revision); + assert_eq!(snapshot.revision, lower_revision); + } } #[test] @@ -1261,6 +1398,70 @@ fn pages_panel_thumbnails_have_a_context_menu() { assert_eq!(h.state().page_clipboard.as_ref().map(|c| c.pages.clone()), Some(vec![2])); } +/// Where the Pages panel draws page `n`'s thumbnail. +fn thumbnail_rect(h: &Harness<'static, PdfCraftApp>, n: usize) -> egui::Rect { + h.get_all_by_label(&format!("Page {n}")).find(|t| t.rect().left() > 1000.0).expect("the Pages panel thumbnail").rect() +} + +/// Whether `r` lies wholly in the part of the Pages panel in view. +fn in_panel_view(h: &Harness<'static, PdfCraftApp>, r: egui::Rect) -> bool { + h.state().views[0].pages_panel.expect("the Pages panel is shown").contains_rect(r) +} + +/// #2781: as in Acrobat, going to a page whose thumbnail is out of sight scrolls the Pages +/// panel to show it. +#[test] +fn going_to_a_far_page_scrolls_its_thumbnail_into_view() { + let mut h = pages_panel_harness(&[(200, 300); 30]); + assert!(!in_panel_view(&h, thumbnail_rect(&h, 25)), "page 25 starts out of sight"); + h.state_mut().views[0].go_to_page(24); + h.run_steps(4); + assert_eq!(h.state().views[0].current, 24); + assert!(in_panel_view(&h, thumbnail_rect(&h, 25)), "page 25's thumbnail is shown"); +} + +/// #2781: a page whose thumbnail is already in view leaves the panel where it is, rather than +/// centring the thumbnail on every page change. +#[test] +fn going_to_a_page_in_view_leaves_the_pages_panel_still() { + // Landscape pages, so several thumbnails share the panel. + let mut h = pages_panel_harness(&[(300, 200); 30]); + h.state_mut().views[0].go_to_page(14); + h.run_steps(4); + let clip = h.state().views[0].pages_panel.expect("the Pages panel is shown"); + // The topmost thumbnail wholly in view, other than the current page's. + let (n, before) = + (1..=30).map(|n| (n, thumbnail_rect(&h, n))).find(|(n, r)| *n != 15 && in_panel_view(&h, *r)).expect("another thumbnail in view"); + assert!( + (before.center().y - clip.center().y).abs() > 50.0, + "page {n} at {before:?} is away from the middle of {clip:?}, where centring would move it" + ); + h.state_mut().views[0].go_to_page(n - 1); + h.run_steps(4); + assert_eq!(h.state().views[0].current, n - 1); + assert_eq!(thumbnail_rect(&h, n), before, "page {n}'s thumbnail stayed put"); +} + +/// #2781: while the user drags thumbnails, a page change does not pull the panel away from +/// where they are being dropped. +#[test] +fn a_page_change_while_thumbnails_are_dragged_does_not_scroll_the_pages_panel() { + let mut h = pages_panel_harness(&[(200, 300); 30]); + let first = thumbnail_rect(&h, 1); + let at = first.center(); + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.run_steps(1); + h.hover_at(at + egui::vec2(0.0, 30.0)); + h.run_steps(2); + assert_eq!(h.state().views[0].panel_drag, Some(vec![0]), "page 1 is being dragged"); + h.state_mut().views[0].go_to_page(24); + h.run_steps(4); + assert_eq!(h.state().views[0].current, 24); + assert_eq!(thumbnail_rect(&h, 1), first, "the panel stays put under the drag"); +} + /// #444, #186: a mouse-wheel notch scrolls as far as the scroll speed preference says; /// trackpad (point) scrolling keeps its own speed. The choice is saved. #[test] @@ -1341,6 +1542,55 @@ fn a_clicked_picture_is_selected_and_copied_in_its_original_format() { assert_eq!(h.state().views[i].picked_image, None); } +/// A picture picked with the Select tool, then deleted in Edit text & images, leaves no highlight +/// behind on the empty spot where it was. +#[test] +fn a_picked_picture_that_is_deleted_in_edit_mode_loses_its_highlight() { + let (_, mut h) = picture_harness(); + let i = h.state().active.unwrap(); + let id = h.state().views[i].id; + let at = h.state().views[i].page_screen_rect(0).expect("on screen").center(); + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + assert!(h.state().views[i].picked_image.is_some(), "picked with the Select tool"); + // Edit text & images: click the picture, delete it with the Delete key. + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + h.hover_at(at); + h.run_steps(1); + h.drag_at(at); + h.run_steps(1); + h.drop_at(at); + h.run_steps(2); + h.key_press(egui::Key::Delete); + // One frame, no more input: the highlight goes at once, it does not wait for a click. + h.run_steps(1); + assert!(h.state().session.get(id).unwrap().page_images(0).is_empty(), "the picture is gone"); + assert_eq!(h.state().views[i].picked_image, None, "and so is its highlight"); +} + +/// The highlight goes in the very edit that removes the picture, not on a later frame: the window +/// only redraws on the next input, so waiting for one leaves the highlight on screen until a click. +#[test] +fn an_edit_clears_the_picked_picture_at_once() { + let (_, mut h) = picture_harness(); + let i = h.state().active.unwrap(); + let at = h.state().views[i].page_screen_rect(0).expect("on screen").center(); + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + assert!(h.state().views[i].picked_image.is_some(), "picked with the Select tool"); + let delete = pdfcraft_engine::Edit::EditPageImage { page: 0, index: 0, change: pdfcraft_engine::ImageEdit::Delete }; + assert!(h.state_mut().apply_edit(delete)); + // No frame has run since the edit. + assert_eq!(h.state().views[i].picked_image, None); +} + #[test] fn right_click_on_a_picture_copies_it_at_full_size() { let (px, mut h) = picture_harness(); @@ -1738,3 +1988,27 @@ fn arrow_keys_follow_the_scroll_speed_preference() { let (slow, normal, fast) = (lines_to_page_2("slow"), lines_to_page_2("normal"), lines_to_page_2("fast")); assert!(fast * 3 < normal * 2 && normal * 3 < slow * 2, "presses to page 2: slow {slow}, normal {normal}, fast {fast}"); } + +/// #2665: next to a very wide page the normal pages sit far to its right, outside the window. +/// Going to one changed the page counter but kept the horizontal scroll, so it stayed out of +/// sight; as in Acrobat, it is brought into view. +#[test] +fn go_to_page_brings_the_page_into_view_horizontally() { + use pdfcraft_ui_egui::canvas::Fit; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("wide.pdf", None, sized_fixture(&[(595, 842), (595, 842), (595, 842), (6000, 2000)])).unwrap(); + app.views[0].fit = Fit::None; + app.views[0].zoom = 1.0; + app + }); + h.run_steps(4); + assert!(h.state().views[0].page_screen_rect(0).is_none(), "page 1 starts off-screen, right of the window"); + h.state_mut().views[0].go_to_page(2); + h.run_steps(4); + let v = &h.state().views[0]; + assert_eq!(v.current, 2); + let (r, vp) = (v.page_screen_rect(2).expect("page 3 on screen"), v.viewport_rect()); + assert!(r.left() >= vp.left() && r.right() <= vp.right(), "page 3 at {r:?}, viewport {vp:?}"); +} diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index a491d3f493..62d7beedf1 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -232,6 +232,7 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result content: vec![Rect::new(36.0, 36.0, 540.0, 720.0)], items: Vec::new(), occur_max: None, + occur_min: 0, }], }; let mut values = HashMap::new(); @@ -261,6 +262,7 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result if tpl.root.common.presence.occupies() { l.flow_subform(&root_node, None, &ctx, 0, 0)?; } + l.satisfy_page_area_minimums()?; l.check_measures()?; let total = l.pages.len(); let count = total.to_string(); @@ -497,11 +499,28 @@ impl Layouter<'_> { // ───────────────────────────────────────────────────────────────────────── pages fn new_page(&mut self) -> Result<(), XfaError> { + let idx = + (0..self.areas.len()).find(|&i| self.areas[i].occur_max.is_none_or(|m| self.uses[i] < m)).unwrap_or(self.areas.len().saturating_sub(1)); + self.new_page_of(idx) + } + + /// Once the body has been laid out, give every page area that has been used fewer times than + /// its `occur min` the pages it still needs, in template order, after the body's pages. + fn satisfy_page_area_minimums(&mut self) -> Result<(), XfaError> { + let areas = Rc::clone(&self.areas); + for (idx, area) in areas.iter().enumerate() { + while self.uses.get(idx).is_some_and(|u| *u < area.occur_min) { + self.new_page_of(idx)?; + } + } + Ok(()) + } + + /// Start a page from page area `idx`. + fn new_page_of(&mut self, idx: usize) -> Result<(), XfaError> { if self.pages.len() >= MAX_PAGES { return Err(XfaError::TooLarge(format!("the form lays out to more than {MAX_PAGES} pages"))); } - let idx = - (0..self.areas.len()).find(|&i| self.areas[i].occur_max.is_none_or(|m| self.uses[i] < m)).unwrap_or(self.areas.len().saturating_sub(1)); let areas = Rc::clone(&self.areas); let Some(area) = areas.get(idx) else { return Err(XfaError::Malformed("the template has no page area".into())); diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs index f4ef3fabce..572675f958 100644 --- a/crates/xfa/src/model.rs +++ b/crates/xfa/src/model.rs @@ -680,6 +680,9 @@ pub struct PageArea { pub items: Vec, /// How many pages may use this master (`None`: unbounded). pub occur_max: Option, + /// How many pages must use this master: a layout that ends with fewer adds pages for it. + /// Zero without an ``. + pub occur_min: usize, } #[derive(Clone, Debug, Default, PartialEq)] diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index f6c69789b4..4582d59b22 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -529,8 +529,11 @@ fn parse_page_set(n: XmlNode, warnings: &mut Vec, depth: usize) -> PageS content.push(Rect::new(0.0, 0.0, width, height)); } let items = pa.children().filter(|c| c.is_element()).filter_map(|c| parse_node(c, warnings, depth + 1)).collect(); - let occur = parse_occur(child(pa, "occur")); - areas.push(PageArea { name: pa.attribute("name").map(str::to_string), width, height, content, items, occur_max: occur.max }); + // A page area with no `` has no limit and no minimum (a subform's default of one + // would exhaust it, and demand a page for every master). + let occur = child(pa, "occur").map(|o| parse_occur(Some(o))); + let (occur_min, occur_max) = occur.map_or((0, None), |o| (o.min, o.max)); + areas.push(PageArea { name: pa.attribute("name").map(str::to_string), width, height, content, items, occur_max, occur_min }); } // Nested page sets: take their areas too. for ps in children(n, "pageSet") { diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 1e8730586d..03eb2c67ad 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -1191,6 +1191,75 @@ fn repeating_subforms_inside_positioned_content_get_their_instances() { assert!(((after.rect.y - rows[2].rect.y) - step).abs() < 0.01, "after: {:?}, last row: {:?}", after.rect, rows[2].rect); } +/// A page area without an `` repeats for as long as the body needs pages. Before, it was +/// treated like a subform (at most once), so once the first body page was out the layout fell +/// back to the last page area, here one limited to a single use, and drew its content over every +/// body page (and listed a copy of its fields per page). +#[test] +fn page_area_without_occur_serves_every_body_page() { + let section = |n: u32| { + format!( + r#""# + ) + }; + let xdp = format!( + r##" + + + +"##, + section(1), + section(2), + section(3), + section(4) + ); + let form = layout_xml(&xdp).unwrap(); + let soms: Vec> = form.pages.iter().map(|p| widgets(p).iter().map(|w| w.som.clone()).collect()).collect(); + let has_button = |page: &Vec| page.iter().any(|s| s.contains("PrintButton")); + // Four body pages, then the once-only page: its button is on that page alone, not over the body. + assert_eq!(form.pages.len(), 5, "{soms:?}"); + assert!(soms[..4].iter().all(|p| !has_button(p)), "the single-use page area is not drawn over body pages: {soms:?}"); + assert_eq!(soms[4].iter().filter(|s| s.contains("PrintButton")).count(), 1, "the required page is laid out once: {soms:?}"); +} + +/// A page area's `occur min` is a page it must have even when the body never reaches it, and a +/// body that already used it does not get another. An area with no `` needs none. +#[test] +fn page_area_minimum_adds_pages_after_the_body_only_when_unused() { + let area = |name: &str, occur: &str| { + format!( + r#"{occur}"# + ) + }; + let pages = |areas: String| { + let xdp = format!( + r##" +"## + ); + layout_xml(&xdp).unwrap().pages.len() + }; + // The body is one page; the required second master adds one, a third with min 2 adds two. + assert_eq!(pages(area("A", "") + &area("B", r#""#)), 2); + assert_eq!(pages(area("A", "") + &area("B", r#""#) + &area("C", r#""#)), 4); + // An area the body already used (it is first, so page 1 is its own) adds nothing. + assert_eq!(pages(area("A", r#""#)), 1); + assert_eq!(pages(area("A", r#""#) + &area("B", "")), 1); + // No `` and `min="0"` need no page. + assert_eq!(pages(area("A", "") + &area("B", r#""#)), 1); +} + /// Areas are named in SOM expressions but are not data scopes; unnamed subforms and areas have /// no segment at all. What the template doesn't have is kept as it is. #[test] diff --git a/crates/xfdf/README.md b/crates/xfdf/README.md index 2b179e2007..f1ca8ac001 100644 --- a/crates/xfdf/README.md +++ b/crates/xfdf/README.md @@ -18,6 +18,16 @@ let report = import(&mut doc, &bytes)?; // XFDF, FDF, XML, CSV o - **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). +XML and XFDF exports preserve tabs and line breaks in field names and comment metadata with +character references, so XML attribute normalization cannot change their identities. + +Plain AcroForm XML imports retain all direct text around comments and processing instructions, +including CDATA and character references. Nested-element and XFA interpretation are unchanged. + +CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV +delimiters without changing field identities. Automatic detection of externally authored, ambiguous +tables remains heuristic. + Import replaces comments with the same `/NM` (and drops the replaced comment's pop-up), draws appearances PdfCraft can draw, and sets values through the form's own checks (formats, validation, recalculation); read-only fields take imported values, as in Acrobat. XML is read diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 50326c4e71..ef5a30a90f 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -197,8 +197,13 @@ fn esc(s: &str) -> String { '<' => o.push_str("<"), '>' => o.push_str(">"), '"' => o.push_str("""), + // XML 1.0 §3.3.3 normalizes literal attribute whitespace, but not references. + '\t' => o.push_str(" "), + '\n' => o.push_str(" "), '\r' => o.push_str(" "), c if (c as u32) < 0x20 && c != '\n' && c != '\t' => {} + // XML 1.0 noncharacters are not allowed even as raw text. + '\u{FFFE}' | '\u{FFFF}' => {} c => o.push(c), } } @@ -612,7 +617,7 @@ pub fn export_data(doc: &Document, format: Format) -> String { out } Format::Csv => { - let q = |s: &str| if s.contains([',', '"', '\n', '\r']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }; + let q = |s: &str| if s.contains([',', '"', '\n', '\r', '\t']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }; format!( "{}\n{}\n", fields.iter().map(|f| q(&f.0)).collect::>().join(","), @@ -1041,7 +1046,7 @@ pub fn merge_csv(files: &[Vec<(String, Vec)>]) -> String { } } } - let q = |s: &str| if s.contains([',', '"', '\n', '\r']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }; + let q = |s: &str| if s.contains([',', '"', '\n', '\r', '\t']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }; let mut out = columns.iter().map(|c| q(c)).collect::>().join(","); out.push('\n'); for f in files { @@ -1101,6 +1106,31 @@ fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result { Ok(report) } +// Inspect the first logical record: tabs within quoted CSV cells are data. A quote +// in the middle of an unquoted TSV name stays literal, as in the TSV reader. +fn has_tab_delimiter(text: &str) -> bool { + let mut quoted = false; + let mut cell_start = true; + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '"' if quoted && chars.peek() == Some(&'"') => { + chars.next(); + } + '"' if quoted || cell_start => { + quoted = !quoted; + cell_start = false; + } + '\t' if !quoted => return true, + '\r' | '\n' if !quoted => return false, + ',' if !quoted => cell_start = true, + _ => cell_start = false, + } + } + // An unclosed quote can be a literal TSV field-name character. + quoted && text.lines().next().is_some_and(|line| line.contains('\t')) +} + /// Form data from XML, CSV or tab-delimited text. fn import_table(doc: &mut Document, text: &str) -> Result { let mut report = Report::default(); @@ -1114,10 +1144,11 @@ fn import_table(doc: &mut Document, text: &str) -> Result { .map(|c| { let name = c.attributes().find(|a| a.name() == "original").map(|a| a.value().to_string()).unwrap_or_else(|| c.tag_name().name().to_string()); - (name, vec![c.text().unwrap_or("").to_string()]) + // Comments and processing instructions do not end a field's character data. + (name, vec![c.children().filter(|node| node.is_text()).filter_map(|node| node.text()).collect()]) }) .collect() - } else if t.lines().next().is_some_and(|l| l.contains('\t')) { + } else if has_tab_delimiter(t) { tab_delimited(t)? } else { // CSV with quotes: a quoted value may hold commas, doubled quotes and line breaks. diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index d244df1adf..a4f6abb2d3 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use pdfcraft_annot::{Markup, Meta, NewAnnotation, Shape, Style, add_annotation, add_reply, summaries}; +use pdfcraft_annot::{Markup, Meta, NewAnnotation, Shape, Style, add_annotation, add_reply, add_text_replacement, summaries}; use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; use pdfcraft_forms::{NewField, add_field, fields, set_value}; @@ -142,6 +142,150 @@ fn values(doc: &Document) -> Vec<(String, Vec)> { fields(doc).into_iter().map(|f| (f.name, f.value)).collect() } +/// #2666: Replace Text writes a StrikeOut and a Caret that replies to it. XFDF names a reply by +/// the `/NM` of what it replies to, so when both carried one id the caret's `inreplyto` was its +/// own `name`: importing it found a comment replying to itself and the pair was lost. +#[test] +fn a_replace_text_pair_survives_an_xfdf_round_trip() { + let mut src = blank(); + let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]]; + let style = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None }; + let meta = |id: &str| Meta { date: Some("D:20261001120000Z".into()), id: id.into() }; + add_text_replacement(&mut src, 0, &quads, "new words", "Ada", &style, &style, &meta("strike"), &meta("caret")).unwrap(); + assert_eq!(summaries(&src).len(), 2, "a strikeout and a caret"); + + let xfdf = export_xfdf(&src, true, false, "a.pdf"); + assert!(xfdf.contains("name=\"strike\"") && xfdf.contains("name=\"caret\""), "each gets its own name: {xfdf}"); + assert!(xfdf.contains("inreplyto=\"strike\""), "the caret replies to the strikeout: {xfdf}"); + assert!(!xfdf.contains("inreplyto=\"caret\""), "never a reply to itself"); + + let mut dst = blank(); + let r = import(&mut dst, xfdf.as_bytes()).unwrap(); + assert_eq!(r.comments, 2, "both comments import: {r:?}"); + let found = summaries(&dst); + let kinds: Vec<&str> = found.iter().map(|s| s.subtype.as_str()).collect(); + assert!(kinds.contains(&"StrikeOut") && kinds.contains(&"Caret"), "{kinds:?}"); + assert_eq!(comment_view(&dst), comment_view(&src), "the pair and its thread survive"); +} + +#[test] +fn xml_exports_drop_xml_noncharacters() { + let mut src = filled(); + set_value(&mut src, "Name", &FieldValue::Text("a\u{FFFE}b\u{FFFF}c".into())).unwrap(); + let xfdf = export_xfdf(&src, false, true, "f.pdf"); + let doc = roxmltree::Document::parse(&xfdf).unwrap_or_else(|e| panic!("XFDF is not well-formed: {e}: {xfdf:?}")); + let value = doc + .descendants() + .find(|n| n.has_tag_name("field") && n.attribute("name") == Some("Name")) + .and_then(|n| n.children().find(|c| c.has_tag_name("value"))) + .and_then(|n| n.text()); + assert_eq!(value, Some("abc")); + let data = export_data(&src, Format::Xml); + roxmltree::Document::parse(&data).unwrap_or_else(|e| panic!("XML is not well-formed: {e}: {data:?}")); +} + +#[test] +fn xml_exports_preserve_whitespace_in_field_names() { + let names = ["Line\nName", "Line\tName", "Line\rName", "Line\r\nName", "\t Leading\nTrailing \t", "Line Name"]; + let value = "value\twith\nlines\r\nand & \"quotes\""; + let mut src = one_field(); + pdfcraft_forms::delete_field(&mut src, "Name").unwrap(); + for (index, name) in names.iter().enumerate() { + let placeholder = format!("field{index}"); + add_field(&mut src, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: true }, Some(&placeholder)).unwrap(); + // Existing PDFs can have leading/trailing whitespace even though add_field trims it. + let field = fields(&src).into_iter().find(|f| f.name == placeholder).unwrap(); + src.update_dict(field.obj, |d| d.set(b"T".to_vec(), PdfString::text(name))).unwrap(); + set_value(&mut src, name, &FieldValue::Text(format!("{index}: {value}"))).unwrap(); + } + let source_bytes = write_full(&src, &SaveOptions::default()).unwrap(); + let src = Document::open(Arc::new(source_bytes.clone())).unwrap(); + for format in [Format::Xml, Format::Xfdf] { + let exported = if format == Format::Xfdf { export_xfdf(&src, false, true, "form.pdf") } else { export_data(&src, format) }; + let xml = roxmltree::Document::parse(&exported).unwrap(); + let exported_names: Vec<_> = if format == Format::Xfdf { + xml.descendants().filter(|n| n.has_tag_name("field")).map(|n| n.attribute("name").unwrap()).collect() + } else { + xml.root_element() + .children() + .filter(|n| n.is_element()) + .map(|n| n.attribute(("http://ns.adobe.com/xfdf-transition/", "original")).unwrap()) + .collect() + }; + assert_eq!(exported_names, names, "{format:?}: {exported}"); + let mut dst = Document::open(Arc::new(source_bytes.clone())).unwrap(); + for name in names { + set_value(&mut dst, name, &FieldValue::Text("replace me".into())).unwrap(); + } + let report = import(&mut dst, exported.as_bytes()).unwrap(); + assert_eq!(report.fields, names.len(), "{format:?}: {report:?}"); + assert!(report.rejected.is_empty(), "{report:?}"); + let saved = write_full(&dst, &SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(saved)).unwrap(); + assert_eq!(values(&reopened), values(&src), "{format:?}"); + } + assert_eq!(write_full(&src, &SaveOptions::default()).unwrap(), source_bytes, "export must not change the source PDF"); +} + +#[test] +fn xfdf_exports_preserve_whitespace_in_comment_attributes() { + let mut src = one_field(); + let shape = Shape::Note { at: [100.0, 500.0], icon: pdfcraft_annot::NoteIcon::Comment }; + let names = ["note\tline\nidentity", "note line identity"]; + let author = "Ada\tLovelace\nReview"; + let subject = "Subject\twith\nlines\r\nand & quotes\""; + for name in names { + add_annotation( + &mut src, + &NewAnnotation { page: 0, style: Style::default_for(&shape), shape: shape.clone(), contents: name.into(), author: author.into() }, + &Meta { id: name.into(), date: None }, + ) + .unwrap(); + } + let annotation_refs = |doc: &Document| { + let page = pdfcraft_model::pages(doc)[0].obj; + doc.get(page) + .as_dict() + .unwrap() + .get(b"Annots") + .map(|a| doc.resolve(a)) + .unwrap() + .as_array() + .unwrap() + .iter() + .filter_map(Object::as_ref) + .filter(|r| doc.get(*r).as_dict().unwrap().name(b"Subtype") == Some(b"Text")) + .collect::>() + }; + for annotation in annotation_refs(&src) { + src.update_dict(annotation, |d| d.set(b"Subj".to_vec(), PdfString::text(subject))).unwrap(); + } + let source_bytes = write_full(&src, &SaveOptions::default()).unwrap(); + let mut dst = Document::open(Arc::new(source_bytes)).unwrap(); + let file = "form\twith\nlines.pdf"; + let xfdf = export_xfdf(&dst, true, false, file); + let xml = roxmltree::Document::parse(&xfdf).unwrap(); + let notes: Vec<_> = xml.descendants().filter(|n| n.has_tag_name("text")).collect(); + assert_eq!(notes.iter().map(|n| n.attribute("name").unwrap()).collect::>(), names); + for note in notes { + assert_eq!(note.attribute("title"), Some(author)); + assert_eq!(note.attribute("subject"), Some(subject)); + } + assert_eq!(xml.descendants().find(|n| n.has_tag_name("f")).unwrap().attribute("href"), Some(file)); + let before = annotation_refs(&dst); + let report = import(&mut dst, xfdf.as_bytes()).unwrap(); + assert_eq!(report.comments, names.len()); + assert_eq!(annotation_refs(&dst), before, "reimport must replace the original comments"); + let saved = write_full(&dst, &SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(saved)).unwrap(); + for (annotation, name) in annotation_refs(&reopened).iter().zip(names) { + let dict = reopened.get(*annotation); + assert_eq!(text_of(&reopened, dict.as_dict().unwrap(), b"NM").as_deref(), Some(name)); + assert_eq!(text_of(&reopened, dict.as_dict().unwrap(), b"T").as_deref(), Some(author)); + assert_eq!(text_of(&reopened, dict.as_dict().unwrap(), b"Subj").as_deref(), Some(subject)); + } +} + #[test] fn xfdf_round_trips_comments_and_values() { let src = filled(); @@ -1011,3 +1155,80 @@ fn xfdf_import_keeps_text_around_xml_comments_and_instructions() { assert_eq!(values(&reopened)[0].1, want, "{xml_text}"); } } + +#[test] +fn xml_form_import_preserves_text_around_comments_and_instructions() { + for (body, expected) in [ + ("leftright", "leftright"), + ("leftmiddleright", "leftmiddleright"), + ("]]>&right", "&right"), + (" left right ", " left right "), + ("", ""), + ] { + let mut original = one_field(); + let root = original.root().unwrap(); + original.update_dict(root, |dict| dict.set(b"PCUnknown".to_vec(), PdfString::text("retained"))).unwrap(); + set_value(&mut original, "Name", &FieldValue::Text("replace me".into())).unwrap(); + let source = write_full(&original, &SaveOptions::default()).unwrap(); + let mut doc = Document::open(Arc::new(source.clone())).unwrap(); + let xml = format!("{body}"); + let report = import(&mut doc, xml.as_bytes()).unwrap(); + assert_eq!(report.fields, 1); + assert!(report.rejected.is_empty()); + for saved in [write_full(&doc, &SaveOptions::default()).unwrap(), write_incremental(&doc, &SaveOptions::default()).unwrap()] { + let reopened = Document::open(Arc::new(saved)).unwrap(); + let name = fields(&reopened).into_iter().find(|f| f.name == "Name").unwrap(); + let want = if expected.is_empty() { Vec::new() } else { vec![expected.to_string()] }; + assert_eq!(name.value, want, "XML body: {body}"); + let root = reopened.get(reopened.root().unwrap()); + assert_eq!(root.as_dict().unwrap().get(b"PCUnknown").unwrap().as_string().unwrap().to_text(), "retained"); + } + assert_eq!(fields(&original).into_iter().find(|f| f.name == "Name").unwrap().value, vec!["replace me"]); + } +} + +#[test] +fn csv_headers_with_tabs_survive_export_import_and_save() { + for name in ["First\tName", "\tName", "Name\t", "Name\t\"quoted\"", "Name\nwith\ta tab"] { + let mut source = one_field(); + add_field(&mut source, 0, [50.0, 650.0, 250.0, 670.0], &NewField::Text { multiline: false }, Some("Other")).unwrap(); + set_value(&mut source, "Other", &FieldValue::Text("other field".into())).unwrap(); + let field = fields(&source).into_iter().find(|f| f.name == "Name").unwrap().obj; + source.update_dict(field, |dict| dict.set(b"T".to_vec(), PdfString::text(name))).unwrap(); + set_value(&mut source, name, &FieldValue::Text("original, \"value\"".into())).unwrap(); + let bytes = write_full(&source, &SaveOptions::default()).unwrap(); + let source = Document::open(Arc::new(bytes.clone())).unwrap(); + let row = data_values(&bytes).unwrap(); + for csv in [export_data(&source, Format::Csv), merge_csv(&[row])] { + let mut doc = Document::open(Arc::new(bytes.clone())).unwrap(); + set_value(&mut doc, name, &FieldValue::Text("replacement".into())).unwrap(); + set_value(&mut doc, "Other", &FieldValue::Text("other replacement".into())).unwrap(); + let report = import(&mut doc, csv.as_bytes()).unwrap(); + assert_eq!(report.fields, 2); + assert!(report.rejected.is_empty(), "{:?}", report.rejected); + for saved in [write_full(&doc, &SaveOptions::default()).unwrap(), write_incremental(&doc, &SaveOptions::default()).unwrap()] { + let reopened = Document::open(Arc::new(saved)).unwrap(); + assert_eq!(fields(&reopened).into_iter().find(|f| f.name == name).unwrap().value, vec!["original, \"value\""], "{csv:?}"); + assert_eq!(values(&reopened), values(&source), "{csv:?}"); + } + } + } +} + +#[test] +fn tabular_import_keeps_tsv_quotes_literal_and_csv_value_tabs() { + for name in ["Name", "Na\"me", "\"Name\"", "\"Name"] { + let mut doc = one_field(); + add_field(&mut doc, 0, [50.0, 650.0, 250.0, 670.0], &NewField::Text { multiline: false }, Some("Other")).unwrap(); + let field = fields(&doc).into_iter().find(|f| f.name == "Name").unwrap().obj; + doc.update_dict(field, |dict| dict.set(b"T".to_vec(), PdfString::text(name))).unwrap(); + let text = format!("{name}\tOther\r\nAda\tother field\r\n"); + let report = import(&mut doc, text.as_bytes()).unwrap(); + assert_eq!(report.fields, 2, "{text:?}"); + assert_eq!(fields(&doc).into_iter().find(|f| f.name == name).unwrap().value, vec!["Ada"]); + } + let mut doc = one_field(); + add_field(&mut doc, 0, [50.0, 650.0, 250.0, 670.0], &NewField::Text { multiline: false }, Some("Other")).unwrap(); + import(&mut doc, b"Name,Other\r\n\"Ada\tLovelace\",other field\r\n").unwrap(); + assert_eq!(fields(&doc).into_iter().find(|f| f.name == "Name").unwrap().value, vec!["Ada\tLovelace"]); +} diff --git a/deny.toml b/deny.toml index 2362d56822..ed9c26c206 100644 --- a/deny.toml +++ b/deny.toml @@ -48,6 +48,13 @@ ignore = [ # winprint's fmt-derive (craft-print's Windows spooler), so it runs at compile time on Windows # and is not in the binary. { id = "RUSTSEC-2024-0370", reason = "compile-time proc macro of winprint's fmt-derive (craft-print Windows printing, #1107)" }, + # zvariant, zbus_names and zvariant_derive are unmaintained (no vulnerability, and no fixed + # release to move to). They reach the tree as the D-Bus stack behind `accesskit_unix` -> + # `atspi`, which is Linux accessibility only, and as a dev-dependency of pdfcraft-platform. + # Revisit when the accesskit/zbus chain moves to a maintained successor. + { id = "RUSTSEC-2026-0370", reason = "zvariant unmaintained; Linux a11y stack via accesskit_unix" }, + { id = "RUSTSEC-2026-0371", reason = "zbus_names unmaintained; Linux a11y stack via accesskit_unix" }, + { id = "RUSTSEC-2026-0372", reason = "zvariant_derive unmaintained; Linux a11y stack via accesskit_unix" }, ] # egui's default UI fonts (Ubuntu-Light, Hack, emoji-icon-font): font licences on the AGENTS.md diff --git a/docs/architecture.md b/docs/architecture.md index a226cc8de6..6b44817e15 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -49,7 +49,8 @@ The PDF object graph *is* the model (`crates/engine/README.md` §The editing mod 3. After every edit an incremental write produces the working file (original bytes plus one revision); the view is refreshed from those bytes, so what you see is what Save writes. 4. Saves are incremental and atomic. A full rewrite happens only where it must: redaction, PDF/A - conversion, print imposition, a changed password, Save As with garbage collection. + conversion, print imposition, a changed password, Save As to another file (garbage-collected, + unless the document is signed). 5. Autosave and crash recovery keep encrypted documents encrypted. Signed documents are protected from rewrites. @@ -101,7 +102,7 @@ Every user-facing feature is reachable headlessly (`AGENTS.md` §3): `cargo xtask ci` runs fmt, clippy (`-D warnings`), tests, the layering check, the WASM check, `xtask assets` (asset licences and hashes), `xtask parity` (checklist claims must cite real commands, tools and tests) and `xtask deny` (dependency licences). Other tasks: `xtask fuzz`, `xtask corpus` and `xtask check` (robustness -sweep over the pinned pdf.js corpus), `xtask text-oracle` (word F1 against `pdftotext`), +sweep over the pinned pdf.js corpus), `xtask text-oracle` (word F1 and reading order against `pdftotext`), `xtask demo-pdf`, `xtask screenshots`, `xtask models` (OCR models), `xtask version`. Releases are built by `.github/workflows/release.yml` ([releasing.md](releasing.md)). diff --git a/docs/editing-images.md b/docs/editing-images.md index eba918ef39..67d3613bf1 100644 --- a/docs/editing-images.md +++ b/docs/editing-images.md @@ -8,7 +8,8 @@ together with its pixels, including nested graphics states, intervening state op 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. +nest one more graphics state each time. Moving a privately cropped figure again likewise +changes the `cm` before its clip instead of adding another. 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/file-format-parity.md b/docs/file-format-parity.md index 9a5f4d20d4..d7230c0962 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -86,7 +86,7 @@ features total). | PowerPoint (`.pptx`) | yes | slides per page: title from largest text, body text, images, tables (no slide overflow reflow / normAutofit; first page sets presentation dimensions) | basic | `create.export-pptx` partial | | RTF | yes | yes | basic | `create.export-rtf` | | HTML (single page, multiple pages) | yes | single file with headings, paragraphs, images, tables | basic | `create.export-html` partial | -| Plain text | yes | yes (reading order) | good (word-F1 0.98 vs `pdftotext`) | `create.export-text`, `xtask text-oracle` | +| Plain text | yes | yes (reading order) | good (word-F1 0.98 vs `pdftotext`; reading order is scored separately, because F1 cannot see it) | `create.export-text`, `xtask text-oracle` | | Accessible text (from tags) | yes | no | none | `create.export-accessible-text` planned | | PNG, JPEG, TIFF | yes | yes | good | `create.export-png`, `create.export-jpeg-tiff` | | JPEG 2000 | yes | no | none | `create.export-jpeg2000` planned | diff --git a/docs/gaps.md b/docs/gaps.md index c006e48d29..2682d090ac 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,5 +1,7 @@ # Where PdfCraft falls short of Acrobat Pro +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (gap 12: reading order is scored, not just word F1) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **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) @@ -139,6 +141,9 @@ update the numbers. (#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. - **Impact:** every one is a user who lost trust. Crash reports outrank feature work (`AGENTS.md` §4). - **Estimate:** 45–80 h (≈ 0.2–0.4 h per issue). **Doc:** this file. @@ -247,6 +252,17 @@ update the numbers. 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. +- **The text score was blind to reading order (2026-10-15).** Word F1 is a bag of words, so a page + emitted back to front scores a perfect 1.000 — and reading order is most of what text extraction + has left to get wrong (#2387, #2586). `cargo xtask text-oracle` now reports a second score: the + longest common subsequence of the two word sequences over the longer one, both cut to 3,000 + words. The hole was not hypothetical. Measuring #2431, a change that moves text and alters + nothing else, **word F1 called 26 of the 29 files whose output changed identical**, where the + order score separated 18 improvements from 6 regressions and named their shape: forms, where a + checkbox's number was parted from its label (`prefilled_f1040.pdf` 0.672 → 0.481). Read it as a + disagreement, like the pixel score: the same split scored as a win on one file and a loss on + another purely because `pdftotext` makes it in one place and not the other. The corpus-wide + first reading has not been taken yet. - **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). @@ -366,6 +382,8 @@ update the numbers. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Gap 12: the text oracle scores reading order as well as word F1, which is a bag of words and cannot see order; the first measurement separated 18 improvements from 6 regressions in a change F1 called unchanged on 26 of 29 files. Estimates unchanged. | +| 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. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 3148823ea5..2b7261f0d0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,8 +1,20 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-18 · **Change:** minor (M4, #1919: repeated page indices accumulate rotations; M4: indirect page rotations refreshed upstream; 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-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged) +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged) +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M4 #2997: Ctrl-drag (Option on macOS) in the Organize grid copies the pages into the drop gap; `page_duplicate` takes `at`; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: preserve XML/XFDF attribute whitespace; refreshed upstream; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; refreshed upstream; 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-16 | **Last updated:** 2026-10-16 | **Change:** minor (M10 #1201 partial: lossless image Decode; 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-15 · **Change:** minor (M2: sampled functions index a flat table, one image decode serves every zoom level, patch meshes copy less; 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) @@ -32,6 +44,7 @@ > **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) +> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M6 #2892: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged) 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), @@ -96,11 +109,12 @@ 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). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs | +| 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). 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). 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. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. | +| 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. Wrapped revision buttons stay above Binding in Document Properties (#2739); a synthetic UI test checks layout and saved revision content. | | 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. | @@ -108,7 +122,9 @@ total matches the parity estimate. | 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. 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. 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. 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). Ctrl-drag (Option on macOS) on a grid thumbnail copies the pages into the drop gap, with a "+" badge and the copies selected (#2997). 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). Rotation queries and edits resolve indirect values on pages and ancestors. Refreshed against upstream `99941daa` (2026-10-15). Repeated page indices accumulate rotations from the latest local value (#1919); saved/reopened regressions retain inherited angles and siblings. 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). Rotation queries and edits resolve indirect values on pages and ancestors. 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 | @@ -116,26 +132,28 @@ total matches the parity estimate. | 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; 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. | +| 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. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Done since: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. (#2892). 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. | +| 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. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references.
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. | +| 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. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).
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. | +| 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. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities.
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 | +| 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). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. 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; 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. 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 | +| 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. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). 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, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. 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). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. 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. 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 | +| 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
Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645). | | 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, 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. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. 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, 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 | +| 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. Long main-menu lists scroll in short windows (#3373). 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 @@ -177,8 +195,22 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st ## Revision history +**2026-10-15 · minor:** M14/#2383 candidate: GPU device requests clamp unsupported limits while retaining the adapter texture extent. Synthetic V3D/downlevel regressions and Windows GPU-required tests pass; Raspberry Pi hardware remains unverified. Estimates unchanged. + | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | M7: Edit text caret height follows its row, with a kittest regression; estimates unchanged | +| 2026-10-19 | minor | M3 #3360: retain each document’s scroll position when switching Read and the docking workspace at fixed zoom. Estimates unchanged. | +| 2026-10-18 | minor | M4, PR #1919 review: repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged. | +| 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. | +| 2026-10-17 | minor | M4 #2997: Ctrl-drag (Option on macOS) in the Organize grid copies the dragged pages into the drop gap (a "+" badge says so; the copies land selected, one undo step); `page_duplicate` takes `at`, and the UI control channel's `ui.drag` modifiers reach the input state. Estimates unchanged. | +| 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. | +| 2026-10-13 | minor | M6: XML/XFDF attribute whitespace survives form and comment export/import; estimates unchanged | +| 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 | minor | M9 #1895: generic appearance rewriting deferred; separate validation overlays preserve original artwork, exports and printing. Baked captions remain unchanged (#79 open). Estimates unchanged. | +| 2026-10-16 | minor | M10 #1201 partial: lossless image export applies component Decode pairs and Indexed mapping. Estimates unchanged. | +| 2026-10-15 | minor | M6: CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Estimates unchanged. | +| 2026-10-13 | minor | M4: rotation queries and edits resolve indirect page-tree values; inherited, negative and large rotations verified through both saves. 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. | @@ -190,6 +222,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | 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-15 | minor | M2: three vendored hayro-interpret patches (31–33): sampled functions index a flat table (`bug1721218` 1.34 → 0.49 s), one JPEG or Flate decode serves every size an image is drawn at (35–40% less render work zooming a scan), patch meshes copy their vertices less (10–16% on mesh-gradient pages); pixel for pixel the same over the pdf.js corpus. 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 | @@ -231,5 +264,6 @@ 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: cleanup removes replacement descriptions only from changed marked-content spans, preserving surviving accessibility text. Estimates unchanged. | | 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 e7698baeef..9119063e20 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -201,7 +201,7 @@ full parity in that area. | C Content editing | 61 | 29 | 7 | 53.3% | 30% | 100–180 | Edit existing paragraphs (font reused or Helvetica substituted, rewrapped), added text and images stay editable, header/footer, watermarks, backgrounds, Bates, links. Missing: robust editing of real-world text (subset fonts, CJK, RTL #766, rotated), lists, vector/object editing, arrange/align, spell check, multi-select move across pages (same-page shipped, #844) | | D Organize pages | 76 | 45 | 3 | 61.2% | 70% | 12–25 | Organize grid, insert/extract/replace/split/combine with links, fields, layers and bookmarks carried over; page labels, boxes, bookmarks from structure. Missing: transitions, attachments editing, portfolios, struct-tree merge, field-name conflicts (destination and annotation fixes #817, #818, #821 shipped) | | E Comments and review | 65 | 46 | 9 | 77.7% | 70% | 12–25 | Every markup type with appearances, replies, status, filters, stamps (custom, dynamic), XFDF/FDF, summaries, flatten, 2D measuring. Missing: rich-text runs, popup windows, summary layouts with pages, per-type hiding (XFDF fidelity fixes #807, #809, #819 shipped) | -| F Forms and JavaScript | 79 | 58 | 8 | 78.5% | 65% | 40–75 | Fill and author every AcroForm field type, Acrobat's AF functions in Acrobat's event order, sandboxed JavaScript (boa), data exchange, Fill & Sign; XFA static/dynamic layout, FormCalc and XFA data (partial). Missing: wider JavaScript object model, document actions, submit, debugger, barcode fields, XFA flattening and full dynamic fidelity | +| F Forms and JavaScript | 79 | 58 | 8 | 78.5% | 65% | 40–75 | Fill and author every AcroForm field type, Acrobat's AF functions in Acrobat's event order, sandboxed JavaScript (boa), data exchange, Fill & Sign; XFA static/dynamic layout, FormCalc and XFA data (partial). CLI info, text and render use the laid-out dynamic pages (#1645). Missing: wider JavaScript object model, document actions, submit, debugger, barcode fields, XFA flattening and full dynamic fidelity | | G Protect, redact, sanitize | 41 | 23 | 2 | 58.5% | 55% | 15–30 | Passwords and permissions, Remove security, redaction that removes glyphs, pixels, vectors, annotations and tags, with verification; word lists, patterns, FOIA/Privacy Act codes; sanitize. Missing: certificate security, pattern locales, custom code sets, partial-word redaction, folder redaction, protected view | | H Digital signatures | 45 | 23 | 10 | 62.2% | 45% | 40–70 | PAdES B-B signing and validation (PKCS #12, macOS Keychain, Windows store with PIN prompts), certification, encrypted documents, certificate usage checks; B-T, LTV, OCSP/CRL with caller-supplied evidence. Missing: PKCS #11 and smart cards outside Windows, timestamp-server preferences, online revocation fetching, FieldMDP and field locking, OS trust store, EU Trusted Lists, compare signed version | | I Scan and OCR | 26 | 5 | 2 | 23.1% | 15% | 40–75 | Searchable image (ocrs, Latin script without accents) on pages, ranges and many files. Missing: every other language (#744 Dutch; accents, CJK, Cyrillic, Arabic), editable-text output, deskew, rotation, despeckle, camera images beyond straightening (found or placed corners): orientation, clean-up, MRC compression, scanners | diff --git a/packaging/windows/pdfcraft.wxs b/packaging/windows/pdfcraft.wxs index 7fae1329f3..d96f2cd78d 100644 --- a/packaging/windows/pdfcraft.wxs +++ b/packaging/windows/pdfcraft.wxs @@ -111,6 +111,11 @@ Resource 1 is the app's lion; resource 2 is the PDF document (build.rs). --> + + diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ffb7bbe245..e7410394b3 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -46,8 +46,8 @@ milestone = "M1" status = "shipped" commands = ["file.open"] tools = ["doc_open"] -evidence = ["crates/cos/src/document.rs::damaged_xref_is_reconstructed", "crates/cos/src/parser.rs::indirect_stream_with_wrong_length_recovers", "crates/cos/src/document.rs::self_referential_length_does_not_overflow"] -notes = "" +evidence = ["crates/cos/src/document.rs::damaged_xref_is_reconstructed", "crates/cos/src/parser.rs::indirect_stream_with_wrong_length_recovers", "crates/cos/src/document.rs::self_referential_length_does_not_overflow", "crates/engine/src/tests.rs::a_repaired_file_is_rewritten_by_save_even_without_edits", "crates/automation/tests/automation.rs::saving_a_repaired_file_puts_the_repair_on_disk", "crates/engine/src/tests.rs::a_signed_repaired_file_keeps_its_bytes_when_saved_without_edits", "crates/cos/src/document.rs::a_table_numbered_from_1_that_begins_with_object_0_is_read_from_0"] +notes = "Save and doc_save write a reconstructed file in full even when nothing was edited, so the repair reaches the disk and the saved file opens without one (#1937); a signed one keeps its bytes, so its signatures stay valid. A repair does not mark the document as having unsaved changes, so closing it does not offer to save the repaired file. A classic table numbered 1 n that begins with object 0's free entry is read from 0 (#2676) and logged." [[feature]] id = "core.malformed-input-safety" @@ -118,8 +118,8 @@ milestone = "M1" status = "shipped" commands = [] tools = [] -evidence = ["crates/filters/src/lib.rs::chain_three_filters_with_predictor", "crates/filters/src/lzw.rs::spec_example", "crates/filters/src/predictor.rs::png_hand_vectors"] -notes = "" +evidence = ["crates/filters/src/lib.rs::chain_three_filters_with_predictor", "crates/filters/src/lzw.rs::spec_example", "crates/filters/src/predictor.rs::png_hand_vectors", "crates/filters/src/lib.rs::raw_flate_fallback_preserves_output_limits", "crates/cos/src/object.rs::raw_flate_fallback_streams_preserve_the_caller_limit"] +notes = "Raw-deflate fallback preserves output limits in strict and tolerant decoding, including misleading zlib headers. Image codecs remain outside this general-purpose filter implementation." [[feature]] id = "core.image-filters" @@ -214,7 +214,7 @@ milestone = "M1" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/editing.rs::restricted_documents_show_a_notice_and_block_page_changes", "crates/crypt/src/tests.rs::permission_bits_follow_table_22", "crates/engine/src/tests.rs::combining_protected_files_is_refused_clearly"] +evidence = ["crates/ui-egui/tests/editing.rs::restricted_documents_show_a_notice_and_block_page_changes", "crates/crypt/src/tests.rs::permission_bits_follow_table_22", "crates/crypt/src/tests.rs::perms_limits_what_an_edited_p_grants", "crates/engine/src/tests.rs::combining_protected_files_is_refused_clearly"] notes = "" [[feature]] @@ -854,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", "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)" +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", "crates/ui-egui/tests/view.rs::rapid_wheel_notches_accelerate_and_follow_touchpad_input", "crates/ui-egui/tests/view.rs::changing_continuous_layout_resets_wheel_acceleration"] +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. Rapid line-wheel notches gain a bounded, constant-space arrival-rate boost (#1072); isolated notches still use the chosen Scroll speed, and page units stay unboosted. 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" @@ -950,8 +950,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/ui.rs::selection_works_in_every_view_rotation"] -notes = "PdfCraft draws a percentage on its fixed 96-dpi basis: 96/72 logical pixels per PDF point at 100%. Acrobat draws it at its Page Display resolution, with logical pixels per PDF point = ppi / 72 × zoom (checked black-box with Custom resolutions of 96, 110 and 120 ppi on one display), so the same percentage can differ in size. Acrobat's Use System Setting resolution is not reproduced. Tracked with misc.prefs-page-display" +evidence = ["crates/ui-egui/tests/ui.rs::selection_works_in_every_view_rotation", "crates/ui-egui/tests/view.rs::the_rail_zoom_field_sets_a_typed_zoom"] +notes = "Typed into the right rail's zoom field (150, 150% or 62.5 %, Enter applies, Escape discards, clamped to 8–6400%) or set through the control channel's zoom option; no preset dropdown in the field yet. PdfCraft draws a percentage on its fixed 96-dpi basis: 96/72 logical pixels per PDF point at 100%. Acrobat draws it at its Page Display resolution, with logical pixels per PDF point = ppi / 72 × zoom (checked black-box with Custom resolutions of 96, 110 and 120 ppi on one display), so the same percentage can differ in size. Acrobat's Use System Setting resolution is not reproduced. Tracked with misc.prefs-page-display" [[feature]] id = "view.zoom-actual-size" @@ -1070,8 +1070,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/view.rs::the_hand_tool_pans_by_dragging", "crates/ui-egui/tests/viewer.rs::v_h_and_space_pick_the_quick_tools"] -notes = "drag to pan; Grab/Grabbing cursors; H picks it, V goes back to Select, Space held pans temporarily" +evidence = ["crates/ui-egui/tests/view.rs::the_hand_tool_pans_by_dragging", "crates/ui-egui/tests/view.rs::the_hand_tool_moves_the_page_with_the_pointer", "crates/ui-egui/tests/viewer.rs::v_h_and_space_pick_the_quick_tools"] +notes = "drag to pan: the page follows the pointer point for point in the frame it moves, and stops where it is let go (no fling); Grab/Grabbing cursors; H picks it, V goes back to Select, Space held pans temporarily. Checked with kittest pointer events, not yet on a desktop" [[feature]] id = "view.rotate-view" @@ -1094,8 +1094,8 @@ milestone = "M3" status = "shipped" commands = ["view.read_mode"] tools = [] -evidence = ["crates/ui-egui/tests/control.rs::inspect_and_click_by_label_and_id", "crates/ui-egui/tests/commands.rs::every_registered_command_is_implemented"] -notes = "" +evidence = ["crates/ui-egui/tests/control.rs::inspect_and_click_by_label_and_id", "crates/ui-egui/tests/commands.rs::every_registered_command_is_implemented", "crates/ui-egui/tests/view.rs::switching_modes_keeps_the_current_page_at_fixed_zoom"] +notes = "Switching between Read and the docking workspace preserves the document’s current scroll position at fixed zoom; each tab keeps its own position" [[feature]] id = "view.full-screen" @@ -1262,8 +1262,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/ui.rs::theme_and_view_options_apply"] -notes = "" +evidence = ["crates/ui-egui/tests/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/viewer.rs::going_to_a_far_page_scrolls_its_thumbnail_into_view", "crates/ui-egui/tests/viewer.rs::going_to_a_page_in_view_leaves_the_pages_panel_still", "crates/ui-egui/tests/viewer.rs::a_page_change_while_thumbnails_are_dragged_does_not_scroll_the_pages_panel"] +notes = "The panel follows the current page (#2781): a page whose thumbnail is out of sight scrolls it into view, as little as it takes; a thumbnail already in view, or clicked, leaves the panel still, and a page change while the user scrolls the panel or drags thumbnails does not move it." [[feature]] id = "view.thumbnail-context-ops" @@ -1866,7 +1866,7 @@ 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", "crates/ui-egui/tests/ui.rs::the_edit_tab_turns_on_edit_text_and_images"] +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", "crates/ui-egui/tests/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row"] 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]] @@ -2130,8 +2130,8 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["image_save"] -evidence = ["crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/automation.rs::editing_page_images_through_tools", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png"] -notes = "right-click an image ▸ Save Image As: JPEG unchanged, others PNG" +evidence = ["crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/automation.rs::editing_page_images_through_tools", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps"] +notes = "right-click an image ▸ Save Image As: JPEG unchanged, others PNG. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Still limited: 16-bit samples use high bytes; general JPEG and mask Decode, ICC transforms, and calibrated colors remain unsupported." [[feature]] id = "edit.image-external-editor" @@ -2620,10 +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/editing.rs::organize_opens_on_the_current_page", "crates/ui-egui/tests/control.rs::organize_overview_is_reachable_through_the_control_channel"] -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." -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", "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. 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." +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" @@ -2646,8 +2644,8 @@ milestone = "M4" status = "shipped" commands = ["page.rotate", "page.rotate_ccw"] tools = ["page_rotate"] -evidence = ["crates/organize/src/tests.rs::rotate_uses_inherited_rotation_and_round_trips", "crates/ui-egui/tests/editing.rs::command_click_toggles_and_rotation_applies_to_selection", "crates/ui-egui/tests/editing.rs::organize_page_actions_rotate_only_the_named_page_and_undo", "crates/ui-egui/tests/editing.rs::organize_local_actions_respect_document_permissions"] -notes = "" +evidence = ["crates/organize/src/tests.rs::rotate_repeated_indices_accumulate_from_the_current_page_value", "crates/organize/src/tests.rs::rotate_uses_inherited_rotation_and_round_trips", "crates/ui-egui/tests/editing.rs::command_click_toggles_and_rotation_applies_to_selection", "crates/ui-egui/tests/editing.rs::organize_page_actions_rotate_only_the_named_page_and_undo", "crates/ui-egui/tests/editing.rs::organize_local_actions_respect_document_permissions", "crates/organize/src/tests.rs::indirect_page_rotation_is_read_and_updated_from_its_effective_value", "crates/organize/src/tests.rs::malformed_indirect_rotations_keep_the_zero_fallback"] +notes = "Rotation queries and edits resolve direct and inherited indirect integer values; full and incremental save regressions preserve sibling rotations. Existing quarter-turn restrictions and malformed-value zero fallback remain." [[feature]] id = "organize.rotate-filters" @@ -2814,8 +2812,8 @@ milestone = "M4" status = "shipped" commands = ["page.replace"] tools = ["page_replace"] -evidence = ["crates/organize/src/tests.rs::replace_pages_swaps_content_but_keeps_annotations", "crates/ui-egui/tests/editing.rs::replace_pages_dialog_swaps_page_content", "crates/ui-egui/tests/editing.rs::replace_button_replaces_selected_pages_in_organize", "crates/automation/tests/automation.rs::replacing_pages_through_tools"] -notes = "content, resources and boxes replaced; links, comments, fields and bookmarks kept" +evidence = ["crates/organize/src/tests.rs::replace_pages_swaps_content_but_keeps_annotations", "crates/ui-egui/tests/editing.rs::replace_pages_dialog_swaps_page_content", "crates/ui-egui/tests/editing.rs::replace_button_replaces_selected_pages_in_organize", "crates/automation/tests/automation.rs::replacing_pages_through_tools", "crates/ui-egui/src/canvas.rs::replacing_a_page_after_layout_never_uploads_an_oversized_texture", "crates/ui-egui/src/canvas.rs::oversized_raster_results_do_not_replace_cached_textures"] +notes = "Content, resources and boxes replaced; links, comments, fields and bookmarks kept. Page-list edits discard demand laid out against the previous geometry; the next frame plans bounded backdrops and tiles. Raster uploads reject empty images and dimensions beyond the current GPU texture limit before replacing cached textures (#3149). This covers the viewer's page, tile, thumbnail, organize and print-preview result paths; it does not change the renderer's output resolution or add a global GPU memory budget." [[feature]] id = "organize.duplicate-pages" @@ -2826,8 +2824,8 @@ milestone = "M4" status = "shipped" commands = ["page.duplicate"] tools = ["page_duplicate"] -evidence = ["crates/ui-egui/tests/ui_group/pageboxes.rs::duplicate_pages_copies_the_current_page", "crates/organize/src/tests.rs::duplicate_pages_inserts_copies_after_the_last"] -notes = "copies share fonts and images with the originals" +evidence = ["crates/ui-egui/tests/ui_group/pageboxes.rs::duplicate_pages_copies_the_current_page", "crates/ui-egui/tests/editing.rs::dragging_with_the_copy_modifier_copies_the_pages_into_the_gap", "crates/ui-egui/tests/control.rs::ctrl_dragging_a_thumbnail_through_the_control_channel_copies_the_page", "crates/organize/src/tests.rs::duplicate_pages_inserts_copies_after_the_last", "crates/organize/src/tests.rs::duplicate_pages_at_inserts_the_copies_there", "crates/automation/tests/automation.rs::page_duplicate_at_inserts_the_copies_there"] +notes = "copies share fonts and images with the originals; Ctrl-drag (Option on macOS) in the page grid drops the copies at the gap instead of moving the pages, with a \"+\" badge and the copies selected (#2997); page_duplicate takes `at` for where the first copy goes (left out: after the last of them)" [[feature]] id = "organize.reverse-pages" @@ -2835,11 +2833,11 @@ title = "Reverse page order" area = "D" tier = "P2" milestone = "M4" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["page.reverse"] +tools = ["page_reverse"] +evidence = ["crates/organize/src/tests.rs::reverse_pages_reorders", "crates/organize/src/tests.rs::cannot_reverse_missing_or_fewer_than_two_pages", "crates/automation/tests/automation.rs::reversing_pages_through_tools", "crates/ui-egui/tests/editing.rs::reverse_pages_reverses_the_selection_or_else_the_whole_document"] +notes = "Organize Pages ▸ More ▸ Reverse pages, or the command palette: two or more selected pages swap among their own positions, otherwise the whole document reverses. One undo step. Not yet: an entry in the page thumbnails' right-click menu" [[feature]] id = "organize.cut-copy-paste-pages" @@ -3197,11 +3195,8 @@ tier = "P2" milestone = "M4" status = "partial" commands = [] -tools = ["doc_info"] -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"] -notes = "Read only: each page's /Trans (style, duration, dimension, motion, direction, Fly scale and backdrop) and /Dur display time are read with the spec's defaults for what is missing or out of range, and doc_info lists them (DocInfo::transitions). Not yet: playing them in full screen, auto-advancing on /Dur, or the Page Transitions dialog that sets them" -tools = [] -evidence = [] +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]] @@ -3486,11 +3481,11 @@ title = "Custom document properties" area = "D" tier = "P2" milestone = "M4" -status = "planned" +status = "partial" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["doc_info", "doc_set_info"] +evidence = ["crates/organize/src/tests.rs::custom_document_properties_are_listed_set_removed_and_their_names_checked", "crates/render/src/inspect.rs::custom_document_properties_are_listed_apart_from_the_standard_ones", "crates/automation/tests/automation.rs::custom_document_properties_are_set_listed_and_cleared_through_the_tools"] +notes = "Through the tools: doc_set_info sets or clears an /Info entry under any name the spec does not use (names checked as Acrobat does: no standard name, no empty name, no spaces or delimiters, at most 127 bytes), and doc_info lists them under custom, apart from the standard entries. Not yet: the Custom tab in Document Properties, and mirroring them into XMP (pdfx:) as Acrobat does" [[feature]] id = "organize.xmp-editor" @@ -3817,8 +3812,8 @@ milestone = "M5" status = "shipped" commands = ["comment.attach"] tools = ["comment_add"] -evidence = ["crates/annot/src/tests.rs::files_attach_as_comments", "crates/ui-egui/tests/comments.rs::attaching_a_file_as_a_comment", "crates/automation/tests/automation.rs::drawing_comments_through_tools"] -notes = "push pin, paperclip, graph or tag icon; the file is embedded and listed in the Attachments panel" +evidence = ["crates/annot/src/tests.rs::files_attach_as_comments", "crates/ui-egui/tests/comments.rs::attaching_a_file_as_a_comment", "crates/ui-egui/tests/comments.rs::an_attachment_comment_opens_its_file_from_the_page_menu", "crates/ui-egui/tests/comments.rs::an_attachment_comment_opens_its_file_from_the_card_menu", "crates/automation/tests/automation.rs::drawing_comments_through_tools"] +notes = "push pin, paperclip, graph or tag icon; the file is embedded and listed in the Attachments panel. The comment's menu, on the page and in the Comments panel, saves the file or opens a PDF in a new tab (#2565)" [[feature]] id = "comment.record-audio" @@ -4165,8 +4160,8 @@ milestone = "M5" status = "shipped" commands = ["comment.export"] tools = ["doc_export_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 = "" +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", "crates/xfdf/src/tests.rs::xfdf_exports_preserve_whitespace_in_comment_attributes"] +notes = "XFDF exports retain tabs and line breaks in comment identities and metadata." [[feature]] id = "comment.export-selected" @@ -4213,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/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" +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", "crates/edit/src/tests.rs::flattening_comments_keeps_annotations_that_are_not_comments"] +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; annotations that are not comments (links, 3D, Screen, Movie, RichMedia, PrinterMark, TrapNet, Watermark) are left in place (#3087)" [[feature]] id = "comment.migrate" @@ -4409,8 +4404,8 @@ milestone = "M2" status = "shipped" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::widget_appearance_states"] -notes = "" +evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/engine/src/tests.rs::validated_signatures_without_layers_keep_their_appearance", "crates/engine/src/tests.rs::real_layered_signatures_keep_their_original_appearance", "crates/engine/src/tests.rs::real_layered_signatures_round_trip_behind_a_user_password", "crates/engine/src/tests.rs::a_metadata_edit_keeps_the_original_signature_appearance", "crates/engine/src/tests.rs::saving_after_an_edit_keeps_the_baked_appearance", "crates/engine/src/tests.rs::chamfered_signature_borders_survive_trust_exports_printing_and_save", "crates/engine/src/tests.rs::mixed_signature_caption_details_survive_trust_exports_printing_and_save"] +notes = "widgets retain their original artwork. Signature validation is a separate viewer page overlay; it is never written into document streams or included in image/text exports, vector printing or Print as Image. Saves retain signed appearance bytes. Generic in-place appearance rewriting is deferred: no supported producer template is positively identified, and caption text plus drawing uniqueness does not establish an icon role. Baked Not Verified captions remain unchanged; issue #79 is not fully resolved." [[feature]] id = "form.fill-text" @@ -4421,10 +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/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"] +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)" -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", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value"] -notes = "in-place editor; Enter or clicking away commits, Escape cancels; no format/keystroke JavaScript yet (M6.4)" [[feature]] id = "form.fill-checkbox-radio" @@ -4699,8 +4692,8 @@ milestone = "M6" status = "shipped" commands = ["form.export_data"] tools = ["doc_export_data"] -evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"] -notes = "XFDF, FDF; XFA forms also export their data as XML or XDP (form.xfa-data)" +evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests.rs::xml_exports_preserve_whitespace_in_field_names"] +notes = "XFDF, FDF; XFA forms also export their data as XML or XDP (form.xfa-data); XML/XFDF exports retain tabs and line breaks in field identities through character references." [[feature]] id = "form.data-xml-csv-txt" @@ -4711,8 +4704,8 @@ milestone = "M6" status = "shipped" commands = ["form.export_data", "form.import_data"] tools = ["doc_export_data", "doc_import_data"] -evidence = ["crates/xfdf/src/tests.rs::form_data_as_xml_csv_and_text", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"] -notes = "Acrobat's XML (xfdf:original), CSV, tab-delimited" +evidence = ["crates/xfdf/src/tests.rs::form_data_as_xml_csv_and_text", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests.rs::xml_exports_preserve_whitespace_in_field_names", "crates/xfdf/src/tests.rs::xml_form_import_preserves_text_around_comments_and_instructions", "crates/xfdf/src/tests.rs::csv_headers_with_tabs_survive_export_import_and_save", "crates/xfdf/src/tests.rs::tabular_import_keeps_tsv_quotes_literal_and_csv_value_tabs"] +notes = "Acrobat's XML (xfdf:original), CSV, tab-delimited; XML/XFDF exports retain tabs and line breaks in field identities through character references; plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Nested-element and XFA interpretation remain outside this flat exchange format. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Ambiguous externally authored tables remain heuristic." [[feature]] id = "form.merge-to-spreadsheet" @@ -4723,8 +4716,8 @@ milestone = "M6" status = "shipped" commands = ["form.merge_data"] tools = ["form_merge_data"] -evidence = ["crates/xfdf/src/tests.rs::data_files_merge_into_a_spreadsheet", "crates/automation/tests/automation.rs::merging_form_data_into_a_spreadsheet", "crates/ui-egui/tests/javascript.rs::merge_data_files_into_a_spreadsheet"] -notes = "FDF, XFDF or filled-in PDF forms \u2192 CSV, a column per field and a row per file." +evidence = ["crates/xfdf/src/tests.rs::data_files_merge_into_a_spreadsheet", "crates/automation/tests/automation.rs::merging_form_data_into_a_spreadsheet", "crates/ui-egui/tests/javascript.rs::merge_data_files_into_a_spreadsheet", "crates/xfdf/src/tests.rs::csv_headers_with_tabs_survive_export_import_and_save"] +notes = "FDF, XFDF or filled-in PDF forms \u2192 CSV, a column per field and a row per file. CSV header tabs, quotes and line breaks retain field identities through import and save/reopen; ambiguous external table detection remains heuristic." [[feature]] id = "form.flatten-fields" @@ -4927,8 +4920,8 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props"] -evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically"] -notes = "text: alignment, default, multi-line, scroll, rich text flag, limit, password, file selection, spell check, comb; choices: items, default, sort, custom text, multiple selection, commit immediately; check boxes and radios: checked by default, radios in unison. Shared GUI offers applicable text/choice flags, alignment, text limits and check styles; choices/defaults remain individual GUI edits" +evidence = ["crates/forms/src/tests.rs::button_caption_preserves_other_widgets_and_shared_dictionaries", "crates/ui-egui/tests/prepare.rs::button_label_is_editable_without_renaming_the_field", "crates/automation/tests/button_caption.rs::button_caption_can_be_changed_after_creation", "crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically"] +notes = "push buttons: per-widget normal label independent of the field name, including clearing and save/reopen; icon-only layout and existing Helvetica limits retained, rollover/down labels and icon layout editing remain unavailable; text: alignment, default, multi-line, scroll, rich text flag, limit, password, file selection, spell check, comb; choices: items, default, sort, custom text, multiple selection, commit immediately; check boxes and radios: checked by default, radios in unison. Shared GUI offers applicable text/choice flags, alignment, text limits and check styles; choices/defaults remain individual GUI edits" [[feature]] id = "form.props-actions" @@ -5095,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/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." +evidence = ["crates/forms/src/tests.rs::detection_finds_blanks_rules_boxes_and_names_them", "crates/forms/src/tests.rs::page_shapes_reads_rectangles_with_points_along_their_sides", "crates/engine/src/tests.rs::detecting_fields_on_a_printed_form", "crates/forms/src/tests.rs::detection_makes_a_row_of_touching_cells_one_comb_field", "crates/engine/src/tests.rs::detecting_comb_boxes_adds_one_comb_field_per_row", "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/automation/tests/automation.rs::detecting_comb_boxes_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, small squares (check boxes) and rows of two or more touching cells of one size (one comb text field, character limit = cells; tested on drawn forms only); a box drawn with points along its sides (a midpoint on each vertical side, as the French 2042 form's cells) is read as the rectangle it is (#2942); 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: dotted leader lines, tables of cells as such, deskewing, radio groups." [[feature]] id = "form.auto-name-fields" @@ -5227,8 +5220,8 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] -evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them"] -notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" +evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions"] +notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro shows the public ISCIII form), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: a page area's relevant=\"-print\" (Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] id = "form.xfa-formcalc" @@ -5311,10 +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", "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"] -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; 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/src/comments.rs::shift_keeps_the_current_proportions_unless_the_box_has_its_own", "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; 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)" +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" @@ -5713,8 +5704,8 @@ milestone = "M8" status = "shipped" commands = ["redact.apply"] tools = ["redact_apply"] -evidence = ["crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared"] -notes = "covered pixels cleared in a copy; DCT/JPX/JBIG2/CCITT images removed whole" +evidence = ["crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared", "crates/redact/src/tests.rs::redaction_clears_pixels_intersected_without_covering_their_centres"] +notes = "intersected pixel footprints are cleared conservatively in a copy; DCT/JPX/JBIG2/CCITT images removed whole" [[feature]] id = "protect.redact-vectors" @@ -5737,8 +5728,8 @@ milestone = "M8" status = "shipped" commands = ["redact.apply"] tools = ["redact_apply"] -evidence = ["crates/redact/src/tests.rs::comments_links_and_fields_under_a_mark_go", "crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing"] -notes = "with pop-ups; fields lose all widgets" +evidence = ["crates/redact/src/tests.rs::comments_links_and_fields_under_a_mark_go", "crates/redact/src/tests.rs::replies_to_a_redacted_comment_go_with_it", "crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing"] +notes = "with pop-ups and replies (/IRT, transitively; replies on other pages are not followed); fields lose all widgets" [[feature]] id = "protect.redact-struct-tree" @@ -5797,8 +5788,8 @@ milestone = "M8" status = "shipped" commands = ["protect.remove_hidden"] tools = ["doc_hidden_info", "doc_remove_hidden"] -evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/ui-egui/tests/ui_group/redact.rs::removing_hidden_information_and_sanitizing", "crates/automation/tests/automation.rs::removing_hidden_information_through_tools"] -notes = "metadata, attachments, comments, form fields (flattened), hidden text, hidden layers, bookmarks, links/actions/scripts, private data; overlapping objects and cropped content pending" +evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/ui-egui/tests/ui_group/redact.rs::removing_hidden_information_and_sanitizing", "crates/automation/tests/automation.rs::removing_hidden_information_through_tools", "crates/redact/src/tests.rs::removed_links_and_comments_leave_the_structure_tree_and_the_saved_file"] +notes = "metadata, attachments, comments, form fields (flattened), hidden text, hidden layers, bookmarks, links/actions/scripts (with the structure tree's references to removed links and comments, #2447), private data; overlapping objects and cropped content pending" [[feature]] id = "protect.sanitize" @@ -5821,10 +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::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"] -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)." -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/automation/tests/automation.rs::auditing_faux_redactions_through_tools", "apps/pdfcraft-cli/tests/audit.rs::audit_rectangles_match_the_tool_in_top_left_points", "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"] -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). 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." +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/audit/src/tests.rs::a_cover_over_one_word_of_a_long_line_is_found", "crates/audit/src/tests.rs::a_box_grazing_a_word_is_not_flagged", "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 hiding half of any one word counts too (#2662), so a box over one number in a long line, or an unapplied mark over it, is found; a sliver under half of every word it touches and under 40% of the line is 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" @@ -5923,8 +5912,8 @@ milestone = "M9" status = "shipped" commands = ["sign.digital", "sign.certify"] tools = ["sign_windows_ids", "sign_document"] -evidence = ["crates/sign/tests/pdf.rs::signing_with_windows_store_identities", "crates/sign/tests/pdf.rs::a_windows_store_identity_signs_with_its_issuers", "crates/sign/tests/pdf.rs::windows_store_enumeration_and_missing_identity", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools", "crates/ui-egui/tests/signing.rs::windows_store_identity_does_not_ask_for_a_file_password", "crates/ui-egui/tests/signing.rs::an_unusable_id_is_shown_with_its_reason_and_cannot_be_chosen"] -notes = "Current User Personal (My) CNG identities: RSA PKCS #1 v1.5 and DER-encoded ECDSA P-256/P-384. Keys never leave Windows; windows: for agents and Windows store labels in the desktop ID picker. The signature embeds the issuers Windows chains the certificate to. Store certificates that can't sign (no private key, another key type, a key CNG can't open) are reported with the reason and Windows error code (greyed in the picker, `unusable` in sign_windows_ids) rather than left out; keys that need a prompt to open are listed and prompt at signing. Tested with throwaway software-backed RSA-2048, P-256 and P-384 certificates and a key-less certificate (run with --ignored). Smart cards and tokens whose driver registers a CNG key storage provider sign with the provider's own PIN dialog (checked by hand with a SafeNet token only; no hardware in CI); still missing: the Local Machine store, legacy CryptoAPI-only providers, P-521/Brainpool/Ed25519 keys, direct PKCS #11." +evidence = ["crates/sign/tests/pdf.rs::signing_with_windows_store_identities", "crates/sign/tests/pdf.rs::a_windows_store_identity_signs_with_its_issuers", "crates/sign/tests/pdf.rs::windows_store_enumeration_and_missing_identity", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools", "crates/ui-egui/tests/signing.rs::windows_store_identity_does_not_ask_for_a_file_password", "crates/ui-egui/tests/signing.rs::an_unusable_id_is_shown_with_its_reason_and_cannot_be_chosen", "crates/sign/tests/chain.rs::a_smart_cards_certificates_that_may_not_sign_say_why_in_id_lists"] +notes = "Current User Personal (My) CNG identities: RSA PKCS #1 v1.5 and DER-encoded ECDSA P-256/P-384. Keys never leave Windows; windows: for agents and Windows store labels in the desktop ID picker. The signature embeds the issuers Windows chains the certificate to. Store certificates that can't sign (no private key, another key type, a key CNG can't open, or a key usage or extended key usage that doesn't allow signing documents, like a CAC's encryption and logon certificates) are reported with the reason and Windows error code (greyed in the picker, `unusable` in sign_windows_ids) rather than left out; keys that need a prompt to open are listed and prompt at signing. Tested with throwaway software-backed RSA-2048, P-256 and P-384 certificates and a key-less certificate (run with --ignored). Smart cards and tokens whose driver registers a CNG key storage provider sign with the provider's own PIN dialog (checked by hand with a SafeNet token only; no hardware in CI); still missing: the Local Machine store, legacy CryptoAPI-only providers, P-521/Brainpool/Ed25519 keys, direct PKCS #11." [[feature]] id = "sign.encrypted-documents" @@ -6235,8 +6224,8 @@ milestone = "M9" status = "shipped" commands = ["sign.panel"] tools = [] -evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/ui-egui/tests/signing.rs::certifying_without_a_visible_signature"] -notes = "signed/valid, problems, invalid and certified bars" +evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/ui-egui/tests/signing.rs::certifying_without_a_visible_signature", "crates/ui-egui/src/canvas.rs::signature_badges_keep_signed_visible_widgets_on_the_page"] +notes = "signed/valid, problems, invalid and certified bars; visible signed widgets also carry a separate viewer validation overlay. Trust and explicit validation invalidate view caches without editing document artwork; baked appearance captions remain unchanged." [[feature]] id = "sign.signature-properties" @@ -6344,7 +6333,7 @@ status = "shipped" commands = ["sign.panel"] tools = [] evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting"] -notes = "" +notes = "repeats reuse the tab, whose name keeps the (signed version) suffix instead of the document title; the tab carries a notice bar naming the revision, with a Close that asks about saving first. Still missing: the snapshot is a full editable document rather than a read-only view, and there is no side-by-side compare (sign.compare-signed-version)" [[feature]] id = "sign.compare-signed-version" @@ -6987,8 +6976,8 @@ milestone = "M10" 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. 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." +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::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/lib.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/lib.rs::a_superscript_beside_a_number_keeps_its_table", "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 (a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), 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" @@ -7087,8 +7076,8 @@ milestone = "M10" status = "shipped" commands = ["export.all_images"] tools = ["doc_export_all_images"] -evidence = ["crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/automation/tests/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/export.rs::export_dialogs_write_images_and_text"] -notes = "each image once (page resources and nested forms); JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JPX/JBIG2/CCITT masks are left out (image exported opaque); JPX/JBIG2/CCITT/separations reported as skipped; minimum size filter" +evidence = ["crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/automation/tests/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/export.rs::export_dialogs_write_images_and_text", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps"] +notes = "each image once (page resources and nested forms); JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JPX/JBIG2/CCITT masks are left out (image exported opaque); JPX/JBIG2/CCITT/separations reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Still limited: 16-bit samples use high bytes; general JPEG and mask Decode, ICC transforms, and calibrated colors remain unsupported." [[feature]] id = "create.export-html" @@ -7273,8 +7262,6 @@ 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", "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" -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" [[feature]] id = "optimize.downsample-images" @@ -7285,10 +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", "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"] -notes = "colour and grayscale, bicubic, effective resolution measured where drawn; image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome not yet" -evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "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", "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 (every use, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); 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" @@ -7791,7 +7776,7 @@ milestone = "M11" status = "partial" commands = ["standards.pdfa"] tools = ["pdfa_verify"] -evidence = ["crates/preflight/src/tests.rs::verify_reports_what_breaks_pdfa", "crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::verify_reports_an_output_intent_that_does_not_match_device_colour", "crates/automation/tests/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/compare.rs::pdfa_dialog_verifies_and_converts"] +evidence = ["crates/preflight/src/tests.rs::verify_reports_what_breaks_pdfa", "crates/preflight/src/tests.rs::standards_identification_uses_namespace_not_prefix", "crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::verify_reports_an_output_intent_that_does_not_match_device_colour", "crates/automation/tests/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/compare.rs::pdfa_dialog_verifies_and_converts"] notes = "PDF/A-2b/3b structural rules: identification, encryption, output intent (present, and RGB/CMYK to match DeviceRGB/DeviceCMYK use), fonts embedded, annotations, actions, images, embedded files. Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison." [[feature]] @@ -9952,8 +9937,6 @@ 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_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 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_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]] @@ -10110,8 +10093,7 @@ status = "partial" commands = [] tools = [] evidence = [] -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 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." -notes = "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. 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 fb6588c142..7b2c37f02f 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -9,16 +9,13 @@ A PdfCraft patch in `hayro-syntax::Page::base_dimensions` expresses the existing | 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); (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-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); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (41) 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); (42) 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; (43) 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`; (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`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::text_rendering_modes_other_than_invisible_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours` | -| 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 | 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); (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349) | (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`; (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path` | +| 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); (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349) | (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`; (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path` | | 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.; (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 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; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306) | (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`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`) | 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` | diff --git a/vendor/hayro-interpret/src/cache.rs b/vendor/hayro-interpret/src/cache.rs index 2dda2e8a7c..e253a7b8c7 100644 --- a/vendor/hayro-interpret/src/cache.rs +++ b/vendor/hayro-interpret/src/cache.rs @@ -72,7 +72,8 @@ pub(crate) struct DecodedImageCache { /// - the dictionary's bytes (`dict_hash`: `/Decode`, `/ColorSpace`, `/SMask`, `/Mask`, /// `/BitsPerComponent`, filters, …) and its object (`obj`: the decryption key depends on it); /// - whether it is decoded as a mask, a stencil mask, raster or mask output, interpolation, -/// the decode target size, and preview or final quality. +/// the decode target size (only for an image a JPX stream decodes, which is the only +/// decode that reads it: PdfCraft patch 32), and preview or final quality. /// /// A transfer function (graphics state `/TR`) also changes the pixels and has no cheap key, so /// an image drawn under one is not cached; neither is an inline image, whose dictionary lives in @@ -95,6 +96,8 @@ pub(crate) struct DecodedImageKey { struct DecodedImageCacheInner { map: HashMap>>, bytes: usize, + /// PdfCraft patch (32): how many keyed decodes ran (cache misses). + decodes: usize, } impl DecodedImageCache { @@ -105,6 +108,12 @@ impl DecodedImageCache { } } + /// PdfCraft patch (32): how many decodes the cache was asked to keep (its misses), for + /// diagnostics and tests. 0 when the lock is poisoned. + pub(crate) fn decodes(&self) -> usize { + self.inner.lock().map(|locked| locked.decodes).unwrap_or(0) + } + /// The same cache, for a preview (`true`) or a final render. pub(crate) fn for_render(&self, preview: bool) -> Self { Self { @@ -141,6 +150,7 @@ impl DecodedImageCache { let Ok(mut locked) = self.inner.lock() else { return val; }; + locked.decodes = locked.decodes.saturating_add(1); // Another thread may have decoded it meanwhile: keep the first copy. if let Some(existing) = locked.map.get(&key) && let Some(existing) = existing.as_ref().and_then(|val| val.downcast_ref::()) diff --git a/vendor/hayro-interpret/src/color.rs b/vendor/hayro-interpret/src/color.rs index dd2edf241d..56bb04f24c 100644 --- a/vendor/hayro-interpret/src/color.rs +++ b/vendor/hayro-interpret/src/color.rs @@ -22,6 +22,25 @@ use std::sync::{Arc, LazyLock, OnceLock}; /// A storage for the components of colors. pub type ColorComponents = SmallVec<[f32; 4]>; +std::thread_local! { + // PdfCraft patch (34): see `tint_transform_runs`. + static TINT_TRANSFORM_RUNS: core::cell::Cell = const { core::cell::Cell::new(0) }; +} + +/// PdfCraft patch (34): how many colours this thread has run through a Separation or DeviceN +/// tint transform, for diagnostics and tests. An image converted through patch (28)'s lookup +/// table runs it once per possible sample value, not once per pixel. Per thread, so a caller +/// counting its own render is not disturbed by renders on other threads. +pub fn tint_transform_runs() -> u64 { + TINT_TRANSFORM_RUNS.with(core::cell::Cell::get) +} + +/// PdfCraft patch (34): count `colours` runs of a tint transform (called once per batch). +fn count_tint_transform_runs(colours: usize) { + let colours = u64::try_from(colours).unwrap_or(u64::MAX); + TINT_TRANSFORM_RUNS.with(|runs| runs.set(runs.get().saturating_add(colours))); +} + /// An RGB color with an alpha channel. #[derive(Debug, Copy, Clone)] pub struct AlphaColor { @@ -857,6 +876,7 @@ impl Separation { impl ToRgb for Separation { fn convert_f32(&self, input: &[f32], output: &mut [u8], _: bool) -> Option<()> { + count_tint_transform_runs(input.len()); let evaluated = input .iter() .flat_map(|n| { @@ -911,6 +931,8 @@ impl DeviceN { impl ToRgb for DeviceN { fn convert_f32(&self, input: &[f32], output: &mut [u8], _: bool) -> Option<()> { + let colours = input.len().checked_div(usize::from(self.num_components)); + count_tint_transform_runs(colours.unwrap_or(0)); let evaluated = input .chunks_exact(self.num_components as usize) .flat_map(|n| { diff --git a/vendor/hayro-interpret/src/context.rs b/vendor/hayro-interpret/src/context.rs index 3e83229c5f..d6772baa15 100644 --- a/vendor/hayro-interpret/src/context.rs +++ b/vendor/hayro-interpret/src/context.rs @@ -140,6 +140,12 @@ impl<'a> InterpreterCache<'a> { decoded_cache: DecodedImageCache::new(), } } + + /// PdfCraft patch (32): how many images were decoded through this cache (its misses, not + /// counting images that are never cached), for diagnostics and tests. + pub fn decoded_image_decodes(&self) -> usize { + self.decoded_cache.decodes() + } } /// A per-page interpretation context that borrows shared data from an [`InterpreterCache`]. diff --git a/vendor/hayro-interpret/src/encode.rs b/vendor/hayro-interpret/src/encode.rs index 498ed909ca..b3f830ba2d 100644 --- a/vendor/hayro-interpret/src/encode.rs +++ b/vendor/hayro-interpret/src/encode.rs @@ -3,7 +3,7 @@ use crate::color::{AlphaColor, ColorComponents, ColorSpace}; use crate::interpret::state::ActiveTransferFunction; use crate::pattern::ShadingPattern; -use crate::shading::{ShadingFunction, ShadingType, Triangle}; +use crate::shading::{ShadingFunction, ShadingType, Triangle, TriangleVertex}; use kurbo::{Affine, Point}; use rustc_hash::FxHashMap; use smallvec::{ToSmallVec, smallvec}; @@ -102,7 +102,7 @@ impl ShadingPattern { } triangles.clear(); patch.to_triangles(&mut triangles); - if !sampler.sample(&triangles, full_transform) { + if !sampler.sample_owned(&mut triangles, full_transform) { break; } } @@ -126,7 +126,7 @@ impl ShadingPattern { } triangles.clear(); patch.to_triangles(&mut triangles); - if !sampler.sample(&triangles, full_transform) { + if !sampler.sample_owned(&mut triangles, full_transform) { break; } } @@ -268,47 +268,62 @@ impl TriangleSampler { /// Sample `triangles`; `false` once a budget is spent (the rest is left unsampled). fn sample(&mut self, triangles: &[Triangle], transform: Affine) -> bool { + if !self.take_page_budget(triangles.len()) { + return false; + } + triangles.iter().all(|t| { + self.sample_one(transformed( + t.p0.clone(), + t.p1.clone(), + t.p2.clone(), + transform, + )) + }) + } + + /// PdfCraft patch (33): [`Self::sample`] for triangles made for this sampling alone (a + /// patch's), moving their vertices instead of copying each one's colours (`triangles` is + /// left empty). + fn sample_owned(&mut self, triangles: &mut Vec, transform: Affine) -> bool { + if !self.take_page_budget(triangles.len()) { + return false; + } + triangles + .drain(..) + .all(|t| self.sample_one(transformed(t.p0, t.p1, t.p2, transform))) + } + + /// Take `count` triangles from the page's budget; `false` (and none left) when it is short. + fn take_page_budget(&mut self, count: usize) -> bool { let page_left = MESH_TRIANGLES_LEFT.with(core::cell::Cell::get); - if page_left < triangles.len() as u64 { + if page_left < count as u64 { MESH_TRIANGLES_LEFT.with(|n| n.set(0)); warn!("page mesh triangle budget exceeded; the rest of the shading is not drawn"); return false; } - MESH_TRIANGLES_LEFT.with(|n| n.set(page_left - triangles.len() as u64)); - for t in triangles { - let t = { - let p0 = transform * t.p0.point; - let p1 = transform * t.p1.point; - let p2 = transform * t.p2.point; + MESH_TRIANGLES_LEFT.with(|n| n.set(page_left - count as u64)); + true + } - let mut v0 = t.p0.clone(); - v0.point = p0; - let mut v1 = t.p1.clone(); - v1.point = p1; - let mut v2 = t.p2.clone(); - v2.point = p2; + /// Sample one triangle in device space; `false` once the visit budget is spent. + fn sample_one(&mut self, t: Triangle) -> bool { + // Only the part of the triangle inside `area`; only such triangles add visits. + let bbox = t.bounding_box().intersect(self.area); + if bbox.width() <= 0.0 || bbox.height() <= 0.0 { + return true; + } + self.visits_left = self.visits_left.saturating_add(MESH_VISITS_PER_TRIANGLE); - Triangle::new(v0, v1, v2) - }; - - // Only the part of the triangle inside `area`; only such triangles add visits. - let bbox = t.bounding_box().intersect(self.area); - if bbox.width() <= 0.0 || bbox.height() <= 0.0 { - continue; - } - self.visits_left = self.visits_left.saturating_add(MESH_VISITS_PER_TRIANGLE); - - for y in (bbox.y0.floor() as u16)..(bbox.y1.ceil() as u16) { - for x in (bbox.x0.floor() as u16)..(bbox.x1.ceil() as u16) { - if self.visits_left == 0 { - warn!("mesh shading visit budget exceeded; the rest is not drawn"); - return false; - } - self.visits_left -= 1; - let point = Point::new(x as f64, y as f64); - if t.contains_point(point) { - self.map.insert((x, y), t.interpolate(point)); - } + for y in (bbox.y0.floor() as u16)..(bbox.y1.ceil() as u16) { + for x in (bbox.x0.floor() as u16)..(bbox.x1.ceil() as u16) { + if self.visits_left == 0 { + warn!("mesh shading visit budget exceeded; the rest is not drawn"); + return false; + } + self.visits_left -= 1; + let point = Point::new(x as f64, y as f64); + if t.contains_point(point) { + self.map.insert((x, y), t.interpolate(point)); } } } @@ -316,6 +331,20 @@ impl TriangleSampler { } } +/// The triangle `v0`, `v1`, `v2` with its points mapped through `transform` (its colours as +/// they are). +fn transformed( + mut v0: TriangleVertex, + mut v1: TriangleVertex, + mut v2: TriangleVertex, + transform: Affine, +) -> Triangle { + v0.point = transform * v0.point; + v1.point = transform * v1.point; + v2.point = transform * v2.point; + Triangle::new(v0, v1, v2) +} + fn encode_function_shading(domain: &[f32; 4], function: &ShadingFunction) -> EncodedShadingType { let domain = kurbo::Rect::new( domain[0] as f64, diff --git a/vendor/hayro-interpret/src/font/mod.rs b/vendor/hayro-interpret/src/font/mod.rs index 5ccf5c48a6..7c8c8d086d 100644 --- a/vendor/hayro-interpret/src/font/mod.rs +++ b/vendor/hayro-interpret/src/font/mod.rs @@ -444,6 +444,29 @@ impl Encoding { Self::BuiltIn => None, } } + + /// PdfCraft patch: the glyph name an Annex D.2 *alias code* reads as when the glyph is taken + /// as text rather than looked up in a font program; `None` for every other code. + /// + /// Annex D.2 gives WinAnsiEncoding codes 0xA0 and 0xAD the glyph names `space` and `hyphen`, + /// the same names it gives 0x20 and 0x2D, and MacRomanEncoding 0xCA `space`. The tables above + /// call them `nbspace` and `sfthyphen`, whose Adobe Glyph List values U+00A0 and U+00AD are + /// *formatting* characters, so extraction hid ink the reader can see: a soft hyphen is + /// invisible, and text that drew one matched no search and no redaction pattern -- one corpus + /// file alone produced 104 of them -- while a non-breaking space did not match the space a + /// reader typed. pdftotext reads both codes as the ordinary character. + /// + /// Only the base table is aliased, in the text direction only. A `/Differences` entry naming + /// `sfthyphen` or `nbspace` is a deliberate statement and keeps its glyph list value, which + /// is what pdftotext does too; and glyph *lookup* is untouched, so a font program's own + /// `sfthyphen` program is still found first. + fn text_alias(&self, code: u8) -> Option<&'static str> { + match (self, code) { + (Self::WinAnsi, 0xA0) | (Self::MacRoman, 0xCA) => Some("space"), + (Self::WinAnsi, 0xAD) => Some("hyphen"), + _ => None, + } + } } /// The font stretch. diff --git a/vendor/hayro-interpret/src/font/standard_font.rs b/vendor/hayro-interpret/src/font/standard_font.rs index c73c75dd22..139454a0b2 100644 --- a/vendor/hayro-interpret/src/font/standard_font.rs +++ b/vendor/hayro-interpret/src/font/standard_font.rs @@ -546,6 +546,14 @@ impl StandardKind { } pub(crate) fn char_code_to_unicode(&self, code: u8) -> Option { + // PdfCraft patch: an Annex D.2 alias code reads as the ordinary character; see + // `Encoding::text_alias`. `code_to_ps_name` is shared with glyph lookup and stays as it + // is, so the alias is applied here, after `/Differences` has had its say. + if !self.encodings.contains_key(&code) + && let Some(name) = self.encoding.text_alias(code) + { + return glyph_name_to_unicode(name); + } self.code_to_ps_name(code).and_then(glyph_name_to_unicode) } diff --git a/vendor/hayro-interpret/src/font/true_type.rs b/vendor/hayro-interpret/src/font/true_type.rs index 40f3359030..b362669859 100644 --- a/vendor/hayro-interpret/src/font/true_type.rs +++ b/vendor/hayro-interpret/src/font/true_type.rs @@ -286,7 +286,13 @@ impl EmbeddedKind { self.differences .get(&code) .map(|s| s.as_str()) + // PdfCraft patch: an Annex D.2 alias code reads as the ordinary character; see + // `Encoding::text_alias`. + .or_else(|| self.encoding.text_alias(code)) .or_else(|| self.encoding.map_code(code)) + // The last resort reads the code as WinAnsi whatever the font said, so it takes the + // same aliasing. + .or_else(|| Encoding::WinAnsi.text_alias(code)) .or_else(|| win_ansi::get(code)) } diff --git a/vendor/hayro-interpret/src/font/type1.rs b/vendor/hayro-interpret/src/font/type1.rs index 32b5e3ddd9..02f76bfb80 100644 --- a/vendor/hayro-interpret/src/font/type1.rs +++ b/vendor/hayro-interpret/src/font/type1.rs @@ -239,6 +239,14 @@ impl Type1Kind { } fn char_code_to_unicode(&self, code: u8) -> Option { + // PdfCraft patch: an Annex D.2 alias code reads as the ordinary character; see + // `Encoding::text_alias`. `code_to_ps_name` is shared with glyph lookup and stays as it + // is, so the alias is applied here, after `/Differences` has had its say. + if !self.encodings.contains_key(&code) + && let Some(name) = self.encoding.text_alias(code) + { + return glyph_name_to_unicode(name); + } self.code_to_ps_name(code).and_then(glyph_name_to_unicode) } } @@ -336,6 +344,14 @@ impl CffKind { } fn char_code_to_unicode(&self, code: u8) -> Option { + // PdfCraft patch: an Annex D.2 alias code reads as the ordinary character; see + // `Encoding::text_alias`. `code_to_ps_name` is shared with glyph lookup and stays as it + // is, so the alias is applied here, after `/Differences` has had its say. + if !self.encodings.contains_key(&code) + && let Some(name) = self.encoding.text_alias(code) + { + return glyph_name_to_unicode(name); + } self.code_to_ps_name(code).and_then(glyph_name_to_unicode) } } diff --git a/vendor/hayro-interpret/src/font/type3.rs b/vendor/hayro-interpret/src/font/type3.rs index ccfeeb7664..324fa03dfe 100644 --- a/vendor/hayro-interpret/src/font/type3.rs +++ b/vendor/hayro-interpret/src/font/type3.rs @@ -108,6 +108,9 @@ impl<'a> Type3<'a> { .encodings .get(&code) .map(|s| s.as_str()) + // PdfCraft patch: an Annex D.2 alias code reads as the ordinary character; + // see `Encoding::text_alias`. + .or_else(|| self.encoding.text_alias(code)) .or_else(|| self.encoding.map_code(code)); name.and_then(crate::font::glyph_name_to_unicode) .or_else(|| (0x20..0x7f).contains(&code).then_some(code as char)) diff --git a/vendor/hayro-interpret/src/function/type0.rs b/vendor/hayro-interpret/src/function/type0.rs index 02bbc41b84..9a82c59786 100644 --- a/vendor/hayro-interpret/src/function/type0.rs +++ b/vendor/hayro-interpret/src/function/type0.rs @@ -3,14 +3,18 @@ use hayro_syntax::bit_reader::BitReader; use hayro_syntax::object::Array; use hayro_syntax::object::Stream; use hayro_syntax::object::dict::keys::{BITS_PER_SAMPLE, DECODE, ENCODE, SIZE}; -use smallvec::{SmallVec, ToSmallVec, smallvec}; -use std::collections::HashMap; +use smallvec::SmallVec; /// A type 0 function (sampled function). #[derive(Debug)] pub(crate) struct Type0 { sizes: IntVec, - table: HashMap, + /// PdfCraft patch (31): the samples in stream order, `range.len()` to a grid point, the + /// first input varying fastest. They were a hash map keyed by each grid point, built again + /// every time a shading using the function was painted. + samples: Vec, + /// PdfCraft patch (31): how many grid points one step along each input moves. + strides: SmallVec<[usize; 4]>, clamper: Clamper, range: TupleVec, bits_per_sample: u8, @@ -85,14 +89,22 @@ impl Type0 { data.truncate(num_expected_entries); } - let table = build_table(&data, &sizes, range.len())?; + // Saturating: the grid has at most `MAX_SAMPLE_ENTRIES` points unless a size is 0, and + // then no point is on it (`entry` checks each coordinate against its size first). + let mut strides = SmallVec::with_capacity(sizes.len()); + let mut stride = 1usize; + for size in &sizes { + strides.push(stride); + stride = stride.saturating_mul(*size as usize); + } Some(Self { sizes, clamper, range, bits_per_sample, - table, + samples: data, + strides, encode, decode, }) @@ -122,29 +134,16 @@ impl Type0 { let in_prev = key.iter().map(|v| v.floor() as u32).collect::(); let in_next = key.iter().map(|v| v.ceil() as u32).collect::(); + let mut coord: IntVec = in_prev.clone(); - let interpolator = Interpolator::new( - key.clone().to_smallvec(), - in_prev, - in_next, - self.sizes.clone(), - self.range.len(), - ); - - let interpolated = interpolator.interpolate(&self.table)?; + let interpolated = self.interpolate(&key, &in_prev, &in_next, &mut coord, 0)?; + // PdfCraft patch (31): `2_u32.pow(32)` overflowed (a panic with overflow checks on). + let max_sample = ((1u64 << self.bits_per_sample) - 1) as f32; let mut out = interpolated .iter() .zip(self.decode.iter()) - .map(|(x, decode)| { - interpolate( - *x, - 0.0, - (2_u32.pow(self.bits_per_sample as u32) - 1) as f32, - decode.0, - decode.1, - ) - }) + .map(|(x, decode)| interpolate(*x, 0.0, max_sample, decode.0, decode.1)) .collect::>(); self.clamper.clamp_output(&mut out); @@ -157,95 +156,57 @@ type FloatVec = SmallVec<[f32; 4]>; type IntVec = SmallVec<[u32; 4]>; // See -struct Interpolator { - input: FloatVec, - sizes: IntVec, - in_prev: IntVec, - in_next: IntVec, - out_len: usize, -} - -impl Interpolator { - fn new( - input: FloatVec, - in_prev: IntVec, - in_next: IntVec, - sizes: IntVec, - out_len: usize, - ) -> Self { - Self { - input, - in_prev, - in_next, - sizes, - out_len, - } - } - - fn interpolate(&self, table: &HashMap) -> Option { - self.interpolate_inner(smallvec![0; self.input.len()], 0, table) - } - - fn interpolate_inner( +impl Type0 { + /// PdfCraft patch (31): the value at `input` between the grid points `in_prev` and + /// `in_next`, interpolating the inputs from `step` on (the earlier ones are placed in + /// `coord`). The same multilinear interpolation, in the same order, as the hash-map + /// version, so the results are bit for bit the same; one level of recursion per input, + /// as before. + fn interpolate( &self, - mut coord: IntVec, + input: &[f32], + in_prev: &[u32], + in_next: &[u32], + coord: &mut IntVec, step: usize, - table: &HashMap, ) -> Option { - if step == self.input.len() - 1 { - if self.in_prev[step] == self.in_next[step] { - coord[step] = self.in_prev[step]; + let (Some(x), Some(prev), Some(next)) = + (input.get(step), in_prev.get(step), in_next.get(step)) + else { + // Every input placed: the grid point itself. + return Some(self.entry(coord)?.iter().map(|n| *n as f32).collect()); + }; + let (x, prev, next) = (*x, *prev, *next); - Some( - table - .get(&Key::from_raw(&self.sizes, &coord))? - .clone() - .iter() - .map(|n| *n as f32) - .collect(), - ) - } else { - coord[step] = self.in_prev[step]; - let val1 = table.get(&Key::from_raw(&self.sizes, &coord))?; - coord[step] = self.in_next[step]; - let val2 = table.get(&Key::from_raw(&self.sizes, &coord))?; - let mut out = smallvec![0.0; self.out_len]; - - for i in 0..self.out_len { - out[i] = interpolate( - self.input[step], - self.in_prev[step] as f32, - self.in_next[step] as f32, - val1[i] as f32, - val2[i] as f32, - ); - } - - Some(out) - } - } else if self.in_prev[step] == self.in_next[step] { - coord[step] = self.in_prev[step]; - self.interpolate_inner(coord, step + 1, table) - } else { - coord[step] = self.in_prev[step]; - let val1 = self.interpolate_inner(coord.clone(), step + 1, table)?; - coord[step] = self.in_next[step]; - let val2 = self.interpolate_inner(coord, step + 1, table)?; - - let mut out = smallvec![0.0; self.out_len]; - - for i in 0..self.out_len { - out[i] = interpolate( - self.input[step], - self.in_prev[step] as f32, - self.in_next[step] as f32, - val1[i], - val2[i], - ); - } - - Some(out) + *coord.get_mut(step)? = prev; + let val1 = self.interpolate(input, in_prev, in_next, coord, step + 1)?; + if prev == next { + return Some(val1); } + *coord.get_mut(step)? = next; + let val2 = self.interpolate(input, in_prev, in_next, coord, step + 1)?; + + Some( + val1.iter() + .zip(val2.iter()) + .map(|(a, b)| interpolate(x, prev as f32, next as f32, *a, *b)) + .collect(), + ) + } + + /// PdfCraft patch (31): the samples of grid point `coord`, or `None` where it is off the + /// grid or the stream ended before it (where the hash map had no entry). + fn entry(&self, coord: &[u32]) -> Option<&[u32]> { + let mut index = 0usize; + for ((c, size), stride) in coord.iter().zip(&self.sizes).zip(&self.strides) { + if c >= size { + return None; + } + index = index.checked_add((*c as usize).checked_mul(*stride)?)?; + } + let n = self.range.len(); + let start = index.checked_mul(n)?; + self.samples.get(start..start.checked_add(n)?) } } @@ -257,71 +218,3 @@ impl Interpolator { /// values, 17^5 points with 4 outputs 5.7 million values. const MAX_SAMPLE_VALUES: usize = 1 << 23; const MAX_SAMPLE_ENTRIES: usize = 1 << 21; - -fn build_table(data: &[u32], sizes: &[u32], n: usize) -> Option> { - let mut key = Key::new(sizes); - let mut table = HashMap::new(); - - let mut first = true; - - for b in data.chunks_exact(n) { - if !first { - key.increment(); - } - - table.insert(key.clone(), b.to_smallvec()); - - first = false; - } - - Some(table) -} - -/// A sampled function consists of a (possibly) multi-dimensional table that we can index -/// into. We do this by representing the entries as a flat list of vectors, where each -/// element in the vector represents the value of the key in that specific dimension. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -struct Key { - sizes: SmallVec<[u32; 4]>, - parts: SmallVec<[u32; 4]>, -} - -impl Key { - fn new(sizes: &[u32]) -> Self { - let parts = smallvec![0; sizes.len()]; - - Self { - sizes: sizes.to_smallvec(), - parts, - } - } - - fn from_raw(sizes: &[u32], parts: &[u32]) -> Self { - Self { - sizes: sizes.to_smallvec(), - parts: parts.to_smallvec(), - } - } - - fn increment(&mut self) -> Option<()> { - self.increment_index(0) - } - - fn increment_index(&mut self, index: usize) -> Option<()> { - let size = *self.sizes.get(index).or_else(|| { - error!("overflowed key in sampled function"); - - None - })?; - let val = self.parts.get_mut(index)?; - - if *val >= (size - 1) { - *val = 0; - self.increment_index(index + 1)?; - } else { - *val += 1; - } - - Some(()) - } -} diff --git a/vendor/hayro-interpret/src/shading.rs b/vendor/hayro-interpret/src/shading.rs index 98fbb58310..2e619e47f2 100644 --- a/vendor/hayro-interpret/src/shading.rs +++ b/vendor/hayro-interpret/src/shading.rs @@ -731,6 +731,10 @@ where { const GRID_SIZE: usize = 20; let mut grid = vec![vec![Point::ZERO; GRID_SIZE]; GRID_SIZE]; + // PdfCraft patch (33): each grid point's colours, interpolated once. Every cell below + // interpolated its four corners itself, so an inner point was interpolated four times + // (1,444 interpolations a patch instead of 400) for the same values. + let mut colors = vec![vec![ColorComponents::new(); GRID_SIZE]; GRID_SIZE]; // Create grid by mapping unit square coordinates. for i in 0..GRID_SIZE { @@ -741,6 +745,7 @@ where // Map unit square coordinate to patch coordinate. let unit_point = Point::new(u, v); grid[i][j] = map_coordinate(unit_point); + colors[i][j] = interpolate(unit_point); } } @@ -751,32 +756,26 @@ where let p01 = grid[i][j + 1]; let p11 = grid[i + 1][j + 1]; - // Calculate unit square coordinates for color interpolation. - let u0 = i as f64 / (GRID_SIZE - 1) as f64; - let u1 = (i + 1) as f64 / (GRID_SIZE - 1) as f64; - let v0 = j as f64 / (GRID_SIZE - 1) as f64; - let v1 = (j + 1) as f64 / (GRID_SIZE - 1) as f64; - - // Create triangle vertices with interpolated colors. + // Create triangle vertices with their grid points' colors. let v00 = TriangleVertex { flag: 0, point: p00, - colors: interpolate(Point::new(u0, v0)), + colors: colors[i][j].clone(), }; let v10 = TriangleVertex { flag: 0, point: p10, - colors: interpolate(Point::new(u1, v0)), + colors: colors[i + 1][j].clone(), }; let v01 = TriangleVertex { flag: 0, point: p01, - colors: interpolate(Point::new(u0, v1)), + colors: colors[i][j + 1].clone(), }; let v11 = TriangleVertex { flag: 0, point: p11, - colors: interpolate(Point::new(u1, v1)), + colors: colors[i + 1][j + 1].clone(), }; let inflate_point = |p: Point, mid: Point| -> Point { @@ -795,16 +794,9 @@ where triangle }; - buffer.push(inflate(Triangle::new( - v00.clone(), - v10.clone(), - v01.clone(), - ))); - buffer.push(inflate(Triangle::new( - v10.clone(), - v11.clone(), - v01.clone(), - ))); + // PdfCraft patch (33): the second triangle takes the vertices the first copied. + buffer.push(inflate(Triangle::new(v00, v10.clone(), v01.clone()))); + buffer.push(inflate(Triangle::new(v10, v11, v01))); } } } diff --git a/vendor/hayro-interpret/src/x_object.rs b/vendor/hayro-interpret/src/x_object.rs index 1138b658f0..21b134ce8c 100644 --- a/vendor/hayro-interpret/src/x_object.rs +++ b/vendor/hayro-interpret/src/x_object.rs @@ -434,11 +434,28 @@ impl<'a> ImageXObject<'a> { is_mask: self.is_mask, is_stencil_mask: self.is_stencil_mask, interpolate: self.interpolate, - target: target_dimension, + // PdfCraft patch (32): see `decode_reads_target`. + target: if self.decode_reads_target() { + target_dimension + } else { + None + }, preview: cache.preview, }) } + /// PdfCraft patch (32): whether decoding this image depends on the size it is drawn at. + /// Only the JPX filter decodes at a reduced resolution for a target size, in the image + /// itself or in the soft mask or mask decoded with it; every other filter decodes the whole + /// image whatever the target. Keyed by the target, a scan was decoded again at every zoom + /// step and kept once per zoom level; without it, one decode serves them all. + fn decode_reads_target(&self) -> bool { + let dict = self.stream.dict(); + uses_jpx(&self.stream) + || dict.get::>(SMASK).is_some_and(|s| uses_jpx(&s)) + || dict.get::>(MASK).is_some_and(|s| uses_jpx(&s)) + } + pub(crate) fn width(&self) -> u32 { self.width } @@ -467,6 +484,20 @@ fn decoded_mask_size(mask: &DecodedMask) -> usize { mask.luma.width as usize * mask.luma.height as usize } +/// PdfCraft patch (32): whether `stream` names the JPX filter, as a name or in an array, under +/// `/Filter` or its inline abbreviation `/F`. Errs towards `true`: a name the decoder would +/// ignore still counts, which only keeps the target in the key as before. +fn uses_jpx(stream: &Stream<'_>) -> bool { + let dict = stream.dict(); + let is_jpx = |name: &Name<'_>| name.deref() == JPX_DECODE; + [F, FILTER].into_iter().any(|key| { + dict.get::>(key).is_some_and(|name| is_jpx(&name)) + || dict + .get::>(key) + .is_some_and(|filters| filters.iter::>().any(|name| is_jpx(&name))) + }) +} + /// PdfCraft patch: pixel bytes held by a decoded raster image and its mask. fn decoded_raster_size(raster: &DecodedRaster) -> usize { let image = match &raster.image { diff --git a/vendor/hayro-syntax/src/filter/ccitt.rs b/vendor/hayro-syntax/src/filter/ccitt.rs index 687d087149..685d680d2f 100644 --- a/vendor/hayro-syntax/src/filter/ccitt.rs +++ b/vendor/hayro-syntax/src/filter/ccitt.rs @@ -17,10 +17,13 @@ pub(crate) fn decode( // 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. + // PdfCraft patch (19): fewer `/Rows` than the image's `/Height` doesn't cut the image short: a + // plotter driver wrote `/Rows` equal to `/Columns`, and the rows after it were drawn black + // (#3308). The decoder still stops where the data ends. let rows = params .get::(ROWS) .filter(|&rows| rows != 0) - .unwrap_or(image_params.height); + .map_or(image_params.height, |rows| rows.max(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/jpx.rs b/vendor/hayro-syntax/src/filter/jpx.rs index 6a788591e3..74efd8b31e 100644 --- a/vendor/hayro-syntax/src/filter/jpx.rs +++ b/vendor/hayro-syntax/src/filter/jpx.rs @@ -26,7 +26,18 @@ pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option image, + Err(_) => { + repaired = without_refused_enumerated_colour(data)?; + hayro_jpeg2000::Image::new(&repaired, &settings).ok()? + } + }; let width = image.width(); let height = image.height(); @@ -87,6 +98,82 @@ pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option Option> { + let header = boxes(data).find(|&(kind, _, _)| kind == *b"jp2h")?; + let (_, start, end) = header; + let inner = data.get(start..end)?; + let (_, ih_start, _) = boxes(inner).find(|&(kind, _, _)| kind == *b"ihdr")?; + // ihdr: height (4), width (4), number of components (2). + let components = u16::from_be_bytes(inner.get(ih_start + 8..ih_start + 10)?.try_into().ok()?); + let replacement: u32 = match components { + 1 => 17, + 3 => 16, + 4 => 12, + _ => return None, + }; + // Where each refused enum is, found before anything is copied: a stream the decoder refused + // for another reason isn't copied for nothing. + let mut refused = Vec::new(); + for (kind, body, body_end) in boxes(inner) { + if kind != *b"colr" { + continue; + } + // colr: METH (1), PREC (1), APPROX (1), then EnumCS (4) when METH is 1. + let colr = inner.get(body..body_end)?; + if colr.first() != Some(&1) { + continue; + } + let enumerated = u32::from_be_bytes(colr.get(3..7)?.try_into().ok()?); + if DECODED_ENUMERATED.contains(&enumerated) { + continue; + } + refused.push(start.checked_add(body)?.checked_add(3)?); + } + if refused.is_empty() { + return None; + } + let mut out = data.to_vec(); + for at in refused { + out.get_mut(at..at.checked_add(4)?)?.copy_from_slice(&replacement.to_be_bytes()); + } + Some(out) +} + +/// The boxes in `data` (ISO 15444-1 I.4): each one's type and the start and end of its contents. +/// Stops at the first box whose length doesn't fit. +fn boxes(data: &[u8]) -> impl Iterator + '_ { + let mut at = 0usize; + core::iter::from_fn(move || { + let length = u32::from_be_bytes(data.get(at..at.checked_add(4)?)?.try_into().ok()?); + let kind: [u8; 4] = data.get(at + 4..at.checked_add(8)?)?.try_into().ok()?; + let (header, size) = match length { + // The rest of the data. + 0 => (8, data.len().checked_sub(at)?), + // An 8-byte length follows the type. + 1 => { + let large = u64::from_be_bytes(data.get(at + 8..at.checked_add(16)?)?.try_into().ok()?); + (16, usize::try_from(large).ok()?) + } + n => (8, usize::try_from(n).ok()?), + }; + let end = at.checked_add(size)?; + if size < header || end > data.len() { + return None; + } + let item = (kind, at + header, end); + at = end; + Some(item) + }) +} + fn scale( data: &[u8], bit_per_component: u8, @@ -134,7 +221,7 @@ fn scale( #[cfg(test)] mod tests { - use super::scale; + use super::{scale, without_refused_enumerated_colour}; use crate::bit_reader::{BitReader, bit_mask}; use alloc::vec::Vec; @@ -188,4 +275,87 @@ mod tests { } } } + + /// A JP2 header (`jp2h`) holding `ihdr` for `components` and the given `colr` bodies, with + /// a codestream box after it. + fn jp2(components: u16, colrs: &[&[u8]]) -> Vec { + let jp2_box = |kind: &[u8], body: &[u8]| { + let mut b = ((8 + body.len()) as u32).to_be_bytes().to_vec(); + b.extend_from_slice(kind); + b.extend_from_slice(body); + b + }; + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&[0, 0, 0, 1, 0, 0, 0, 1]); + ihdr.extend_from_slice(&components.to_be_bytes()); + ihdr.extend_from_slice(&[7, 7, 0, 0]); + let mut header = jp2_box(b"ihdr", &ihdr); + for c in colrs { + header.extend(jp2_box(b"colr", c)); + } + let mut out = jp2_box(b"ftyp", b"jp2 \0\0\0\0jp2 "); + out.extend(jp2_box(b"jp2h", &header)); + out.extend(jp2_box(b"jp2c", &[0xff, 0x4f])); + out + } + + fn enumerated(e: u32) -> Vec { + let mut c = alloc::vec![1, 0, 0]; + c.extend_from_slice(&e.to_be_bytes()); + c + } + + /// The enumerated colour spaces found in the copy's `colr` boxes, in order. + fn enums_in(data: &[u8]) -> Vec { + data.windows(4) + .enumerate() + .filter(|(_, w)| *w == b"colr") + .filter(|(i, _)| data.get(i + 4) == Some(&1)) + .map(|(i, _)| u32::from_be_bytes(data[i + 7..i + 11].try_into().unwrap())) + .collect() + } + + #[test] + fn refused_enumerated_colour_spaces_become_the_component_counts_default() { + // CIEJab (19) and other refused spaces, by component count. + for (components, refused, expected) in [(3, 19, 16), (3, 22, 16), (1, 15, 17), (4, 13, 12), (3, 1, 16)] { + let data = jp2(components, &[&enumerated(refused)]); + let out = without_refused_enumerated_colour(&data).unwrap(); + assert_eq!(enums_in(&out), [expected], "{refused} with {components} component(s)"); + assert_eq!(out.len(), data.len(), "only the enum changes"); + } + // Several colour specifications: each refused one, and only those. + let out = without_refused_enumerated_colour(&jp2(3, &[&enumerated(19), &enumerated(14), &enumerated(24)])).unwrap(); + assert_eq!(enums_in(&out), [16, 14, 16]); + // An 8-byte box length (LBox 1) is read too. + let mut long = jp2(3, &[&enumerated(19)]); + let at = long.windows(4).position(|w| w == b"jp2h").unwrap() - 4; + let len = u32::from_be_bytes(long[at..at + 4].try_into().unwrap()) as u64 + 8; + long.splice(at..at + 4, [0, 0, 0, 1]); + long.splice(at + 8..at + 8, (len).to_be_bytes()); + assert_eq!(enums_in(&without_refused_enumerated_colour(&long).unwrap()), [16]); + } + + #[test] + fn what_the_decoder_handles_or_cant_be_read_is_left_alone() { + for known in [12, 14, 16, 17, 18, 20, 21] { + assert_eq!(without_refused_enumerated_colour(&jp2(3, &[&enumerated(known)])), None, "{known}"); + } + // An ICC profile (METH 2), a component count with no default, no header at all. + // (Its bytes where an enum would be read say 19: only METH 1 is an enum.) + assert_eq!(without_refused_enumerated_colour(&jp2(3, &[&[2, 0, 0, 0, 0, 0, 19, 0, 0]])), None); + assert_eq!(without_refused_enumerated_colour(&jp2(2, &[&enumerated(19)])), None); + assert_eq!(without_refused_enumerated_colour(&[0xff, 0x4f, 0xff, 0x51]), None); + // Damaged lengths: none of these may panic, and none is changed. + let good = jp2(3, &[&enumerated(19)]); + for cut in 0..good.len() { + let _ = without_refused_enumerated_colour(&good[..cut]); + } + for bad in [0x0000_0004_u32, 0x7fff_ffff, 0xffff_ffff] { + let mut data = good.clone(); + let at = data.windows(4).position(|w| w == b"colr").unwrap() - 4; + data[at..at + 4].copy_from_slice(&bad.to_be_bytes()); + assert_eq!(without_refused_enumerated_colour(&data), None, "{bad:#x}"); + } + } } diff --git a/vendor/hayro-syntax/src/filter/lzw_flate.rs b/vendor/hayro-syntax/src/filter/lzw_flate.rs index f9da1590b2..f1ab1aae73 100644 --- a/vendor/hayro-syntax/src/filter/lzw_flate.rs +++ b/vendor/hayro-syntax/src/filter/lzw_flate.rs @@ -534,23 +534,27 @@ pub(crate) mod flate { /// Copies `left` bytes from `distance` back in the output, up to the limit. Returns how /// many are still to copy. A copy whose source does not exist writes nothing. - fn copy_back(&mut self, valid: bool, distance: usize, mut left: usize) -> usize { - while left > 0 && self.output.len() < self.limit { - if valid { - let byte = self - .output - .len() - .checked_sub(distance) - .and_then(|index| self.output.get(index)) - .copied(); - if let Some(byte) = byte { - self.output.push(byte); - } + fn copy_back(&mut self, valid: bool, distance: usize, left: usize) -> usize { + let len = self.output.len(); + // Without a source the copy writes nothing, and it runs out only at the limit. + let Some(mut start) = len.checked_sub(distance).filter(|_| valid && distance > 0) else { + return if len < self.limit { 0 } else { left }; + }; + // PdfCraft patch: copy in runs of up to `distance` bytes. Each run reads only bytes + // already in the output, so the bytes are the ones a byte-at-a-time copy writes. + let copy = left.min(self.limit.saturating_sub(len)); + let mut done = 0; + while done < copy { + let end = start.saturating_add((copy - done).min(distance)); + if end > self.output.len() { + break; } - left -= 1; + self.output.extend_from_within(start..end); + done += end - start; + start = end; } - left + left - done } fn read_dynamic_tables(&mut self) -> Option<(HuffmanTable, HuffmanTable)> { @@ -1292,6 +1296,52 @@ mod tests { ); } + /// PdfCraft patch: 300 zero bytes, zlib-compressed: one literal, then copies of distance 1 that + /// are longer than their distance. + const ZEROS_300: [u8; 13] = [0x78, 0xda, 0x63, 0x60, 0x18, 0x05, 0xc4, 0x02, 0x00, 0x01, 0x2c, 0x00, 0x01]; + + #[test] + fn back_reference_longer_than_its_distance_repeats_its_source() { + let mut state = flate::FlateState::new(&ZEROS_300); + let mut out = Vec::new(); + state.decode_into(&ZEROS_300, &mut out, 1000); + + assert_eq!(out, vec![0; 300]); + } + + #[test] + fn back_reference_cut_by_the_limit_resumes_in_the_next_call() { + let mut state = flate::FlateState::new(&ZEROS_300); + let mut out = Vec::new(); + state.decode_into(&ZEROS_300, &mut out, 100); + assert_eq!(out, vec![0; 100]); + + state.decode_into(&ZEROS_300, &mut out, 1000); + assert_eq!(out, vec![0; 300]); + } + + #[test] + fn back_reference_reads_bytes_written_by_an_earlier_call() { + // "abc" repeated 2000 times, zlib-compressed: copies reach 3 bytes back, and the calls below + // stop every 7 bytes, so most copies start in an earlier call's output. + const ABC_2000: [u8; 32] = [ + 0x78, 0xda, 0xed, 0xc2, 0x41, 0x11, 0x00, 0x00, 0x0c, 0x02, 0xa0, 0xac, 0x6a, 0xff, 0x0e, 0x0b, + 0xb1, 0x2f, 0x1c, 0xe9, 0xa2, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xfe, 0x1e, 0x2f, 0x01, 0xf9, 0x59, + ]; + let expected = b"abc".repeat(2000); + let mut state = flate::FlateState::new(&ABC_2000); + let mut out = Vec::new(); + loop { + let before = out.len(); + state.decode_into(&ABC_2000, &mut out, before + 7); + if out.len() == before || out.len() >= expected.len() { + break; + } + } + + assert_eq!(out, expected); + } + #[test] fn predictor_paeth() { predictor_test( diff --git a/vendor/hayro-syntax/src/object/stream.rs b/vendor/hayro-syntax/src/object/stream.rs index 833610817b..a80f0730cb 100644 --- a/vendor/hayro-syntax/src/object/stream.rs +++ b/vendor/hayro-syntax/src/object/stream.rs @@ -353,14 +353,25 @@ impl FilterResult<'_> { } } +/// PdfCraft patch (20): the end-of-line after the `stream` keyword. Microsoft Reporting Services +/// writes `stream \r\n`, a space before the line break, which the specification does not allow. +/// Without this the content stream was taken to start at the space, failed to inflate, and the +/// page rendered blank (bank statements, invoices) while other viewers showed it. Only spaces and +/// tabs are skipped, and only when a line break follows, so stream data is never swallowed. +fn forward_stream_eol(r: &mut Reader<'_>) -> Option<()> { + r.forward_while(|b| b == b' ' || b == b'\t'); + r.forward_tag(b"\n") + .or_else(|| r.forward_tag(b"\r\n")) + // Technically not allowed, but no reason to not try it. + .or_else(|| r.forward_tag(b"\r")) +} + fn parse_proper<'a>(r: &mut Reader<'a>, dict: &Dict<'a>) -> Option> { let length = dict.get::(LENGTH)?; r.skip_white_spaces_and_comments(); r.forward_tag(b"stream")?; - r.forward_tag(b"\n") - .or_else(|| r.forward_tag(b"\r\n")) - .or_else(|| r.forward_tag(b"\r"))?; + forward_stream_eol(r)?; let data = r.read_bytes(length as usize)?; r.skip_white_spaces(); r.forward_tag(b"endstream")?; @@ -376,11 +387,7 @@ fn parse_fallback<'a>(r: &mut Reader<'a>, dict: &Dict<'a>) -> Option> // A wrong `/Length` still recovers, because that keyword sits directly after the dict. r.skip_white_spaces_and_comments(); r.forward_tag(b"stream")?; - - r.forward_tag(b"\n") - .or_else(|| r.forward_tag(b"\r\n")) - // Technically not allowed, but no reason to not try it. - .or_else(|| r.forward_tag(b"\r"))?; + forward_stream_eol(r)?; let tail = r.tail()?; let endstream_offset = find_needle(tail, b"endstream")?; diff --git a/vendor/hayro/src/lib.rs b/vendor/hayro/src/lib.rs index 43381ab679..6333ed5808 100644 --- a/vendor/hayro/src/lib.rs +++ b/vendor/hayro/src/lib.rs @@ -61,7 +61,7 @@ use vello_cpu::{Level, Pixmap, RenderMode}; mod renderer; /// PdfCraft patch: exported for its regression test. -pub use renderer::{image_resampling_size, tiling_cell_scale}; +pub use renderer::{image_resampling_size, tiling_cell_scale, usable_cell_scale}; /// PdfCraft patch: exported for its regression test. #[doc(hidden)] pub use renderer::set_skip_offscreen_paths; diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index 0619b4c6dd..6aa53716ad 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -526,14 +526,7 @@ impl Renderer { }, }; - // A miter limit that isn't finite leaves miters unbounded: never skipped. - let miter = stroke.miter_limit.abs(); - let reach = stroke.width / 2.0 - * if miter.is_finite() { - miter.max(std::f64::consts::SQRT_2) - } else { - f64::INFINITY - }; + let reach = stroke_reach(&stroke); self.ctx.set_stroke(stroke); reach } @@ -1142,6 +1135,65 @@ impl Renderer { }; xs = xs.max(min_x_scale).min(max_x_scale); ys = ys.max(min_y_scale).min(max_y_scale); + // PdfCraft patch (20): a step too large for a cell pixmap, where what is + // painted can only show the first cell (PowerPoint writes `/XStep + // 2147480100` for a picture fill drawn once, #3349): the cell's `/BBox` is + // drawn at full scale with a transparent border and padded, not tiled. + // Shrinking the cell to fit the step left a picture under one pixel. + // How far a stroke's paint reaches past the path, miters included, in + // device pixels: the most the transform stretches any direction (a skew + // stretches some further than either axis), times the reach. + let reach = if is_stroke { + path_transform.spectral_norm() * stroke_reach(self.ctx.stroke()) + } else { + 0.0 + }; + if usable_cell_scale(xs, ys) + && (xs * t.x_step.abs() > MAX_PIXMAP_SIZE || ys * t.y_step.abs() > MAX_PIXMAP_SIZE) + && shows_one_cell(&t.matrix, path_transform, path, reach, bbox, t.x_step, t.y_step) + { + const BORDER: f64 = 1.0; + let pix_width = ((bbox.width() as f32 * xs).ceil() as u16).saturating_add(2); + let pix_height = ((bbox.height() as f32 * ys).ceil() as u16).saturating_add(2); + let mut renderer = Self { + ctx: RenderContext::new_with( + pix_width, + pix_height, + derive_settings(self.ctx.render_settings()), + ), + color_replacement: None, + in_annotations: false, + cur_mask: None, + inside_pattern: true, + soft_mask_cache: HashMap::default(), + outline_cache: self.outline_cache.clone(), + in_type3_glyph: false, + scaler: self.scaler, + preview: self.preview, + image_transparency_stack: Vec::new(), + }; + let initial_transform = Affine::translate((BORDER, BORDER)) + * Affine::scale_non_uniform(xs as f64, ys as f64) + * Affine::translate((-bbox.x0, -bbox.y0)); + t.interpret(&mut renderer, initial_transform, is_stroke); + let mut pix = Pixmap::new(pix_width, pix_height); + renderer.ctx.flush(); + let mut resources = vello_cpu::Resources::default(); + renderer.ctx.render_to_pixmap(&mut resources, &mut pix); + paint_transform = path_transform.inverse() * t.matrix * initial_transform.inverse(); + let image = Image { + image: ImageSource::Pixmap(Arc::new(pix)), + sampler: ImageSampler { + x_extend: peniko::Extend::Pad, + y_extend: peniko::Extend::Pad, + quality: ImageQuality::Medium, + alpha: 1.0, + }, + }; + self.ctx.set_paint_transform(paint_transform); + self.ctx.set_paint(PaintType::Image(image)); + return clip_path; + } // PdfCraft patch: the cell pixmap is `step × scale` pixels, which only the // u16 cast below bounded (a fuzzed /XStep far beyond /BBox gave 65535² cells). xs = tiling_cell_scale(xs, t.x_step, MAX_PIXMAP_SIZE); @@ -1894,6 +1946,46 @@ fn convert_blend_mode(blend_mode: BlendMode) -> peniko::BlendMode { peniko::BlendMode::new(mix, Compose::SrcOver) } +/// PdfCraft patch: how far a stroke's paint reaches past its path, in user units: half the width +/// times the larger of the miter limit and √2 (a miter join or a square cap). A miter limit that +/// isn't finite leaves miters unbounded, so the reach is too. +fn stroke_reach(stroke: &kurbo::Stroke) -> f64 { + let miter = stroke.miter_limit.abs(); + stroke.width / 2.0 + * if miter.is_finite() { + miter.max(std::f64::consts::SQRT_2) + } else { + f64::INFINITY + } +} + +/// PdfCraft patch (20): whether a cell's scale can draw it at full size: finite and positive. A +/// `/BBox` too thin to divide by (1e-40 wide passes the zero-area check) gives an infinite scale, +/// which would send non-finite geometry to the cell renderer; such a cell keeps the old path. +pub fn usable_cell_scale(xs: f32, ys: f32) -> bool { + xs.is_finite() && ys.is_finite() && xs > 0.0 && ys > 0.0 +} + +/// PdfCraft patch (20): whether `path` (drawn with `path_transform`, its paint reaching `reach` +/// device pixels past it), mapped into the space of a tiling pattern with matrix `matrix`, can +/// only meet the first cell's `/BBox`: every other cell's content lies a whole step or more +/// away. False when the matrix can't be inverted or the bounds aren't finite. +pub(crate) fn shows_one_cell(matrix: &Affine, path_transform: &Affine, path: &BezPath, reach: f64, bbox: Rect, x_step: f32, y_step: f32) -> bool { + if matrix.determinant().abs() < 1e-12 || !matrix.determinant().is_finite() { + return false; + } + let device = (*path_transform * path.clone()).bounding_box().inflate(reach, reach); + let bounds = (matrix.inverse() * device.to_path(0.1)).bounding_box(); + let (sx, sy) = (f64::from(x_step.abs()), f64::from(y_step.abs())); + let finite = [bounds.x0, bounds.y0, bounds.x1, bounds.y1, sx, sy].iter().all(|v| v.is_finite()); + // Cell k's content spans `bbox + k × step`; with k = ±1 out of reach, so is every other. + finite + && bounds.x1 < bbox.x0 + sx + && bounds.x0 > bbox.x1 - sx + && bounds.y1 < bbox.y0 + sy + && bounds.y0 > bbox.y1 - sy +} + /// PdfCraft patch: the scale for a tiling cell, lowered so that the cell pixmap (`|step| × /// scale` pixels a side) stays within `max_pixels`. Geometry is unchanged (the pattern transform /// is derived from the scale); an over-large step only renders at lower resolution. diff --git a/xtask/src/gates.rs b/xtask/src/gates.rs index 6591233e91..8e7db8cf08 100644 --- a/xtask/src/gates.rs +++ b/xtask/src/gates.rs @@ -196,8 +196,13 @@ pub fn check(args: &[String]) -> anyhow::Result<()> { } /// `cargo xtask text-oracle [--limit N]`: compare `pdfcraft-cli text` with poppler's `pdftotext` -/// (external oracle process, never linked) over the corpus. Reports word-level F1 per file and the -/// median. Plan target (M2): median ≥ 0.97. +/// (external oracle process, never linked) over the corpus. +/// +/// Two scores per file, because one hides the other. **Word F1** ([`f1`]) is a bag of words: it +/// catches a character read wrong, a glyph not read at all, a font we cannot map, and is blind to +/// order. **Reading order** ([`order_score`]) catches what F1 cannot see — a column read into the +/// middle of its neighbour, a figure's labels threaded through a paragraph. Reports both; gates +/// neither. Plan target (M2): median F1 ≥ 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); let pdftotext = match pdfcraft_oracle::pdftotext() { @@ -211,8 +216,11 @@ pub fn text_oracle(args: &[String]) -> anyhow::Result<()> { files.sort(); let cli = release_cli(); let mut scores = Vec::new(); + let mut orders = Vec::new(); let mut worst: Vec<(f64, String)> = Vec::new(); + let mut worst_order: Vec<(f64, String)> = Vec::new(); let mut not_permitted = 0usize; + let mut truncated = 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 @@ -225,29 +233,49 @@ pub fn text_oracle(args: &[String]) -> anyhow::Result<()> { let ours = run_capture(Command::new(&cli).arg("text").arg(f), 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)); + let (sa, sb) = (word_sequence(&ours), word_sequence(&theirs)); + let (a, b) = (words(&sa), words(&sb)); if b.len() < 20 { continue; // image-only or near-empty per the oracle } let score = f1(&a, &b); + let (order, cut) = order_score(&sa, &sb); + truncated = truncated.saturating_add(usize::from(cut)); + let name = f.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); scores.push(score); - worst.push((score, f.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default())); + orders.push(order); + worst.push((score, name.clone())); + worst_order.push((order, name)); } if scores.is_empty() { bail!("no comparable files"); } - scores.sort_by(f64::total_cmp); + let summary = |v: &mut Vec| { + v.sort_by(f64::total_cmp); + let mean = v.iter().sum::() / v.len() as f64; + (v[v.len() / 2], mean, v[v.len() / 10]) + }; + let compared = scores.len(); + let (median, mean, p10) = summary(&mut scores); + let (omedian, omean, op10) = summary(&mut orders); worst.sort_by(|a, b| a.0.total_cmp(&b.0)); - 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]); + worst_order.sort_by(|a, b| a.0.total_cmp(&b.0)); + println!("text oracle: {compared} files with text; median F1 {median:.3}, mean {mean:.3}, p10 {p10:.3}"); + println!( + " reading order: median {omedian:.3}, mean {omean:.3}, p10 {op10:.3}{}", + if truncated > 0 { format!(" ({truncated} cut to {MAX_ORDER_WORDS} words)") } else { String::new() } + ); if not_permitted > 0 { println!(" {not_permitted} skipped: the document forbids copying its text, which we honour and pdftotext does not"); } - println!("lowest:"); + println!("lowest F1:"); for (s, n) in worst.iter().take(12) { println!(" {s:.3} {n}"); } + println!("lowest reading order:"); + for (s, n) in worst_order.iter().take(12) { + println!(" {s:.3} {n}"); + } Ok(()) } @@ -416,14 +444,57 @@ fn run_capture_bytes(cmd: &mut Command, timeout_s: u64) -> Option> { Some(out) } -fn words(s: &str) -> BTreeMap { +/// The words of `s`, in the order they were extracted. +fn word_sequence(s: &str) -> Vec { + s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).map(str::to_lowercase).collect() +} + +fn words(seq: &[String]) -> BTreeMap { let mut m = BTreeMap::new(); - for w in s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()) { - *m.entry(w.to_lowercase()).or_insert(0) += 1; + for w in seq { + *m.entry(w.clone()).or_insert(0) += 1; } m } +/// Both word sequences are cut to this length before [`order_score`] compares them: the +/// comparison is quadratic, and three pages of prose is plenty of signal about reading order. +const MAX_ORDER_WORDS: usize = 3000; + +/// How much of the longer word sequence reads in the same order in both, and whether either side +/// was cut to [`MAX_ORDER_WORDS`] first. +/// +/// [`f1`] is a bag of words and cannot see order at all: a page emitted back to front scores a +/// perfect 1.000 against the page read properly. Reading order is most of what extraction has +/// left to get +/// wrong — a column read into the middle of its neighbour, a figure's labels threaded through a +/// paragraph, a form's value parted from its label — so it needs a number of its own. Reviewing +/// #2431, a change that moves text and alters nothing else, F1 called 26 of the 29 files whose +/// output changed identical, while this separated 18 improvements from 6 regressions. +/// +/// The score is the length of the longest common subsequence over the longer side. Like every +/// other oracle number here it is a *disagreement* with Poppler, not a verdict: the same split +/// scores as a win on one file and a loss on another purely because `pdftotext` happens to make +/// it in one place and not the other. +fn order_score(a: &[String], b: &[String]) -> (f64, bool) { + let cut = a.len() > MAX_ORDER_WORDS || b.len() > MAX_ORDER_WORDS; + let a = a.get(..a.len().min(MAX_ORDER_WORDS)).unwrap_or(a); + let b = b.get(..b.len().min(MAX_ORDER_WORDS)).unwrap_or(b); + let (n, m) = (a.len(), b.len()); + if n == 0 || m == 0 { + return (0.0, cut); + } + // Two rows of the usual table: after row `i`, `prev[j]` answers `a[..i]` against `b[..j]`. + let (mut prev, mut cur) = (vec![0u32; m + 1], vec![0u32; m + 1]); + for x in a { + for (j, y) in b.iter().enumerate() { + cur[j + 1] = if x == y { prev[j].saturating_add(1) } else { cur[j].max(prev[j + 1]) }; + } + std::mem::swap(&mut prev, &mut cur); + } + (f64::from(prev[m]) / n.max(m) as f64, cut) +} + fn f1(a: &BTreeMap, b: &BTreeMap) -> f64 { let common: usize = a.iter().map(|(w, n)| (*n).min(*b.get(w).unwrap_or(&0))).sum(); let (na, nb): (usize, usize) = (a.values().sum(), b.values().sum()); @@ -436,7 +507,42 @@ fn f1(a: &BTreeMap, b: &BTreeMap) -> f64 { #[cfg(test)] mod tests { - use super::root; + use super::{f1, order_score, root, word_sequence, words}; + + /// The two scores answer different questions, and the point of adding the second one is the + /// first row: F1 cannot tell a page from the same page read backwards. + #[test] + fn reading_order_sees_what_word_f1_cannot() { + let score = |ours: &str, theirs: &str| { + let (a, b) = (word_sequence(ours), word_sequence(theirs)); + (f1(&words(&a), &words(&b)), order_score(&a, &b).0) + }; + + // Two columns, read as columns and read interleaved: every word is there either way. + let columns = "left one left two left three right one right two right three"; + let woven = "left one right one left two right two left three right three"; + let (same_words, out_of_order) = score(woven, columns); + assert_eq!(same_words, 1.0, "F1 is a bag of words and cannot see the difference"); + assert!(out_of_order < 0.7, "reading order can: {out_of_order}"); + + // It is not blind to the thing F1 measures, either: a missing word costs both. + let (f, o) = score("left one left two left", columns); + assert!(f < 0.9 && o < 0.9, "{f} {o}"); + + // Identical text, and the degenerate inputs. + assert_eq!(score(columns, columns), (1.0, 1.0)); + assert_eq!(score("", columns).1, 0.0); + assert_eq!(score(columns, "").1, 0.0); + assert_eq!(order_score(&[], &[]), (0.0, false)); + + // Case and punctuation are not order: both sides are folded the same way. + assert_eq!(score("Left, one. LEFT two!", "left one left two"), (1.0, 1.0)); + + // Over the cap, the comparison still answers, and says that it was cut. + let long: Vec = (0..super::MAX_ORDER_WORDS + 10).map(|i| i.to_string()).collect(); + let (s, cut) = order_score(&long, &long); + assert!((s - 1.0).abs() < 1e-9 && cut, "{s} {cut}"); + } // Issue #211: Finder lists the app under Open With > Recommended only when the // bundle claims PDFs by UTI (#176).