diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000000..1910ea4bd3
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,32 @@
+version: 2
+updates:
+ # Maintain Rust / Cargo dependencies
+ - package-ecosystem: "cargo"
+ directories:
+ - "/"
+ - "/crates/filters/fuzz"
+ schedule:
+ interval: "daily"
+ time: "06:00"
+ timezone: "America/New_York"
+ # Consolidate updates into a single PR rather than one PR per crate
+ groups:
+ cargo-dependencies:
+ patterns:
+ - "*"
+ commit-message:
+ prefix: "chore(deps)"
+
+ # Maintain GitHub Actions workflows
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ time: "06:00"
+ timezone: "America/New_York"
+ groups:
+ actions:
+ patterns:
+ - "*"
+ commit-message:
+ prefix: "ci(deps)"
diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml
new file mode 100644
index 0000000000..ce817da129
--- /dev/null
+++ b/.github/workflows/nix.yml
@@ -0,0 +1,33 @@
+# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
+# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
+name: Nix
+
+on:
+ push:
+ branches: [main]
+ paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
+ pull_request:
+ paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: nix-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ eval:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: cachix/install-nix-action@448cde739abec1eaf0646249e3d25bb8d9bc7702 # v31.11.2
+ - name: nix flake check --no-build
+ run: |
+ if [ -f flake.nix ]; then
+ nix flake check --no-build --all-systems
+ else
+ echo "No flake.nix present in this repository; skipping check."
+ fi
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index c9acb0512a..5cfee8d55a 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -27,8 +27,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; reviewed by a native Lithuanian-speaking contributor | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; reviewed by a native Vietnamese-speaking contributor | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
+| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
@@ -278,6 +277,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile |
| `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/pdf.rs): the legacy adbe.x509.rsa_sha1 format |
| `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
+| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file |
diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml
index 3f3791f719..db3dd6124f 100644
--- a/ATTRIBUTION.toml
+++ b/ATTRIBUTION.toml
@@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "cefa955aa92790d6773cb1d1e15e19019b3165a00528d57d6617362e1d1988ab"
+sha256 = "6eee0b34db6529f26235bfc07fa1fdb9030b8e7b0b10ede271ca9242cb491919"
# ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences.
# ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`).
@@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "1de29a6cc089be31cb6206bf41e0715f8ddcf74ef887b45ccee235d743bed223"
+sha256 = "9870c0fb9f7198155be16adee7f45d36cb9a254af90b545a3ce5b04cbbbf24d4"
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
@@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "8c6d5b1505d76bc7ba606c98660afa60b1152c5b4c2e7d345104a6ef3d280ed2"
+sha256 = "fc31e40877ce115cfb7033c57dd6acabcae3725b249364387e3fe23e04d94e5e"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.tsv"
@@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "8a8aed18bd8fceabd1389580361ba75b0dccf141d845650a353d8dfe19c95536"
+sha256 = "5de19834f293efaea68cb6b7f51a71d320391f53345f56cfebcd73dab828b665"
[[asset]]
path = "crates/ui-egui/src/i18n/pt-br.tsv"
@@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "1c197ac045810950aceb733152a296f2224f38857b1169032fdc7092ab4aa3f7"
+sha256 = "cef0d22341d0722f029c6543fc846fce164331b7ad8f693a6754b7567b9d9e95"
[[asset]]
path = "crates/ui-egui/src/i18n/es.tsv"
@@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "a9cf0e062b006b40f549ce2316b88d4b0196d70bd9cecd66c12d980d699bf679"
+sha256 = "c0ca7761cbabd8096bc9af54cdf6b88b3ae0cd8210003db8b3356e2706e44fc8"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
@@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "ef5c84bd13ef8318f7deab7f42136cfe3c14cbef6fd5231014b94167baaecd2a"
+sha256 = "a8afd51c3296f44b0446d6b06492b1348444b3e3c448a326ad213d18ce7af714"
[[asset]]
path = "crates/ui-egui/src/i18n/de.tsv"
@@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "be689af8bb22537879269ad9ab9bdcc4b730665cf0b27c6bf4bad560cacf3b69"
+sha256 = "7676a2dfaa7d66e2e9ef608d2d9de7f863557dcecbef6d94bafa981097405e6d"
[[asset]]
path = "crates/ui-egui/src/i18n/te.tsv"
@@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "989d2e5f57cf22c21a85951835ca9fbd05e314da67eb4c17332df1f03679fa00"
+sha256 = "9a22996c0308ea5b20ec5e328504cd83cbb560147763faaf0e6987f974844c29"
[[asset]]
path = "crates/ui-egui/src/i18n/it.tsv"
@@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "425c42acb8d98ac31b216b13c05a285e0676318dd379dcfb9d57cc7329e9d7a5"
+sha256 = "eacc972d65a2d951e94e1a8cf944638e8328f73f6c05759de6059a425f159cd7"
[[asset]]
path = "crates/ui-egui/src/i18n/pl.tsv"
@@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "988bcf2e0322a46c0464ff7b91ab5d90193b0ea7f454812d12595eb843de6fc7"
+sha256 = "dfa4aa540cc7d61fa22a27a949e6eec0f78dcc5b883d0e4e004d695e460b95aa"
[[asset]]
path = "crates/ui-egui/src/i18n/ru.tsv"
@@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "ebe7aa9152baff38ae7e36aeb4f6a7ed2198166ab93425a612a66d53e43cb363"
+sha256 = "27e02e7bb3d378bceca76988a9dceaa68d974f57cbee962954deca6184cf62aa"
[[asset]]
path = "crates/ui-egui/src/i18n/hu.tsv"
@@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "69d2da21949f02c3c40f71159e377eef2fdd1a87f749d6c504e1d151ef33f643"
+sha256 = "9675edd36c5ae54d9893fd10c4325aab664941bf714c08fbfeef9134fa42056c"
[[asset]]
path = "crates/ui-egui/src/i18n/bg.tsv"
@@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "98359685aa555f391846f2259ac302ce1234c7a180fb4abe07c36e0c1c62211e"
+sha256 = "ce18fc96f8516aa917e4a8bcfd290edec13a9800b3d1ee102e4b2a71b8467d2b"
[[asset]]
path = "crates/ui-egui/src/i18n/kk.tsv"
@@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "9f6296d9c79c848167fc80bb4cfaacc9e60f72edb70cc1d70f7b6088ada83578"
+sha256 = "286a1e2e051748eb4bcee3906918dc78d0704f8a37bde22dc50d3376d5e75017"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"
@@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "3528e85afd4117de2c22b9f37d32a380a073a49834fa5a3063d21b803606ffbf"
+sha256 = "77593b7497c67f4a79cc31fa43fdd6eae11601dd87c079a8e666b25cdd9117ba"
[[asset]]
path = "crates/ui-egui/src/i18n/uk.tsv"
@@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "4db7e23074d44305f4584e2cd8b9a0fc35b433cb187dad493c8f7d78a8cd4ad2"
+sha256 = "d7d93a03f22ab380696f69b20b12104bf30e78c4440b683df63594af716894e2"
[[asset]]
path = "crates/ui-egui/src/i18n/ar.tsv"
@@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "2af01de9509db4b4ae61193f41ff0f7f3b70ca5f91cd2afda667e90d34980b25"
+sha256 = "889a0d0a9ea53ca0a818c2ba672c053c232d2cca140f8d63127353e6f36505fe"
[[asset]]
path = "crates/ui-egui/src/i18n/tr.tsv"
@@ -217,31 +217,20 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "de988b6959572931c4878a0588109c236bf8c5d46ef57c7a406b6ab42de51339"
+sha256 = "bae8b21651ae2a69c71c91b69ba79727254dbfb078c1be312a13383332934927"
[[asset]]
path = "crates/ui-egui/src/i18n/lt.tsv"
title = "Lithuanian interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; reviewed by a native Lithuanian-speaking contributor"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "63955758227bd57329b22dc7c16ed9cee8b0a15df9453477755849c886424dd5"
+sha256 = "7fa70f68a9fa24d0b1cac05fe3aaabaeaca9750c57416124bd317a4eda806d5a"
-[[asset]]
-path = "crates/ui-egui/src/i18n/vi.tsv"
-title = "Vietnamese interface translation catalog"
-author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; reviewed by a native Vietnamese-speaking contributor"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "b54adaa6515dbfb0de3859b30989054e5fcc4f56e66e9cc713c7bf509bdbbac3"
-
[[asset]]
path = "assets/icons/accessibility.svg"
title = "Lucide icon \"accessibility\""
@@ -3354,6 +3343,17 @@ kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
sha256 = "d1a443233345724859de8e4fb48968ba91e885e12ddbd710701548bd91b39428"
+[[asset]]
+path = "crates/ui-egui/src/i18n/vi.tsv"
+title = "Vietnamese interface translation catalog"
+author = "PdfCraft contributors"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed"
+licence = "MIT OR Apache-2.0"
+licence_file = "LICENSE-MIT"
+kind = "translation"
+usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
+sha256 = "95b5fc6d10f7bc8371688772741870448de716fa2dd9af62fdfe4e009d269b45"
+
[[asset]]
path = "crates/ui-egui/src/i18n/sl.tsv"
title = "Slovene interface translation catalog"
@@ -3363,9 +3363,10 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "5881cc7fbfe9da5f5f611309dacf267f95db3a143b916a113b649839019b292c"
+sha256 = "3ac2b113045b7e399dd6d9ddb7773e5b0874c8b43c2ba4cca5854598a2a7ba92"
[[asset]]
+
path = "vendor/vello_common/assets/probe.png"
title = "vello_common probe reference image (rendered from probe.rgba)"
author = "Linebender vello contributors"
diff --git a/Cargo.lock b/Cargo.lock
index 91ed3c4904..68a758c2fb 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1142,6 +1142,11 @@ dependencies = [
"libc",
]
+[[package]]
+name = "craft-docpath"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
+
[[package]]
name = "craft-print"
version = "0.1.0"
@@ -3933,7 +3938,7 @@ dependencies = [
[[package]]
name = "pdfcraft"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"craft-quit",
"eframe",
@@ -3958,7 +3963,7 @@ dependencies = [
[[package]]
name = "pdfcraft-a11y"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3970,7 +3975,7 @@ dependencies = [
[[package]]
name = "pdfcraft-annot"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
@@ -3981,7 +3986,7 @@ dependencies = [
[[package]]
name = "pdfcraft-audit"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3992,7 +3997,7 @@ dependencies = [
[[package]]
name = "pdfcraft-automation"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"base64 0.22.1",
"flate2",
@@ -4013,7 +4018,7 @@ dependencies = [
[[package]]
name = "pdfcraft-cli"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"base64 0.22.1",
"pdfcraft-audit",
@@ -4021,6 +4026,7 @@ dependencies = [
"pdfcraft-cos",
"pdfcraft-engine",
"pdfcraft-platform",
+ "pdfcraft-preflight",
"pdfcraft-render",
"rustix 1.1.5",
"serde_json",
@@ -4029,18 +4035,18 @@ dependencies = [
[[package]]
name = "pdfcraft-compare"
-version = "0.8.0"
+version = "0.9.0"
[[package]]
name = "pdfcraft-content"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-cos"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"hayro-syntax",
"pdfcraft-crypt",
@@ -4054,7 +4060,7 @@ dependencies = [
[[package]]
name = "pdfcraft-create"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"encoding_rs",
"hayro-syntax",
@@ -4070,7 +4076,7 @@ dependencies = [
[[package]]
name = "pdfcraft-crypt"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"aes",
"md-5",
@@ -4082,7 +4088,7 @@ dependencies = [
[[package]]
name = "pdfcraft-edit"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"hayro-syntax",
"kurbo 0.13.1",
@@ -4097,9 +4103,10 @@ dependencies = [
[[package]]
name = "pdfcraft-engine"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"chrono",
+ "craft-docpath",
"icu_properties",
"idna",
"image",
@@ -4129,20 +4136,21 @@ dependencies = [
"pdfcraft-xfdf",
"png 0.18.1",
"thiserror 2.0.21",
+ "tiff",
"unicode-segmentation",
"url",
]
[[package]]
name = "pdfcraft-export"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"flate2",
]
[[package]]
name = "pdfcraft-filters"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"flate2",
"proptest",
@@ -4152,7 +4160,7 @@ dependencies = [
[[package]]
name = "pdfcraft-fonts"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"harfrust",
"pdfcraft-cos",
@@ -4162,7 +4170,7 @@ dependencies = [
[[package]]
name = "pdfcraft-forms"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4174,18 +4182,18 @@ dependencies = [
[[package]]
name = "pdfcraft-geom"
-version = "0.8.0"
+version = "0.9.0"
[[package]]
name = "pdfcraft-js"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"boa_engine",
]
[[package]]
name = "pdfcraft-measure"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4198,14 +4206,14 @@ dependencies = [
[[package]]
name = "pdfcraft-model"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-ocr"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"ocrs",
"pdfcraft-fonts",
@@ -4215,7 +4223,7 @@ dependencies = [
[[package]]
name = "pdfcraft-optimize"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"image",
"pdfcraft-annot",
@@ -4228,11 +4236,11 @@ dependencies = [
[[package]]
name = "pdfcraft-oracle"
-version = "0.8.0"
+version = "0.9.0"
[[package]]
name = "pdfcraft-organize"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4243,7 +4251,7 @@ dependencies = [
[[package]]
name = "pdfcraft-platform"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"async-channel",
"futures-lite",
@@ -4253,16 +4261,18 @@ dependencies = [
[[package]]
name = "pdfcraft-preflight"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
+ "flate2",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-model",
+ "roxmltree",
]
[[package]]
name = "pdfcraft-print"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"craft-print",
"pdfcraft-content",
@@ -4273,7 +4283,7 @@ dependencies = [
[[package]]
name = "pdfcraft-redact"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4289,7 +4299,7 @@ dependencies = [
[[package]]
name = "pdfcraft-render"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"bytemuck",
"flate2",
@@ -4308,7 +4318,7 @@ dependencies = [
[[package]]
name = "pdfcraft-sign"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"aes",
"aws-lc-rs",
@@ -4342,11 +4352,11 @@ dependencies = [
[[package]]
name = "pdfcraft-testkit"
-version = "0.8.0"
+version = "0.9.0"
[[package]]
name = "pdfcraft-ui-egui"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"arboard",
"base64 0.22.1",
@@ -4390,7 +4400,7 @@ dependencies = [
[[package]]
name = "pdfcraft-web"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"eframe",
"egui",
@@ -4402,7 +4412,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfa"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"base64 0.22.1",
"image",
@@ -4416,7 +4426,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfdf"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-cos",
diff --git a/Cargo.toml b/Cargo.toml
index 597dd316fa..ec89098021 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,7 +5,7 @@ exclude = ["vendor"]
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
[workspace.package]
-version = "0.8.0"
+version = "0.9.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -18,40 +18,42 @@ craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0",
craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f58bc1a3df490992d633393d0898af0588ce882c" }
# Shared system spooler; document composition remains in pdfcraft-print.
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "666c358065261a2816b376fa3bb10538dbf53040" }
+# Whether a path stored in a document may be opened (GoToR link targets).
+craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
# internal
-pdfcraft-geom = { path = "crates/geom", version = "0.8.0" }
-pdfcraft-filters = { path = "crates/filters", version = "0.8.0" }
-pdfcraft-cos = { path = "crates/cos", version = "0.8.0" }
-pdfcraft-crypt = { path = "crates/crypt", version = "0.8.0" }
-pdfcraft-organize = { path = "crates/organize", version = "0.8.0" }
-pdfcraft-measure = { path = "crates/measure", version = "0.8.0" }
-pdfcraft-annot = { path = "crates/annot", version = "0.8.0" }
-pdfcraft-audit = { path = "crates/audit", version = "0.8.0" }
-pdfcraft-fonts = { path = "crates/fonts", version = "0.8.0" }
-pdfcraft-model = { path = "crates/model", version = "0.8.0" }
-pdfcraft-content = { path = "crates/content", version = "0.8.0" }
-pdfcraft-redact = { path = "crates/redact", version = "0.8.0" }
-pdfcraft-print = { path = "crates/print", version = "0.8.0" }
-pdfcraft-xfdf = { path = "crates/xfdf", version = "0.8.0" }
-pdfcraft-sign = { path = "crates/sign", version = "0.8.0" }
-pdfcraft-optimize = { path = "crates/optimize", version = "0.8.0" }
-pdfcraft-a11y = { path = "crates/a11y", version = "0.8.0" }
-pdfcraft-oracle = { path = "crates/oracle", version = "0.8.0" }
-pdfcraft-testkit = { path = "crates/testkit", version = "0.8.0" }
-pdfcraft-ocr = { path = "crates/ocr", version = "0.8.0" }
-pdfcraft-js = { path = "crates/js", version = "0.8.0" }
-pdfcraft-compare = { path = "crates/compare", version = "0.8.0" }
-pdfcraft-preflight = { path = "crates/preflight", version = "0.8.0" }
-pdfcraft-export = { path = "crates/export", version = "0.8.0" }
-pdfcraft-edit = { path = "crates/edit", version = "0.8.0" }
-pdfcraft-create = { path = "crates/create", version = "0.8.0" }
-pdfcraft-forms = { path = "crates/forms", version = "0.8.0" }
-pdfcraft-xfa = { path = "crates/xfa", version = "0.8.0" }
-pdfcraft-render = { path = "crates/render", version = "0.8.0" }
-pdfcraft-engine = { path = "crates/engine", version = "0.8.0" }
-pdfcraft-platform = { path = "crates/platform", version = "0.8.0" }
-pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.8.0" }
-pdfcraft-automation = { path = "crates/automation", version = "0.8.0", default-features = false }
+pdfcraft-geom = { path = "crates/geom", version = "0.9.0" }
+pdfcraft-filters = { path = "crates/filters", version = "0.9.0" }
+pdfcraft-cos = { path = "crates/cos", version = "0.9.0" }
+pdfcraft-crypt = { path = "crates/crypt", version = "0.9.0" }
+pdfcraft-organize = { path = "crates/organize", version = "0.9.0" }
+pdfcraft-measure = { path = "crates/measure", version = "0.9.0" }
+pdfcraft-annot = { path = "crates/annot", version = "0.9.0" }
+pdfcraft-audit = { path = "crates/audit", version = "0.9.0" }
+pdfcraft-fonts = { path = "crates/fonts", version = "0.9.0" }
+pdfcraft-model = { path = "crates/model", version = "0.9.0" }
+pdfcraft-content = { path = "crates/content", version = "0.9.0" }
+pdfcraft-redact = { path = "crates/redact", version = "0.9.0" }
+pdfcraft-print = { path = "crates/print", version = "0.9.0" }
+pdfcraft-xfdf = { path = "crates/xfdf", version = "0.9.0" }
+pdfcraft-sign = { path = "crates/sign", version = "0.9.0" }
+pdfcraft-optimize = { path = "crates/optimize", version = "0.9.0" }
+pdfcraft-a11y = { path = "crates/a11y", version = "0.9.0" }
+pdfcraft-oracle = { path = "crates/oracle", version = "0.9.0" }
+pdfcraft-testkit = { path = "crates/testkit", version = "0.9.0" }
+pdfcraft-ocr = { path = "crates/ocr", version = "0.9.0" }
+pdfcraft-js = { path = "crates/js", version = "0.9.0" }
+pdfcraft-compare = { path = "crates/compare", version = "0.9.0" }
+pdfcraft-preflight = { path = "crates/preflight", version = "0.9.0" }
+pdfcraft-export = { path = "crates/export", version = "0.9.0" }
+pdfcraft-edit = { path = "crates/edit", version = "0.9.0" }
+pdfcraft-create = { path = "crates/create", version = "0.9.0" }
+pdfcraft-forms = { path = "crates/forms", version = "0.9.0" }
+pdfcraft-xfa = { path = "crates/xfa", version = "0.9.0" }
+pdfcraft-render = { path = "crates/render", version = "0.9.0" }
+pdfcraft-engine = { path = "crates/engine", version = "0.9.0" }
+pdfcraft-platform = { path = "crates/platform", version = "0.9.0" }
+pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.9.0" }
+pdfcraft-automation = { path = "crates/automation", version = "0.9.0", default-features = false }
# pdf
hayro = "0.7.1"
diff --git a/README.md b/README.md
index bd33ad23be..2a65b66457 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
PdfCraft
- The PDF workbench; an open-source, clean-room reimplementation of Adobe Acrobat, rebuilt in pure Rust.
+ The PDF workbench; an open-source, modern PDF reader and editor, built in pure Rust.
Read, organize, combine, split and secure PDFs in a fast, native app, written in Rust from the ground up.
macOS · Windows · Linux · FreeBSD · the web
@@ -417,16 +417,16 @@ Logs, environment variables and other development notes are in [docs/development
## What's next
-PdfCraft is young and moving fast. The aim is a workbench where you can view, organize, annotate, fill, sign and edit PDFs, at parity with Acrobat Pro.
+PdfCraft is young and moving fast. The aim is a workbench where you can view, organize, annotate, fill, sign and edit PDFs, for professional and everyday workflows.
-**Where it stands (October 2026), honestly:** PdfCraft is **alpha**. Most of Acrobat Pro's offline features exist in some form (57% of all tracked features, 94% of the must-haves). For everyday work (viewing, commenting, filling and signing forms, organizing pages) it is about 55% of the way to replacing Acrobat; against all of Acrobat Pro, about 49%; for someone who only needs the essentials, about 66%. Every everyday workflow can be completed, but converting and sharing is weak: no Office-to-PDF, and Word export that loses layout.
+**Where it stands (October 2026), honestly:** PdfCraft is **alpha**. Most essential offline PDF features exist in some form (57% of the features tracked in [docs/target-app-parity.md](docs/target-app-parity.md), 94% of core essentials). For everyday work (viewing, commenting, filling and signing forms, organizing pages) readiness is about 55%; across all professional workflows, about 49%; for someone who only needs the essentials, about 66%. Every everyday workflow can be completed, but converting and sharing is weak: no office-document-to-PDF, and .docx export that loses layout.
- **Good today:** viewing and search; organizing, combining and splitting; most kinds of comment; filling and authoring forms (with sandboxed JavaScript); passwords, redaction and sanitizing; basic digital signatures; printing; the Accessibility Checker; agent control through the CLI and MCP.
- **Still borrowed:** pages are drawn by the `hayro` crate while our own renderer is built.
-- **Thin or missing:** reliable editing of existing text (especially CJK and right-to-left), OCR beyond Latin script, Office import/export, signature tokens and online long-term validation, Preflight and PDF/X/UA, printing from the web build, physical printer fidelity, and full XFA forms.
-- **Hardening:** fuzzing still turns up crashes and hangs on hostile files; each one is fixed with a regression test. Quality has not yet been compared with Acrobat side by side.
+- **Thin or missing:** reliable editing of existing text (especially CJK and right-to-left), OCR beyond Latin script, office document import/export, signature tokens and online long-term validation, Preflight and PDF/X/UA, printing from the web build, physical printer fidelity, and full XFA forms.
+- **Hardening:** fuzzing still turns up crashes and hangs on hostile files; each one is fixed with a regression test. Quality has not yet been benchmarked side by side.
-**Next, in order:** Office-to-PDF and a Word export that keeps layout, the reported bugs and the Chinese interface font, a fidelity harness against Acrobat and the renderer, editing existing content, then the Pro workflows (OCR, Office, Preflight, signatures, accessibility tagging) and 1.0 polish.
+**Next, in order:** office-document-to-PDF and a .docx export that keeps layout, the reported bugs and the Chinese interface font, a fidelity harness against a reference renderer, editing existing content, then professional workflows (OCR, office formats, Preflight, signatures, accessibility tagging) and 1.0 polish.
The summary is in **[ROADMAP.md](ROADMAP.md)**; the full assessment by dimension and area is in [docs/target-app-parity.md](docs/target-app-parity.md), the ranked gaps in [docs/gaps.md](docs/gaps.md), and the milestones in [docs/roadmap.md](docs/roadmap.md).
@@ -581,8 +581,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
part of this repository and PdfCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
-Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. PdfCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.
-
Made by the ArtCraft team and community.
diff --git a/ROADMAP.md b/ROADMAP.md
index c9958f6876..99f3e068ef 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,6 +2,19 @@
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
+> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
+> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged)
+> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged)
+> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M4, #1919: repeated page indices accumulate rotations; M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged)
+> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged)
+> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M4 #2997: Ctrl-drag (Option on macOS) in the Organize grid copies the pages into the drop gap; `page_duplicate` takes `at`; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
+> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged)
+> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M2/gap 12: the Annex D.2 alias codes read as the ink they draw in both tables; the text oracle scores reading order; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
+> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
+> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M4: indirect page rotations; M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
+> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M6: preserve XML/XFDF attribute whitespace; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
+> **Last reviewed:** 2026-10-16 | **Last updated:** 2026-10-16 | **Change:** minor (M10 #1201 partial: lossless image Decode; estimates unchanged) | **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M4 #1910: Organize Pages opens on the current page; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -26,6 +39,7 @@
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (#594 paragraph drags validated in the Linux test environment; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced: #774, #756 and the #799–#821 batch; earlier: localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
+> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M6 #2892: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged)
PdfCraft's one-page summary: how close it is to Adobe Acrobat Pro's offline feature set, and what
comes next. Adobe's cloud services (Document Cloud, Acrobat Sign requests, shared review, the AI
@@ -156,7 +170,37 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
## Progress log
+
+- **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged.
+
+- **2026-10-18 (M6, #2892):** Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged.
+
+- **2026-10-18 (M7, #3212):** Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, with original content preserved.
+- **2026-10-15 (M7, Edit text caret):** In a text region that mixes sizes (a 24 pt title over 12 pt lines), the inline editor's caret on a small row was as tall as the region's first size and reached into the row below: egui draws the caret at least one row of the text field's font tall, and that font had the paragraph's first size. The field's font is now half the region's smallest size, so each row's own height decides. Kittest regression; checked in the app on a real document. Estimates unchanged.
+- **2026-10-16 (M3, #2781):** The Pages panel follows the current page, as Acrobat's page thumbnails do: going to a page whose thumbnail is out of sight (scrolling, Page Down, Go to page, a bookmark) scrolls the panel just enough to show it. A thumbnail already in view, or clicked, leaves the panel where it is, and a page change while the user scrolls the panel or drags thumbnails does not move it. Estimates unchanged.
+- **2026-10-16 (M12, #1645):** CLI info, text and render use the engine's dynamic XFA layout rather than placeholder pages. Synthetic two-page and encrypted-form regressions cover counts, text, pixels, copy permissions and unchanged input bytes. Estimates unchanged.
+
+- **2026-10-18 (M4, PR #1919 review):** repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged.
+- **2026-10-15 (M4, #1919 refresh):** Integrated upstream `99941daa` while retaining the indirect page rotation correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged.
+- **2026-10-18 (M3, #3149):** Page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged.
+- **2026-10-19 (M3, #3360):** Switching between Read and the docking workspace retains the current document’s scroll position at fixed zoom, including within-page position; separate tabs retain their own offsets. Estimates unchanged.
+- **2026-10-18 (M8, #1350):** Integrated current main while preserving content-stream comment boundaries. Hidden-text cleanup retires unused named replacement descriptions on rewritten pages and forms, including inherited/shared resource holders, without removing descriptions still used by surviving content. Synthetic saved-byte and preservation regressions cover the cleanup. Estimates unchanged.
+- **2026-10-17 (M4, #2997):** Dragging page thumbnails in the Organize grid with Ctrl held (Option on macOS) drops copies at the gap instead of moving the pages: a "+" badge sits by the pointer, the copies land at the gap selected, and one undo step ("Duplicate pages") takes them out again. `page_duplicate` takes `at` (1-based, where the first copy goes; left out, after the last of them) so agents can drive the same thing, and the UI control channel's `ui.drag` modifiers now reach gestures that read the input state. Estimates unchanged.
+- **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged.
+- **2026-10-15 (M6, #1917 refresh):** Integrated upstream `99941daa` while retaining the XML/XFDF attribute whitespace correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged.
+- **2026-10-15 (M1, #2267 refresh):** Integrated upstream `99941daa` while retaining the raw-deflate fallback correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged.
+- **2026-10-16 (M10, #1201 partial):** Lossless image export applies each component's Decode pair. Indexed values round and clip before palette lookup. Color-key masks keep raw samples. The existing 16-bit precision limit remains. JPEG and mask Decode, ICC transforms, and calibrated colors remain outside this fix. Estimates unchanged.
+- **2026-10-16 (M3, #2739):** Wrapped revision buttons stay above Binding in Document Properties. A synthetic regression checks control bounds and opens a saved revision at two viewport widths. Estimates unchanged.
+- **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged.
+- **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged.
+- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of the public ISCIII form, with its button, is page 7 of 7). Acrobat Pro shows the form as 7 pages with that page last, as here; when printing, Acrobat keeps that page but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet. Synthetic regression tests. Estimates unchanged.
+- **2026-10-15 (M8, #2447):** Removing links or comments (Remove Hidden Information, Sanitize) also drops the tagged-PDF structure tree's object references to them, so the full rewrite no longer keeps a removed link's URL or a comment's text. Regression seen to fail without the fix. Estimates unchanged.
+- **2026-10-15 (M6, CSV tab-containing headers):** CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Synthetic export/import and incremental/full save regressions cover header tabs, quotes and line breaks, plus ordinary TSV controls. Formula-cell policy is unchanged. Estimates unchanged.
+- **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged.
+- **2026-10-13 (M6, data exchange fidelity):** XML and XFDF exports preserve tabs and line breaks in field names and comment metadata using character references. Saved-PDF regressions cover distinct names that XML would normalize to the same attribute value, restored field values and replacement of the original comment identities. Estimates unchanged.
+- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. Synthetic regression test; the page area's `occur min` (a once-only page of its own) is still not laid out, recorded in `form.xfa-dynamic`. Estimates unchanged.
- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
+- **2026-10-15 (M14, #2383 candidate):** Device-limit requests now clamp unsupported maxima and minimum alignments before enabling the adapter's full 2D texture extent. This addresses the reported V3D request of 16 shader variables on an adapter supporting 15. Synthetic regressions fail before the clamp and pass afterward, along with the existing HiDPI case and Windows GPU-required application tests. Raspberry Pi startup remains unverified; milestone and overall estimates are unchanged.
- **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged.
- **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor).
- **2026-10-12 (M2, inline-image boundaries):** Unfiltered inline images with known sample layouts keep pixel bytes that look like the `EI` terminator when content is rewritten. Regression tests cover row padding, colour-space and parameter aliases, masks and malformed sizes; classic and packed saved PDFs retain the pixels in independent readers and renderers. Filtered and resource-defined layouts retain the existing tolerant heuristic. Estimates unchanged.
@@ -166,6 +210,9 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design.
- **2026-10-12 (M10, Edit on a scanned page):** Turning Edit on (its tab, Edit text & images or Add text) for a scanned page (an image covering at least half of a page with no text of its own; comments and text boxes over it do not count) recognizes the page first, like Acrobat's "Converting scanned page to Editable Text and Images", so its words open for editing. Once per page per window, one undoable "Recognize text" step, with the language of the Recognize Text dialog (English only for now, gap 7). Pages with real text (a flyer with a background picture), read-only files and other tools are left alone. The photographs on a scan are still part of the one page image.
+- **2026-10-13 (M3, typed zoom):** The right rail's zoom percentage is an editable field: type 150, 150% or 62.5 % and press Enter, Escape discards, and nonsense keeps the current zoom. The control channel's zoom option shares its parser, which now refuses zero, negative and non-finite values.
+- **2026-10-13 (M4, indirect page rotation):** Rotation queries and edits resolve indirect integer values on pages and their page-tree ancestors. Turning a page already at 270° by 90° now saves it at 0°, including when its rotation is inherited. Synthetic full/incremental save-and-reopen tests cover nested inheritance, sibling preservation, negative/large angles and invalid-value fallback. Estimates unchanged.
+- **2026-10-14 (M1, Flate fallback):** Raw deflate whose first bytes resemble a zlib header no longer loses a decoding-limit error during fallback. Strict and tolerant filter regressions and a COS stream regression cover refusal at the caller limit and successful decoding within it. No parity status or estimate changes.
- **2026-10-12 (M6, XML field names):** AcroForm XML data export constrains generated element names to XML 1.0 NCNames, so Latin-1 names such as `Area²` and `Aµ` produce readable data files. Original PDF field names travel through the existing `xfdf:original` attribute; previously valid tags, including Greek and CJK, keep their spelling. Saved-PDF regressions cover invalid prefixes, combining letters and normalized-name collisions. Estimates unchanged.
- **2026-10-12 (M6, CSV carriage returns):** Form-data CSV export quotes carriage returns in field names and values, so a trailing carriage return cannot turn into a record separator and disappear on reimport. Saved-PDF round-trip regressions cover CR, LF, CRLF, commas and quotes; estimates unchanged.
- **2026-10-13 (community, M14, #1553 review):** Relative Fontconfig includes resolve beside each including config, including nested configuration selected through `FONTCONFIG_PATH`. Directory includes accept only digit-prefixed `*.conf` in lexical order. Isolated regressions reproduce both review findings; configuration size/count/depth limits, cycles, symlinked config and valid reset ordering are covered. Estimates unchanged.
@@ -174,6 +221,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-12 (M14, #1395):** XDG desktops use the configuration base for app.ron, safely copying existing preferences without overwriting a destination. Original preferences, logs, recovery and digital IDs are retained; failed migration falls back to the original location. Portable and non-XDG locations remain unchanged; estimates unchanged.
- **2026-10-12 (M10, Windows print progress, follow-up to #944):** While a job spools, the progress card counts its sheets ("Sending sheet 2 of 3 to Office…") as craft-print reports them (`submit_with_progress`, storytold/craft-libs#27), and steps aside for another long job's card. Pins craft-libs 666c358. Estimates unchanged.
+- **2026-10-15 (M2, renderer speed):** Three vendored hayro-interpret patches (31–33), pixel for pixel the same over the first three pages of every pdf.js corpus file. A sampled (Type 0) function keeps its samples in one flat array instead of a hash map rebuilt every time a shading using it is painted: `bug1721218` (InDesign) draws in 0.49 s (was 1.34 s), and a 2,097,152-sample ramp painted 30 times in under a second (was 38 s). One decode of a JPEG or Flate image serves every size it is drawn at (only JPX decodes for a size), so zooming a 300-dpi scan in and out costs 35–40% less render work. Coons and tensor patch meshes copy their vertices' colours less: mesh-gradient pages draw 10–16% faster (page 8 of #1584's annual report 773 → 696 ms). Estimates unchanged.
- 2026-10-12 · M3: selected page text can be copied from Edit and the canvas context menu (#1225). Five focused regressions, full CI and native Unicode clipboard checks pass; floating Copy shares the document permission gate. Estimates unchanged.
- **2026-10-13 (M2, page-label prefixes):** Preserve literal leading and trailing whitespace in page-label prefixes without changing generic metadata normalization. Nine original public-inspection cases cover prefix spaces, encodings and empty-prefix controls. Existing empty-prefix fallback remains a compatibility policy, not a conformance claim. Estimates unchanged.
@@ -284,6 +332,7 @@ Newest first: the date, what moved, and the new overall estimate.
- **2026-10-11 (M5, partial comment imports):** Replacing an editable named comment through XFDF or FDF (including an indirect name in the source PDF) preserves its object identity, so existing replies and review statuses remain attached after saving, reopening and exporting again. Replaced pop-ups are still removed. Locked comments, redaction marks and their pop-ups stay unchanged, including when their names are indirect; imported redactions remain visible. Synthetic round-trip tests cover both formats, incremental and full saves; milestone and effort estimates unchanged.
+- **2026-10-15 (M9, #1895):** Preserve original signature artwork and show validation through a separate page overlay. Generic in-place appearance rewriting is deferred: neither a caption substring nor a unique drawing establishes an icon role. Signed regressions cover chamfered borders, mixed signer/caption text, image/text exports, both print modes and saves. Snapshot reuse and trust/cache refresh remain. Baked Not Verified captions are not replaced; #79 remains open. Estimates unchanged.
- **2026-10-10 (M9, signature graphics):** A visible digital signature can show a graphic where the signer's name is large, as Acrobat's Configure Signature Appearance does: the saved Fill & Sign signature (drawn strokes smoothed as ink, typed outlines in the script font, or an image) or an imported PNG/JPEG. Sign as previews it and remembers the choice for the next signing; `sign_document` takes `graphic` (`image`, `text` or `strokes`). Graphics are checked and capped before drawing (non-finite or unbounded points, mismatched pixel buffers, oversized images are refused with an error the caller can act on); images keep their alpha as a soft mask. `sign.signature-appearance` stays partial (no named saved appearances or PDF-page graphics). Overall estimates unchanged.
- **2026-10-11 (M14, absolute interface sizes):** A chosen Interface size is now absolute, as PhotoCraft's UI Scale is: "100%" is one screen pixel per point whatever scale the display reports, so on a 1080p screen whose system reports 200 % it draws at a true 100 % (#456, reported after the first change). Auto still keeps the display's scale and adds GNOME's text scaling, and its menu entry shows the size it draws at. The menu offers PhotoCraft's sizes (75–300 %).
- **2026-10-11 (automation, #971):** `text_find` reads pages in growing batches (8, doubling to 128) and stops once `limit` is reached, so a bounded search on a long document no longer extracts and caches every page first. Results say `complete`, and a capped one gives `next` (`start_page`, `skip`) to continue. Still open from #971: a byte budget for the automation text cache and per-page warnings for pages whose text can't be extracted.
@@ -489,8 +538,20 @@ Newest first: the date, what moved, and the new overall estimate.
## Revision history
+**2026-10-15 · minor:** M14/#2383 candidate: GPU device requests clamp unsupported limits while retaining the adapter texture extent. Synthetic V3D/downlevel regressions and Windows GPU-required tests pass; Raspberry Pi hardware remains unverified. Estimates unchanged.
+
| Date | Change | Summary |
|---|---|---|
+| 2026-10-15 | minor | M7: the Edit text caret is as tall as the row it is on in a region with several sizes; estimates unchanged. |
+| 2026-10-18 | minor | M4, PR #1919 review: repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged. |
+| 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. |
+| 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. |
+| 2026-10-13 | minor | M6: XML/XFDF attribute whitespace survives form and comment export/import; estimates unchanged |
+| 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. |
+| 2026-10-13 | minor | M9: separate page validation overlays preserve signature artwork, and snapshot tabs reuse instead of multiplying; baked captions remain unchanged (#79 open); estimates unchanged |
+| 2026-10-16 | minor | M10 #1201 partial: lossless image export applies component Decode pairs and Indexed mapping. Estimates unchanged. |
+| 2026-10-15 | minor | M6: CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Estimates unchanged. |
+| 2026-10-13 | minor | M4: rotation queries and edits resolve indirect page-tree values; inherited, negative and large rotations verified through both saves. Estimates unchanged. |
| 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. |
| 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. |
| 2026-10-13 | minor | M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. Estimates unchanged. |
@@ -511,7 +572,10 @@ Newest first: the date, what moved, and the new overall estimate.
| 2026-10-10 | major | Restructured to the progress-docs standard (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; assessment, gaps and milestones moved to `docs/`; log sorted newest first and a stray merged entry dated |
| 2026-10-07 | minor | 806 tracked features, 51.0% shipped, effort-weighted ≈ 30–35%, 600–1,100 h |
| 2026-10-05 | major | Honest assessment and "Where we're lacking" added |
+| 2026-10-12 | minor | M8: cleanup removes replacement descriptions only from changed marked-content spans, preserving surviving accessibility text. Estimates unchanged. |
| 2026-10-12 | minor | M8: image redaction removes alternate images and unsupported masks only on affected images. Estimates unchanged. |
- **2026-10-12 (M8):** Redaction cleans indirect and form structure references, preserving surviving form tags.
- **2026-10-13 (M7):** Edit a PDF ▸ Add content: Header and footer, Watermark and Background open an Add / Update / Remove menu (Update and Remove enabled only when the document has that mark), as Acrobat's Watermark control does. The catalogue gains `ToolItem::actions`, so any tool can list more commands (#1192).
+
+- **2026-10-18 (M8, #1350):** Published the history-preserving integration; simplified the inherited-form resource guard for Rust 1.96 Clippy. Formatting, 64 redaction tests, scoped Clippy, layering, assets and the scoped wasm check pass locally. CI remains pending; estimates unchanged.
diff --git a/apps/pdfcraft-cli/Cargo.toml b/apps/pdfcraft-cli/Cargo.toml
index d7b52fbd66..bab8c28a88 100644
--- a/apps/pdfcraft-cli/Cargo.toml
+++ b/apps/pdfcraft-cli/Cargo.toml
@@ -18,6 +18,7 @@ serde_json = { workspace = true }
pdfcraft-engine = { workspace = true }
pdfcraft-audit = { workspace = true }
pdfcraft-cos = { workspace = true }
+pdfcraft-preflight = { workspace = true }
pdfcraft-automation = { workspace = true }
pdfcraft-platform = { workspace = true }
base64 = "0.22"
diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs
index 0e24800d39..5888e51114 100644
--- a/apps/pdfcraft-cli/src/main.rs
+++ b/apps/pdfcraft-cli/src/main.rs
@@ -57,6 +57,7 @@ macro_rules! mcp_usage {
const USAGE: &str = concat!(
"\
pdfcraft-cli info [--password PW] document summary as JSON
+ (includes any declared PDF/A or PDF/UA level)
pdfcraft-cli render --page N [--dpi 96] --out x.png (.png, .jpg, .tif or .pam)
pdfcraft-cli text [--page N] extracted text (pages separated by form feeds)
pdfcraft-cli edit --out out.pdf [--rotate 1,3:90] [--delete 2,4] [--move 5:1]
@@ -298,29 +299,45 @@ fn info(args: &[String]) -> Result<(), CliError> {
let path = *positional(args).first().ok_or("info: missing file")?;
let bytes = read(path)?;
let password = flag(args, "--password");
- let info = inspect(bytes.clone(), password).map_err(|e| e.to_string())?;
+ let mut info = inspect(bytes.clone(), password).map_err(|e| e.to_string())?;
+ let pdf_pages = info.pages.len();
+ let acroform_fields = info.fields.len();
// Dynamic XFA forms: say what laying the template out gives (what the app shows).
let xfa_layout = (info.xfa == Some(pdfcraft_render::Xfa::Dynamic))
.then(|| {
let mut s = pdfcraft_engine::Session::new();
let id = s.open("info.pdf", None, bytes.clone(), password).ok()?;
let d = s.get(id)?;
- d.xfa.as_ref().map(|x| serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings }))
+ d.xfa.as_ref().map(|x| {
+ info = d.info.clone();
+ // The in-memory layout is an appended revision, not the file being inspected.
+ info.file_size = bytes.len();
+ serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })
+ })
})
.flatten();
+ // What the document declares as a standard, read the way the Standards panel reads it, so
+ // `info` and the app agree. A file that declares nothing leaves these null and empty.
+ let declared =
+ pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map(|doc| pdfcraft_preflight::declared(&doc)).unwrap_or_default();
let json = serde_json::json!({
"file": path,
"pdf_version": info.pdf_version,
"file_size": info.file_size,
"pages": info.pages.len(),
+ "pdf_pages": pdf_pages, "acroform_fields": acroform_fields,
"first_page_pt": info.pages.first().map(|p| [p.width, p.height]),
"title": info.title, "author": info.author, "producer": info.producer, "creator": info.creator,
"encrypted": info.encrypted, "tagged": info.tagged, "javascript": info.has_javascript,
+ "pdfa": declared.pdfa.as_ref().map(|(part, conformance)| format!("PDF/A-{part}{}", conformance.to_lowercase())),
+ "pdfua": declared.pdfua,
+ "output_intents": declared.output_intents,
"xfa": info.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }),
"xfa_layout": xfa_layout,
"bookmarks": info.outline.len(), "annotations": info.annotations.len(), "fields": info.fields.len(),
"links": info.links.len(), "layers": info.layers.len(), "attachments": info.attachments.len(),
"transitions": info.transitions.len(),
+ "custom_properties": info.custom.len(),
"page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::>(),
"warnings": info.warnings,
});
@@ -348,7 +365,19 @@ fn text(args: &[String]) -> Result<(), CliError> {
}
}
let password = flag(args, "--password");
- let bytes = read(path)?;
+ let mut bytes = read(path)?;
+ // Printing the text is copying it, so ask what `text_extract` asks (#1645): without the
+ // permissions password, a document that doesn't allow copying gives no text.
+ let mut session = pdfcraft_engine::Session::new();
+ if let Some(doc) = session.open(path, None, bytes.clone(), password).ok().and_then(|id| session.get(id)) {
+ // The engine's hint is about editing in the app; on the command line the way in is
+ // `--password`.
+ doc.check_copying()
+ .map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?;
+ if doc.xfa.is_some() {
+ bytes = doc.display.clone();
+ }
+ }
let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: password.map(Arc::from), ..Default::default() });
// A document the renderer could not open (wrong or missing password, unparseable file) has
// no pages, and "extract every page" of nothing would print nothing and exit 0 (#132). Ask
@@ -359,15 +388,6 @@ fn text(args: &[String]) -> Result<(), CliError> {
return Err("text: the document could not be parsed".into());
}
}
- // Printing the text is copying it, so ask what `text_extract` asks (#1645): without the
- // permissions password, a document that doesn't allow copying gives no text.
- let mut session = pdfcraft_engine::Session::new();
- if let Some(doc) = session.open(path, None, bytes, password).ok().and_then(|id| session.get(id)) {
- // The engine's hint is about editing in the app; on the command line the way in is
- // `--password`.
- doc.check_copying()
- .map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?;
- }
let pages: Vec = match selected_page {
Some(page) => vec![page],
None => (0..r.page_count()).collect(),
@@ -588,16 +608,22 @@ fn render(args: &[String]) -> Result<(), CliError> {
// The app renders a display copy that draws comments missing their /AP streams; the CLI
// must agree with it (and with independent viewers), while keeping the file it reads intact.
let bytes = read(path)?;
- let mut r = PageRenderer::new(
- pdfcraft_engine::display_bytes_for(bytes.clone(), flag(args, "--password")),
- RenderConfig { password: flag(args, "--password").map(Arc::from), reject_oversize: true, ..Default::default() },
- );
+ let password = flag(args, "--password");
+ let display = if inspect(bytes.clone(), password).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) {
+ let mut session = pdfcraft_engine::Session::new();
+ let id = session.open(path, None, bytes, password).map_err(|e| e.to_string())?;
+ session.get(id).ok_or("render: the document could not be opened")?.display.clone()
+ } else {
+ pdfcraft_engine::display_bytes_for(bytes, password)
+ };
+ let mut r = PageRenderer::new(display, RenderConfig { password: password.map(Arc::from), reject_oversize: true, ..Default::default() });
let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false });
if let Some(e) = p.error {
return Err(e.into());
}
let bytes = match format {
- Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f)?,
+ // With `reject_oversize` the page is drawn at `--dpi` or not at all, so the file can say so.
+ Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f, f64::from(dpi))?,
None => {
// PAM (netpbm RGB_ALPHA): the raw premultiplied pixels, for tools that read it.
let mut pam = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", p.width, p.height).into_bytes();
diff --git a/apps/pdfcraft-cli/tests/info_standards.rs b/apps/pdfcraft-cli/tests/info_standards.rs
new file mode 100644
index 0000000000..68be86228c
--- /dev/null
+++ b/apps/pdfcraft-cli/tests/info_standards.rs
@@ -0,0 +1,87 @@
+//! `info` reports the standard a document declares, as the Standards panel does (#3319). A file
+//! carrying `pdfaid:part` and `pdfaid:conformance` in its XMP metadata was summarised with no
+//! mention of PDF/A at all, though the app reads the same declaration and shows it.
+
+use std::process::Command;
+
+const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
+
+/// A one-page PDF, with `metadata` as the catalog's XMP stream when given.
+fn fixture(metadata: Option<&str>) -> Vec {
+ let content = b"0 0 0 rg 10 10 40 20 re f";
+ let catalog = match metadata {
+ Some(_) => "<< /Type /Catalog /Pages 2 0 R /Metadata 5 0 R >>".to_string(),
+ None => "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
+ };
+ let mut objs: Vec> = vec![
+ catalog.into_bytes(),
+ b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
+ b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 60] /Contents 4 0 R >>".to_vec(),
+ format!("<< /Length {} >> stream\n", content.len()).into_bytes(),
+ ];
+ objs[3].extend_from_slice(content);
+ objs[3].extend_from_slice(b"\nendstream");
+ if let Some(xmp) = metadata {
+ let mut stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >> stream\n", xmp.len()).into_bytes();
+ stream.extend_from_slice(xmp.as_bytes());
+ stream.extend_from_slice(b"\nendstream");
+ objs.push(stream);
+ }
+ let mut out = b"%PDF-1.7\n".to_vec();
+ let mut offsets = Vec::new();
+ for (i, o) in objs.iter().enumerate() {
+ offsets.push(out.len());
+ out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
+ out.extend_from_slice(o);
+ out.extend_from_slice(b"\nendobj\n");
+ }
+ let xref = out.len();
+ out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
+ for off in &offsets {
+ out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
+ }
+ out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
+ out
+}
+
+fn xmp(part: &str, conformance: &str) -> String {
+ format!(
+ "\n\
+\n \n\
+ \n\
+ {part}\n {conformance}\n\
+ \n \n\n"
+ )
+}
+
+fn info_of(bytes: &[u8], name: &str) -> serde_json::Value {
+ let dir = std::env::temp_dir().join(format!("pdfcraft-info-standards-{}-{name}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("f.pdf");
+ std::fs::write(&path, bytes).unwrap();
+ let out = Command::new(BIN).args(["info", path.to_str().unwrap()]).output().unwrap();
+ assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
+ let json = serde_json::from_slice(&out.stdout).expect("info prints JSON");
+ let _ = std::fs::remove_dir_all(&dir);
+ json
+}
+
+#[test]
+fn info_names_the_declared_pdfa_level() {
+ let declared = info_of(&fixture(Some(&xmp("2", "B"))), "pdfa2b");
+ assert_eq!(declared["pdfa"], serde_json::json!("PDF/A-2b"), "{declared}");
+ assert_eq!(declared["pdfua"], serde_json::Value::Null);
+ assert_eq!(declared["output_intents"], serde_json::json!([]));
+ // Lower-case conformance in the file still reads as the level's own spelling.
+ assert_eq!(info_of(&fixture(Some(&xmp("3", "a"))), "pdfa3a")["pdfa"], serde_json::json!("PDF/A-3a"));
+}
+
+#[test]
+fn info_declares_nothing_for_a_plain_pdf() {
+ let plain = info_of(&fixture(None), "plain");
+ assert_eq!(plain["pdfa"], serde_json::Value::Null, "{plain}");
+ assert_eq!(plain["pdfua"], serde_json::Value::Null);
+ // The rest of the summary is unaffected.
+ assert_eq!(plain["pages"], serde_json::json!(1));
+ assert_eq!(plain["pdf_version"], serde_json::json!("1.7"));
+}
diff --git a/apps/pdfcraft-cli/tests/render.rs b/apps/pdfcraft-cli/tests/render.rs
index b0ab245601..695431b65b 100644
--- a/apps/pdfcraft-cli/tests/render.rs
+++ b/apps/pdfcraft-cli/tests/render.rs
@@ -56,6 +56,17 @@ fn jpeg_and_tiff_names_get_those_formats() {
assert!(bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*"), "not a TIFF: {:?}", &bytes[..4]);
}
+#[test]
+fn the_file_records_the_dpi_it_was_rendered_at() {
+ // The engine's tests check each format; this checks that `--dpi` reaches the file.
+ let pdf = fixture("dpi");
+ let jpg = tmp("dpi", "p1.jpg");
+ assert!(render(&pdf, &jpg).status.success());
+ let bytes = std::fs::read(&jpg).unwrap();
+ assert_eq!(&bytes[6..11], b"JFIF\0");
+ assert_eq!(bytes[13..18], [1, 0, 36, 0, 36], "JFIF density in dots per inch");
+}
+
#[test]
fn a_pam_name_still_gets_the_netpbm_stream() {
let pdf = fixture("pam");
diff --git a/apps/pdfcraft-cli/tests/xfa.rs b/apps/pdfcraft-cli/tests/xfa.rs
new file mode 100644
index 0000000000..1b5ff7bc9d
--- /dev/null
+++ b/apps/pdfcraft-cli/tests/xfa.rs
@@ -0,0 +1,136 @@
+//! CLI page commands must use the same dynamic XFA layout as the app and tools (#1645).
+
+use std::path::PathBuf;
+use std::process::{Command, Output};
+use std::sync::Arc;
+
+const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
+
+fn fixture(test: &str) -> PathBuf {
+ let xdp = r#"
+
+
+
+
+
+
+ First XFA page
+ Ada
+
+ Second XFA page
+
+"#;
+ let objects = [
+ "<< /Type /Catalog /Pages 2 0 R /NeedsRendering true /AcroForm << /XFA 5 0 R /Fields [] >> >>".to_string(),
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
+ "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>".into(),
+ "<< /Length 0 >>\nstream\n\nendstream".into(),
+ format!("<< /Length {} >>\nstream\n{xdp}\nendstream", xdp.len()),
+ ];
+ let mut bytes = b"%PDF-1.7\n".to_vec();
+ let mut offsets = Vec::new();
+ for (i, object) in objects.iter().enumerate() {
+ offsets.push(bytes.len());
+ bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
+ }
+ let xref = bytes.len();
+ bytes.extend_from_slice(b"xref\n0 6\n0000000000 65535 f \n");
+ for offset in offsets {
+ bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
+ }
+ bytes.extend_from_slice(format!("trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
+ let dir = std::env::temp_dir().join(format!("pdfcraft-cli-xfa-{test}-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("form.pdf");
+ std::fs::write(&path, bytes).unwrap();
+ path
+}
+
+fn success(output: &Output) {
+ assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
+}
+
+#[test]
+fn info_reports_the_laid_out_pages_and_fields() {
+ let path = fixture("info");
+ let original = std::fs::read(&path).unwrap();
+ let output = Command::new(BIN).arg("info").arg(&path).output().unwrap();
+ success(&output);
+ let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
+ assert_eq!(info["xfa_layout"]["pages"], 2, "the synthetic form lays out into two pages");
+ assert_eq!(info["pages"], 2, "top-level page count must agree with the app");
+ assert_eq!(info["fields"], 1);
+ assert_eq!(info["pdf_pages"], 1);
+ assert_eq!(info["acroform_fields"], 0);
+ assert_eq!(info["first_page_pt"], serde_json::json!([200.0, 300.0]));
+ assert_eq!(info["file_size"], original.len());
+ assert_eq!(std::fs::read(path).unwrap(), original, "inspection must not write the layout to the input");
+}
+
+#[test]
+fn text_reads_the_layout_including_a_page_absent_from_the_placeholder() {
+ let path = fixture("text");
+ let output = Command::new(BIN).arg("text").arg(&path).output().unwrap();
+ success(&output);
+ let text = String::from_utf8(output.stdout).unwrap();
+ assert!(text.contains("First XFA page") && text.contains("Second XFA page"), "{text:?}");
+ let output = Command::new(BIN).arg("text").arg(&path).args(["--page", "2"]).output().unwrap();
+ success(&output);
+ let text = String::from_utf8(output.stdout).unwrap();
+ assert!(text.contains("Second XFA page") && !text.contains("First XFA page"), "{text:?}");
+}
+
+#[test]
+fn render_matches_the_engine_layout_on_the_second_page() {
+ use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
+ let path = fixture("render");
+ let original = std::fs::read(&path).unwrap();
+ let mut session = pdfcraft_engine::Session::new();
+ let id = session.open("form.pdf", None, Arc::new(original.clone()), None).unwrap();
+ let doc = session.get(id).unwrap();
+ assert_eq!(doc.info.pages.len(), 2);
+ assert!(doc.text_lines(1).iter().any(|line| line.text.contains("Second XFA page")));
+ let mut renderer = PageRenderer::new(doc.display.clone(), RenderConfig::default());
+ let expected = renderer.render(RenderRequest { page: 1, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
+ assert!(expected.error.is_none(), "{:?}", expected.error);
+ assert!(expected.rgba.as_chunks::<4>().0.iter().filter(|[r, _, _, a]| *r < 128 && *a > 0).count() > 10, "the layout has visible ink");
+ let out = path.with_extension("pam");
+ let output = Command::new(BIN).arg("render").arg(&path).args(["--page", "2", "--dpi", "72", "--out"]).arg(&out).output().unwrap();
+ success(&output);
+ let actual = std::fs::read(out).unwrap();
+ let header = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", expected.width, expected.height);
+ assert!(actual.starts_with(header.as_bytes()));
+ assert_eq!(&actual[header.len()..], &expected.rgba[..]);
+ assert_eq!(std::fs::read(path).unwrap(), original);
+}
+
+#[test]
+fn a_laid_out_form_keeps_the_original_copy_permissions() {
+ let path = fixture("permissions");
+ let mut doc = pdfcraft_cos::Document::open(Arc::new(std::fs::read(&path).unwrap())).unwrap();
+ doc.set_encryption(&pdfcraft_cos::NewEncryption {
+ algorithm: pdfcraft_cos::Algorithm::Aes256,
+ user_password: "openme",
+ owner_password: "boss",
+ permissions: -17,
+ encrypt_metadata: true,
+ seed: [17; 32],
+ })
+ .unwrap();
+ let original = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap();
+ std::fs::write(&path, &original).unwrap();
+ let output = Command::new(BIN).arg("text").arg(&path).args(["--password", "openme"]).output().unwrap();
+ assert!(!output.status.success());
+ assert!(output.stdout.is_empty());
+ assert!(String::from_utf8_lossy(&output.stderr).contains("don't allow copying"));
+ let output = Command::new(BIN).arg("text").arg(&path).args(["--password", "boss", "--page", "2"]).output().unwrap();
+ success(&output);
+ assert!(String::from_utf8_lossy(&output.stdout).contains("Second XFA page"));
+ let output = Command::new(BIN).arg("info").arg(&path).args(["--password", "openme"]).output().unwrap();
+ success(&output);
+ let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
+ assert_eq!(info["pages"], 2);
+ assert_eq!(info["encrypted"], true);
+ assert_eq!(info["file_size"], original.len());
+ assert_eq!(std::fs::read(path).unwrap(), original);
+}
diff --git a/apps/pdfcraft-web/src/main.rs b/apps/pdfcraft-web/src/main.rs
index a46a4674b9..73d14f4a6c 100644
--- a/apps/pdfcraft-web/src/main.rs
+++ b/apps/pdfcraft-web/src/main.rs
@@ -70,6 +70,10 @@ fn main() {
app.host_save = Some(bridge.save_fn());
app.host_dirty = Some(bridge.dirty_fn());
bridge.post_ready();
+ } else if query_param("host").as_deref() == Some("parent") {
+ eframe::web_sys::console::warn_1(
+ &"PdfCraft: ?host=parent needs a framing page on the same origin; saves download instead".into(),
+ );
}
Ok(Box::new(app))
}),
@@ -130,7 +134,8 @@ async fn fetch_bytes(url: &str) -> Result, String> {
/// - `pdfcraft:dirty` (to the parent): `{ dirty }` whenever unsaved work appears or goes away.
/// - `pdfcraft:failed` (to the parent): `{ error }` when the app couldn't start.
///
-/// Only the parent window on the app's own origin is heard or answered.
+/// Only the parent window on the app's own origin is heard or answered. With a parent on
+/// another origin there is no bridge: the app runs as if unframed and saves download.
#[cfg(target_arch = "wasm32")]
mod host {
use eframe::wasm_bindgen::{JsCast, JsValue, closure::Closure};
@@ -143,7 +148,9 @@ mod host {
}
impl Bridge {
- /// `None` when the page isn't framed (there is no parent to talk to).
+ /// `None` when the page isn't framed (there is no parent to talk to), or when the parent
+ /// is on another origin: messages only go to this origin, so the browser would drop
+ /// every save without an error while the document showed as saved (#2167).
pub fn new() -> Option {
let window = web_sys::window()?;
let parent = window.parent().ok().flatten()?;
@@ -151,6 +158,10 @@ mod host {
return None;
}
let origin = window.location().origin().ok()?;
+ // Reading another origin's location throws, which is `None` too.
+ if parent.location().origin().ok()? != origin {
+ return None;
+ }
Some(Self { window, parent, origin })
}
diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml
index a27401c700..3ce1c96ef3 100644
--- a/apps/pdfcraft/Cargo.toml
+++ b/apps/pdfcraft/Cargo.toml
@@ -54,8 +54,9 @@ winit = { version = "0.30", default-features = false, features = ["wayland"] }
[target.'cfg(target_os = "windows")'.dependencies]
# Which GPU drives which display (EnumDisplayDevices), so the window is drawn on the GPU that
-# shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it).
-winsafe = { version = "0.0.29", default-features = false, features = ["user"] }
+# shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it),
+# and `advapi` for the session id that names the single-instance socket (#3338).
+winsafe = { version = "0.0.29", default-features = false, features = ["user", "advapi"] }
# Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects (src/motion.rs), through WinRT's
# `UISettings`, whose bindings are safe to call (winsafe's `SystemParametersInfo` is an unsafe
# fn). Already in the tree through wgpu's DX12 backend; this adds one small namespace to it.
diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs
index 7a0bb20432..721b7da1b3 100644
--- a/apps/pdfcraft/src/main.rs
+++ b/apps/pdfcraft/src/main.rs
@@ -231,7 +231,10 @@ fn main() -> eframe::Result {
// running and exits (#282, #317, #1726). Any option means the caller wants this launch's own
// window.
let may_hand_off = !new_window && !files.is_empty() && options.is_empty() && control_file.is_none() && !create_images;
- let instance = match single_instance::claim(&single_instance::absolute(&files), may_hand_off) {
+ // Absolute for this instance too: links relative to a document resolve against its folder,
+ // never the working directory.
+ let files = single_instance::absolute(&files);
+ let instance = match single_instance::claim(&files, may_hand_off) {
single_instance::Claim::HandedOff => return Ok(()),
single_instance::Claim::Primary(server) => Some(server),
single_instance::Claim::Alone => None,
@@ -377,6 +380,7 @@ fn app_creator<'a>(
if let Some(json) = cc.storage.and_then(|s| s.get_string("pdfcraft").or_else(|| s.get_string(LEGACY_STORAGE_KEY))) {
app.restore(&json);
}
+ app.window_state = pdfcraft_ui_egui::window_state::WindowState::started_with(cc.storage);
app.integrated_titlebar = integrated;
app.window_controls = window_controls;
app.system_text_scale = text_scale::system();
@@ -562,7 +566,7 @@ fn configure_gpu(native: &mut eframe::NativeOptions) {
setup.instance_descriptor.flags = setup.instance_descriptor.flags.difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
// egui-wgpu requests only 8192 pixels even when the adapter can render larger surfaces.
// A restored 3440-point window at 250% DPI requests 8600 pixels and otherwise panics in
- // Surface::configure before the app starts. Keep the renderer's other device requirements,
+ // Surface::configure before the app starts. Keep the renderer's other compatible requirements,
// but enable the adapter's actual 2D texture extent (also for smaller/downlevel adapters).
let device_descriptor = std::sync::Arc::clone(&setup.device_descriptor);
setup.device_descriptor = std::sync::Arc::new(move |adapter| {
@@ -603,8 +607,9 @@ fn configure_gpu(native: &mut eframe::NativeOptions) {
}
/// Surface textures must fit the device's enabled limits, not just the physical GPU's limits.
-/// Changing only the 2D extent preserves egui-wgpu's backend-specific downlevel requirements.
+/// Clamp egui-wgpu's backend-specific requirements before enabling the adapter's full 2D extent.
fn surface_texture_limits(mut required: eframe::wgpu::Limits, supported: &eframe::wgpu::Limits) -> eframe::wgpu::Limits {
+ required = required.or_worse_values_from(supported);
required.max_texture_dimension_2d = supported.max_texture_dimension_2d;
required
}
@@ -909,16 +914,51 @@ mod tests {
fn surface_limits_preserve_other_requirements_and_never_overrequest() {
for required in [eframe::wgpu::Limits::default(), eframe::wgpu::Limits::downlevel_webgl2_defaults()] {
for extent in [4096, 8192, 16384] {
- let supported = eframe::wgpu::Limits { max_texture_dimension_2d: extent, ..required.clone() };
+ let supported = eframe::wgpu::Limits {
+ max_texture_dimension_2d: extent,
+ max_bind_groups: 8,
+ min_uniform_buffer_offset_alignment: 128,
+ min_storage_buffer_offset_alignment: 128,
+ ..required.clone()
+ };
let enabled = super::surface_texture_limits(required.clone(), &supported);
let mut expected = required.clone();
expected.max_texture_dimension_2d = extent;
assert_eq!(enabled, expected);
assert_eq!(enabled.max_texture_dimension_2d, supported.max_texture_dimension_2d);
+ assert!(enabled.check_limits(&supported));
}
}
}
+ #[test]
+ fn surface_limits_fit_v3d_shader_variable_limit() {
+ // Issue #2383: V3D supports 15 variables, below the default request of 16.
+ let required = eframe::wgpu::Limits::default();
+ let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required.clone() };
+ let enabled = super::surface_texture_limits(required.clone(), &supported);
+ assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}");
+ let expected = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required };
+ assert_eq!(enabled, expected);
+ }
+
+ #[test]
+ fn surface_limits_fit_downlevel_maxima_and_minimum_alignments() {
+ let required = eframe::wgpu::Limits::downlevel_webgl2_defaults();
+ let supported = eframe::wgpu::Limits {
+ max_texture_dimension_2d: 2048,
+ max_bind_groups: 2,
+ max_vertex_attributes: 8,
+ min_uniform_buffer_offset_alignment: 512,
+ min_storage_buffer_offset_alignment: 512,
+ ..required.clone()
+ };
+ let enabled = super::surface_texture_limits(required, &supported);
+ assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}");
+ // Larger minimum alignments are weaker capabilities, unlike maximum limits.
+ assert_eq!(enabled, supported);
+ }
+
#[test]
fn a_window_larger_than_the_gpu_limit_gets_a_surface_within_it() {
use eframe::egui_wgpu::winit::surface_fit;
diff --git a/apps/pdfcraft/src/single_instance.rs b/apps/pdfcraft/src/single_instance.rs
index ecfa12f5c3..751785d0c5 100644
--- a/apps/pdfcraft/src/single_instance.rs
+++ b/apps/pdfcraft/src/single_instance.rs
@@ -153,7 +153,7 @@ pub fn claim(files: &[String], may_hand_off: bool) -> Claim {
/// `%LOCALAPPDATA%` is unset.
#[cfg(windows)]
fn socket_path() -> Option {
- let name = socket_name(std::env::var("SESSIONNAME").ok().as_deref());
+ let name = socket_name(session_id(), std::env::var("SESSIONNAME").ok().as_deref());
// %LOCALAPPDATA% is private to the user's sign-in, so other accounts can't receive this
// user's file paths or send it files to open. A portable copy uses it too (#889), under its
// own name so it never hands files to an installed copy (which keeps its settings elsewhere).
@@ -184,13 +184,31 @@ fn display_socket_name(display: Option<&str>) -> String {
if display.is_empty() { "instance.sock".to_string() } else { format!("instance-{display}.sock") }
}
-/// The socket's file name for a Windows session (`SESSIONNAME`: `Console`, `RDP-Tcp#3`…). The
-/// same user signed in twice (at the console and over Remote Desktop) gets one socket per session,
-/// so a file never opens in a window on the other screen. Only letters, digits and `-` are kept.
+/// The Windows session this process runs in, from its access token: the console, each Remote
+/// Desktop sign-in and session 0 (services) each have their own. `None` if Windows won't say.
+#[cfg(windows)]
+fn session_id() -> Option {
+ use winsafe::{HACCESSTOKEN, TokenInfo, co::TOKEN_INFORMATION_CLASS};
+ match HACCESSTOKEN::GetCurrentProcessToken().GetTokenInformation(TOKEN_INFORMATION_CLASS::SessionId) {
+ Ok(TokenInfo::SessionId(id)) => Some(*id),
+ _ => None,
+ }
+}
+
+/// The socket's file name for a Windows session. The same user signed in twice (at the console
+/// and over Remote Desktop) gets one socket per session, so a file never opens in a window on the
+/// other screen. The session is the token's session id (`session`): a launch from Task Scheduler,
+/// a service or a remote shell has no `SESSIONNAME` but still runs in a session, so it finds the
+/// window of the session it runs in (#3338), and never one in another session (review on #3340).
+/// Only if the id can't be read does `SESSIONNAME` (`Console`, `RDP-Tcp#3`…) name it, letters,
+/// digits and `-` kept.
#[cfg_attr(not(windows), allow(dead_code))]
-fn socket_name(session: Option<&str>) -> String {
- let session: String = session.unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').take(32).collect();
- if session.is_empty() { "instance.sock".to_string() } else { format!("instance-{session}.sock") }
+fn socket_name(session: Option, session_name: Option<&str>) -> String {
+ if let Some(id) = session {
+ return format!("instance-s{id}.sock");
+ }
+ let name: String = session_name.unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').take(32).collect();
+ if name.is_empty() { "instance.sock".to_string() } else { format!("instance-{name}.sock") }
}
/// Held while a launch finds out whether it is the first, so launches that start together (several
@@ -359,12 +377,20 @@ mod tests {
#[test]
fn each_windows_session_gets_its_own_socket_name() {
- assert_eq!(socket_name(Some("Console")), "instance-Console.sock");
- assert_eq!(socket_name(Some("RDP-Tcp#3")), "instance-RDP-Tcp3.sock");
- assert_ne!(socket_name(Some("RDP-Tcp#3")), socket_name(Some("RDP-Tcp#4")));
- assert_eq!(socket_name(Some(r"..\x/y:z")), "instance-xyz.sock", "never a path");
- assert_eq!(socket_name(None), "instance.sock");
- assert_eq!(socket_name(Some("")), "instance.sock");
+ // The session id decides: #3338, a launch from Task Scheduler (no SESSIONNAME) in the
+ // console's session finds the window opened from Explorer there...
+ assert_eq!(socket_name(Some(1), None), socket_name(Some(1), Some("Console")));
+ // ...and (review on #3340) one in a Remote Desktop session never reaches the console's.
+ assert_ne!(socket_name(Some(2), None), socket_name(Some(1), Some("Console")));
+ assert_ne!(socket_name(Some(2), Some("RDP-Tcp#3")), socket_name(Some(3), Some("RDP-Tcp#3")));
+ assert_eq!(socket_name(Some(4_000_000_000), None), "instance-s4000000000.sock");
+ // Only without an id does SESSIONNAME name it.
+ assert_eq!(socket_name(None, Some("Console")), "instance-Console.sock");
+ assert_eq!(socket_name(None, Some("RDP-Tcp#3")), "instance-RDP-Tcp3.sock");
+ assert_ne!(socket_name(None, Some("RDP-Tcp#3")), socket_name(None, Some("RDP-Tcp#4")));
+ assert_eq!(socket_name(None, Some(r"..\x/y:z")), "instance-xyz.sock", "never a path");
+ assert_eq!(socket_name(None, None), "instance.sock");
+ assert_eq!(socket_name(None, Some("")), "instance.sock");
}
#[test]
@@ -394,11 +420,21 @@ mod tests {
assert_eq!(display_socket_name(Some("")), "instance.sock");
}
+ /// The socket is named by the session the process runs in, which Windows always gives.
+ #[cfg(windows)]
+ #[test]
+ fn this_process_has_a_session_id() {
+ let id = super::session_id();
+ assert!(id.is_some(), "the access token names the session");
+ let name = socket_name(id, None);
+ assert!(socket_path().is_none_or(|p| p.file_name().is_some_and(|f| f.to_string_lossy().ends_with(&name))), "{name}");
+ }
+
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
fn socket(test: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("pdfcraft-instance-{}-{test}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
- dir.join(socket_name(None))
+ dir.join(socket_name(None, None))
}
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
diff --git a/contributors/people.toml b/contributors/people.toml
index da3cfc6a77..cd8ef99f72 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -145,3 +145,6 @@ sync_github_name = false
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
+
+[people.MortenHusted]
+sync_github_name = true
diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs
index f4973aa021..b7f9d62697 100644
--- a/crates/annot/src/lib.rs
+++ b/crates/annot/src/lib.rs
@@ -2282,6 +2282,11 @@ fn line_endings_of(d: &Dict) -> Option> {
/// Replace Text (Acrobat's proposal): strike out `quads` and add a caret at the end of the
/// struck text holding `replacement`, grouped with the strikeout (`/IRT` + `/RT /Group`) so they
/// move, list and delete as one. Returns the strikeout's index.
+///
+/// The two annotations need their own `/NM`: XFDF names a reply by the `/NM` of what it replies
+/// to, so one id for both writes `inreplyto` equal to the caret's own `name`, and a reader that
+/// resolves names (Acrobat, and our own importer) loses the pair. `caret_meta` is a second id
+/// from the caller, so the edit stays deterministic.
#[allow(clippy::too_many_arguments)]
pub fn add_text_replacement(
doc: &mut Document,
@@ -2292,6 +2297,7 @@ pub fn add_text_replacement(
strike: &Style,
caret: &Style,
meta: &Meta,
+ caret_meta: &Meta,
) -> Result {
let last = *quads.last().ok_or_else(|| AnnotError::Invalid("select the text to replace first".into()))?;
let s = add_annotation(
@@ -2317,7 +2323,7 @@ pub fn add_text_replacement(
let c = add_annotation(
doc,
&NewAnnotation { page, shape: Shape::Caret { rect }, style: caret.clone(), contents: replacement.into(), author: author.into() },
- meta,
+ caret_meta,
)?;
let (_, cr) = annot_ref(doc, page, c)?;
doc.update_dict(cr, |d| {
diff --git a/crates/annot/src/links.rs b/crates/annot/src/links.rs
index 2e45b4e4d4..4d3eef632b 100644
--- a/crates/annot/src/links.rs
+++ b/crates/annot/src/links.rs
@@ -178,6 +178,19 @@ fn write_style(d: &mut Dict, s: &LinkStyle) {
d.set(b"H".to_vec(), Object::name(s.highlight.code()));
}
+/// Bytes of `u` as a 7-bit ASCII URI string (§12.6.4.8).
+fn uri_bytes(u: &str) -> Vec {
+ let mut out = Vec::with_capacity(u.len());
+ for &b in u.as_bytes() {
+ if (0x21..=0x7e).contains(&b) {
+ out.push(b);
+ } else {
+ out.extend_from_slice(format!("%{b:02X}").as_bytes());
+ }
+ }
+ out
+}
+
fn write_action(doc: &Document, d: &mut Dict, a: &LinkAction) -> Result<(), AnnotError> {
match a {
LinkAction::Page(p) => {
@@ -194,7 +207,7 @@ fn write_action(doc: &Document, d: &mut Dict, a: &LinkAction) -> Result<(), Anno
let mut act = Dict::new();
act.set(b"S".to_vec(), Object::name("URI"));
// URIs are 7-bit ASCII strings (§12.6.4.8).
- act.set(b"URI".to_vec(), PdfString::literal(u.as_bytes().to_vec()));
+ act.set(b"URI".to_vec(), PdfString::literal(uri_bytes(u)));
d.set(b"A".to_vec(), Object::Dict(act));
}
LinkAction::Other(_) => {}
diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs
index faed409abb..d3d448e520 100644
--- a/crates/annot/src/tests.rs
+++ b/crates/annot/src/tests.rs
@@ -588,6 +588,25 @@ fn links_are_added_edited_listed_and_removed() {
assert!(links::add(&mut doc, 0, [0.0, 0.0, 1.0, 1.0], &LinkAction::Page(0), &LinkStyle::default()).is_err(), "too small");
}
+#[test]
+fn link_uri_is_percent_encoded_to_7_bit_ascii() {
+ use crate::links::{self, LinkAction, LinkStyle};
+ let mut doc = fixture();
+ for url in ["https://example.com/ação", "https://example.com/a%20b"] {
+ links::add(&mut doc, 0, [10.0, 10.0, 110.0, 30.0], &LinkAction::Uri(url.into()), &LinkStyle::default()).unwrap();
+ }
+ let doc = reopen(&doc);
+ let urls: Vec = links::list(&doc)
+ .into_iter()
+ .filter_map(|link| match link.action {
+ LinkAction::Uri(url) => Some(url),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(urls, ["https://example.com/a%C3%A7%C3%A3o", "https://example.com/a%20b"]);
+ assert!(urls.iter().all(|url| url.is_ascii()));
+}
+
#[test]
fn link_edit_rejects_non_finite_and_overflowed_rectangles_without_mutating_the_pdf() {
use crate::links::{self, LinkAction, LinkStyle};
@@ -784,7 +803,7 @@ fn replace_text_groups_a_strikeout_and_a_caret() {
let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]];
let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None };
let blue = Style { color: [0.0, 0.47, 0.84], opacity: 1.0, width: 1.0, fill: None };
- let i = add_text_replacement(&mut doc, 0, &quads, "new words", "Ada", &red, &blue, &meta("r")).unwrap();
+ let i = add_text_replacement(&mut doc, 0, &quads, "new words", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).unwrap();
let doc = reopen(&doc);
let a = list(&doc, 0);
let strike = &a[i];
@@ -792,13 +811,21 @@ fn replace_text_groups_a_strikeout_and_a_caret() {
let caret = a.iter().find(|d| d.name(b"Subtype") == Some(b"Caret")).unwrap();
assert_eq!((text(caret, b"Contents").as_str(), caret.name(b"RT")), ("new words", Some(&b"Group"[..])));
assert!(caret.reference(b"IRT").is_some());
+ // #2666: the pair needs its own `/NM` each. XFDF names a reply by the `/NM` of what it
+ // replies to, so sharing one id writes `inreplyto` equal to the caret's own `name` and the
+ // pair does not survive an export and import.
+ assert_eq!(text(caret, b"NM").as_str(), "r-caret");
+ assert_eq!(text(strike, b"NM").as_str(), "r");
+ assert_ne!(text(caret, b"NM"), text(strike, b"NM"), "the caret must not carry the strikeout's name");
+ let irt = caret.reference(b"IRT").unwrap();
+ assert_eq!(text(doc.get(irt).as_dict().unwrap(), b"NM").as_str(), "r", "the caret replies to the strikeout");
let r = rect(caret);
assert!(r[0] < 200.0 && r[2] > 200.0 && r[1] < 706.0, "at the end of the line, below its baseline: {r:?}");
// Deleting the strikeout takes the caret with it (the group).
let mut doc = doc;
delete_annotation(&mut doc, 0, i).unwrap();
assert!(!list(&doc, 0).iter().any(|d| d.name(b"Subtype") == Some(b"Caret")));
- assert!(add_text_replacement(&mut doc, 0, &[], "x", "Ada", &red, &blue, &meta("r")).is_err());
+ assert!(add_text_replacement(&mut doc, 0, &[], "x", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).is_err());
}
#[test]
diff --git a/crates/audit/README.md b/crates/audit/README.md
index 5dd15de3af..bda5cc977e 100644
--- a/crates/audit/README.md
+++ b/crates/audit/README.md
@@ -16,9 +16,9 @@ It reports two kinds of finding:
A properly applied redaction removes the text, so it never flags. Page backgrounds, table
shading and highlights don't either: they're painted before the text, or light against it.
-A rectangle covers a text line when it hides at least 40% of the area of the words the line
-draws, or at least 40% of the line's box together with a positive-area intersection with one
-of its words. The gap an applied redaction leaves (a `TJ` adjustment where the text was) is in the line's box but
+A rectangle covers a text line when it hides at least half of one of the line's words, at
+least 40% of the area of the words the line draws, or at least 40% of the line's box together
+with a positive-area intersection with one of its words. The gap an applied redaction leaves (a `TJ` adjustment where the text was) is in the line's box but
holds no word, so a redaction box over it alone does not count.
Limits, by design and documented rather than hidden:
@@ -29,8 +29,8 @@ Limits, by design and documented rather than hidden:
- covers inside form XObjects are found, but only text drawn directly in the page content is
checked under them;
- annotation rectangles are compared in unrotated user space, so `/Rotate` pages may misalign;
-- a cover that hides less than 40% of a line's box and less than 40% of its words (a short
- value in the middle of a long line) is not flagged.
+- a cover that hides less than half of every word it touches, less than 40% of a line's box and
+ less than 40% of its words (a sliver of a long value) is not flagged.
It fails closed: an undecodable content stream, malformed `/Annots`, form XObjects nested deeper
than 32 or in a cycle, or a walk past the operator bound is an error, never "no findings".
diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs
index 204d237b3d..723d086d63 100644
--- a/crates/audit/src/lib.rs
+++ b/crates/audit/src/lib.rs
@@ -87,6 +87,9 @@ pub enum AuditError {
/// Fraction of a text line's ink (the area of its words), or of its box, that must lie under a
/// cover to count as hidden (see [`covers`]).
const COVER_FRACTION: f64 = 0.4;
+/// Fraction of one word's area that must lie under a cover to count as hidden on its own, however
+/// long the line around it (see [`covers`]).
+const WORD_FRACTION: f64 = 0.5;
/// WCAG contrast ratio below which the text is unreadable against the cover.
const COVER_CONTRAST: f64 = 3.0;
@@ -138,7 +141,10 @@ fn is_cover(rect: &cover::FilledRect, line: &TextLine) -> bool {
rect.pos > line.paint_pos() && covers(&rect.rect, line) && contrast(line.color, rect.rgb) < COVER_CONTRAST
}
-/// Does `cover` hide a substantial part of what `line` draws? Two ways, either is enough:
+/// Does `cover` hide a substantial part of what `line` draws? Three ways, any is enough:
+///
+/// - Half of one word lies under the cover. A box over one number in a long line hides a few
+/// percent of the line, and the number all the same (#2662).
///
/// - 40% of the area of the line's words (its `ink`) lies under the cover. Measured on the words,
/// not the box: an applied redaction leaves a gap that draws nothing (a `TJ` adjustment where
@@ -155,6 +161,9 @@ fn covers(cover: &[f64; 4], line: &TextLine) -> bool {
let total: f64 = boxes.iter().map(area).sum();
if total > 0.0 { boxes.iter().map(hidden).sum::() / total } else { 0.0 }
};
+ if line.ink.iter().any(|w| area(w) > 0.0 && hidden(w) / area(w) >= WORD_FRACTION) {
+ return true;
+ }
let inked = fraction(&line.ink) >= COVER_FRACTION;
let boxed = fraction(std::slice::from_ref(&line.rect)) >= COVER_FRACTION;
let word_intersects = || line.ink.iter().any(|w| hidden(w) > 0.0);
diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs
index d7d83a7db3..63d9220cca 100644
--- a/crates/audit/src/tests.rs
+++ b/crates/audit/src/tests.rs
@@ -175,6 +175,47 @@ fn unapplied_redact_mark_is_found() {
assert!(findings[0].covered_text.contains("DO NOT SHARE"));
}
+/// #2662: a box over one number of a long line hides a few percent of the line, and all of the
+/// number. Found as a cover, and as an unapplied mark over the same word.
+#[test]
+fn a_cover_over_one_word_of_a_long_line_is_found() {
+ // Helvetica 11: "The contract value is " ends near x 132, "98765" spans about 132-163.
+ let line = b"BT /F1 11 Tf 30 100 Td (The contract value is 98765 and the rest of this long line continues here.) Tj ET
+";
+ let mut content = line.to_vec();
+ content.extend_from_slice(
+ b"0 g
+131 96 33 14 re f
+",
+ );
+ let findings = audit_redactions(&open(one_page(&content, ""))).expect("audit must run");
+ assert_eq!(findings.len(), 1, "{findings:?}");
+ assert_eq!(findings[0].kind, FindingKind::CoveredText);
+ assert!(findings[0].covered_text.contains("98765"), "{findings:?}");
+
+ let mut b = Builder::new();
+ b.add("<< /Type /Catalog /Pages 2 0 R >>");
+ b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
+ b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>");
+ b.add_stream(line);
+ b.add(FONT);
+ b.add("<< /Type /Annot /Subtype /Redact /Rect [131 96 164 110] >>");
+ let findings = audit_redactions(&open(b.build())).expect("audit must run");
+ assert_eq!(findings.len(), 1, "{findings:?}");
+ assert_eq!(findings[0].kind, FindingKind::UnappliedMark);
+}
+
+/// A box that only grazes the words beside it (less than half of each) is not a cover.
+#[test]
+fn a_box_grazing_a_word_is_not_flagged() {
+ let content = b"BT /F1 11 Tf 30 100 Td (The contract value is 98765 and the rest of this long line continues here.) Tj ET
+0 g
+131 96 6 14 re f
+";
+ let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
+ assert!(findings.is_empty(), "{findings:?}");
+}
+
#[test]
fn background_before_text_is_not_flagged() {
// An opaque dark rectangle painted *before* the text is a background, not a cover.
@@ -303,15 +344,17 @@ fn partial_value_is_found(rotated: bool) {
/// Keep the historic detections with several covers, lines and text-spacing states.
#[test]
fn faux_redactions_with_multiple_covers_lines_and_spacing_are_found() {
- let cases: [&[u8]; 4] = [
- b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj ET\n0 g\n72 738 65 16 re f\n140 738 40 16 re f\n",
- b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj 0 -15 Td (Account: 987654321) Tj ET\n0 g\n115 723 100 30 re f\n",
- b"BT /F1 11 Tf 8 Tc 72 742 Td (Account: 1234) Tj ET\n0 g\n145 738 90 16 re f\n",
- b"BT /F1 11 Tf 250 Tz 72 742 Td (Account: 1234) Tj ET\n0 g\n100 738 160 16 re f\n",
+ // The second box of the first case hides most of the number (less than 40% of the line): a
+ // finding of its own since covers are also measured per word (#2662).
+ let cases: [(&[u8], usize); 4] = [
+ (b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj ET\n0 g\n72 738 65 16 re f\n140 738 40 16 re f\n", 2),
+ (b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj 0 -15 Td (Account: 987654321) Tj ET\n0 g\n115 723 100 30 re f\n", 1),
+ (b"BT /F1 11 Tf 8 Tc 72 742 Td (Account: 1234) Tj ET\n0 g\n145 738 90 16 re f\n", 1),
+ (b"BT /F1 11 Tf 250 Tz 72 742 Td (Account: 1234) Tj ET\n0 g\n100 738 160 16 re f\n", 1),
];
- for content in cases {
+ for (content, expected) in cases {
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
- assert_eq!(findings.len(), 1, "{}: {findings:?}", String::from_utf8_lossy(content));
+ assert_eq!(findings.len(), expected, "{}: {findings:?}", String::from_utf8_lossy(content));
}
}
@@ -457,3 +500,32 @@ fn forms_nested_past_depth_limit_fail_closed() {
let result = audit_redactions(&open(nested_forms(33)));
assert!(matches!(result, Err(crate::AuditError::TooDeep)), "{result:?}");
}
+
+#[test]
+fn comment_across_streams_neither_invents_text_nor_a_cover() {
+ let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Visible text) Tj ET % note", b"0 g 70 690 200 40 re f"));
+ assert!(audit_redactions(&doc).unwrap().is_empty(), "commented cover must not be reported");
+ let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Before comment) Tj ET % note", b"BT /F1 24 Tf 72 650 Td (After comment) Tj ET"));
+ let text: String = pdfcraft_edit::text_lines(&doc, 0).unwrap().into_iter().map(|line| line.text).collect();
+ assert!(!text.contains("After comment"), "editor exposed phantom text: {text}");
+}
+
+#[test]
+fn reading_blocks_respect_stream_comments_and_split_operands() {
+ let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Before comment) Tj ET % note", b"BT /F1 24 Tf 72 650 Td (After comment) Tj ET"));
+ let text: String = pdfcraft_edit::reading_blocks(&doc, 0).unwrap().into_iter().map(|block| block.editor_text).collect();
+ assert_eq!(text, "Before comment");
+ let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Split operand)", b"Tj ET"));
+ let text: String = pdfcraft_edit::reading_blocks(&doc, 0).unwrap().into_iter().map(|block| block.editor_text).collect();
+ assert_eq!(text, "Split operand");
+ // Marked content opened in one stream applies to text in the next: its /ActualText replaces
+ // the glyphs, as when the streams are read joined.
+ for (s1, s2) in [
+ (b"BT /F1 24 Tf 72 700 Td /Span <> BDC (Glyphs) Tj EMC ET".as_slice()),
+ (b"BT /F1 24 Tf 72 700 Td /Span <> BDC", b"(Glyphs) Tj EMC ET"),
+ ] {
+ let doc = open(two_streams(s1, s2));
+ let text: String = pdfcraft_edit::reading_blocks(&doc, 0).unwrap().into_iter().map(|block| block.editor_text).collect();
+ assert_eq!(text, "Replacement text", "{}", String::from_utf8_lossy(s1));
+ }
+}
diff --git a/crates/automation/README.md b/crates/automation/README.md
index 4e27702a32..669ea61463 100644
--- a/crates/automation/README.md
+++ b/crates/automation/README.md
@@ -64,6 +64,20 @@ semantics; they never silently act on partial text.
## Shared field properties
+For an individual push button, `form_set_props` also edits its visible normal label:
+
+```json
+{"doc": 1, "field": "clear_button", "caption": "Clear Form", "widget": 1}
+```
+
+`widget` is a 1-based index (default 1), used only with `caption`; an empty caption clears the
+label. Neither renames the field. `form_fields` returns the first widget's `caption` and all
+`captions` as `[{"widget": 1, "caption": "Clear Form"}]`. Other widgets and icon-only layouts
+keep their settings. CLI and MCP use the same edit, including undo/redo and save/reopen.
+The desktop exposes it as **Button Properties → Options → Label**. Rollover/down labels and
+icon layout are not edited here; the existing caption font and unsupported-character warning
+policy apply.
+
`form_set_props` accepts either `field` (one name) or `fields` (1–1000 unique names):
```json
@@ -75,7 +89,7 @@ The batch is atomic and creates one undo step. A missing or locked field, invali
unknown argument or malformed appearance refuses the whole edit. Set `locked: false` to
unlock and change fields together. Only supplied properties change: each field/widget keeps
its other colours, fonts, values and unknown PDF dictionary keys. `"none"` removes a border
-or fill colour. Names, rectangles and rotations require the single-field form. Results keep
+or fill colour. Names, rectangles, rotations and captions require the single-field form. Results keep
`field` for single edits and return `fields` for batches. CLI and MCP use the same tool.
## MCP server
diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs
index 1e8d71bd1c..8130ed084d 100644
--- a/crates/automation/src/a11y.rs
+++ b/crates/automation/src/a11y.rs
@@ -918,11 +918,16 @@ impl Automation {
let list: Vec = found
.iter()
.map(|(p, c)| {
- let kind = match c.kind {
- pdfcraft_engine::detect::Kind::Text => "text",
- pdfcraft_engine::detect::Kind::CheckBox => "checkbox",
+ let (kind, comb) = match c.kind {
+ pdfcraft_engine::detect::Kind::Text => ("text", None),
+ pdfcraft_engine::detect::Kind::CheckBox => ("checkbox", None),
+ pdfcraft_engine::detect::Kind::Comb { cells } => ("text", Some(cells)),
};
- json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) })
+ let mut v = json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) });
+ if let Some(cells) = comb {
+ v["comb"] = json!(cells);
+ }
+ v
})
.collect();
if a.opt_bool("add")?.unwrap_or(true) && !found.is_empty() {
diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs
index 710952d7c2..faefc5fa7c 100644
--- a/crates/automation/src/content.rs
+++ b/crates/automation/src/content.rs
@@ -138,7 +138,7 @@ impl Automation {
.added()
.iter()
.filter(|x| x.page == page)
- .nth((index - 1).max(0) as usize)
+ .nth(usize::try_from(index.saturating_sub(1)).unwrap_or(usize::MAX))
.filter(|_| index >= 1)
.ok_or_else(|| failed(format!("page {} has no added item {index} (see content_list)", page + 1)))?;
Ok((page, index as usize - 1, it.content.clone(), ph))
diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs
index 7c60425a8c..f2eab5ddc0 100644
--- a/crates/automation/src/forms.rs
+++ b/crates/automation/src/forms.rs
@@ -233,6 +233,19 @@ impl Automation {
o.insert("unsupported_scripts".into(), json!(f.actions.unsupported));
}
match f.kind {
+ FormFieldKind::PushButton => {
+ o.insert("caption".into(), json!(w.and_then(|w| w.caption.as_deref()).unwrap_or_default()));
+ o.insert(
+ "captions".into(),
+ json!(
+ f.widgets
+ .iter()
+ .enumerate()
+ .map(|(i, w)| { json!({"widget": i.saturating_add(1), "caption": w.caption.as_deref().unwrap_or_default()}) })
+ .collect::>()
+ ),
+ );
+ }
FormFieldKind::Radio => {
o.insert("options".into(), json!((0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect::>()));
}
@@ -327,6 +340,9 @@ impl Automation {
}
pub(crate) fn form_set_props(&mut self, a: &Args) -> Result {
+ if a.get("widget").is_some() && a.get("caption").is_none() {
+ return Err(bad("widget requires caption"));
+ }
let single = a.opt_str("field")?;
let bulk = a.get("fields").is_some();
if single.is_some() == bulk {
@@ -344,7 +360,7 @@ impl Automation {
return Err(bad(format!("field {name:?} is listed more than once")));
}
}
- for key in ["name", "rect", "rotation"] {
+ for key in ["name", "rect", "rotation", "caption", "widget"] {
if a.get(key).is_some() {
return Err(bad(format!("{key} applies to one field; use field instead of fields")));
}
@@ -398,6 +414,15 @@ impl Automation {
rect,
name: a.opt_str("name")?.map(str::to_owned),
tooltip: a.opt_str("tooltip")?.map(str::to_owned),
+ caption: a
+ .opt_str("caption")?
+ .map(|caption| -> Result<_> {
+ let wi = a.opt_int("widget")?.unwrap_or(1);
+ let wi =
+ usize::try_from(wi).ok().and_then(|wi| wi.checked_sub(1)).ok_or_else(|| bad("widget must be a positive 1-based index"))?;
+ Ok((wi, caption.to_owned()))
+ })
+ .transpose()?,
read_only: a.opt_bool("read_only")?,
required: a.opt_bool("required")?,
multiline: a.opt_bool("multiline")?,
diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs
index ad18f71124..0e2e0dd698 100644
--- a/crates/automation/src/lib.rs
+++ b/crates/automation/src/lib.rs
@@ -283,6 +283,13 @@ impl Automation {
let to = self.position(&a, "to")?;
self.apply(&a, Edit::MovePages { pages, to })?
}
+ "page_reverse" => {
+ let pages = match a.opt_ints("pages")? {
+ Some(_) => self.pages(&a, "pages")?,
+ None => (0..self.doc(&a)?.info.pages.len()).collect(),
+ };
+ self.apply(&a, Edit::ReversePages { pages })?
+ }
"page_insert_blank" => self.insert_blank(&a)?,
"page_insert_file" => self.insert_file(&a)?,
"page_extract" => self.page_extract(&a)?,
@@ -373,7 +380,8 @@ impl Automation {
}
"page_duplicate" => {
let pages = self.pages(&a, "pages")?;
- self.apply(&a, Edit::DuplicatePages { pages })?
+ let at = self.opt_position(&a, "at")?;
+ self.apply(&a, Edit::DuplicatePages { pages, at })?
}
"page_set_box" => self.page_set_box(&a)?,
"page_transition_set" => self.page_transition_set(&a)?,
@@ -932,11 +940,24 @@ impl Automation {
// A save that adds or removes encryption, or must not keep the old revision (Sanitize,
// redaction), is rewritten in full even when an incremental save was asked for.
let incremental = !full && bytes.starts_with(&self.doc(a)?.saved_bytes());
+ self.publish_save(id, &target, bytes, incremental, write)
+ }
+
+ /// Validate the prepared bytes before publishing them or marking the document saved.
+ fn publish_save(
+ &mut self,
+ id: DocId,
+ target: &Path,
+ bytes: Arc>,
+ incremental: bool,
+ write: impl FnOnce(&Path, &[u8]) -> Result<()>,
+ ) -> Result {
self.session.check_save(id, &bytes).map_err(failed)?;
- write(&target, &bytes)?;
+ write(target, &bytes)?;
let path = target.to_string_lossy().into_owned();
self.session.mark_saved(id, bytes.clone(), Some(path.clone())).map_err(failed)?;
- Ok(json!({ "path": path, "bytes": bytes.len(), "incremental": incremental, "document": summary(self.doc(a)?) }))
+ let doc = self.session.get(id).ok_or_else(|| failed("the saved document is no longer open"))?;
+ Ok(json!({ "path": path, "bytes": bytes.len(), "incremental": incremental, "document": summary(doc) }))
}
fn doc_revisions(&mut self, a: &Args) -> Result {
@@ -1733,16 +1754,21 @@ impl Automation {
let mut used = std::collections::HashSet::new();
for (i, (first, last, bytes)) in parts.iter().enumerate() {
let safe = |t: &str| t.chars().map(|c| if c.is_alphanumeric() || " -_.,()".contains(c) { c } else { '_' }).collect::();
- let file = match titles.iter().find(|t| t.0 + 1 == *first) {
- Some((_, t)) => format!("{stem}-{}.pdf", safe(t)),
- None => format!("{stem}-part{}.pdf", i + 1),
+ let mut base = match titles.iter().find(|t| t.0 + 1 == *first) {
+ Some((_, t)) => format!("{stem}-{}", safe(t)),
+ None => format!("{stem}-part{}", i + 1),
};
+ // A file name is at most 255 bytes: keep the base to 200, cut between characters, which leaves room for ".pdf" and a -N suffix.
+ if let Some((end, _)) = base.char_indices().find(|(at, c)| at + c.len_utf8() > 200) {
+ base.truncate(end);
+ }
+ let file = format!("{base}.pdf");
// Equal titles (or titles that sanitize alike) get -2, -3, ... so no part overwrites another; compared case-insensitively.
- let mut unique = file.clone();
+ let mut unique = file;
let mut n = 1;
while !used.insert(unique.to_lowercase()) {
n += 1;
- unique = format!("{}-{n}.pdf", file.trim_end_matches(".pdf"));
+ unique = format!("{base}-{n}.pdf");
}
let path = child(&dir, &unique);
write_atomic(&path, bytes)?;
@@ -1768,12 +1794,17 @@ impl Automation {
/// A 1-based insertion position (1 = before the first page, n+1 = after the last) → 0-based.
fn position(&self, a: &Args, key: &str) -> Result {
+ self.opt_position(a, key)?.ok_or_else(|| Args::missing(key))
+ }
+
+ /// `position`, but `None` when the key is absent.
+ fn opt_position(&self, a: &Args, key: &str) -> Result