diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000000..2408084cf9 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +reviews: + auto_review: + # Dependabot bumps are checked by CI; skip CodeRabbit reviews on them. + ignore_usernames: + - "dependabot[bot]" diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index ce817da129..01cb9cd663 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -18,16 +18,6 @@ concurrency: 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 + nix: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/nix.yml@main diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 0000000000..ecee45a6d8 --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,20 @@ +name: SonarCloud + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: sonarcloud-${{ github.ref }} + cancel-in-progress: true + +jobs: + sonarcloud: + # Tracked at @main so org-wide CI improvements propagate without PR churn. + uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main + secrets: inherit diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 14609784ac..1a934d419b 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 = "da86ae2d0c8ed2c976f9faa6a6e4f21b18fc76e5ab3638fc7bc6f69d1893b404" +sha256 = "2c4db646dd92672a4a9ba459165a75d63b4ecfa59bf6a3959fbcdcb6aace8a8c" # 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 = "45f2c18b50a2f7879a3aa81112107642f4b396340503d61cad703747d6189a0d" +sha256 = "fb4e629411b1f6ab6638d146f52ae6e5df072e4a361f6bbbe7c3447a5d3e4470" [[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 = "be39c1b72ee171eef693759d882cf711929fa3dcddb87bd15830e19dc575b841" +sha256 = "672e8b3c67eef7f88d5786b61141f63266fe469b3ef4f152ca4cb79c4ee2325a" [[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 = "3dd24819053808bd1ccfeeceef2ae70aedfe3bbb571c0d936928aa81f5955202" +sha256 = "1dadcc143efc901193649ccd8e1999c79fcd2ce04933e338db508e196269efae" [[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 = "13d767888304b286163a2b7672584ce92b4119fbb22955258e6467485b29c7b5" +sha256 = "7a5ad176c089fcf4b34f1eb2aa1fbb7497c5be7a105319f2555cd6794aa4b08e" [[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 = "f173c035f6e2a723d69b6bfa9b2b00cbfc653bed830f93d28b309e0dfebd7bb9" +sha256 = "c16a452306b04b5d2e43ee703ff1af9b33cd6971be1711e1afa2411954db402b" [[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 = "7aa0cb381b2d382b012c6fbc661a9f5cd0e2a253b4399296a39132c9ab767370" +sha256 = "1c514e34f01a6523ae35f30c69fd7c224bcdb7eb84ea56b4b13e99ca49f98e62" [[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 = "4da28646b2e1136951d1a8dd16a09b76ac29175245942a01937666ee7e42f7cc" +sha256 = "ea4019ccb45272759b33bbd7767b0fb63b3ba05194737a23f498e31e1513f4b7" [[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 = "52ca6321dc2161791ee0ce4e0abfaaf43423704671c282b8aad57d3dc44c6fa9" +sha256 = "46c1d612080b7cd976d78ea8f43f0bdd738a13080f8fd978e626d92a233259e5" [[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 = "2b30e6b8b5458a37d7cbb639c6821b65ea1a1fadc745766736104aba835af8e0" +sha256 = "a6d5e0acbda6c12d6945590d590d307a04a57a10c41253fc931a901bc20ed716" [[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 = "afefb3776c225e8a74d593cfaac8310c6828f429253e10464afc1230de809b4a" +sha256 = "4198ccc5c430d900ad7bb94fb18204403f75dd8f96c7b2cc8aec7d83b4d7c025" [[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 = "d165d80b38ac764d9c836684c93debac742ab9044e546b1a516e35a7c86cab67" +sha256 = "a523d73af1f5289e79f646115009cb612e30f78456edb2fb89a4cb3a8f58e040" [[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 = "8e0aef0dc46f436576494f57c9a6bdc36d342644eb8e494f0f6856a9b5d6f73b" +sha256 = "5a9c5f04aeb668f82daee1cc65318424d1a4d2ffeebff40ab9b28d2f06929bd6" [[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 = "9418b5b093eb7e6a9b40a911a88ea81b3505bd9c889d9cf9f62efa5ee7d5b593" +sha256 = "d33a4e1fb3d7d62077886b15e011370db590e2f54cebb5ff8bad0a82f0443e0b" [[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 = "b81f5326237bcad2d0fce4059510f965b1f79e48d06e0ca876190db9af8dd6e5" +sha256 = "e2a417d4bfc9492a8e2504bb916745c2cb7158bec9c5308c388226b2e4fc13b0" [[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 = "aa9be42c9f774c8e3f05ef348bb99835d0759c55c691c9dca70b9429608a87b8" +sha256 = "c76b8a968a5602bf1aa3d08d5f411a6e3ae2935d6ea3450d5765dcef86ec3651" [[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 = "a500169e8b601a652908c18cec34edf1bbc6c963a9aff8b426483881a08b54d5" +sha256 = "da3a37ccfe4ed177034d44e7891e8ae713123c3e2861354c545a9a8d147f30a5" [[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 = "bd0f0638e85a3469eebf7063301457742b1b2ff525bec1562c5e79b3bc92d06e" +sha256 = "1bb8a48b9041c7ae6b252def603298b1199f307063ccb5ec51df1afaaf56d8b9" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "cd44a7e7420c06ed41100a5eee6c9fbb21f77c29d51b928875bc02975aa061a1" +sha256 = "9d18524f3ffa8f862a0cfc9606476bcf01b40113facc65679c5e46ea96522cbf" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" @@ -228,7 +228,7 @@ 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 = "f94f7287af0811c05c83c45e0b7871eff4fe9dd72cf045eb976a5ba0a23458a9" +sha256 = "074375f4017fba26735223640d4e52d1e8cba7edbbb7e0f4329a76856a952a0f" [[asset]] @@ -3352,7 +3352,7 @@ 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 = "a22c1ed30e5302294c68cfefc0722a1d100bd394019f68b4cae878e28217be3d" +sha256 = "729eacc77ef4ffcf2dadb13976f84d2b7016a7ddb3cf06eded18224b9230c3f8" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" @@ -3363,7 +3363,7 @@ 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 = "0075d7d9435776e4b51179615757f5cbb604f51489ed49db922c54e9249007c1" +sha256 = "716d8819c188d0fce66f9f53ce3fdf9651190def48df11b3d1cf957c4348831b" [[asset]] diff --git a/Cargo.lock b/Cargo.lock index 68a758c2fb..e9505abbc0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1887,7 +1887,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -3938,7 +3938,7 @@ dependencies = [ [[package]] name = "pdfcraft" -version = "0.9.0" +version = "0.11.0" dependencies = [ "craft-quit", "eframe", @@ -3963,7 +3963,7 @@ dependencies = [ [[package]] name = "pdfcraft-a11y" -version = "0.9.0" +version = "0.11.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3975,7 +3975,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.9.0" +version = "0.11.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -3986,7 +3986,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.9.0" +version = "0.11.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3997,7 +3997,7 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.9.0" +version = "0.11.0" dependencies = [ "base64 0.22.1", "flate2", @@ -4018,7 +4018,7 @@ dependencies = [ [[package]] name = "pdfcraft-cli" -version = "0.9.0" +version = "0.11.0" dependencies = [ "base64 0.22.1", "pdfcraft-audit", @@ -4035,18 +4035,18 @@ dependencies = [ [[package]] name = "pdfcraft-compare" -version = "0.9.0" +version = "0.11.0" [[package]] name = "pdfcraft-content" -version = "0.9.0" +version = "0.11.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-cos" -version = "0.9.0" +version = "0.11.0" dependencies = [ "hayro-syntax", "pdfcraft-crypt", @@ -4060,7 +4060,7 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.9.0" +version = "0.11.0" dependencies = [ "encoding_rs", "hayro-syntax", @@ -4076,7 +4076,7 @@ dependencies = [ [[package]] name = "pdfcraft-crypt" -version = "0.9.0" +version = "0.11.0" dependencies = [ "aes", "md-5", @@ -4088,7 +4088,7 @@ dependencies = [ [[package]] name = "pdfcraft-edit" -version = "0.9.0" +version = "0.11.0" dependencies = [ "hayro-syntax", "kurbo 0.13.1", @@ -4103,7 +4103,7 @@ dependencies = [ [[package]] name = "pdfcraft-engine" -version = "0.9.0" +version = "0.11.0" dependencies = [ "chrono", "craft-docpath", @@ -4143,14 +4143,14 @@ dependencies = [ [[package]] name = "pdfcraft-export" -version = "0.9.0" +version = "0.11.0" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-filters" -version = "0.9.0" +version = "0.11.0" dependencies = [ "flate2", "proptest", @@ -4160,7 +4160,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.9.0" +version = "0.11.0" dependencies = [ "harfrust", "pdfcraft-cos", @@ -4170,7 +4170,7 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.9.0" +version = "0.11.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4182,18 +4182,18 @@ dependencies = [ [[package]] name = "pdfcraft-geom" -version = "0.9.0" +version = "0.11.0" [[package]] name = "pdfcraft-js" -version = "0.9.0" +version = "0.11.0" dependencies = [ "boa_engine", ] [[package]] name = "pdfcraft-measure" -version = "0.9.0" +version = "0.11.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4206,14 +4206,14 @@ dependencies = [ [[package]] name = "pdfcraft-model" -version = "0.9.0" +version = "0.11.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.9.0" +version = "0.11.0" dependencies = [ "ocrs", "pdfcraft-fonts", @@ -4223,7 +4223,7 @@ dependencies = [ [[package]] name = "pdfcraft-optimize" -version = "0.9.0" +version = "0.11.0" dependencies = [ "image", "pdfcraft-annot", @@ -4236,11 +4236,11 @@ dependencies = [ [[package]] name = "pdfcraft-oracle" -version = "0.9.0" +version = "0.11.0" [[package]] name = "pdfcraft-organize" -version = "0.9.0" +version = "0.11.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4251,7 +4251,7 @@ dependencies = [ [[package]] name = "pdfcraft-platform" -version = "0.9.0" +version = "0.11.0" dependencies = [ "async-channel", "futures-lite", @@ -4261,7 +4261,7 @@ dependencies = [ [[package]] name = "pdfcraft-preflight" -version = "0.9.0" +version = "0.11.0" dependencies = [ "flate2", "pdfcraft-content", @@ -4272,7 +4272,7 @@ dependencies = [ [[package]] name = "pdfcraft-print" -version = "0.9.0" +version = "0.11.0" dependencies = [ "craft-print", "pdfcraft-content", @@ -4283,7 +4283,7 @@ dependencies = [ [[package]] name = "pdfcraft-redact" -version = "0.9.0" +version = "0.11.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4299,7 +4299,7 @@ dependencies = [ [[package]] name = "pdfcraft-render" -version = "0.9.0" +version = "0.11.0" dependencies = [ "bytemuck", "flate2", @@ -4318,7 +4318,7 @@ dependencies = [ [[package]] name = "pdfcraft-sign" -version = "0.9.0" +version = "0.11.0" dependencies = [ "aes", "aws-lc-rs", @@ -4352,11 +4352,11 @@ dependencies = [ [[package]] name = "pdfcraft-testkit" -version = "0.9.0" +version = "0.11.0" [[package]] name = "pdfcraft-ui-egui" -version = "0.9.0" +version = "0.11.0" dependencies = [ "arboard", "base64 0.22.1", @@ -4400,7 +4400,7 @@ dependencies = [ [[package]] name = "pdfcraft-web" -version = "0.9.0" +version = "0.11.0" dependencies = [ "eframe", "egui", @@ -4412,7 +4412,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.9.0" +version = "0.11.0" dependencies = [ "base64 0.22.1", "image", @@ -4426,7 +4426,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfdf" -version = "0.9.0" +version = "0.11.0" dependencies = [ "pdfcraft-annot", "pdfcraft-cos", @@ -4934,7 +4934,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -5484,7 +5484,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -5554,7 +5554,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -5565,9 +5565,9 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "aws-lc-rs", "ring", @@ -6431,7 +6431,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.5", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -7306,9 +7306,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb" dependencies = [ "rustls-pki-types", ] @@ -7514,7 +7514,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ec89098021..18afce999f 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.9.0" +version = "0.11.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -21,39 +21,39 @@ craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1. # 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.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 } +pdfcraft-geom = { path = "crates/geom", version = "0.11.0" } +pdfcraft-filters = { path = "crates/filters", version = "0.11.0" } +pdfcraft-cos = { path = "crates/cos", version = "0.11.0" } +pdfcraft-crypt = { path = "crates/crypt", version = "0.11.0" } +pdfcraft-organize = { path = "crates/organize", version = "0.11.0" } +pdfcraft-measure = { path = "crates/measure", version = "0.11.0" } +pdfcraft-annot = { path = "crates/annot", version = "0.11.0" } +pdfcraft-audit = { path = "crates/audit", version = "0.11.0" } +pdfcraft-fonts = { path = "crates/fonts", version = "0.11.0" } +pdfcraft-model = { path = "crates/model", version = "0.11.0" } +pdfcraft-content = { path = "crates/content", version = "0.11.0" } +pdfcraft-redact = { path = "crates/redact", version = "0.11.0" } +pdfcraft-print = { path = "crates/print", version = "0.11.0" } +pdfcraft-xfdf = { path = "crates/xfdf", version = "0.11.0" } +pdfcraft-sign = { path = "crates/sign", version = "0.11.0" } +pdfcraft-optimize = { path = "crates/optimize", version = "0.11.0" } +pdfcraft-a11y = { path = "crates/a11y", version = "0.11.0" } +pdfcraft-oracle = { path = "crates/oracle", version = "0.11.0" } +pdfcraft-testkit = { path = "crates/testkit", version = "0.11.0" } +pdfcraft-ocr = { path = "crates/ocr", version = "0.11.0" } +pdfcraft-js = { path = "crates/js", version = "0.11.0" } +pdfcraft-compare = { path = "crates/compare", version = "0.11.0" } +pdfcraft-preflight = { path = "crates/preflight", version = "0.11.0" } +pdfcraft-export = { path = "crates/export", version = "0.11.0" } +pdfcraft-edit = { path = "crates/edit", version = "0.11.0" } +pdfcraft-create = { path = "crates/create", version = "0.11.0" } +pdfcraft-forms = { path = "crates/forms", version = "0.11.0" } +pdfcraft-xfa = { path = "crates/xfa", version = "0.11.0" } +pdfcraft-render = { path = "crates/render", version = "0.11.0" } +pdfcraft-engine = { path = "crates/engine", version = "0.11.0" } +pdfcraft-platform = { path = "crates/platform", version = "0.11.0" } +pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.11.0" } +pdfcraft-automation = { path = "crates/automation", version = "0.11.0", default-features = false } # pdf hayro = "0.7.1" diff --git a/README.md b/README.md index 2a65b66457..af49129eca 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,13 @@

- License: MIT OR Apache-2.0 - Written in Rust - Platforms: macOS, Windows, Linux, FreeBSD, web - No account, no telemetry + License: MIT OR Apache-2.0 + Written in Rust + Platforms: macOS, Windows, Linux, FreeBSD, web + No account, no telemetry +
+ Scanned by SonarCloud + Reviewed by CodeRabbit

diff --git a/ROADMAP.md b/ROADMAP.md index b1283e7751..b910ce0b62 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -218,6 +218,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-12 (M3, drawing markups):** While a comment is being drawn, the pointer in a band along any edge of the view (or just past it) scrolls the view up, down, left or right, so points beyond what is shown can be reached. A polygon, cloud or run of connected lines takes back its last point with Backspace, Delete or Undo (the first point's removal cancels it). Edge scrolling has unit tests of its maths only; it has not been exercised end to end in the app by an automated test. - **2026-10-12 (gap 6, #1319, #1305, #1324):** Editing an XFA form keeps a radio item's stored text when it contains a space, so a later layout still selects it. Replace Pages on a split selection replaces those pages and leaves the gap; editing the dialog range still replaces the span. Marking a figure decorative rewrites only that figure's content stream, so a header, added text and unknown stream entries stay. Estimates unchanged. - **2026-10-15 (M14, gap 24):** `misc.localization` and `misc.installers` move from planned to partial. Localization: 22 catalogs ship, but Chinese shows tofu in releases, Telugu has no release font and Arabic is not mirrored. Installers: macOS and Windows releases are signed, Linux packages are not. The Acrobat menu diff itself remains open. Estimates unchanged. +- **2026-10-15 (M14, PNG export):** Export ▸ Image as PNG, `pdfcraft-cli render` and the `page_render` automation/MCP tool write an opaque page as RGB at deflate level 3 instead of RGBA at the default level 6: the files stay the same size and are written 3–5× faster (a 300-dpi scan drawn at 220 dpi: 805 → 167 ms; a text page at 220 dpi: 70 → 40 ms; an annual-report page at 150 dpi: 179 → 64 ms). A page with translucent pixels is still written as RGBA. The automation tools use the engine's encoder instead of a copy of it. Estimates unchanged. - **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design. - **2026-10-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. diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index 60bd9eef4c..26f36728cb 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -17,7 +17,6 @@ pdfcraft-platform = { workspace = true } serde_json = { workspace = true } sha2 = "0.11" tempfile = "3.27" -png = "0.18" base64 = { version = "0.22", optional = true } @@ -28,6 +27,7 @@ mcp = ["dep:base64"] [dev-dependencies] pdfcraft-fonts = { workspace = true } +png = "0.18" flate2.workspace = true pdfcraft-model = { workspace = true } pdfcraft-xfa = { workspace = true } diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 932a3e5688..c3a66a9265 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -2537,24 +2537,9 @@ fn outline(items: &[pdfcraft_render::OutlineItem]) -> Value { Value::Array(items.iter().map(|o| json!({ "title": o.title, "page": o.page.map(|p| p + 1), "children": outline(&o.children) })).collect()) } +/// A rendered raster as PNG, through the engine's encoder (Export ▸ Image writes the same). fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result> { - let mut rgba = premultiplied.to_vec(); - for px in rgba.as_chunks_mut::<4>().0 { - let a = u32::from(px[3]); - if a != 0 && a != 255 { - for c in &mut px[..3] { - *c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8; - } - } - } - let mut out = Vec::new(); - let mut enc = png::Encoder::new(&mut out, width, height); - enc.set_color(png::ColorType::Rgba); - enc.set_depth(png::BitDepth::Eight); - let mut w = enc.write_header().map_err(failed)?; - w.write_image_data(&rgba).map_err(failed)?; - w.finish().map_err(failed)?; - Ok(out) + pdfcraft_engine::export::encode_png(width, height, premultiplied).map_err(failed) } /// Extract the text of `pages` (0-based) with one renderer per worker thread. diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 84f59000a6..efcd57a11f 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -3880,15 +3880,13 @@ fn natural_image_stamps_through_tools_on_rotated_pages() { let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap(); let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; let info = reader.next_frame(&mut pixels).unwrap(); - assert_eq!(info.color_type, png::ColorType::Rgba); - for ((x, y), colour) in [(80, 90), (120, 90), (80, 110), (120, 110)].into_iter().zip([ - [240, 20, 20, 255], - [20, 180, 20, 255], - [20, 20, 240, 255], - [230, 180, 20, 255], - ]) { - let offset = ((y * info.width + x) * 4) as usize; - assert_eq!(&pixels[offset..offset + 4], &colour, "page rotation {degrees}"); + // A rendered page is opaque, so it comes as RGB. + assert_eq!(info.color_type, png::ColorType::Rgb); + for ((x, y), colour) in + [(80, 90), (120, 90), (80, 110), (120, 110)].into_iter().zip([[240, 20, 20], [20, 180, 20], [20, 20, 240], [230, 180, 20]]) + { + let offset = ((y * info.width + x) * 3) as usize; + assert_eq!(&pixels[offset..offset + 3], &colour, "page rotation {degrees}"); } } } @@ -6515,7 +6513,8 @@ fn reading_colors_workdir() -> PathBuf { dir } -/// Decodes a `page_render` PNG (pages here are 200×300 pt) into RGBA pixels. +/// Decodes a `page_render` PNG (pages here are 200×300 pt) into RGBA pixels. Opaque pages +/// come as RGB; their alpha is 255. fn png_pixels(content: &Content) -> Vec { let Content::Png { data, width, height } = content else { panic!("expected a PNG") }; assert_eq!((*width, *height), (200, 300)); @@ -6523,7 +6522,11 @@ fn png_pixels(content: &Content) -> Vec { let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; let info = reader.next_frame(&mut pixels).unwrap(); assert_eq!((info.width, info.height), (200, 300)); - pixels + match info.color_type { + png::ColorType::Rgba => pixels, + png::ColorType::Rgb => pixels.as_chunks::<3>().0.iter().flat_map(|p| [p[0], p[1], p[2], 255]).collect(), + other => panic!("unexpected colour type {other:?}"), + } } /// The RGBA pixel at (`x`, `y`), top-left origin, of a 200-wide image. diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 0bf4e2f79d..a1820e2d99 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -138,23 +138,48 @@ pub fn encode_image(width: u32, height: u32, premultiplied: &[u8], format: Image } } -/// Premultiplied RGBA → PNG (straight alpha). +/// Premultiplied RGBA → PNG (straight alpha). A raster with no translucent pixel (a rendered +/// page always is one: it is drawn on white) is written as RGB. +/// +/// Deflate level 3 with adaptive filtering: on rendered pages it writes files the size of the +/// `png` crate's default (level 6) in a third to a fifth of the time. Measured with RGB at +/// level 3 against RGBA at the default: a 300-dpi scan drawn at 220 dpi took 805 → 167 ms +/// (4.46 → 4.47 MB), a text page at 220 dpi 70 → 40 ms (0.85 → 0.89 MB), and an annual +/// report page at 150 dpi 179 → 64 ms (1.41 → 1.38 MB). pub fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result, String> { - let mut rgba = premultiplied.to_vec(); - for px in rgba.as_chunks_mut::<4>().0 { - let a = u32::from(px[3]); - if a != 0 && a != 255 { - for c in &mut px[..3] { - *c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8; + // Checked here, not left to the encoder: `as_chunks` below drops a partial trailing pixel, + // and the opaque path would write the rest as a valid image. + let expected = usize::try_from(width).ok().zip(usize::try_from(height).ok()).and_then(|(w, h)| w.checked_mul(h)?.checked_mul(4)); + if expected != Some(premultiplied.len()) { + return Err(match expected { + Some(n) => format!("a {width}×{height} raster is {n} bytes of RGBA, got {}", premultiplied.len()), + None => format!("a {width}×{height} raster is too large"), + }); + } + let pixels = premultiplied.as_chunks::<4>().0; + let opaque = pixels.iter().all(|px| px[3] == 255); + let (color, data) = if opaque { + (png::ColorType::Rgb, pixels.iter().flat_map(|px| [px[0], px[1], px[2]]).collect::>()) + } else { + let mut rgba = premultiplied.to_vec(); + for px in rgba.as_chunks_mut::<4>().0 { + let a = u32::from(px[3]); + if a != 0 && a != 255 { + for c in &mut px[..3] { + *c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8; + } } } - } + (png::ColorType::Rgba, rgba) + }; let mut out = Vec::new(); let mut enc = png::Encoder::new(&mut out, width, height); - enc.set_color(png::ColorType::Rgba); + enc.set_color(color); enc.set_depth(png::BitDepth::Eight); + enc.set_deflate_compression(png::DeflateCompression::Level(3)); + enc.set_filter(png::Filter::Adaptive); let mut w = enc.write_header().map_err(|e| e.to_string())?; - w.write_image_data(&rgba).map_err(|e| e.to_string())?; + w.write_image_data(&data).map_err(|e| e.to_string())?; w.finish().map_err(|e| e.to_string())?; Ok(out) } @@ -260,3 +285,54 @@ mod image_name_tests { assert_eq!(image_file_name("doc", &image(0), 1, 0), "doc_Page_1_Image_0001.png"); } } + +#[cfg(test)] +mod png_tests { + use super::encode_png; + + /// The colour type and pixels of a PNG. + fn decode(png: &[u8]) -> (png::ColorType, Vec) { + let mut reader = png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap(); + let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; + let frame = reader.next_frame(&mut pixels).unwrap(); + pixels.truncate(frame.buffer_size()); + (frame.color_type, pixels) + } + + /// A rendered page has no translucent pixel: it is written as RGB, each pixel exactly as + /// rendered (the alpha channel would only make the file bigger and slower to write). + #[test] + fn opaque_rasters_are_written_as_rgb_with_their_exact_pixels() { + let rgba = [[255, 255, 255, 255], [0, 0, 0, 255], [200, 30, 90, 255], [1, 2, 3, 255], [17, 250, 128, 255], [90, 90, 90, 255]]; + let (color, pixels) = decode(&encode_png(3, 2, rgba.as_flattened()).unwrap()); + assert_eq!(color, png::ColorType::Rgb); + assert_eq!(pixels, rgba.iter().flat_map(|p| [p[0], p[1], p[2]]).collect::>()); + } + + /// A raster with translucent pixels keeps its alpha, with the colours un-premultiplied. + #[test] + fn translucent_rasters_keep_straight_alpha() { + let rgba = [[64, 0, 32, 128], [0, 0, 0, 0], [10, 20, 30, 255]]; + let (color, pixels) = decode(&encode_png(3, 1, rgba.as_flattened()).unwrap()); + assert_eq!(color, png::ColorType::Rgba); + assert_eq!(pixels, [128, 0, 64, 128, 0, 0, 0, 0, 10, 20, 30, 255]); + } + + /// A buffer that is not exactly `width × height` RGBA pixels is an error, opaque or not. The + /// opaque path used to drop a partial trailing pixel and write the rest as a valid image. + #[test] + fn rasters_of_the_wrong_length_are_rejected() { + let cases: [(&str, u32, u32, &[u8]); 7] = [ + ("opaque, a partial pixel after", 1, 1, &[10, 20, 30, 255, 0]), + ("opaque, three bytes after", 1, 1, &[10, 20, 30, 255, 0, 0, 0]), + ("translucent, a partial pixel after", 1, 1, &[10, 20, 30, 128, 0]), + ("opaque, a pixel short", 2, 1, &[10, 20, 30, 255]), + ("translucent, a pixel too many", 1, 1, &[10, 20, 30, 128, 1, 2, 3, 4]), + ("empty", 1, 1, &[]), + ("a size whose byte count overflows", u32::MAX, u32::MAX, &[10, 20, 30, 255]), + ]; + for (what, width, height, data) in cases { + assert!(encode_png(width, height, data).is_err(), "{what}"); + } + } +} diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 52ca6c276f..415382b232 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -84,9 +84,19 @@ pub(crate) struct Scope<'a> { pub report: &'a mut Report, /// Sanitize: remove hidden text (invisible render modes, or wholly outside this page box). pub hidden_text: Option<[f64; 4]>, - /// Sanitize: optional content groups (and membership dictionaries) that are off; content - /// marked with them is removed. - pub hidden_layers: Vec, + /// Sanitize: optional content groups that are off; content marked with them is removed. + pub hidden_layers: HashSet, + /// Whether this pass is evaluating optional-content rules. An empty off set is meaningful: + /// policies such as `/AllOff` can still hide content when every group is on. + pub layers_enabled: bool, + /// Off groups referenced by optional content that remains visible and therefore still needs + /// its original default state after sanitization. + pub retained_off_layers: HashSet, + /// Cache membership results so repeated blocks cannot repeatedly walk hostile expressions. + ocmd_cache: HashMap, + /// Direct `/OC` dictionaries on XObjects have no object reference of their own. Cache them + /// by the containing XObject so repeated `Do` calls share the same bounded evaluation. + xobject_oc_cache: HashMap, /// Content removed from hidden layers (blocks and XObjects). pub layer_blocks: usize, fonts: HashMap, Rc>, @@ -101,7 +111,11 @@ impl<'a> Scope<'a> { mode, report, hidden_text: None, - hidden_layers: Vec::new(), + hidden_layers: HashSet::new(), + layers_enabled: false, + retained_off_layers: HashSet::new(), + ocmd_cache: HashMap::new(), + xobject_oc_cache: HashMap::new(), layer_blocks: 0, fonts: HashMap::new(), used_names: Vec::new(), @@ -111,20 +125,26 @@ impl<'a> Scope<'a> { /// Sanitizing visits every form XObject (not only those under a region). fn everywhere(&self) -> bool { - self.hidden_text.is_some() || !self.hidden_layers.is_empty() + self.hidden_text.is_some() || self.layers_enabled } /// Is this optional-content reference (an OCG or OCMD) hidden? - fn layer_hidden(&self, doc: &Document, o: &Object) -> bool { - if self.hidden_layers.is_empty() { + fn layer_hidden(&mut self, doc: &Document, o: &Object) -> bool { + if !self.layers_enabled { return false; } - if let Some(r) = o.as_ref() - && self.hidden_layers.contains(&r) - { - return true; + let reference = o.as_ref(); + if let Some(r) = reference { + if let Some(hidden) = self.ocmd_cache.get(&r) { + return *hidden; + } + if self.hidden_layers.contains(&r) { + self.ocmd_cache.insert(r, true); + return true; + } } - // A membership dictionary (default policy AnyOn): hidden when all its groups are. + // Evaluate the policy against the same default configuration used to select hidden + // groups. A membership dictionary's default policy is AnyOn. let d = doc.resolve(o); let Some(d) = d.as_dict() else { return false }; if d.name(b"Type") != Some(b"OCMD") { @@ -135,7 +155,188 @@ impl<'a> Scope<'a> { Some(_) => d.get(b"OCGs").and_then(Object::as_ref).into_iter().collect(), None => Vec::new(), }; - !groups.is_empty() && groups.iter().all(|g| self.hidden_layers.contains(g)) + let fail_closed = || groups.iter().any(|g| self.hidden_layers.contains(g)); + let hidden = if let Some(expression) = d.get(b"VE") { + let visible = self.visibility_expression(doc, expression, 0, &mut HashSet::new(), &mut 4096); + match visible { + Some(visible) => !visible, + None => fail_closed() || self.expression_mentions_hidden(doc, expression, 0, &mut HashSet::new(), &mut 4096), + } + } else if groups.is_empty() { + false + } else { + let any_on = groups.iter().any(|g| !self.hidden_layers.contains(g)); + let all_on = groups.iter().all(|g| !self.hidden_layers.contains(g)); + let policy = d.get(b"P").map(|p| doc.resolve(p)).and_then(|p| p.as_name().map(|name| name.to_vec())); + match (d.get(b"P"), policy.as_deref()) { + (None, _) | (Some(_), Some(b"AnyOn")) => !any_on, + (Some(_), Some(b"AllOn")) => !all_on, + (Some(_), Some(b"AnyOff")) => all_on, + (Some(_), Some(b"AllOff")) => any_on, + // An invalid policy must not make a candidate hidden block survive sanitization. + _ => fail_closed(), + } + }; + if !hidden { + self.retained_off_layers.extend(groups.iter().filter(|g| self.hidden_layers.contains(g)).copied()); + if let Some(expression) = d.get(b"VE") { + let mut referenced = HashSet::new(); + if Self::collect_hidden_expression_groups(doc, expression, &self.hidden_layers, 0, &mut HashSet::new(), &mut 4096, &mut referenced) { + self.retained_off_layers.extend(referenced); + } else { + // If an expression cannot be fully walked, preserve every off group so that + // the content we kept does not change visibility after configuration cleanup. + self.retained_off_layers.extend(self.hidden_layers.iter().copied()); + } + } + } + if let Some(r) = reference { + self.ocmd_cache.insert(r, hidden); + } + hidden + } + + fn xobject_layer_hidden(&mut self, doc: &Document, xobject: ObjRef, oc: &Object) -> bool { + if oc.as_ref().is_some() { + return self.layer_hidden(doc, oc); + } + if let Some(hidden) = self.xobject_oc_cache.get(&xobject) { + return *hidden; + } + let hidden = self.layer_hidden(doc, oc); + self.xobject_oc_cache.insert(xobject, hidden); + hidden + } + + fn collect_hidden_expression_groups( + doc: &Document, + expression: &Object, + hidden_layers: &HashSet, + depth: usize, + path: &mut HashSet, + budget: &mut usize, + found: &mut HashSet, + ) -> bool { + if depth > 64 || *budget == 0 { + return false; + } + *budget -= 1; + let reference = expression.as_ref(); + if let Some(r) = reference + && !path.insert(r) + { + return false; + } + let resolved = doc.resolve(expression); + let complete = match &*resolved { + Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => { + if let Some(r) = reference + && hidden_layers.contains(&r) + { + found.insert(r); + } + reference.is_some() + } + Object::Array(items) => { + let Some(Object::Name(operator)) = items.first() else { return false }; + let operands = match operator.as_slice() { + b"And" | b"Or" if (2..=4097).contains(&items.len()) => items.get(1..), + b"Not" if items.len() == 2 => items.get(1..), + _ => None, + }; + operands.is_some_and(|operands| { + operands.iter().all(|item| Self::collect_hidden_expression_groups(doc, item, hidden_layers, depth + 1, path, budget, found)) + }) + } + _ => false, + }; + if let Some(r) = reference { + path.remove(&r); + } + complete + } + + /// Evaluate an optional-content visibility expression (`/VE`) against the default config. + /// Malformed, cyclic, or excessive expressions return `None` so the caller can fail closed. + fn visibility_expression( + &self, + doc: &Document, + expression: &Object, + depth: usize, + path: &mut HashSet, + budget: &mut usize, + ) -> Option { + if depth > 64 || *budget == 0 { + return None; + } + *budget -= 1; + let reference = expression.as_ref(); + if let Some(r) = reference + && !path.insert(r) + { + return None; + } + let resolved = doc.resolve(expression); + let result = match &*resolved { + Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => reference.map(|r| !self.hidden_layers.contains(&r)), + Object::Array(items) if (2..=4097).contains(&items.len()) => { + let operator = match items.first() { + Some(Object::Name(n)) => n.as_slice(), + _ => return None, + }; + match operator { + b"And" if items.len() >= 2 => { + let mut visible = true; + for item in items.iter().skip(1) { + visible &= self.visibility_expression(doc, item, depth + 1, path, budget)?; + } + Some(visible) + } + b"Or" if items.len() >= 2 => { + let mut visible = false; + for item in items.iter().skip(1) { + visible |= self.visibility_expression(doc, item, depth + 1, path, budget)?; + } + Some(visible) + } + b"Not" if items.len() == 2 => Some(!self.visibility_expression(doc, &items[1], depth + 1, path, budget)?), + _ => None, + } + } + _ => None, + }; + if let Some(r) = reference { + path.remove(&r); + } + result + } + + /// Conservatively find hidden groups in an expression that could not be evaluated. Unknown + /// syntax, cycles, and exhausted budgets count as a possible hidden reference. + fn expression_mentions_hidden(&self, doc: &Document, expression: &Object, depth: usize, path: &mut HashSet, budget: &mut usize) -> bool { + if depth > 64 || *budget == 0 { + return true; + } + *budget -= 1; + if let Some(r) = expression.as_ref() { + if self.hidden_layers.contains(&r) { + return true; + } + if !path.insert(r) { + return true; + } + } + let resolved = doc.resolve(expression); + let found = match &*resolved { + Object::Array(items) => items.iter().any(|item| self.expression_mentions_hidden(doc, item, depth + 1, path, budget)), + Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => expression.as_ref().is_none_or(|r| self.hidden_layers.contains(&r)), + Object::Dict(_) => true, + _ => false, + }; + if let Some(r) = expression.as_ref() { + path.remove(&r); + } + found } fn hits(&self, b: [f64; 4]) -> bool { @@ -534,7 +735,7 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< // 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() { + if scope.layers_enabled { let props = res_dict(doc, resources, b"Properties"); let (kept, removed) = strip_hidden_layers(doc, scope, parsed.ops, &props); parsed.ops = kept; @@ -928,11 +1129,13 @@ fn xobject( let obj = doc.get(r); let Object::Stream(s) = &*obj else { return None }; match s.dict.name(b"Subtype") { - _ if s.dict.get(b"OC").is_some_and(|oc| scope.layer_hidden(doc, oc)) => { + _ if s.dict.get(b"OC").is_some_and(|oc| scope.xobject_layer_hidden(doc, r, oc)) => { + // Verify mode must count hidden XObjects too: scan uses that count to decide + // whether the HiddenLayers pass is necessary. + scope.layer_blocks += 1; if scope.mode == Mode::Verify { return None; } - scope.layer_blocks += 1; out.replaced.push(name); out.doomed.push(r); Some(None) @@ -983,6 +1186,11 @@ fn xobject( } if scope.depth >= MAX_DEPTH { if scope.mode == Mode::Verify { + // Sanitization removes a Form it cannot enter, so the verification scan + // must count that conservative removal or it may decide to skip the pass. + if scope.everywhere() { + scope.layer_blocks += 1; + } return None; } scope.report.forms_removed += 1; @@ -998,7 +1206,14 @@ fn xobject( out.doomed.push(r); return Some(None); } - Err(_) => return None, + Err(_) => { + // Apply drops an undecodable Form while sanitizing. Count it during Verify + // so a zero-count preflight cannot leave its potentially hidden bytes behind. + if scope.everywhere() { + scope.layer_blocks += 1; + } + return None; + } }; let own = s.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()); let res = own.clone().unwrap_or_else(|| resources.clone()); @@ -1073,7 +1288,7 @@ fn xobject( /// Remove marked-content blocks of hidden layers (`/OC /name BDC … EMC`, nested blocks /// included). Returns the remaining operators and how many blocks went. -fn strip_hidden_layers(doc: &Document, scope: &Scope<'_>, ops: Vec, props: &Dict) -> (Vec, usize) { +fn strip_hidden_layers(doc: &Document, scope: &mut Scope<'_>, ops: Vec, props: &Dict) -> (Vec, usize) { let mut out = Vec::with_capacity(ops.len()); // For each open marked-content block: does it hide its contents? let mut stack: Vec = Vec::new(); diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 1940e376a8..bafa769bd3 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -138,12 +138,13 @@ fn hidden_layers(doc: &Document) -> Vec { } /// Glyphs of hidden text, or content of hidden layers, on every page (`write` = remove them). -fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Result { +fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Result<(usize, std::collections::HashSet), RedactError> { let off = if layers { hidden_layers(doc) } else { Vec::new() }; - if !text && off.is_empty() { - return Ok(0); + if !text && !layers { + return Ok((0, std::collections::HashSet::new())); } let mut total = 0; + let mut retained_off_layers = std::collections::HashSet::new(); // 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(); @@ -164,13 +165,18 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re let c = page.crop(doc); scope.hidden_text = Some([c[0].min(c[2]), c[1].min(c[3]), c[0].max(c[2]), c[1].max(c[3])]); } - scope.hidden_layers = off.clone(); + scope.hidden_layers = off.iter().copied().collect(); + scope.layers_enabled = layers; 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; + let glyphs = scope.report.glyphs; + let retained_for_page = std::mem::take(&mut scope.retained_off_layers); + drop(scope); + total += out.residue + blocks + glyphs; + retained_off_layers.extend(retained_for_page); if !write { continue; } @@ -216,7 +222,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, &retired_properties)?; - Ok(total) + Ok((total, retained_off_layers)) } /// How many items each category would remove (categories with nothing are included as 0). @@ -239,10 +245,10 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { Hidden::Attachments => sub(doc, &names, b"EmbeddedFiles").map_or(0, |t| name_tree_len(doc, &t, 0)) + count_sub(b"FileAttachment"), Hidden::Comments => annots.iter().filter(|a| is_comment(a.name(b"Subtype").unwrap_or(b""))).count(), Hidden::FormFields => pdfcraft_forms::fields(doc).len(), - Hidden::HiddenText => content_pass(&mut doc2, true, false, false).unwrap_or(0), + Hidden::HiddenText => content_pass(&mut doc2, true, false, false).map(|(n, _)| n).unwrap_or(0), Hidden::HiddenLayers => { let off = hidden_layers(doc).len(); - if off == 0 { 0 } else { off + content_pass(&mut doc2, false, true, false).unwrap_or(0) } + off + content_pass(&mut doc2, false, true, false).map(|(n, _)| n).unwrap_or(0) } Hidden::Bookmarks => sub(doc, &cat, b"Outlines").map_or(0, |o| outline_len(doc, o.get(b"First"), 0)), Hidden::LinksActionsScripts => { @@ -274,9 +280,11 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result 0) || (layers && count(Hidden::HiddenLayers) > 0) { - content_pass(doc, text, layers, true)?; - } + let retained_off_layers = if (text && count(Hidden::HiddenText) > 0) || (layers && count(Hidden::HiddenLayers) > 0) { + content_pass(doc, text, layers, true)?.1 + } else { + std::collections::HashSet::new() + }; // 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 { @@ -394,7 +402,7 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result { // The off layers leave the configuration (their content is gone already). if let Some(mut oc) = sub(doc, &cat, b"OCProperties") { - let keep = |o: &Object| !o.as_ref().is_some_and(|r| off.contains(&r)); + let keep = |o: &Object| !o.as_ref().is_some_and(|r| off.contains(&r) && !retained_off_layers.contains(&r)); if let Some(Object::Array(a)) = oc.get(b"OCGs").map(|g| (*doc.resolve(g)).clone()) { oc.set(b"OCGs".to_vec(), Object::Array(a.into_iter().filter(keep).collect())); } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index da1ecf12ab..8bc5ec1418 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -492,6 +492,420 @@ fn a_hidden_layers_image_does_not_survive_through_inherited_resources() { assert!(!bytes.windows(16).any(|w| w == b"HIDDENLAYERIMAGE"), "the off layer's image survived the save"); } +#[test] +fn hidden_ocmd_all_on_content_is_removed_before_its_off_group_is_unlinked() { + use crate::sanitize::{Hidden, remove_hidden}; + + let mut doc = one_page( + b"BT /F1 10 Tf 10 200 Td (PUBLIC) Tj ET /OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC", + "/Properties << /M 9 0 R >>", + vec![ + b"<< /Type /OCG /Name (On) >>".to_vec(), + b"<< /Type /OCG /Name (Off) >>".to_vec(), + b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOn >>".to_vec(), + ], + ); + let root = doc.root().unwrap(); + doc.update_dict(root, |d| { + d.set( + b"OCProperties".to_vec(), + Object::Dict(pdfcraft_cos::Dict::from_iter([ + (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), + ( + b"D".to_vec(), + Object::Dict(pdfcraft_cos::Dict::from_iter([ + (b"Order".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), + (b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))])), + ])), + ), + ])), + ); + }) + .unwrap(); + + let before = pdfcraft_edit::text_lines(&doc, 0).unwrap(); + assert!(before.iter().any(|line| line.text.contains("PUBLIC"))); + assert!(before.iter().any(|line| line.text.contains("SECRET")), "extractor sees source text before sanitization"); + remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); + + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let lines = pdfcraft_edit::text_lines(&saved, 0).unwrap(); + assert!(lines.iter().any(|line| line.text.contains("PUBLIC")), "visible content remains: {lines:?}"); + assert!(!lines.iter().any(|line| line.text.contains("SECRET")), "hidden text is extractable: {lines:?}"); + assert!(!write_full(&saved, &SaveOptions::default()).unwrap().windows(6).any(|w| w == b"SECRET")); +} + +#[test] +fn hidden_ocmd_content_in_a_form_and_nested_marked_blocks_is_removed() { + use crate::sanitize::{Hidden, remove_hidden}; + + let mut doc = one_page( + b"/Fm Do", + "/Properties << /M 9 0 R >> /XObject << /Fm 10 0 R >>", + vec![ + b"<< /Type /OCG /Name (On) >>".to_vec(), + b"<< /Type /OCG /Name (Off) >>".to_vec(), + b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOn >>".to_vec(), + stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> /Properties << /M 9 0 R >> >>", + b"/OC /M BDC /OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC EMC", + ), + ], + ); + let root = doc.root().unwrap(); + doc.update_dict(root, |d| { + d.set( + b"OCProperties".to_vec(), + Object::Dict(Dict::from_iter([ + (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), + (b"D".to_vec(), Object::Dict(Dict::from_iter([(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))]))), + ])), + ); + }) + .unwrap(); + + remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(!pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET"))); +} + +#[test] +fn hidden_ocmd_alloff_is_sanitized_when_every_group_is_on() { + use crate::sanitize::{Hidden, remove_hidden, scan}; + + let mut doc = one_page( + b"/OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC", + "/Properties << /M 9 0 R >>", + vec![ + b"<< /Type /OCG /Name (On) >>".to_vec(), + b"<< /Type /OCG /Name (Unused) >>".to_vec(), + b"<< /Type /OCMD /OCGs [7 0 R] /P /AllOff >>".to_vec(), + ], + ); + let counts = scan(&doc); + assert!(counts.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0), "{counts:?}"); + remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); + + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(!pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET"))); +} + +#[test] +fn hidden_ve_not_is_sanitized_when_its_group_is_on() { + use crate::sanitize::{Hidden, remove_hidden, scan}; + + let mut doc = one_page( + b"/OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC", + "/Properties << /M 9 0 R >>", + vec![b"<< /Type /OCG /Name (On) >>".to_vec(), b"<< /Type /OCG /Name (Unused) >>".to_vec(), b"<< /Type /OCMD /VE [/Not 7 0 R] >>".to_vec()], + ); + let counts = scan(&doc); + assert!(counts.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0), "{counts:?}"); + remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); + + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(!pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET"))); +} + +fn all_on_ocmd_xobject(subtype: &str, policy: &str) -> Document { + let membership = match policy { + "AllOff" => "<< /Type /OCMD /OCGs [7 0 R] /P /AllOff >>", + "Not" => "<< /Type /OCMD /VE [/Not 7 0 R] >>", + _ => "<< /Type /OCMD /OCGs [7 0 R] /P /AllOff >>", + }; + let (object_number, oc) = if policy == "DirectAllOff" { (8, membership) } else { (9, "8 0 R") }; + let xobject = match subtype { + "Image" => stream( + &format!("/Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /OC {oc}"), + b"PRIVATE_HIDDEN_XOBJECT_IMAGE", + ), + _ => stream( + &format!("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> >> /OC {oc}"), + b"BT /F1 10 Tf 10 100 Td (PRIVATE_HIDDEN_XOBJECT_FORM) Tj ET", + ), + }; + let mut extras = vec![b"<< /Type /OCG /Name (On) >>".to_vec()]; + if object_number == 9 { + extras.push(membership.as_bytes().to_vec()); + } + extras.push(xobject); + let mut doc = one_page(b"/X Do", &format!("/XObject << /X {object_number} 0 R >>"), extras); + let root = doc.root().unwrap(); + doc.update_dict(root, |d| { + d.set( + b"OCProperties".to_vec(), + Object::Dict(Dict::from_iter([ + (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0))])), + (b"D".to_vec(), Object::Dict(Dict::from_iter([(b"BaseState".to_vec(), Object::name("ON"))]))), + ])), + ); + }) + .unwrap(); + doc +} + +#[test] +fn hidden_all_on_ocmd_image_and_form_xobjects_are_counted_and_removed() { + use crate::sanitize::{Hidden, remove_hidden, scan}; + + let mut failures = Vec::new(); + for subtype in ["Image", "Form"] { + for policy in ["AllOff", "Not"] { + let mut doc = all_on_ocmd_xobject(subtype, policy); + let before = scan(&doc); + let hidden_count = before.iter().find(|(h, _)| *h == Hidden::HiddenLayers).map_or(0, |(_, n)| *n); + if hidden_count == 0 { + failures.push(format!("{subtype} /{policy} was not counted: {before:?}")); + } + + match remove_hidden(&mut doc, &[Hidden::HiddenLayers]) { + Ok(removed) if removed.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0) => {} + Ok(removed) => failures.push(format!("{subtype} /{policy} remove report is empty: {removed:?}")), + Err(error) => failures.push(format!("{subtype} /{policy} remove failed: {error}")), + } + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + if bytes.windows(b"PRIVATE_HIDDEN_XOBJECT_IMAGE".len()).any(|w| w == b"PRIVATE_HIDDEN_XOBJECT_IMAGE") { + failures.push(format!("{subtype} /{policy} image payload remains in full save")); + } + if bytes.windows(b"PRIVATE_HIDDEN_XOBJECT_FORM".len()).any(|w| w == b"PRIVATE_HIDDEN_XOBJECT_FORM") { + failures.push(format!("{subtype} /{policy} Form payload remains in full save")); + } + let saved = Document::open(Arc::new(bytes)).unwrap(); + if pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("PRIVATE_HIDDEN_XOBJECT_FORM")) { + failures.push(format!("{subtype} /{policy} Form text remains extractable")); + } + if !pdfcraft_edit::page_images(&saved, 0).unwrap().is_empty() { + failures.push(format!("{subtype} /{policy} image remains on the page")); + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn repeated_image_with_direct_alloff_membership_is_counted_per_draw() { + use crate::sanitize::{Hidden, scan}; + + let mut doc = all_on_ocmd_xobject("Image", "DirectAllOff"); + let page = pdfcraft_model::pages(&doc)[0].obj; + let contents = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), b"/X Do /X Do /X Do"))); + doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(contents))).unwrap(); + + let original = write_full(&doc, &SaveOptions::default()).unwrap(); + let counts = scan(&doc); + assert_eq!(counts.iter().find(|(h, _)| *h == Hidden::HiddenLayers).map(|(_, n)| *n), Some(3), "{counts:?}"); + assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), original, "Verify scan must not mutate the document"); +} + +#[test] +fn undecodable_form_is_counted_and_removed_by_hidden_layer_sanitization() { + use crate::sanitize::{Hidden, remove_hidden, scan}; + + let mut doc = one_page( + b"/Bad Do", + "/XObject << /Bad 7 0 R >>", + vec![stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Filter /UnknownFilter", b"PRIVATE_UNDECODABLE_FORM")], + ); + let counts = scan(&doc); + assert!(counts.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0), "{counts:?}"); + remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); + + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + assert!(!bytes.windows(b"PRIVATE_UNDECODABLE_FORM".len()).any(|w| w == b"PRIVATE_UNDECODABLE_FORM")); +} + +#[test] +fn form_chain_at_max_depth_is_counted_and_removed_by_hidden_layer_sanitization() { + use crate::sanitize::{Hidden, remove_hidden, scan}; + + const PRIVATE: &[u8] = b"PRIVATE_FORM_BEYOND_MAX_DEPTH"; + // MAX_DEPTH is 12. The final Form is encountered at that depth and is removed + // conservatively without being entered. Verify must count that removal or sanitize + // can skip the HiddenLayers pass entirely. + let mut forms = Vec::new(); + for depth in 0..=12 { + let content = if depth == 12 { + b"BT /F1 10 Tf 10 100 Td (PRIVATE_FORM_BEYOND_MAX_DEPTH) Tj ET".to_vec() + } else { + format!("/F{} Do", depth + 1).into_bytes() + }; + forms.push(stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", &content)); + } + let xobjects = (0..=12).map(|depth| format!("/F{depth} {} 0 R", 7 + depth)).collect::>().join(" "); + let mut doc = one_page(b"/F0 Do", &format!("/XObject << {xobjects} >>"), forms); + assert!( + write_full(&doc, &SaveOptions::default()).unwrap().windows(PRIVATE.len()).any(|w| w == PRIVATE), + "fixture lacked nested payload before sanitization" + ); + + let counts = scan(&doc); + assert!(counts.iter().any(|(hidden, count)| *hidden == Hidden::HiddenLayers && *count > 0), "depth-limited Form was not counted: {counts:?}"); + let removed = remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); + assert!( + removed.iter().any(|(hidden, count)| *hidden == Hidden::HiddenLayers && *count > 0), + "depth-limited Form was not reported removed: {removed:?}" + ); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + assert!(!bytes.windows(PRIVATE.len()).any(|w| w == PRIVATE), "depth-limited Form payload remains in full save"); +} + +#[test] +fn hidden_ocmd_resolves_an_indirect_policy_name() { + use crate::sanitize::{Hidden, remove_hidden}; + + let mut doc = one_page( + b"/OC /M BDC BT /F1 10 Tf 10 100 Td (VISIBLE) Tj ET EMC", + "/Properties << /M 9 0 R >>", + vec![ + b"<< /Type /OCG /Name (On) >>".to_vec(), + b"<< /Type /OCG /Name (Off) >>".to_vec(), + b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P 12 0 R >>".to_vec(), + ], + ); + doc.set(ObjRef::new(12, 0), Object::name("AnyOn")); + let root = doc.root().unwrap(); + doc.update_dict(root, |d| { + d.set( + b"OCProperties".to_vec(), + Object::Dict(Dict::from_iter([ + (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), + (b"D".to_vec(), Object::Dict(Dict::from_iter([(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))]))), + ])), + ); + }) + .unwrap(); + + remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("VISIBLE"))); +} + +#[test] +fn surviving_anyoff_and_not_content_keeps_its_off_group_state() { + use crate::sanitize::{Hidden, remove_hidden}; + + for use_not in [false, true] { + let mut doc = one_page( + b"/OC /M BDC BT /F1 10 Tf 10 100 Td (VISIBLE) Tj ET EMC", + "/Properties << /M 9 0 R >>", + vec![ + b"<< /Type /OCG /Name (On) >>".to_vec(), + b"<< /Type /OCG /Name (Off) >>".to_vec(), + if use_not { b"<< /Type /OCMD /VE [/Not 8 0 R] >>".to_vec() } else { b"<< /Type /OCMD /OCGs [8 0 R] /P /AnyOff >>".to_vec() }, + ], + ); + let root = doc.root().unwrap(); + doc.update_dict(root, |d| { + d.set( + b"OCProperties".to_vec(), + Object::Dict(Dict::from_iter([ + (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), + (b"D".to_vec(), Object::Dict(Dict::from_iter([(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))]))), + ])), + ); + }) + .unwrap(); + + remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); + let root = doc.root().unwrap(); + let catalog = doc.get(root); + let oc = catalog.as_dict().and_then(|d| d.get(b"OCProperties")).map(|o| doc.resolve(o)); + let default = oc.as_deref().and_then(|o| o.as_dict()).and_then(|d| d.get(b"D")).map(|o| doc.resolve(o)); + let off = default.as_deref().and_then(|o| o.as_dict()).and_then(|d| d.get(b"OFF")).map(|o| doc.resolve(o)); + assert!( + off.as_deref().and_then(Object::as_array).is_some_and(|a| a.iter().any(|o| o.as_ref() == Some(ObjRef::new(8, 0)))), + "off group lost for /VE Not={use_not}" + ); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("VISIBLE"))); + } +} + +#[test] +fn hidden_ocmd_policy_matches_the_default_configuration() { + use crate::sanitize::{Hidden, remove_hidden}; + + let cases = [ + (None, false, None, false), + (Some("AnyOn"), false, None, false), + (Some("AllOn"), false, None, true), + (Some("AnyOff"), false, None, false), + (Some("AllOff"), false, None, true), + (Some("UnknownPolicy"), false, None, true), + (None, true, None, true), + (None, false, Some("and"), true), + (None, false, Some("and_single_not"), true), + (None, false, Some("and_single_off"), true), + (None, false, Some("or_single_on"), false), + (None, false, Some("or_single_off"), true), + (Some("AllOn"), false, Some("not_off"), false), + (None, false, Some("malformed_not"), true), + (None, false, Some("malformed_without_ocgs"), true), + ]; + for (policy, malformed_policy, visibility, should_remove) in cases { + let mut doc = one_page( + b"/OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC", + "/Properties << /M 9 0 R >>", + vec![ + b"<< /Type /OCG /Name (On) >>".to_vec(), + b"<< /Type /OCG /Name (Off) >>".to_vec(), + b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOn >>".to_vec(), + ], + ); + let mut membership = Dict::new(); + membership.set(b"Type".to_vec(), Object::name("OCMD")); + if visibility != Some("malformed_without_ocgs") { + membership.set(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])); + } + if let Some(policy) = policy { + membership.set(b"P".to_vec(), Object::name(policy)); + } else if malformed_policy { + membership.set(b"P".to_vec(), Object::Int(17)); + } + match visibility { + Some("and") => membership + .set(b"VE".to_vec(), Object::Array(vec![Object::name("And"), Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), + Some("and_single_not") => membership.set( + b"VE".to_vec(), + Object::Array(vec![Object::name("Not"), Object::Array(vec![Object::name("And"), Object::Ref(ObjRef::new(7, 0))])]), + ), + Some("and_single_off") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("And"), Object::Ref(ObjRef::new(8, 0))])), + Some("or_single_on") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("Or"), Object::Ref(ObjRef::new(7, 0))])), + Some("or_single_off") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("Or"), Object::Ref(ObjRef::new(8, 0))])), + Some("not_off") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("Not"), Object::Ref(ObjRef::new(8, 0))])), + Some("malformed_not") => membership + .set(b"VE".to_vec(), Object::Array(vec![Object::name("Not"), Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), + Some("malformed_without_ocgs") => { + membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("UnknownOperator"), Object::Ref(ObjRef::new(8, 0))])) + } + _ => {} + } + doc.set(ObjRef::new(9, 0), Object::Dict(membership)); + let root = doc.root().unwrap(); + doc.update_dict(root, |d| { + d.set( + b"OCProperties".to_vec(), + Object::Dict(Dict::from_iter([ + (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), + ( + b"D".to_vec(), + Object::Dict(Dict::from_iter([ + (b"Order".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), + (b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))])), + ])), + ), + ])), + ); + }) + .unwrap(); + + remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let has_secret = pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET")); + assert_eq!(has_secret, !should_remove, "policy {policy:?}, malformed={malformed_policy}, VE={visibility:?}"); + } +} + #[test] fn redacting_an_xfa_field_drops_the_xfa_packet_so_its_value_cannot_survive() { // A static AcroForm+XFA form whose field value lives both in the widget /V and in the XFA diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 45641178de..c10ab74d15 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -1553,13 +1553,24 @@ mod tests { ]); shared_bad.entered.wait(); shared_healthy.entered.wait(); - assert!(pool.try_recv().is_none(), "shared timeouts retry instead of answering yet"); + // This watchdog pass retries both pages privately. Page 1's retry has no gate, so on a + // loaded machine it can finish before this same call reads the results: that early + // answer is the healthy page, never a timeout error. + let early = pool.try_recv(); + assert!( + early.as_ref().is_none_or(|page| page.request.page == 1 && page.error.is_none()), + "shared timeouts retry instead of answering with an error: {:?}", + early.as_ref().map(|page| (page.request.page, &page.error)) + ); shared_bad.release.wait(); shared_healthy.release.wait(); shared_bad.finished.wait(); shared_healthy.finished.wait(); private_bad.entered.wait(); - let healthy = pool.results.recv_timeout(Duration::from_secs(30)).expect("healthy private retry"); + let healthy = match early { + Some(page) => page, + None => pool.results.recv_timeout(Duration::from_secs(30)).expect("healthy private retry"), + }; assert!(healthy.error.is_none(), "{:?}", healthy.error); assert_eq!((healthy.request.page, healthy.width, healthy.height, healthy.request.tag), (1, 100, 50, 41)); pool.watchdog(); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index e01d752c40..48d1d3c44c 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -500,9 +500,9 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ui.add_space(6.0); ui.painter().vline(ui.cursor().left(), ui.max_rect().y_range().shrink(12.0), Stroke::new(1.0, t.divider)); ui.add_space(10.0); - for (mode, label) in - [(Mode::AllTools, "All tools"), (Mode::Read, "Read"), (Mode::Edit, "Edit"), (Mode::Convert, "Convert"), (Mode::Sign, "E-Sign")] - { + // Acrobat's mode bar has no Read tab: reading is what the viewer does with no tool + // in use. Read mode (the panes hidden) stays a View command and a default workspace. + for (mode, label) in [(Mode::AllTools, "All tools"), (Mode::Edit, "Edit"), (Mode::Convert, "Convert"), (Mode::Sign, "E-Sign")] { if widgets::mode_tab(ui, tl!(label), app.mode == mode).clicked() { app.switch_mode(mode, true); } diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 4e1907c0ff..5ef94ef28e 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2716,3 +2716,5 @@ certificate Version الإصدار Attaching files is not available on the web yet إرفاق الملفات غير متاح على الويب بعد Attach {name} إرفاق {name} Delete attachment {key} حذف المرفق {key} + Less أقل + Other options خيارات أخرى diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 177de516fc..d44feeeacd 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2402,3 +2402,5 @@ animation effects Off Изключени Attaching files is not available on the web yet Прикачването на файлове още не е налично в уеб версията Attach {name} Прикачване на {name} Delete attachment {key} Изтриване на прикачения файл {key} + Less По-малко + Other options Други опции diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 78e0f4e8d9..e5757db266 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2513,3 +2513,5 @@ animation effects Off Vypnuto Attaching files is not available on the web yet Připojování souborů zatím na webu není k dispozici Attach {name} Připojit {name} Delete attachment {key} Odstranit přílohu {key} + Less Méně + Other options Další možnosti diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6ff7393f0e..b5bb6ebede 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2440,3 +2440,5 @@ animation effects Off Aus Attaching files is not available on the web yet Dateien anhängen ist im Web noch nicht verfügbar Attach {name} {name} anhängen Delete attachment {key} Anhang {key} löschen + Less Weniger + Other options Weitere Optionen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index ed18e13966..374e486ceb 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2445,3 +2445,5 @@ animation effects Off Ανενεργά Attaching files is not available on the web yet Η επισύναψη αρχείων δεν είναι ακόμη διαθέσιμη στον ιστό Attach {name} Επισύναψη {name} Delete attachment {key} Διαγραφή συνημμένου {key} + Less Λιγότερα + Other options Άλλες επιλογές diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2aef858021..49ff0fc301 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2570,6 +2570,8 @@ animation effects Off Desactivados Make properties default Usar como propiedades predeterminadas Pages selected Páginas seleccionadas Page properties Propiedades de página + Less Menos + Other options Otras opciones # 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. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index bc9d917f81..c7781331f0 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2439,3 +2439,5 @@ animation effects Off Désactivés Attaching files is not available on the web yet Joindre des fichiers n'est pas encore disponible sur le web Attach {name} Joindre {name} Delete attachment {key} Supprimer la pièce jointe {key} + Less Moins + Other options Autres options diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 06767e1b2f..3f1d474f62 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2438,3 +2438,5 @@ animation effects Off Ki Attaching files is not available on the web yet Fájlok csatolása a weben még nem érhető el Attach {name} {name} csatolása Delete attachment {key} {key} melléklet törlése + Less Kevesebb + Other options Egyéb beállítások diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 4cea4488b3..79540ca7a8 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2651,3 +2651,5 @@ certificate SHA-256 digest Impronta SHA-256 Attaching files is not available on the web yet Allegare file non è ancora disponibile sul web Attach {name} Allega {name} Delete attachment {key} Elimina allegato {key} + Less Meno + Other options Altre opzioni diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 0d457f4190..9e3ddcc806 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2439,3 +2439,5 @@ animation effects Off オフ Attaching files is not available on the web yet Web 版ではまだファイルを添付できません Attach {name} {name} を添付 Delete attachment {key} 添付ファイル {key} を削除 + Less 折りたたむ + Other options その他のオプション diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index c30346f328..1abff77e95 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2437,3 +2437,5 @@ animation effects Off Өшірулі Attaching files is not available on the web yet Вебте файл тіркеу әзірге қолжетімсіз Attach {name} {name} тіркеу Delete attachment {key} {key} тіркемесін жою + Less Азырақ + Other options Басқа параметрлер diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 06ff78caf9..7484a7969f 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2690,3 +2690,5 @@ theme Appearance Išvaizda Attaching files is not available on the web yet Failų pridėjimas žiniatinklyje dar negalimas Attach {name} Pridėti {name} Delete attachment {key} Ištrinti priedą {key} + Less Mažiau + Other options Kitos parinktys diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 540eedfd77..a00e8daa20 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2415,3 +2415,5 @@ animation effects Off Wyłączone Attaching files is not available on the web yet Dołączanie plików nie jest jeszcze dostępne w wersji webowej Attach {name} Dołącz {name} Delete attachment {key} Usuń załącznik {key} + Less Mniej + Other options Inne opcje diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3e717ebd8e..6f83ad01e6 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2453,3 +2453,5 @@ animation effects Off Desativados Attaching files is not available on the web yet Anexar arquivos ainda não está disponível na web Attach {name} Anexar {name} Delete attachment {key} Excluir anexo {key} + Less Menos + Other options Outras opções diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index bc87248046..bc86b22c94 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2352,3 +2352,5 @@ animation effects Off Выкл Attaching files is not available on the web yet Прикрепление файлов в веб-версии пока недоступно Attach {name} Прикрепить {name} Delete attachment {key} Удалить вложение {key} + Less Меньше + Other options Другие параметры diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 06d0decda3..abdc99e0e5 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2444,3 +2444,5 @@ animation effects Off Izklopljeni Attaching files is not available on the web yet Prilaganje datotek na spletu še ni na voljo Attach {name} Priloži {name} Delete attachment {key} Izbriši prilogo {key} + Less Manj + Other options Druge možnosti diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index f7d13bb1e0..f67e54db23 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2348,3 +2348,5 @@ animation effects Off ఆఫ్ Attaching files is not available on the web yet వెబ్‌లో ఫైల్‌లను జోడించడం ఇంకా అందుబాటులో లేదు Attach {name} {name}ను జోడించు Delete attachment {key} జోడింపు {key}ను తొలగించు + Less తక్కువ + Other options ఇతర ఎంపికలు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f9e8709259..4d9f322ab3 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2435,3 +2435,5 @@ animation effects Off Kapalı Attaching files is not available on the web yet Dosya eklemek web'de henüz kullanılamıyor Attach {name} {name} ekle Delete attachment {key} {key} ekini sil + Less Daha az + Other options Diğer seçenekler diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 371245a802..0e52a8abba 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2461,3 +2461,5 @@ animation effects Off Вимкнено Attaching files is not available on the web yet Прикріплення файлів у веб-версії поки недоступне Attach {name} Прикріпити {name} Delete attachment {key} Видалити вкладення {key} + Less Менше + Other options Інші параметри diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 0a881477f6..7896b7798a 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2683,6 +2683,8 @@ key Space Space 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ừ… + Less Ít hơn + Other options Tùy chọn khác # 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. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a46ebc0821..bf71527790 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2403,3 +2403,5 @@ animation effects Off 关 Attaching files is not available on the web yet 网页版尚不支持附加文件 Attach {name} 附加 {name} Delete attachment {key} 删除附件 {key} + Less 收起 + Other options 其他选项 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index d2b583c794..0218646d69 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2389,3 +2389,5 @@ animation effects Off 關 Attaching files is not available on the web yet 網頁版尚不支援附加檔案 Attach {name} 附加 {name} Delete attachment {key} 刪除附件 {key} + Less 收起 + Other options 其他選項 diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index ce87f805f6..804a0dd150 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2,7 +2,7 @@ //! right-hand panels (Comments, Bookmarks, Pages, Fields, Layers, Attachments). use egui::{Align, Align2, Color32, CornerRadius, Layout, Rect, Sense, Stroke, pos2, vec2}; -use pdfcraft_engine::catalog::{self, Availability, TOOL_GROUPS, ToolGroup}; +use pdfcraft_engine::catalog::{self, Availability, TOOL_GROUPS, ToolGroup, ToolItem}; use pdfcraft_render::{DocInfo, FieldKind, OutlineItem}; use crate::theme::{self, Tokens}; @@ -144,6 +144,10 @@ fn tool_row(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup) -> egui::Response { } fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static ToolGroup) { + if g.id == "edit" { + edit_panel(app, ui, t, g); + return; + } let (back, close) = panel_header(ui, t, g.label, true); if back { app.left = LeftPanel::AllTools; @@ -185,20 +189,6 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static }); ui.add_space(6.0); } - // Edit a PDF shows Format text at the top while text is selected or being added. - if g.id == "edit" { - if app.quick_tool == crate::QuickTool::EditText { - let count = app.active_ids().map_or(0, |(i, _)| app.views[i].objects.count()); - if count > 0 { - ui.label(crate::i18n::trn(crate::i18n::current(), count as u64, "{n} object selected", "{n} objects selected")); - ui.label(tl!("Drag the selection to move it. Esc clears the selection.")); - } else { - ui.label(tl!("Shift-click to select several objects. Drag empty space to select an area.")); - } - ui.add_space(6.0); - } - format_section(app, ui, t); - } let mut run = None; // Redact a PDF has Acrobat's footer: Clear all / Redact all. let footer = g.id == "redact"; @@ -238,31 +228,13 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static ui.painter().rect_filled(r, CornerRadius::same(4), fill); ui.painter().text(r.center(), Align2::CENTER_CENTER, chip_label, font, cfg); let resp = theme::hand(resp).on_hover_text(format!("{} — {}", tl!(item.label), item.command)); + // An entry with further commands opens a menu; its caret goes before the chip. if item.actions.is_empty() { if resp.clicked() { run = Some(item.command); } - } else { - // A caret before the chip says the entry opens a menu (Add / Update / Remove). - let c = r.left_center() - vec2(10.0, 0.0); - ui.painter().add(egui::Shape::convex_polygon( - vec![c + vec2(-3.5, -2.0), c + vec2(3.5, -2.0), c + vec2(0.0, 2.5)], - fg, - egui::Stroke::NONE, - )); - egui::Popup::menu(&resp).show(|ui| { - ui.set_min_width(180.0); - for (n, id) in item.actions.iter().enumerate() { - let Some(spec) = pdfcraft_engine::commands::command(id) else { continue }; - // Add stays usable with no document open: it asks for a file first. - let enabled = (n == 0 && app.active.is_none()) || app.command_enabled(spec); - let label = crate::i18n::menu_label(spec.id, spec.label); - if ui.add_enabled(enabled, egui::Button::new(label)).clicked() { - run = Some(spec.id); - ui.close(); - } - } - }); + } else if let Some(cmd) = actions_menu(app, ui, &resp, item, r.left_center() - vec2(10.0, 0.0), fg) { + run = Some(cmd); } } } @@ -301,6 +273,172 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static } } +/// The page operations Edit shows as a row of icon buttons, as Acrobat's Edit panel does. +const EDIT_PAGE_BUTTONS: [&str; 4] = ["page.rotate", "page.insert", "page.delete", "page.extract"]; +/// Add content items shown before More. +const EDIT_CONTENT_SHOWN: usize = 5; +/// Other options: the tools Acrobat's Edit panel links to. +const EDIT_OTHER_TOOLS: [&str; 3] = ["combine", "redact", "form"]; + +/// Edit a PDF, laid out as Acrobat's Edit panel: the title with Preferences and Close; Format text +/// while text is being edited or added; Modify page as a row of icon buttons and Organize pages; +/// Add content with the rarer items under More; and Other options, the neighbouring tools. +/// Edit text & images itself is on the floating toolbar, and on from the moment Edit opens. +/// Every item comes from the catalogue, so its label, icon and availability have one source. +fn edit_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static ToolGroup) { + let items = || g.sections.iter().flat_map(|s| s.items.iter()); + let item = |command: &str| items().find(|i| i.command == command); + let mut run = None; + let mut tool = None; + // Back to All tools only when Edit was opened from there; the Edit tab is its own way back. + let from_all_tools = app.mode != crate::Mode::Edit; + ui.horizontal(|ui| { + if from_all_tools && icons::button(ui, "chevron-left", 26.0, false, tl!("Back to all tools")).clicked() { + app.left = LeftPanel::AllTools; + app.mode = crate::Mode::AllTools; + } + // "Edit" under the Edit tab, as Acrobat titles it; the tool's own name from All tools. + let title = if from_all_tools { tl!(g.label) } else { tl!("Edit") }; + let title_width = (ui.available_width() - 2.0 * (26.0 + ui.spacing().item_spacing.x)).max(1.0); + ui.add_sized([title_width, 26.0], egui::Label::new(egui::RichText::new(title).font(theme::semibold(15.5)).color(t.text)).truncate()) + .on_hover_text(title); + if icons::button(ui, "settings", 26.0, false, tl!("Preferences")).clicked() { + run = Some("app.preferences"); + } + if icons::button(ui, "x", 26.0, false, tl!("Close panel")).clicked() { + app.left_open = false; + } + }); + ui.add_space(2.0); + ui.separator(); + ui.add_space(4.0); + // Several objects selected: how many, and how to move them. Acrobat's panel has no idle hint. + let count = app.active_ids().filter(|_| app.quick_tool == crate::QuickTool::EditText).map_or(0, |(i, _)| app.views[i].objects.count()); + if count > 0 { + ui.label(crate::i18n::trn(crate::i18n::current(), count as u64, "{n} object selected", "{n} objects selected")); + ui.label(tl!("Drag the selection to move it. Esc clears the selection.")); + ui.add_space(6.0); + } + // Format text at the top while text is selected or being added. + format_section(app, ui, t); + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + widgets::section_title(ui, tl!("Modify page")); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + for i in EDIT_PAGE_BUTTONS.iter().filter_map(|c| item(c)) { + // Enabled as the command itself is: a document that forbids page assembly refuses these. + let enabled = pdfcraft_engine::commands::command(i.command).is_some_and(|spec| app.command_enabled(spec)); + let resp = ui.add_enabled_ui(enabled, |ui| icons::button(ui, i.icon, 34.0, false, tl!(i.label))).inner; + if resp.clicked() { + run = Some(i.command); + } + } + }); + ui.add_space(2.0); + // Organize pages is a workspace of its own: its panel replaces this one, with the grid. + if let Some(i) = item("page.organize") + && edit_row(ui, t, i.icon, tl!(i.label), i.availability).clicked() + { + // With no document there is no grid to open: the command asks for a file first. + if app.active.is_some() { + tool = Some("organize"); + } else { + run = Some("page.organize"); + } + } + + widgets::section_title(ui, tl!("Add content")); + let content: Vec<_> = + g.sections.iter().filter(|s| s.title == "Add content").flat_map(|s| s.items.iter()).filter(|i| i.command != "edit.edit_text").collect(); + let more_id = egui::Id::new("edit_panel_more"); + let more = ui.data(|d| d.get_temp::(more_id)).unwrap_or(false); + let shown = if more { content.len() } else { EDIT_CONTENT_SHOWN.min(content.len()) }; + for i in content.iter().take(shown) { + let resp = edit_row(ui, t, i.icon, tl!(i.label), i.availability); + // Header and footer, Watermark and Background open their Add / Update / Remove menu. + if i.actions.is_empty() { + if resp.clicked() { + run = Some(i.command); + } + } else if let Some(cmd) = actions_menu(app, ui, &resp, i, resp.rect.right_center() - vec2(14.0, 0.0), t.text) { + run = Some(cmd); + } + } + if content.len() > EDIT_CONTENT_SHOWN { + ui.add_space(4.0); + let label = if more { tl!("Less") } else { tl!("More") }; + let resp = ui + .add( + egui::Label::new(egui::RichText::new(label).color(t.accent_text).font(theme::medium(13.0))) + .selectable(false) + .sense(Sense::click()), + ) + .on_hover_cursor(egui::CursorIcon::PointingHand); + if resp.clicked() { + ui.data_mut(|d| d.insert_temp(more_id, !more)); + } + } + + widgets::section_title(ui, tl!("Other options")); + for other in EDIT_OTHER_TOOLS.iter().filter_map(|id| catalog::group(id)) { + if edit_row(ui, t, other.icon, tl!(other.label), other.availability).clicked() { + tool = Some(other.id); + } + } + }); + if let Some(id) = tool { + app.select_tool(id); + } + if let Some(cmd) = run { + app.run_command(cmd); + } +} + +/// A tool entry with further commands (Add / Update / Remove): a caret at `caret` says it opens a +/// menu, and each command is enabled by its own needs. Returns the command chosen. +fn actions_menu(app: &PdfCraftApp, ui: &egui::Ui, resp: &egui::Response, item: &ToolItem, caret: egui::Pos2, color: Color32) -> Option<&'static str> { + ui.painter().add(egui::Shape::convex_polygon( + vec![caret + vec2(-3.5, -2.0), caret + vec2(3.5, -2.0), caret + vec2(0.0, 2.5)], + color, + egui::Stroke::NONE, + )); + let mut run = None; + egui::Popup::menu(resp).show(|ui| { + ui.set_min_width(180.0); + for (n, id) in item.actions.iter().enumerate() { + let Some(spec) = pdfcraft_engine::commands::command(id) else { continue }; + // Add stays usable with no document open: it asks for a file first. + let enabled = (n == 0 && app.active.is_none()) || app.command_enabled(spec); + let label = crate::i18n::menu_label(spec.id, spec.label); + if ui.add_enabled(enabled, egui::Button::new(label)).clicked() { + run = Some(spec.id); + ui.close(); + } + } + }); + run +} + +/// One row of the Edit panel: a monochrome icon and the label, as Acrobat draws them. A planned +/// item is muted and says when it ships when hovered. +fn edit_row(ui: &mut egui::Ui, t: &Tokens, icon: &str, label: &str, availability: Availability) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + let press = theme::Press::track(ui, &resp); + press.wash(ui, rect, 6, false); + let body = rect.translate(press.offset()); + let ready = availability == Availability::Ready; + let fg = if ready { t.text } else { t.text_muted }; + icons::paint(ui, Rect::from_min_size(body.min + vec2(6.0, 8.0), vec2(18.0, 18.0)), icon, 17.0, if ready { t.text } else { t.text_faint }); + row_label(ui, label, theme::regular(13.5), fg, body.left_center() + vec2(34.0, 0.0), rect.width() - 42.0); + let resp = theme::hand(resp); + match availability { + Availability::Ready => resp, + Availability::Planned(m) => resp.on_hover_text(format!("{} {m}", tl!("Planned for milestone"))), + Availability::Provider => resp.on_hover_text(tl!("Optional: needs an AI provider you configure")), + } +} + /// Add a stamp: Dynamic, Sign Here and Standard Business stamps; click one, then click on the /// page to place it. fn stamp_palette(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { diff --git a/crates/ui-egui/tests/control.rs b/crates/ui-egui/tests/control.rs index e6af166d77..20fa4cb230 100644 --- a/crates/ui-egui/tests/control.rs +++ b/crates/ui-egui/tests/control.rs @@ -119,26 +119,28 @@ fn control_resize_reports_observed_dimensions_and_rejects_invalid_sizes() { #[test] fn control_focus_traverses_real_widgets_and_keyboard_activates() { let (mut h, c) = harness(); - ok(&mut h, &c, "ui.focus", json!({"label": "Read"})); - let read = ok(&mut h, &c, "ui.inspect", json!({"query": "Read", "role": "Button"})); - assert_eq!(read["widgets"][0]["focused"], true, "{read}"); - assert_eq!(read["widgets"][0]["focusable"], true); + ok(&mut h, &c, "ui.focus", json!({"label": "Convert"})); + let convert = ok(&mut h, &c, "ui.inspect", json!({"query": "Convert", "role": "Button"})); + let focused = + |found: &serde_json::Value, label: &str| found["widgets"].as_array().unwrap().iter().any(|w| w["label"] == label && w["focused"] == true); + assert!(focused(&convert, "Convert"), "{convert}"); + assert!(convert["widgets"].as_array().unwrap().iter().any(|w| w["label"] == "Convert" && w["focusable"] == true)); ok(&mut h, &c, "ui.key", json!({"key": "Tab"})); - let edit = ok(&mut h, &c, "ui.inspect", json!({"query": "Edit", "role": "Button"})); - assert!(edit["widgets"].as_array().unwrap().iter().any(|w| w["label"] == "Edit" && w["focused"] == true), "{edit}"); + let sign = ok(&mut h, &c, "ui.inspect", json!({"query": "E-Sign", "role": "Button"})); + assert!(focused(&sign, "E-Sign"), "{sign}"); ok(&mut h, &c, "ui.key", json!({"key": "Tab", "modifiers": ["shift"]})); - let read = ok(&mut h, &c, "ui.inspect", json!({"query": "Read", "role": "Button"})); - assert_eq!(read["widgets"][0]["focused"], true); + let convert = ok(&mut h, &c, "ui.inspect", json!({"query": "Convert", "role": "Button"})); + assert!(focused(&convert, "Convert"), "{convert}"); ok(&mut h, &c, "ui.key", json!({"key": "Enter"})); let state = ok(&mut h, &c, "ui.state", json!({})); - assert_eq!(state["mode"], "Read"); + assert_eq!(state["mode"], "Convert"); assert_eq!(state["documents"][0]["dirty"], false); } #[test] fn control_button_focus_preserves_document_undo_redo_shortcuts() { let (mut h, c) = harness_pages(2); - ok(&mut h, &c, "ui.focus", json!({"label": "Read"})); + ok(&mut h, &c, "ui.focus", json!({"label": "Convert"})); ok(&mut h, &c, "ui.key", json!({"key": "Enter"})); ok(&mut h, &c, "ui.click", json!({"label": "All tools"})); assert!(h.ctx.egui_wants_keyboard_input()); @@ -176,9 +178,9 @@ fn control_focus_rejects_missing_ambiguous_disabled_and_nonfocusable_targets() { json!({"label": "does not exist"}), json!({"id": "not a number"}), json!({"id": 0}), - json!({"id": 1, "label": "Read"}), + json!({"id": 1, "label": "Convert"}), json!({"label": false}), - json!({"label": "Read", "force": true}), + json!({"label": "Convert", "force": true}), json!([]), ] { assert!(call(&mut h, &c, "ui.focus", params).is_err()); @@ -186,9 +188,10 @@ fn control_focus_rejects_missing_ambiguous_disabled_and_nonfocusable_targets() { let widgets = ok(&mut h, &c, "ui.inspect", json!({})); let nonfocusable = widgets["widgets"].as_array().unwrap().iter().find(|w| w["focusable"] == false).unwrap(); assert!(call(&mut h, &c, "ui.focus", json!({"id": nonfocusable["id"]})).is_err()); - let read = widgets["widgets"].as_array().unwrap().iter().find(|w| w["label"] == "Read").unwrap(); - ok(&mut h, &c, "ui.focus", json!({"id": read["id"]})); - assert_eq!(ok(&mut h, &c, "ui.inspect", json!({"query": "Read", "role": "Button"}))["widgets"][0]["focused"], true); + let convert = widgets["widgets"].as_array().unwrap().iter().find(|w| w["label"] == "Convert").unwrap(); + ok(&mut h, &c, "ui.focus", json!({"id": convert["id"]})); + let found = ok(&mut h, &c, "ui.inspect", json!({"query": "Convert", "role": "Button"})); + assert!(found["widgets"].as_array().unwrap().iter().any(|w| w["label"] == "Convert" && w["focused"] == true), "{found}"); // Home has no active document, so its document-only toolbar controls are disabled. ok(&mut h, &c, "ui.click", json!({"label": "Home"})); assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["home"], true); @@ -740,8 +743,8 @@ fn japanese_controls_and_search_keep_command_ids() { let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "ja" })); assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["language"], "ja"); - ok(&mut h, &c, "ui.click", json!({ "label": "閲覧" })); - assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Read"); + ok(&mut h, &c, "ui.click", json!({ "label": "変換" })); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Convert"); ok(&mut h, &c, "ui.command", json!({ "id": "app.preferences" })); let prefs = ok(&mut h, &c, "ui.inspect", json!({ "query": "表示言語" })); @@ -766,8 +769,8 @@ fn japanese_controls_and_search_keep_command_ids() { fn german_preferences_and_search_keep_command_ids() { let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "de" })); - ok(&mut h, &c, "ui.click", json!({ "label": "Lesen" })); - assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Read"); + ok(&mut h, &c, "ui.click", json!({ "label": "Konvertieren" })); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Convert"); ok(&mut h, &c, "ui.command", json!({ "id": "app.preferences" })); let prefs = ok(&mut h, &c, "ui.inspect", json!({ "query": "Sprache der Oberfläche" })); assert!(prefs["count"].as_u64().unwrap() > 0, "{prefs}"); @@ -958,23 +961,24 @@ fn simplified_chinese_signature_prompts_and_errors_keep_document_state() { #[test] fn inspect_and_click_by_label_and_id() { let (mut h, c) = harness(); - let found = ok(&mut h, &c, "ui.inspect", json!({ "query": "read" })); - let read = found["widgets"].as_array().unwrap().iter().find(|w| w["label"] == "Read" && w["clickable"] == true).cloned().expect("a Read tab"); - let rect = read["rect"].as_array().unwrap(); + let found = ok(&mut h, &c, "ui.inspect", json!({ "query": "convert" })); + let convert = + found["widgets"].as_array().unwrap().iter().find(|w| w["label"] == "Convert" && w["clickable"] == true).cloned().expect("a Convert tab"); + let rect = convert["rect"].as_array().unwrap(); assert!(rect[2].as_f64().unwrap() > rect[0].as_f64().unwrap()); - ok(&mut h, &c, "ui.click", json!({ "label": "Read" })); - assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Read"); + ok(&mut h, &c, "ui.click", json!({ "label": "Convert" })); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Convert"); let all = ok(&mut h, &c, "ui.inspect", json!({ "query": "all tools" })); let id = all["widgets"].as_array().unwrap().iter().find(|w| w["label"] == "All tools" && w["clickable"] == true).unwrap()["id"].clone(); ok(&mut h, &c, "ui.click", json!({ "id": id })); assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "AllTools"); - // Clicking a point works too (here: the Read tab's centre). + // Clicking a point works too (here: the Convert tab's centre). let [x0, y0, x1, y1] = [0, 1, 2, 3].map(|i| rect[i].as_f64().unwrap()); ok(&mut h, &c, "ui.click", json!({ "x": (x0 + x1) / 2.0, "y": (y0 + y1) / 2.0 })); - assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Read"); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Convert"); let err = call(&mut h, &c, "ui.click", json!({ "label": "No such button" })).unwrap_err(); assert!(err.contains("no enabled clickable widget"), "{err}"); @@ -1622,10 +1626,10 @@ fn click_and_drag_refuse_coordinates_that_overflow_like_move() { let e = call(&mut h, &c, method, params.clone()).expect_err(&format!("{method} {params} is refused")); assert!(e.starts_with(&format!("{method}: ")) && e.contains("finite"), "{e}"); h.run_steps(4); - assert_eq!(ok(&mut h, &c, "ui.inspect", json!({ "query": "Read", "role": "Button" }))["count"], 1); + assert_eq!(ok(&mut h, &c, "ui.inspect", json!({ "query": "Convert", "role": "Button" }))["count"], 1); } - let read = ok(&mut h, &c, "ui.inspect", json!({ "query": "Read", "role": "Button" }))["widgets"][0]["rect"].clone(); - let (x, y) = (read[0].as_f64().unwrap() + 4.0, read[1].as_f64().unwrap() + 4.0); + let convert = ok(&mut h, &c, "ui.inspect", json!({ "query": "Convert", "role": "Button" }))["widgets"][0]["rect"].clone(); + let (x, y) = (convert[0].as_f64().unwrap() + 4.0, convert[1].as_f64().unwrap() + 4.0); assert_eq!(ok(&mut h, &c, "ui.click", json!({ "x": x, "y": y }))["clicked"], json!([x, y])); ok(&mut h, &c, "ui.drag", json!({ "from": [x, y], "to": [x + 10.0, y], "steps": 2 })); } diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index d41041e52e..1890c1c320 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -3256,11 +3256,17 @@ fn paragraph_drag_points(h: &Harness<'static, PdfCraftApp>, text: &str, offset: fn drag_paragraph(h: &mut Harness<'static, PdfCraftApp>, text: &str, offset: [f64; 2]) { let (from, to) = paragraph_drag_points(h, text, offset); - h.get_by_label("Shift-click to select several objects. Drag empty space to select an area."); + assert!( + h.query_all_by_label_contains("object selected").next().is_none() && h.query_all_by_label_contains("objects selected").next().is_none(), + "no objects selected" + ); assert!(h.state().views[0].line_editor.is_none()); drag(h, from, to); assert!(h.state().views[0].line_editor.is_none(), "a body drag must not open the typing editor"); - h.get_by_label("Shift-click to select several objects. Drag empty space to select an area."); + assert!( + h.query_all_by_label_contains("object selected").next().is_none() && h.query_all_by_label_contains("objects selected").next().is_none(), + "no objects selected" + ); } fn assert_dragged_paragraphs(actual: &[pdfcraft_engine::TextBlock], expected: &[pdfcraft_engine::TextBlock]) { diff --git a/crates/ui-egui/tests/object_selection.rs b/crates/ui-egui/tests/object_selection.rs index 343483378c..ceb700a1c0 100644 --- a/crates/ui-egui/tests/object_selection.rs +++ b/crates/ui-egui/tests/object_selection.rs @@ -146,7 +146,10 @@ fn marquee_and_mixed_group_drag_move_once_and_undo_together() { h.key_press_modifiers(Modifiers::COMMAND, Key::Z); h.run_steps(4); assert_eq!(objects(&h), before); - h.get_by_label("Shift-click to select several objects. Drag empty space to select an area."); + assert!( + h.query_all_by_label_contains("object selected").next().is_none() && h.query_all_by_label_contains("objects selected").next().is_none(), + "no objects selected" + ); h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z); h.run_steps(4); assert_eq!(objects(&h), after); @@ -171,7 +174,10 @@ fn modifier_clicks_extend_an_ordinary_selection_and_toggle_objects() { assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty); h.key_press(Key::Escape); h.run_steps(3); - h.get_by_label("Shift-click to select several objects. Drag empty space to select an area."); + assert!( + h.query_all_by_label_contains("object selected").next().is_none() && h.query_all_by_label_contains("objects selected").next().is_none(), + "no objects selected" + ); } #[test] @@ -221,7 +227,10 @@ fn a_document_change_invalidates_old_selection_before_the_next_drag() { .apply(id, Edit::AddText { page: 0, text: AddedText { text: "A new item".into(), rect: [40.0, 210.0, 150.0, 240.0], ..Default::default() } }) .unwrap(); h.run_steps(4); - h.get_by_label("Shift-click to select several objects. Drag empty space to select an area."); + assert!( + h.query_all_by_label_contains("object selected").next().is_none() && h.query_all_by_label_contains("objects selected").next().is_none(), + "no objects selected" + ); assert_eq!(objects(&h).len(), 5); } diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index d30ab1b8dd..a74e588790 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -192,6 +192,21 @@ fn read_mode_rail_buttons_open_their_panels() { h.get_by_label("Alpha section"); } +/// The mode bar matches Acrobat's: All tools, Edit, Convert and E-Sign, with no Read tab. Read +/// mode is still a View command. +#[test] +fn the_mode_bar_has_no_read_tab() { + use pdfcraft_ui_egui::Mode; + let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); + for label in ["All tools", "Edit", "Convert", "E-Sign"] { + assert!(h.query_all_by_label(label).next().is_some(), "the {label} tab is there"); + } + assert!(h.query_all_by_label("Read").next().is_none(), "no Read tab"); + h.state_mut().run_command("view.read_mode"); + h.run_steps(2); + assert_eq!(h.state().mode, Mode::Read); +} + /// A document that asks to open in Full Screen mode (`/PageMode /FullScreen`) is asked about /// first, as in Acrobat: "Not now" leaves the window as it is, "Full Screen" enters it. #[test] @@ -227,7 +242,7 @@ fn read_mode_closes_the_panes_and_leaving_it_brings_them_back() { use pdfcraft_ui_egui::{Mode, RightPanel}; let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); assert_eq!(h.state().right, Some(RightPanel::Comments)); - h.get_by_label("Read").click(); + h.state_mut().run_command("view.read_mode"); h.run_steps(3); assert_eq!(h.state().mode, Mode::Read); assert_eq!(h.state().right, None); @@ -254,7 +269,7 @@ fn read_mode_hides_the_quick_toolbar() { 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.state_mut().run_command("view.read_mode"); 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); @@ -286,7 +301,7 @@ fn a_pane_closed_in_read_mode_stays_closed_when_leaving_it() { use pdfcraft_ui_egui::{Mode, RightPanel}; let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); assert_eq!(h.state().right, Some(RightPanel::Comments)); - h.get_by_label("Read").click(); + h.state_mut().run_command("view.read_mode"); h.run_steps(3); assert_eq!(h.state().right, None); // Open Comments from the rail, then close it again. @@ -1425,3 +1440,38 @@ fn the_edit_tab_turns_on_edit_text_and_images() { h.run_steps(3); assert_eq!(h.state().quick_tool, QuickTool::Hand); } + +/// Organize pages in the Edit panel opens the Organize workspace: its own panel and the page grid, +/// not the grid behind the Edit panel. +#[test] +fn organize_pages_from_the_edit_panel_opens_its_panel() { + use pdfcraft_ui_egui::LeftPanel; + let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); + h.get_by_label("Edit").click(); + h.run_steps(3); + assert_eq!(h.state().left, LeftPanel::Tool("edit")); + assert!( + h.query_by_label("Shift-click to select several objects. Drag empty space to select an area.").is_none(), + "no idle hint above Format text, as in Acrobat" + ); + h.get_by_label("Organize pages").click(); + h.run_steps(3); + assert_eq!(h.state().left, LeftPanel::Tool("organize")); + assert!(h.state().views[0].organize, "the page grid is showing"); +} + +/// Header and footer, Watermark and Background keep their Add / Update / Remove menu in the Edit +/// panel, as under All tools: Update and Remove wait for the document to have that kind of mark. +#[test] +fn the_edit_panel_keeps_the_add_update_remove_menu() { + let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); + h.get_by_label("Edit").click(); + h.run_steps(3); + h.get_by_label("Watermark").click(); + h.run_steps(3); + assert_eq!(enabled(&h, "Add watermark…"), Some(true)); + assert_eq!(enabled(&h, "Remove watermark"), Some(false), "nothing to remove yet"); + h.get_by_label("Add watermark…").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Marks(pdfcraft_engine::MarkKind::Watermark))); +} diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs index 9cf99021a3..39ea83df76 100644 --- a/crates/ui-egui/tests/view.rs +++ b/crates/ui-egui/tests/view.rs @@ -1594,7 +1594,7 @@ fn switching_modes_keeps_the_current_page_at_fixed_zoom() { 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.state_mut().run_command("view.read_mode"); 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(); diff --git a/docs/roadmap.md b/docs/roadmap.md index 5d73511e9c..ed56c8aeaa 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -19,6 +19,7 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4 #1310 #1311 #1312: undo, revert and the next save keep the last saved revision; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-15 · **Change:** minor (M14: PNG export writes opaque pages as RGB at deflate level 3, the same size 3–5× faster; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -222,6 +223,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | 2026-10-12 | minor | M2: preserve whitespace-bounded EI bytes in known unfiltered inline images; row padding, masks and aliases tested, with independent saved-PDF pixel checks. Estimates unchanged. | | 2026-10-12 | minor | M1 #1338: a full rewrite keeps unknown trailer entries and objects reachable only from them. Estimates unchanged. | | 2026-10-12 | minor | M7 #1443: adding a word to a multi-line paragraph keeps its line breaks. Composite subset fonts whose `/W` leaves out default-width glyphs (Chrome, Skia) no longer measure those letters as missing, and paragraphs drawn turned on the page group into rows along their own baselines. Estimates unchanged. | +| 2026-10-15 | minor | M14: PNG export (Export ▸ Image, `pdfcraft-cli render`, `page_render`) writes opaque pages as RGB at deflate level 3: the same file size, 3–5× faster to write. Estimates unchanged. | | 2026-10-14 | minor | M14: Italian catalog completed (2,528 entries) and dropped from the ≈ 2,000–2,140-entry group; estimates unchanged | | 2026-10-12 | minor | M4 #1310 #1311 #1312: undo back to the opened file is clean, Revert returns to the last saved file, and the next save keeps that revision. Estimates unchanged. | | 2026-10-14 | minor | M5: reconciled PR #1457 with upstream main, preserving comment submission shortcuts and upstream roadmap progress. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0e5191d6aa..cac558c0bb 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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", "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" +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/ui.rs::the_mode_bar_has_no_read_tab", "crates/ui-egui/tests/view.rs::switching_modes_keeps_the_current_page_at_fixed_zoom"] +notes = "View ▸ Read mode (and the Read mode workspace) hides the panes and tool chrome. The mode bar has no Read tab, as in Acrobat, where reading is the viewer with no tool in use: its tabs are All tools, Edit, Convert and E-Sign. 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" diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000000..30e80a0c5f --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,2 @@ +sonar.projectKey=storytold_pdfcraft +sonar.organization=storytold diff --git a/xtask/src/version.rs b/xtask/src/version.rs index 0673653178..ed091509bc 100644 --- a/xtask/src/version.rs +++ b/xtask/src/version.rs @@ -15,12 +15,21 @@ pub fn read(manifest: &str) -> Result { Ok(v) } -/// `manifest` with the workspace version replaced; everything else is byte-for-byte unchanged. +/// `manifest` with the workspace version replaced, together with the matching `version` of every +/// path dependency in `[workspace.dependencies]` (the workspace's own crates, which must agree +/// with it or `cargo update` refuses them); everything else is byte-for-byte unchanged. pub fn replace(manifest: &str, new: &str) -> Result { validate(new)?; + let old = read(manifest)?; let (idx, _) = find_line(manifest)?; + let (old_pin, new_pin) = (format!("version = \"{old}\""), format!("version = \"{new}\"")); let mut out = String::with_capacity(manifest.len() + 8); + let mut in_deps = false; for (i, line) in manifest.split_inclusive('\n').enumerate() { + let t = line.trim(); + if t.starts_with('[') { + in_deps = t == "[workspace.dependencies]"; + } if i == idx { let eol = if line.ends_with("\r\n") { "\r\n" @@ -30,6 +39,8 @@ pub fn replace(manifest: &str, new: &str) -> Result { "" }; out.push_str(&format!("version = \"{new}\"{eol}")); + } else if in_deps && line.contains("path = \"") && line.contains(&old_pin) { + out.push_str(&line.replace(&old_pin, &new_pin)); } else { out.push_str(line); } @@ -131,6 +142,18 @@ mod tests { assert!(out.contains("foo = { version = \"1\" }")); } + #[test] + fn moves_the_workspace_crates_pins_with_it() { + let manifest = "[workspace.package]\nversion = \"0.1.0\"\n\n[workspace.dependencies]\n\ + own = { path = \"crates/own\", version = \"0.1.0\" }\n\ + dep = { version = \"0.1.0\" }\n\ + other = { path = \"crates/other\", version = \"0.2\" }\n"; + let out = replace(manifest, "0.2.0").unwrap(); + assert!(out.contains("own = { path = \"crates/own\", version = \"0.2.0\" }"), "{out}"); + assert!(out.contains("dep = { version = \"0.1.0\" }"), "a registry dependency keeps its version"); + assert!(out.contains("other = { path = \"crates/other\", version = \"0.2\" }")); + } + #[test] fn keeps_crlf_line_endings() { let crlf = MANIFEST.replace('\n', "\r\n");