From cfb71d850e99a41cd47e0c87e2f8449b8abf6a9e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 15 Oct 2026 23:57:29 -0400 Subject: [PATCH 01/12] ci: switch to shared nix workflow from craft-common-actions --- .github/workflows/nix.yml | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 18392587a8..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@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 - - 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 From 6fb6b7cf6fd8dc105fdd56a764d207cff6140694 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 19 Oct 2026 22:21:10 -0400 Subject: [PATCH 02/12] Release 0.10.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 72 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 68 +++++++++++++++++++++++++-------------------------- 2 files changed, 70 insertions(+), 70 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 68a758c2fb..d56c63a31f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3938,7 +3938,7 @@ dependencies = [ [[package]] name = "pdfcraft" -version = "0.9.0" +version = "0.10.0" dependencies = [ "craft-quit", "eframe", @@ -3963,7 +3963,7 @@ dependencies = [ [[package]] name = "pdfcraft-a11y" -version = "0.9.0" +version = "0.10.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3975,7 +3975,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.9.0" +version = "0.10.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -3986,7 +3986,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.9.0" +version = "0.10.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3997,7 +3997,7 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.9.0" +version = "0.10.0" dependencies = [ "base64 0.22.1", "flate2", @@ -4018,7 +4018,7 @@ dependencies = [ [[package]] name = "pdfcraft-cli" -version = "0.9.0" +version = "0.10.0" dependencies = [ "base64 0.22.1", "pdfcraft-audit", @@ -4035,18 +4035,18 @@ dependencies = [ [[package]] name = "pdfcraft-compare" -version = "0.9.0" +version = "0.10.0" [[package]] name = "pdfcraft-content" -version = "0.9.0" +version = "0.10.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-cos" -version = "0.9.0" +version = "0.10.0" dependencies = [ "hayro-syntax", "pdfcraft-crypt", @@ -4060,7 +4060,7 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.9.0" +version = "0.10.0" dependencies = [ "encoding_rs", "hayro-syntax", @@ -4076,7 +4076,7 @@ dependencies = [ [[package]] name = "pdfcraft-crypt" -version = "0.9.0" +version = "0.10.0" dependencies = [ "aes", "md-5", @@ -4088,7 +4088,7 @@ dependencies = [ [[package]] name = "pdfcraft-edit" -version = "0.9.0" +version = "0.10.0" dependencies = [ "hayro-syntax", "kurbo 0.13.1", @@ -4103,7 +4103,7 @@ dependencies = [ [[package]] name = "pdfcraft-engine" -version = "0.9.0" +version = "0.10.0" dependencies = [ "chrono", "craft-docpath", @@ -4143,14 +4143,14 @@ dependencies = [ [[package]] name = "pdfcraft-export" -version = "0.9.0" +version = "0.10.0" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-filters" -version = "0.9.0" +version = "0.10.0" dependencies = [ "flate2", "proptest", @@ -4160,7 +4160,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.9.0" +version = "0.10.0" dependencies = [ "harfrust", "pdfcraft-cos", @@ -4170,7 +4170,7 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.9.0" +version = "0.10.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4182,18 +4182,18 @@ dependencies = [ [[package]] name = "pdfcraft-geom" -version = "0.9.0" +version = "0.10.0" [[package]] name = "pdfcraft-js" -version = "0.9.0" +version = "0.10.0" dependencies = [ "boa_engine", ] [[package]] name = "pdfcraft-measure" -version = "0.9.0" +version = "0.10.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4206,14 +4206,14 @@ dependencies = [ [[package]] name = "pdfcraft-model" -version = "0.9.0" +version = "0.10.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.9.0" +version = "0.10.0" dependencies = [ "ocrs", "pdfcraft-fonts", @@ -4223,7 +4223,7 @@ dependencies = [ [[package]] name = "pdfcraft-optimize" -version = "0.9.0" +version = "0.10.0" dependencies = [ "image", "pdfcraft-annot", @@ -4236,11 +4236,11 @@ dependencies = [ [[package]] name = "pdfcraft-oracle" -version = "0.9.0" +version = "0.10.0" [[package]] name = "pdfcraft-organize" -version = "0.9.0" +version = "0.10.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4251,7 +4251,7 @@ dependencies = [ [[package]] name = "pdfcraft-platform" -version = "0.9.0" +version = "0.10.0" dependencies = [ "async-channel", "futures-lite", @@ -4261,7 +4261,7 @@ dependencies = [ [[package]] name = "pdfcraft-preflight" -version = "0.9.0" +version = "0.10.0" dependencies = [ "flate2", "pdfcraft-content", @@ -4272,7 +4272,7 @@ dependencies = [ [[package]] name = "pdfcraft-print" -version = "0.9.0" +version = "0.10.0" dependencies = [ "craft-print", "pdfcraft-content", @@ -4283,7 +4283,7 @@ dependencies = [ [[package]] name = "pdfcraft-redact" -version = "0.9.0" +version = "0.10.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4299,7 +4299,7 @@ dependencies = [ [[package]] name = "pdfcraft-render" -version = "0.9.0" +version = "0.10.0" dependencies = [ "bytemuck", "flate2", @@ -4318,7 +4318,7 @@ dependencies = [ [[package]] name = "pdfcraft-sign" -version = "0.9.0" +version = "0.10.0" dependencies = [ "aes", "aws-lc-rs", @@ -4352,11 +4352,11 @@ dependencies = [ [[package]] name = "pdfcraft-testkit" -version = "0.9.0" +version = "0.10.0" [[package]] name = "pdfcraft-ui-egui" -version = "0.9.0" +version = "0.10.0" dependencies = [ "arboard", "base64 0.22.1", @@ -4400,7 +4400,7 @@ dependencies = [ [[package]] name = "pdfcraft-web" -version = "0.9.0" +version = "0.10.0" dependencies = [ "eframe", "egui", @@ -4412,7 +4412,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.9.0" +version = "0.10.0" dependencies = [ "base64 0.22.1", "image", @@ -4426,7 +4426,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfdf" -version = "0.9.0" +version = "0.10.0" dependencies = [ "pdfcraft-annot", "pdfcraft-cos", diff --git a/Cargo.toml b/Cargo.toml index ec89098021..6a3e2aacd2 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.10.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.10.0" } +pdfcraft-filters = { path = "crates/filters", version = "0.10.0" } +pdfcraft-cos = { path = "crates/cos", version = "0.10.0" } +pdfcraft-crypt = { path = "crates/crypt", version = "0.10.0" } +pdfcraft-organize = { path = "crates/organize", version = "0.10.0" } +pdfcraft-measure = { path = "crates/measure", version = "0.10.0" } +pdfcraft-annot = { path = "crates/annot", version = "0.10.0" } +pdfcraft-audit = { path = "crates/audit", version = "0.10.0" } +pdfcraft-fonts = { path = "crates/fonts", version = "0.10.0" } +pdfcraft-model = { path = "crates/model", version = "0.10.0" } +pdfcraft-content = { path = "crates/content", version = "0.10.0" } +pdfcraft-redact = { path = "crates/redact", version = "0.10.0" } +pdfcraft-print = { path = "crates/print", version = "0.10.0" } +pdfcraft-xfdf = { path = "crates/xfdf", version = "0.10.0" } +pdfcraft-sign = { path = "crates/sign", version = "0.10.0" } +pdfcraft-optimize = { path = "crates/optimize", version = "0.10.0" } +pdfcraft-a11y = { path = "crates/a11y", version = "0.10.0" } +pdfcraft-oracle = { path = "crates/oracle", version = "0.10.0" } +pdfcraft-testkit = { path = "crates/testkit", version = "0.10.0" } +pdfcraft-ocr = { path = "crates/ocr", version = "0.10.0" } +pdfcraft-js = { path = "crates/js", version = "0.10.0" } +pdfcraft-compare = { path = "crates/compare", version = "0.10.0" } +pdfcraft-preflight = { path = "crates/preflight", version = "0.10.0" } +pdfcraft-export = { path = "crates/export", version = "0.10.0" } +pdfcraft-edit = { path = "crates/edit", version = "0.10.0" } +pdfcraft-create = { path = "crates/create", version = "0.10.0" } +pdfcraft-forms = { path = "crates/forms", version = "0.10.0" } +pdfcraft-xfa = { path = "crates/xfa", version = "0.10.0" } +pdfcraft-render = { path = "crates/render", version = "0.10.0" } +pdfcraft-engine = { path = "crates/engine", version = "0.10.0" } +pdfcraft-platform = { path = "crates/platform", version = "0.10.0" } +pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.10.0" } +pdfcraft-automation = { path = "crates/automation", version = "0.10.0", default-features = false } # pdf hayro = "0.7.1" From 59a5a83774442ad43ba0dff2069b58d3a2d1d7df Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 20 Oct 2026 11:29:59 -0400 Subject: [PATCH 03/12] docs: add SonarCloud and CodeRabbit badges to README (#3271) * docs: add SonarCloud and CodeRabbit badges and standardize to flat-square * docs: put SonarCloud and CodeRabbit badges on their own line --- README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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

From b77d57000c22c760f731206f6cf14486d149de72 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 20 Oct 2026 11:38:32 -0400 Subject: [PATCH 04/12] Release 0.10.1: rebuild with the shared workflow's exact Linux tarball pick 0.10.0's Arch Linux and Flatpak aarch64 jobs picked up the pdfcraft-cli tarball (storytold/craft-common-actions#35). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 72 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 68 +++++++++++++++++++++++++-------------------------- 2 files changed, 70 insertions(+), 70 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d56c63a31f..2399531ef4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3938,7 +3938,7 @@ dependencies = [ [[package]] name = "pdfcraft" -version = "0.10.0" +version = "0.10.1" dependencies = [ "craft-quit", "eframe", @@ -3963,7 +3963,7 @@ dependencies = [ [[package]] name = "pdfcraft-a11y" -version = "0.10.0" +version = "0.10.1" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3975,7 +3975,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.10.0" +version = "0.10.1" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -3986,7 +3986,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.10.0" +version = "0.10.1" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3997,7 +3997,7 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.10.0" +version = "0.10.1" dependencies = [ "base64 0.22.1", "flate2", @@ -4018,7 +4018,7 @@ dependencies = [ [[package]] name = "pdfcraft-cli" -version = "0.10.0" +version = "0.10.1" dependencies = [ "base64 0.22.1", "pdfcraft-audit", @@ -4035,18 +4035,18 @@ dependencies = [ [[package]] name = "pdfcraft-compare" -version = "0.10.0" +version = "0.10.1" [[package]] name = "pdfcraft-content" -version = "0.10.0" +version = "0.10.1" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-cos" -version = "0.10.0" +version = "0.10.1" dependencies = [ "hayro-syntax", "pdfcraft-crypt", @@ -4060,7 +4060,7 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.10.0" +version = "0.10.1" dependencies = [ "encoding_rs", "hayro-syntax", @@ -4076,7 +4076,7 @@ dependencies = [ [[package]] name = "pdfcraft-crypt" -version = "0.10.0" +version = "0.10.1" dependencies = [ "aes", "md-5", @@ -4088,7 +4088,7 @@ dependencies = [ [[package]] name = "pdfcraft-edit" -version = "0.10.0" +version = "0.10.1" dependencies = [ "hayro-syntax", "kurbo 0.13.1", @@ -4103,7 +4103,7 @@ dependencies = [ [[package]] name = "pdfcraft-engine" -version = "0.10.0" +version = "0.10.1" dependencies = [ "chrono", "craft-docpath", @@ -4143,14 +4143,14 @@ dependencies = [ [[package]] name = "pdfcraft-export" -version = "0.10.0" +version = "0.10.1" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-filters" -version = "0.10.0" +version = "0.10.1" dependencies = [ "flate2", "proptest", @@ -4160,7 +4160,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.10.0" +version = "0.10.1" dependencies = [ "harfrust", "pdfcraft-cos", @@ -4170,7 +4170,7 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.10.0" +version = "0.10.1" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4182,18 +4182,18 @@ dependencies = [ [[package]] name = "pdfcraft-geom" -version = "0.10.0" +version = "0.10.1" [[package]] name = "pdfcraft-js" -version = "0.10.0" +version = "0.10.1" dependencies = [ "boa_engine", ] [[package]] name = "pdfcraft-measure" -version = "0.10.0" +version = "0.10.1" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4206,14 +4206,14 @@ dependencies = [ [[package]] name = "pdfcraft-model" -version = "0.10.0" +version = "0.10.1" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.10.0" +version = "0.10.1" dependencies = [ "ocrs", "pdfcraft-fonts", @@ -4223,7 +4223,7 @@ dependencies = [ [[package]] name = "pdfcraft-optimize" -version = "0.10.0" +version = "0.10.1" dependencies = [ "image", "pdfcraft-annot", @@ -4236,11 +4236,11 @@ dependencies = [ [[package]] name = "pdfcraft-oracle" -version = "0.10.0" +version = "0.10.1" [[package]] name = "pdfcraft-organize" -version = "0.10.0" +version = "0.10.1" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4251,7 +4251,7 @@ dependencies = [ [[package]] name = "pdfcraft-platform" -version = "0.10.0" +version = "0.10.1" dependencies = [ "async-channel", "futures-lite", @@ -4261,7 +4261,7 @@ dependencies = [ [[package]] name = "pdfcraft-preflight" -version = "0.10.0" +version = "0.10.1" dependencies = [ "flate2", "pdfcraft-content", @@ -4272,7 +4272,7 @@ dependencies = [ [[package]] name = "pdfcraft-print" -version = "0.10.0" +version = "0.10.1" dependencies = [ "craft-print", "pdfcraft-content", @@ -4283,7 +4283,7 @@ dependencies = [ [[package]] name = "pdfcraft-redact" -version = "0.10.0" +version = "0.10.1" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4299,7 +4299,7 @@ dependencies = [ [[package]] name = "pdfcraft-render" -version = "0.10.0" +version = "0.10.1" dependencies = [ "bytemuck", "flate2", @@ -4318,7 +4318,7 @@ dependencies = [ [[package]] name = "pdfcraft-sign" -version = "0.10.0" +version = "0.10.1" dependencies = [ "aes", "aws-lc-rs", @@ -4352,11 +4352,11 @@ dependencies = [ [[package]] name = "pdfcraft-testkit" -version = "0.10.0" +version = "0.10.1" [[package]] name = "pdfcraft-ui-egui" -version = "0.10.0" +version = "0.10.1" dependencies = [ "arboard", "base64 0.22.1", @@ -4400,7 +4400,7 @@ dependencies = [ [[package]] name = "pdfcraft-web" -version = "0.10.0" +version = "0.10.1" dependencies = [ "eframe", "egui", @@ -4412,7 +4412,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.10.0" +version = "0.10.1" dependencies = [ "base64 0.22.1", "image", @@ -4426,7 +4426,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfdf" -version = "0.10.0" +version = "0.10.1" dependencies = [ "pdfcraft-annot", "pdfcraft-cos", diff --git a/Cargo.toml b/Cargo.toml index 6a3e2aacd2..b00b726dcb 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.10.0" +version = "0.10.1" 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.10.0" } -pdfcraft-filters = { path = "crates/filters", version = "0.10.0" } -pdfcraft-cos = { path = "crates/cos", version = "0.10.0" } -pdfcraft-crypt = { path = "crates/crypt", version = "0.10.0" } -pdfcraft-organize = { path = "crates/organize", version = "0.10.0" } -pdfcraft-measure = { path = "crates/measure", version = "0.10.0" } -pdfcraft-annot = { path = "crates/annot", version = "0.10.0" } -pdfcraft-audit = { path = "crates/audit", version = "0.10.0" } -pdfcraft-fonts = { path = "crates/fonts", version = "0.10.0" } -pdfcraft-model = { path = "crates/model", version = "0.10.0" } -pdfcraft-content = { path = "crates/content", version = "0.10.0" } -pdfcraft-redact = { path = "crates/redact", version = "0.10.0" } -pdfcraft-print = { path = "crates/print", version = "0.10.0" } -pdfcraft-xfdf = { path = "crates/xfdf", version = "0.10.0" } -pdfcraft-sign = { path = "crates/sign", version = "0.10.0" } -pdfcraft-optimize = { path = "crates/optimize", version = "0.10.0" } -pdfcraft-a11y = { path = "crates/a11y", version = "0.10.0" } -pdfcraft-oracle = { path = "crates/oracle", version = "0.10.0" } -pdfcraft-testkit = { path = "crates/testkit", version = "0.10.0" } -pdfcraft-ocr = { path = "crates/ocr", version = "0.10.0" } -pdfcraft-js = { path = "crates/js", version = "0.10.0" } -pdfcraft-compare = { path = "crates/compare", version = "0.10.0" } -pdfcraft-preflight = { path = "crates/preflight", version = "0.10.0" } -pdfcraft-export = { path = "crates/export", version = "0.10.0" } -pdfcraft-edit = { path = "crates/edit", version = "0.10.0" } -pdfcraft-create = { path = "crates/create", version = "0.10.0" } -pdfcraft-forms = { path = "crates/forms", version = "0.10.0" } -pdfcraft-xfa = { path = "crates/xfa", version = "0.10.0" } -pdfcraft-render = { path = "crates/render", version = "0.10.0" } -pdfcraft-engine = { path = "crates/engine", version = "0.10.0" } -pdfcraft-platform = { path = "crates/platform", version = "0.10.0" } -pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.10.0" } -pdfcraft-automation = { path = "crates/automation", version = "0.10.0", default-features = false } +pdfcraft-geom = { path = "crates/geom", version = "0.10.1" } +pdfcraft-filters = { path = "crates/filters", version = "0.10.1" } +pdfcraft-cos = { path = "crates/cos", version = "0.10.1" } +pdfcraft-crypt = { path = "crates/crypt", version = "0.10.1" } +pdfcraft-organize = { path = "crates/organize", version = "0.10.1" } +pdfcraft-measure = { path = "crates/measure", version = "0.10.1" } +pdfcraft-annot = { path = "crates/annot", version = "0.10.1" } +pdfcraft-audit = { path = "crates/audit", version = "0.10.1" } +pdfcraft-fonts = { path = "crates/fonts", version = "0.10.1" } +pdfcraft-model = { path = "crates/model", version = "0.10.1" } +pdfcraft-content = { path = "crates/content", version = "0.10.1" } +pdfcraft-redact = { path = "crates/redact", version = "0.10.1" } +pdfcraft-print = { path = "crates/print", version = "0.10.1" } +pdfcraft-xfdf = { path = "crates/xfdf", version = "0.10.1" } +pdfcraft-sign = { path = "crates/sign", version = "0.10.1" } +pdfcraft-optimize = { path = "crates/optimize", version = "0.10.1" } +pdfcraft-a11y = { path = "crates/a11y", version = "0.10.1" } +pdfcraft-oracle = { path = "crates/oracle", version = "0.10.1" } +pdfcraft-testkit = { path = "crates/testkit", version = "0.10.1" } +pdfcraft-ocr = { path = "crates/ocr", version = "0.10.1" } +pdfcraft-js = { path = "crates/js", version = "0.10.1" } +pdfcraft-compare = { path = "crates/compare", version = "0.10.1" } +pdfcraft-preflight = { path = "crates/preflight", version = "0.10.1" } +pdfcraft-export = { path = "crates/export", version = "0.10.1" } +pdfcraft-edit = { path = "crates/edit", version = "0.10.1" } +pdfcraft-create = { path = "crates/create", version = "0.10.1" } +pdfcraft-forms = { path = "crates/forms", version = "0.10.1" } +pdfcraft-xfa = { path = "crates/xfa", version = "0.10.1" } +pdfcraft-render = { path = "crates/render", version = "0.10.1" } +pdfcraft-engine = { path = "crates/engine", version = "0.10.1" } +pdfcraft-platform = { path = "crates/platform", version = "0.10.1" } +pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.10.1" } +pdfcraft-automation = { path = "crates/automation", version = "0.10.1", default-features = false } # pdf hayro = "0.7.1" From 22bedbf2af25bd4cdc1c188b5eff6ec3d2d089fb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 20 Oct 2026 11:38:59 -0400 Subject: [PATCH 05/12] Skip CodeRabbit reviews on Dependabot PRs (#3692) Adds .coderabbit.yaml so CodeRabbit no longer reviews or comments on PRs opened by dependabot[bot]. Co-authored-by: Claude Opus 5.5 --- .coderabbit.yaml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .coderabbit.yaml 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]" From d695bc56545944fe9c7181901c61d0bf1c11c887 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Tue, 20 Oct 2026 18:17:34 -0600 Subject: [PATCH 06/12] fix(redact): evaluate OCMD visibility during sanitization (#3522) * fix(redact): evaluate OCMD visibility during sanitization Fixes #3499 by evaluating OCMD policies and bounded visibility expressions against the default optional-content configuration before removing hidden groups. Unknown or malformed visibility data is handled conservatively. * fix(redact): preserve OCMD visibility during sanitization * fix(redact): count hidden XObjects during verification * test(redact): cover hidden form depth limit --- crates/redact/src/interp.rs | 251 +++++++++++++++++++-- crates/redact/src/sanitize.rs | 32 ++- crates/redact/src/tests.rs | 414 ++++++++++++++++++++++++++++++++++ 3 files changed, 667 insertions(+), 30 deletions(-) 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 From 3e3064532f04fd6ec769c6bd7b2c97464757970e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 21 Oct 2026 15:27:33 -0400 Subject: [PATCH 07/12] Release: PdfCraft v0.11.0 (#3893) * Release: PdfCraft v0.11.0 - Bump the workspace to 0.11.0. - Fix main's red CI (cargo-deny): update rustls-webpki 0.103.15 -> 0.103.16 (RUSTSEC-2026-0382) and webpki-root-certs 1.0.9 -> 1.0.10 (RUSTSEC-2026-0383), both reached through winprint's sigstore build dependency. - `cargo xtask version set` now moves the workspace crates' pins in [workspace.dependencies] with the version; before, `cargo update --workspace` refused the bump and the pins had to be edited by hand. Co-Authored-By: Claude Opus 5.5 * Tests: a healthy private retry may answer during the watchdog pass that scheduled it shared_timeouts_retry_healthy_pages_privately_before_blacklisting failed on CI (PR #3893): the try_recv that runs the watchdog spawns the private retries, and page 1's retry has no gate, so on a loaded runner its healthy result arrived before that same call read the channel. Accept that early answer (page 1, no error) and use it as the healthy result; an error still fails. Reproduced by sleeping 300 ms after the watchdog in try_recv: the old test fails every time, the new one passes. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 92 ++++++++++++++++++------------------- Cargo.toml | 68 +++++++++++++-------------- crates/render/src/raster.rs | 15 +++++- xtask/src/version.rs | 25 +++++++++- 4 files changed, 117 insertions(+), 83 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2399531ef4..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.10.1" +version = "0.11.0" dependencies = [ "craft-quit", "eframe", @@ -3963,7 +3963,7 @@ dependencies = [ [[package]] name = "pdfcraft-a11y" -version = "0.10.1" +version = "0.11.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3975,7 +3975,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.10.1" +version = "0.11.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -3986,7 +3986,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.10.1" +version = "0.11.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3997,7 +3997,7 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.10.1" +version = "0.11.0" dependencies = [ "base64 0.22.1", "flate2", @@ -4018,7 +4018,7 @@ dependencies = [ [[package]] name = "pdfcraft-cli" -version = "0.10.1" +version = "0.11.0" dependencies = [ "base64 0.22.1", "pdfcraft-audit", @@ -4035,18 +4035,18 @@ dependencies = [ [[package]] name = "pdfcraft-compare" -version = "0.10.1" +version = "0.11.0" [[package]] name = "pdfcraft-content" -version = "0.10.1" +version = "0.11.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-cos" -version = "0.10.1" +version = "0.11.0" dependencies = [ "hayro-syntax", "pdfcraft-crypt", @@ -4060,7 +4060,7 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.10.1" +version = "0.11.0" dependencies = [ "encoding_rs", "hayro-syntax", @@ -4076,7 +4076,7 @@ dependencies = [ [[package]] name = "pdfcraft-crypt" -version = "0.10.1" +version = "0.11.0" dependencies = [ "aes", "md-5", @@ -4088,7 +4088,7 @@ dependencies = [ [[package]] name = "pdfcraft-edit" -version = "0.10.1" +version = "0.11.0" dependencies = [ "hayro-syntax", "kurbo 0.13.1", @@ -4103,7 +4103,7 @@ dependencies = [ [[package]] name = "pdfcraft-engine" -version = "0.10.1" +version = "0.11.0" dependencies = [ "chrono", "craft-docpath", @@ -4143,14 +4143,14 @@ dependencies = [ [[package]] name = "pdfcraft-export" -version = "0.10.1" +version = "0.11.0" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-filters" -version = "0.10.1" +version = "0.11.0" dependencies = [ "flate2", "proptest", @@ -4160,7 +4160,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.10.1" +version = "0.11.0" dependencies = [ "harfrust", "pdfcraft-cos", @@ -4170,7 +4170,7 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.10.1" +version = "0.11.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4182,18 +4182,18 @@ dependencies = [ [[package]] name = "pdfcraft-geom" -version = "0.10.1" +version = "0.11.0" [[package]] name = "pdfcraft-js" -version = "0.10.1" +version = "0.11.0" dependencies = [ "boa_engine", ] [[package]] name = "pdfcraft-measure" -version = "0.10.1" +version = "0.11.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4206,14 +4206,14 @@ dependencies = [ [[package]] name = "pdfcraft-model" -version = "0.10.1" +version = "0.11.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.10.1" +version = "0.11.0" dependencies = [ "ocrs", "pdfcraft-fonts", @@ -4223,7 +4223,7 @@ dependencies = [ [[package]] name = "pdfcraft-optimize" -version = "0.10.1" +version = "0.11.0" dependencies = [ "image", "pdfcraft-annot", @@ -4236,11 +4236,11 @@ dependencies = [ [[package]] name = "pdfcraft-oracle" -version = "0.10.1" +version = "0.11.0" [[package]] name = "pdfcraft-organize" -version = "0.10.1" +version = "0.11.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4251,7 +4251,7 @@ dependencies = [ [[package]] name = "pdfcraft-platform" -version = "0.10.1" +version = "0.11.0" dependencies = [ "async-channel", "futures-lite", @@ -4261,7 +4261,7 @@ dependencies = [ [[package]] name = "pdfcraft-preflight" -version = "0.10.1" +version = "0.11.0" dependencies = [ "flate2", "pdfcraft-content", @@ -4272,7 +4272,7 @@ dependencies = [ [[package]] name = "pdfcraft-print" -version = "0.10.1" +version = "0.11.0" dependencies = [ "craft-print", "pdfcraft-content", @@ -4283,7 +4283,7 @@ dependencies = [ [[package]] name = "pdfcraft-redact" -version = "0.10.1" +version = "0.11.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4299,7 +4299,7 @@ dependencies = [ [[package]] name = "pdfcraft-render" -version = "0.10.1" +version = "0.11.0" dependencies = [ "bytemuck", "flate2", @@ -4318,7 +4318,7 @@ dependencies = [ [[package]] name = "pdfcraft-sign" -version = "0.10.1" +version = "0.11.0" dependencies = [ "aes", "aws-lc-rs", @@ -4352,11 +4352,11 @@ dependencies = [ [[package]] name = "pdfcraft-testkit" -version = "0.10.1" +version = "0.11.0" [[package]] name = "pdfcraft-ui-egui" -version = "0.10.1" +version = "0.11.0" dependencies = [ "arboard", "base64 0.22.1", @@ -4400,7 +4400,7 @@ dependencies = [ [[package]] name = "pdfcraft-web" -version = "0.10.1" +version = "0.11.0" dependencies = [ "eframe", "egui", @@ -4412,7 +4412,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.10.1" +version = "0.11.0" dependencies = [ "base64 0.22.1", "image", @@ -4426,7 +4426,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfdf" -version = "0.10.1" +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 b00b726dcb..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.10.1" +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.10.1" } -pdfcraft-filters = { path = "crates/filters", version = "0.10.1" } -pdfcraft-cos = { path = "crates/cos", version = "0.10.1" } -pdfcraft-crypt = { path = "crates/crypt", version = "0.10.1" } -pdfcraft-organize = { path = "crates/organize", version = "0.10.1" } -pdfcraft-measure = { path = "crates/measure", version = "0.10.1" } -pdfcraft-annot = { path = "crates/annot", version = "0.10.1" } -pdfcraft-audit = { path = "crates/audit", version = "0.10.1" } -pdfcraft-fonts = { path = "crates/fonts", version = "0.10.1" } -pdfcraft-model = { path = "crates/model", version = "0.10.1" } -pdfcraft-content = { path = "crates/content", version = "0.10.1" } -pdfcraft-redact = { path = "crates/redact", version = "0.10.1" } -pdfcraft-print = { path = "crates/print", version = "0.10.1" } -pdfcraft-xfdf = { path = "crates/xfdf", version = "0.10.1" } -pdfcraft-sign = { path = "crates/sign", version = "0.10.1" } -pdfcraft-optimize = { path = "crates/optimize", version = "0.10.1" } -pdfcraft-a11y = { path = "crates/a11y", version = "0.10.1" } -pdfcraft-oracle = { path = "crates/oracle", version = "0.10.1" } -pdfcraft-testkit = { path = "crates/testkit", version = "0.10.1" } -pdfcraft-ocr = { path = "crates/ocr", version = "0.10.1" } -pdfcraft-js = { path = "crates/js", version = "0.10.1" } -pdfcraft-compare = { path = "crates/compare", version = "0.10.1" } -pdfcraft-preflight = { path = "crates/preflight", version = "0.10.1" } -pdfcraft-export = { path = "crates/export", version = "0.10.1" } -pdfcraft-edit = { path = "crates/edit", version = "0.10.1" } -pdfcraft-create = { path = "crates/create", version = "0.10.1" } -pdfcraft-forms = { path = "crates/forms", version = "0.10.1" } -pdfcraft-xfa = { path = "crates/xfa", version = "0.10.1" } -pdfcraft-render = { path = "crates/render", version = "0.10.1" } -pdfcraft-engine = { path = "crates/engine", version = "0.10.1" } -pdfcraft-platform = { path = "crates/platform", version = "0.10.1" } -pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.10.1" } -pdfcraft-automation = { path = "crates/automation", version = "0.10.1", 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/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/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"); From 30b8c60b557a8d79e4114a984e2ca47b3a89b628 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 15:47:03 -0400 Subject: [PATCH 08/12] ci: add SonarCloud scan via shared reusable workflow Calls storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main with secrets: inherit. Adds SonarCloud badge to README where missing and removes the redundant sonar-project.properties (config now passed by the reusable workflow). --- .github/workflows/sonarcloud.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/sonarcloud.yml 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 From b1bf1cf01ff8d513a7a9bcb87d99933f2fb643f8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 16:00:45 -0400 Subject: [PATCH 09/12] ci: add sonar-project.properties for CI-based analysis Marks the project ineligible for SonarCloud Automatic Analysis, resolving the CI-vs-automatic-analysis conflict so the SonarCloud workflow succeeds. --- sonar-project.properties | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 sonar-project.properties 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 From 322a1b2ac01a56d926406f784a48a0d5cdeda79e Mon Sep 17 00:00:00 2001 From: abc2cb Date: Thu, 22 Oct 2026 00:33:59 +0300 Subject: [PATCH 10/12] M7: lay out the Edit panel as Acrobat's (#2245) * M7: the Edit tab turns on Edit text & images, as in Acrobat Entering Edit boxes every paragraph and image on the page at once, so text is editable with one click instead of first finding Edit text & images in the tool list. The floating toolbar gets an Edit text & images button while Edit is open or the tool is in use. Leaving Edit (or turning the tool off there) applies a paragraph still open in the editor, as clicking away from it does; left open, its page kept showing the paragraph's text removed. Co-Authored-By: Claude Opus 5.5 * M7: parity notes for the Edit tab turning on Edit text & images Co-Authored-By: Claude Opus 5.5 * M7: the panel-reveal test looks for the Edit panel's own row With Edit text & images on, its name is on the floating toolbar as well as in the panel, so the label alone matched twice and no longer showed that the Tools tab was revealed. Co-Authored-By: Claude Opus 5.5 * M7: lay out the Edit panel as Acrobat's Under the Edit tab the panel is titled Edit, with Preferences and Close; Modify page is a row of icon buttons (rotate, insert, delete, extract) and Organize pages; Add content lists Text, Image, Header and footer, Watermark and Link, with the rest under More; Other options links Combine files, Redact a PDF and Prepare a form. The milestone banner and Ready chips are gone; a planned item is muted and says when it ships. Every row still comes from the catalogue. Edit text & images is on the floating toolbar. Co-Authored-By: Claude Opus 5.5 * M7: Organize pages in the Edit panel opens the Organize workspace It ran page.organize, which showed the page grid behind the Edit panel; Acrobat replaces the panel with Organize pages' own. It now selects the Organize tool, so its panel and the grid open together. Co-Authored-By: Claude Opus 5.5 * M7: translate the Edit panel's Less and Other options Every catalogue gets the two strings the Acrobat-style Edit panel adds. Co-Authored-By: Claude Opus 5.5 * chore(i18n): refresh the catalogue hashes in ATTRIBUTION.toml Co-Authored-By: Claude Opus 5.5 * M7: no idle selection hint above Format text in the Edit panel "Shift-click to select several objects. Drag empty space to select an area." sat at the top of the Edit panel whenever Edit text & images was on, pushing Format text down; Acrobat's panel has no such line. The selection count and how to move it still show while objects are selected. Tests that read the hint as "nothing selected" check that no selection count shows instead. Co-Authored-By: Claude Opus 5.5 * M7: let auto-deref find the Edit panel's buttons (clippy) Co-Authored-By: Claude Opus 5.5 * M7: the Edit panel keeps main's Add / Update / Remove menu Main gave Header and footer, Watermark and Background a menu of Add, Update and Remove in the tool panel (#1857). This branch draws the Edit group in its own panel, so those rows ran only Add and Update / Remove were unreachable there. Both panels now share one helper for the caret and the menu. Co-Authored-By: Claude Opus 5.5 * Edit panel: Modify page buttons follow command enablement; Organize pages with no document asks for a file CodeRabbit on #2245: the four Modify page buttons were enabled from the catalogue (always Ready) while execute() refuses them on a document that forbids page assembly; they now use command_enabled. Organize pages with no document open selected a panel with no grid to show; it now runs page.organize, which asks for a file first. Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: abc2cb <271337822+abc2cb@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- ATTRIBUTION.toml | 44 ++--- crates/ui-egui/src/i18n/ar.tsv | 2 + crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/kk.tsv | 2 + crates/ui-egui/src/i18n/lt.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sl.tsv | 2 + crates/ui-egui/src/i18n/te.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/panels.rs | 210 +++++++++++++++++++---- crates/ui-egui/tests/editing.rs | 10 +- crates/ui-egui/tests/object_selection.rs | 15 +- crates/ui-egui/tests/ui.rs | 35 ++++ 27 files changed, 295 insertions(+), 63 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index db3dd6124f..fbabdf6d44 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 = "6eee0b34db6529f26235bfc07fa1fdb9030b8e7b0b10ede271ca9242cb491919" +sha256 = "e2ea97b4c68e13c22ef7f6f39f9a29d21af0e562005e2fdc9e7dfdf29effe0bb" # 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 = "9870c0fb9f7198155be16adee7f45d36cb9a254af90b545a3ce5b04cbbbf24d4" +sha256 = "ac0a78f36b91c005b9543e6028b78bb44949fea1bf7991bf0791d407696cd832" [[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 = "fc31e40877ce115cfb7033c57dd6acabcae3725b249364387e3fe23e04d94e5e" +sha256 = "c52b9a2aa403937e70859fe60b51719c97475c24927322d7534f5da0bdb5f420" [[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 = "5de19834f293efaea68cb6b7f51a71d320391f53345f56cfebcd73dab828b665" +sha256 = "0dcad418fa6399955adf2a8398aec307c2c73b13a37ca78ffe142e00491ef586" [[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 = "cef0d22341d0722f029c6543fc846fce164331b7ad8f693a6754b7567b9d9e95" +sha256 = "130f0256d4ba09bc9cb6a87fc973bad96d6b155703b713341f0afead9ad6b4b3" [[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 = "c0ca7761cbabd8096bc9af54cdf6b88b3ae0cd8210003db8b3356e2706e44fc8" +sha256 = "d1093647d2af3d51f77a7b53f05ef1f45442128d40e696022624fbbece5bd0f0" [[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 = "a8afd51c3296f44b0446d6b06492b1348444b3e3c448a326ad213d18ce7af714" +sha256 = "135d00bc60ef62f45ebee7c7fd83bc7433fd4054a04fb3901b2d2e8bdd2871c7" [[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 = "7676a2dfaa7d66e2e9ef608d2d9de7f863557dcecbef6d94bafa981097405e6d" +sha256 = "0cb38f42fe45831495699f81837fb0b2d5bb16b1b69153359c4376e9bc1e6e04" [[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 = "9a22996c0308ea5b20ec5e328504cd83cbb560147763faaf0e6987f974844c29" +sha256 = "ce97821a0fd6bf758e88e654f70985ed1e94c81e36f4e31ee3fc2f05ca39d99f" [[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 = "eacc972d65a2d951e94e1a8cf944638e8328f73f6c05759de6059a425f159cd7" +sha256 = "45567329bdb7884b0b54e5c5161e6e73452d85200945f1ba3661eab1fa35da06" [[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 = "dfa4aa540cc7d61fa22a27a949e6eec0f78dcc5b883d0e4e004d695e460b95aa" +sha256 = "421ef329305624ebfc661a8d9cd82131e79ec279b8be47d4baec29a226e494d9" [[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 = "27e02e7bb3d378bceca76988a9dceaa68d974f57cbee962954deca6184cf62aa" +sha256 = "97ed35e8959777a5f15b087136e53385430241e8522c7dc72a383c72dc0a27fd" [[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 = "9675edd36c5ae54d9893fd10c4325aab664941bf714c08fbfeef9134fa42056c" +sha256 = "bc012e0dd9ad0d212a32b71bfc75a42ffcd058484aab28d173078384c9da480f" [[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 = "ce18fc96f8516aa917e4a8bcfd290edec13a9800b3d1ee102e4b2a71b8467d2b" +sha256 = "bd611d470f40a01b4442119f7189dabbe58b4904dbce398115399f02b2ecddaa" [[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 = "286a1e2e051748eb4bcee3906918dc78d0704f8a37bde22dc50d3376d5e75017" +sha256 = "1132e904bc1a2bcc8239987aa74b2fa75ca17d8eb20b5458eec7e2c075c0445a" [[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 = "77593b7497c67f4a79cc31fa43fdd6eae11601dd87c079a8e666b25cdd9117ba" +sha256 = "11dd8250c667ca49c40b827632b2d83cad93d07a9d5a5f27b5b9d657293efb6b" [[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 = "d7d93a03f22ab380696f69b20b12104bf30e78c4440b683df63594af716894e2" +sha256 = "6aa0788d784532fb67a0b589cecc27f727e6178221ee892c3de7fe8f75790607" [[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 = "889a0d0a9ea53ca0a818c2ba672c053c232d2cca140f8d63127353e6f36505fe" +sha256 = "ed84a08b83035bc6f4d328b916692d40140c2748d383741d04fba6e013a76bb3" [[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 = "bae8b21651ae2a69c71c91b69ba79727254dbfb078c1be312a13383332934927" +sha256 = "1a5b3e9ad3f524e04bdb60e11f4a1424ca749a5ed4840e22abeaa97d24d235e8" [[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 = "7fa70f68a9fa24d0b1cac05fe3aaabaeaca9750c57416124bd317a4eda806d5a" +sha256 = "136f1e68fc4caa5e6e8fe395551bffdc9ace93bb00e2da5ba59318846494420d" [[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 = "95b5fc6d10f7bc8371688772741870448de716fa2dd9af62fdfe4e009d269b45" +sha256 = "052ecb803601ebc547934136c936fb5527d6dc97e49cf8cfbde0aa2b1a6cb8c7" [[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 = "3ac2b113045b7e399dd6d9ddb7773e5b0874c8b43c2ba4cca5854598a2a7ba92" +sha256 = "299c274d071478c49430d8dbc2a1979085fa4f8735324697e8ac0db3d3adce7c" [[asset]] diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 8697887e10..4bb58ba8e1 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2711,3 +2711,5 @@ certificate Version الإصدار Make properties default جعل الخصائص افتراضية Pages selected الصفحات المحددة Page properties خصائص الصفحة + Less أقل + Other options خيارات أخرى diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 813a2c7ae8..584fafda3f 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2397,3 +2397,5 @@ animation effects Off Изключени Make properties default Задаване като свойства по подразбиране Pages selected Избрани страници Page properties Свойства на страницата + Less По-малко + Other options Други опции diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9936325523..d347ca95d7 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2508,3 +2508,5 @@ animation effects Off Vypnuto Make properties default Nastavit vlastnosti jako výchozí Pages selected Vybrané stránky Page properties Vlastnosti stránky + 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 4d9207eb9d..cb1ffc2fd9 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2435,3 +2435,5 @@ animation effects Off Aus Make properties default Eigenschaften als Standard festlegen Pages selected Ausgewählte Seiten Page properties Seiteneigenschaften + 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 06fe2b540b..2bde0b50b9 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2440,3 +2440,5 @@ animation effects Off Ανενεργά Make properties default Ορισμός ιδιοτήτων ως προεπιλογή Pages selected Επιλεγμένες σελίδες Page properties Ιδιότητες σελίδας + Less Λιγότερα + Other options Άλλες επιλογές diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 79c668992c..fbc9f54c98 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 9bdcf0bc8a..bde247a8d8 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2434,3 +2434,5 @@ animation effects Off Désactivés Make properties default Définir comme propriétés par défaut Pages selected Pages sélectionnées Page properties Propriétés de la page + 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 d537677c7d..16efc30a0d 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2433,3 +2433,5 @@ animation effects Off Ki Make properties default Tulajdonságok beállítása alapértelmezettként Pages selected Kijelölt oldalak Page properties Oldaltulajdonságok + 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 35cba7a410..c46f9cf81b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2646,3 +2646,5 @@ certificate Key usage Utilizzo chiave certificate Self-signed Autofirmato certificate SHA-1 digest Impronta SHA-1 certificate SHA-256 digest Impronta SHA-256 + 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 e845a736da..54bc546338 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2434,3 +2434,5 @@ animation effects Off オフ Make properties default プロパティをデフォルトに設定 Pages selected 選択したページ Page properties ページのプロパティ + Less 折りたたむ + Other options その他のオプション diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 9acb4ec1be..5ca11b941d 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2432,3 +2432,5 @@ animation effects Off Өшірулі Make properties default Қасиеттерді әдепкі ету Pages selected Таңдалған беттер Page properties Бет қасиеттері + Less Азырақ + Other options Басқа параметрлер diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index d1dfc33044..2674dfc86b 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2685,3 +2685,5 @@ key Space Tarpas The password must have at least 6 characters. Slaptažodį turi sudaryti bent 6 simboliai. This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Šis failas diske pasikeitė. Įkelkite jį iš naujo, kad pamatytumėte naują versiją; neįrašyti pakeitimai bus prarasti. theme Appearance Išvaizda + 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 c432a6a92e..d6d5e5e93b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2410,3 +2410,5 @@ animation effects Off Wyłączone Make properties default Ustaw właściwości jako domyślne Pages selected Zaznaczone strony Page properties Właściwości strony + 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 89ff80f9dd..ee041f4af1 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2448,3 +2448,5 @@ animation effects Off Desativados Make properties default Tornar propriedades padrão Pages selected Páginas selecionadas Page properties Propriedades da página + 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 73f21aa0d8..72fcb1ddcf 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2347,3 +2347,5 @@ animation effects Off Выкл Make properties default Сделать свойства стандартными Pages selected Выделенные страницы Page properties Свойства страницы + Less Меньше + Other options Другие параметры diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index dd169bc961..009131307f 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2439,3 +2439,5 @@ animation effects Off Izklopljeni Make properties default Nastavi lastnosti kot privzete Pages selected Izbrane strani Page properties Lastnosti strani + 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 47aee36f8b..80008d1912 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2343,3 +2343,5 @@ animation effects Off ఆఫ్ Make properties default లక్షణాలను డిఫాల్ట్‌గా చేయి Pages selected ఎంచుకున్న పేజీలు Page properties పేజీ లక్షణాలు + Less తక్కువ + Other options ఇతర ఎంపికలు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 95ca0b608c..b185f0262e 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2430,3 +2430,5 @@ animation effects Off Kapalı Make properties default Özellikleri varsayılan yap Pages selected Seçilen sayfalar Page properties Sayfa özellikleri + 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 7993a06185..c25eba25b7 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2456,3 +2456,5 @@ animation effects Off Вимкнено Make properties default Зробити властивості типовими Pages selected Вибрані сторінки Page properties Властивості сторінки + Less Менше + Other options Інші параметри diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index bae599bf6a..dbf9eb2245 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 1ddecc655f..a71b3f30e9 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2398,3 +2398,5 @@ animation effects Off 关 Make properties default 将属性设为默认 Pages selected 选定的页面 Page properties 页面属性 + Less 收起 + Other options 其他选项 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index bb5ec3649b..630bfbf34b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2384,3 +2384,5 @@ animation effects Off 關 Make properties default 將內容設為預設 Pages selected 選取的頁面 Page properties 頁面屬性 + Less 收起 + Other options 其他選項 diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 0f2c601497..8d6660b643 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/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..118dee8f74 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -1425,3 +1425,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))); +} From 8f3456f29c9fbcb94a46e91fd9cdf58588a323a0 Mon Sep 17 00:00:00 2001 From: Piohrun Date: Wed, 21 Oct 2026 23:34:11 +0200 Subject: [PATCH 11/12] =?UTF-8?q?fix(engine):=20PNG=20export=20writes=20op?= =?UTF-8?q?aque=20pages=20as=20RGB=20at=20deflate=20level=203=20(3?= =?UTF-8?q?=E2=80=935=C3=97=20faster,=20same=20size)=20(#2425)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(engine): PNG export writes opaque pages as RGB at deflate level 3 `encode_png` wrote every raster as RGBA at the png crate's default level (zlib level 6). A rendered page is always opaque, so the alpha channel only made the file bigger and slower to write. An opaque raster is now written as RGB, at deflate level 3 with adaptive filtering; a raster with translucent pixels is still written as RGBA with straight alpha. The files come out the same size, 3-5x faster (release build, Linux): 300-dpi scan drawn at 220 dpi 805 -> 167 ms 4.46 -> 4.47 MB text page at 220 dpi 70 -> 40 ms 0.85 -> 0.89 MB annual-report page at 150 dpi 179 -> 64 ms 1.41 -> 1.38 MB This is the encoder behind Export > Image (PNG), `pdfcraft-cli render` and Print as Image; the automation tools (`page_render` and the other page previews, MCP included) had a copy of it and now call it instead, so png is only a dev-dependency of pdfcraft-automation. Two automation tests that read `page_render` output as RGBA now accept the RGB a page renders as. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MoEx9Nj8KC4WKDcf5YaokQ * fix(engine): PNG export rejects a raster of the wrong length encode_png now checks that the buffer is exactly width × height × 4 bytes (checked arithmetic) before choosing RGB or RGBA. The opaque path's as_chunks::<4>() dropped a partial trailing pixel, so encode_png(1, 1, &[10, 20, 30, 255, 0]) wrote a valid 1×1 PNG where the old RGBA encoder returned an error. Regression test export::png_tests::rasters_of_the_wrong_length_are_rejected covers opaque and translucent buffers with a partial pixel, a pixel short, a pixel too many, an empty buffer and a size whose byte count overflows; it fails on the previous head at the reviewer's case. Review: https://github.com/storytold/pdfcraft/pull/2425#discussion_r4281603405 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MoEx9Nj8KC4WKDcf5YaokQ --------- Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/automation/Cargo.toml | 2 +- crates/automation/src/lib.rs | 19 +----- crates/automation/tests/automation.rs | 25 ++++--- crates/engine/src/export.rs | 96 ++++++++++++++++++++++++--- docs/roadmap.md | 2 + 6 files changed, 106 insertions(+), 39 deletions(-) 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 0e2e0dd698..af0583717f 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -2506,24 +2506,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 cbf40f1962..f760398a1e 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/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. | From 583c1ec68c75024d77066983a79c05f27141f820 Mon Sep 17 00:00:00 2001 From: abc2cb Date: Thu, 22 Oct 2026 00:34:16 +0300 Subject: [PATCH 12/12] M3: the mode bar has no Read tab, as in Acrobat (#2240) * M3: the mode bar has no Read tab, as in Acrobat Acrobat's mode bar is All tools, Edit, Convert and E-Sign: reading is what the viewer does with no tool in use. Read mode (the panes hidden) stays a View command and a default workspace. Tests that clicked the Read tab as an ordinary mode-bar button use Convert instead. Co-Authored-By: Claude Opus 5.5 * M3: parity notes for the mode bar without a Read tab Co-Authored-By: Claude Opus 5.5 * M3: the Read-mode tests enter through the View command The mode bar no longer has a Read tab, so `read_mode_hides_the_quick_toolbar` (ui.rs) and `switching_modes_keeps_the_current_page_at_fixed_zoom` (view.rs) clicked a button that is gone, failing the test jobs on ubuntu-latest and windows-latest. Both run `view.read_mode` now, as the other read-mode tests in this PR do; the second is the test the `view.read-mode` parity entry cites. `cargo test -p pdfcraft-ui-egui --test ui --test view`: 56 and 55 passed. --------- Co-authored-by: abc2cb <271337822+abc2cb@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/chrome.rs | 6 ++-- crates/ui-egui/tests/control.rs | 62 ++++++++++++++++++--------------- crates/ui-egui/tests/ui.rs | 21 +++++++++-- crates/ui-egui/tests/view.rs | 2 +- parity/acrobat-features.toml | 4 +-- 5 files changed, 57 insertions(+), 38 deletions(-) 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/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/ui.rs b/crates/ui-egui/tests/ui.rs index 118dee8f74..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. 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/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7aca7d5c8b..6caa46d3a2 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"