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/ci.yml b/.github/workflows/ci.yml
index 8249dcf0c7..388b44f032 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -48,13 +48,21 @@ jobs:
echo "CRAFT_FONTS_DIR=$GITHUB_WORKSPACE/craft-fonts" >> "$GITHUB_ENV"
echo "CRAFT_FONTS_REQUIRED=1" >> "$GITHUB_ENV"
- - name: Install Linux system dependencies (egui/winit/rfd)
+ - name: Install Linux system dependencies (egui/winit/rfd) and the oracles
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
- libgl1-mesa-dev libgtk-3-dev
+ libgl1-mesa-dev libgtk-3-dev \
+ qpdf poppler-utils
+
+ # The independent oracles (crates/oracle) are only worth having when they run. With this
+ # set, a missing one fails the job instead of printing a skip notice that reads as a pass.
+ # Only the tools the job actually uses are installed above.
+ - name: Require the oracles (Linux)
+ if: runner.os == 'Linux'
+ run: echo "PDFCRAFT_ORACLES_REQUIRED=1" >> "$GITHUB_ENV"
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
diff --git a/.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 3b451f246d..5cfee8d55a 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -4,7 +4,7 @@
Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
-## In this repository (270)
+## In this repository (272)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
@@ -27,7 +27,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/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 |
@@ -277,7 +277,9 @@ 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 |
## Compiled in through dependencies (6)
diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml
index d6257f77b6..48d56dbd60 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 = "a4b95023bc2ab99b8712acecb529959b261704400398551f3f73390de887f621"
+sha256 = "26e9a240b7125681c2379e29cfd71937c0b1c6e0c5a9a026dacf3906845d8cb4"
# 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 = "6427553770a182872b31963640ae25c17bef7ca7749aa6f94ba0f2fd32d9cf50"
+sha256 = "3649b775b7808ca6f2a9da1b56a6dfc3634b57541efa0dc325ebf041a994482c"
[[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 = "2c55dc2c15a19f1c718037afe2ca0ac413978776fe065a1d8c48ff8f76e28338"
+sha256 = "0506e401d7357af9873111e03bf911f13f40be7857be6ab31dce687b4ecb8735"
[[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 = "ce58e69fbcfd0f399d6cdd7d9fccf5ff4509e4ee47c58e6426daee7c59fd4f89"
+sha256 = "7b74e3624571e4b3642baed5b2daef23b6a1b506fb0598df35627fd4ab4735fe"
[[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 = "1037bf896b4d7bfe2c1ecd2e4b488bb4c78655cc867d16cb65e714adf907b0c2"
+sha256 = "0df329eb84e7c58f7ea363f5bc782a04ffdddaaece73395f2f8e278214847d3e"
[[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 = "6ab674b38045f215dcb643aedafe6763f336a670f321b8011df5f6bb0b9238cc"
+sha256 = "dc7857bc1b140f0aec7bf1959ad035797d6dd1faaba6b41b639fb9078f703bd0"
[[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 = "bbba95ed5c1bcbae086a3cbe30b05d8d0daae5a90e279d0636b33e8f7f1b85fe"
+sha256 = "41c942beee26b0c7c7e8d93552f7d8396ca6b4d105c1025c0afbed7ddf7d9d51"
[[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 = "915785e53cf2844a19d2abd7120f2f1792e2555b1224f441ec3cf6985a841b40"
+sha256 = "9ddca6461147cb35293752b1b40fdbfbd94fe074b90071e889a870f760cc9d2c"
[[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 = "1632acf0d68b8127203b0435d67659fdd8975df6a4100715e64d301892746d2f"
+sha256 = "2426510d3535176a4525ebce4a1a819eb13cfe10ba5acdf47c5856f1d6b0626a"
[[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 = "2ed055bcec912a14caba6e58b2855a07c19ab7e58500151cbe5f33ef56e80c18"
+sha256 = "691e3b6babf52534961c35c6a1a2811f0ed7897c2e7cf2374bb9b9ad0055dc35"
[[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 = "22a6ef932a0560b27df30bd09b8cc9ec1e463affd1c76ece9732b836155d8536"
+sha256 = "7ff4c8a636dc2dd0d57b26d0532ea1267e0f9e3cfb3772f407e935afb21f8250"
[[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 = "016d47686f8d7bad07637433465a8a94e4a55f3dd86301adaab02db98b221abe"
+sha256 = "532a739bc039db930952dc9548dc97bb4773e1b2657d693b668b21a9cafa9e6d"
[[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 = "b709824ac987be697e3db542524c0bfb64546460c470898689163814ca356541"
+sha256 = "49e8a211b044c23a19a6267b2ee839b29b9809277f6f1d0cae410b3b49df9e97"
[[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 = "cd50450b84a8bada1c75c3d04e4f1f5f7ea5dba9c8b5f4955d6757677bdd5799"
+sha256 = "1463d7f57aa28e9d2632af09e0db72db297390a281dc6f50b7185678309ab7ec"
[[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 = "9277a45401d06cac680506ab5b7100093df3b7bca4bc3f1130145d387947446d"
+sha256 = "bc40d7ec61f6a33f758bb0300e64f039deab71a7f0a4ad048ae8225b353d7357"
[[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 = "d9e20c4e60c4460105f6395e2ab6bb5f49c574399ded9130499e03f29559088f"
+sha256 = "bd322f64034763c27d9132757bfba9cf7a26ee276841eef1697c3cb150bafb82"
[[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 = "cafd5475da2d8c2613a84f7790fb5b9c421945163621468dff2b3f0b0d86c12e"
+sha256 = "a2f31769fbfae70f690b96613ecd73b9a44bf966cbc01d97d7f8c8934e051c64"
[[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 = "fa4521f7ecc50ffb8e5186ad5de80f9ba4a67799f580e9cb8a84c824efd7d699"
+sha256 = "579f67e82d430a959b7edd0f1598b2bba615f04131c3551b214f1a44cf2d22e0"
[[asset]]
path = "crates/ui-egui/src/i18n/tr.tsv"
@@ -217,18 +217,19 @@ 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 = "a84024e205bb9b71b801de9f9d5f0b7967007c4d4da44bd7bab2ea415daa3de8"
+sha256 = "1887dac4914482e78374c310f70f51b3f5b2e0eb98d5715fb3d7764567de1666"
[[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 = "fc41d9d7007ec2111bde170b0581036c083b410d582fe753e156abe272d963f7"
+sha256 = "6ee132715f8cebca0fafdee1e5d2ece4316483c373b0d5ee6179790391ffd327"
+
[[asset]]
path = "assets/icons/accessibility.svg"
@@ -3342,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 = "cb5645e571588db59ce5ec5f5bc697434d772ca6bba72dce8bad8252f064f50e"
+
[[asset]]
path = "crates/ui-egui/src/i18n/sl.tsv"
title = "Slovene interface translation catalog"
@@ -3351,4 +3363,16 @@ 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 = "4f7f2a3b506bb5590b32ca51165d99fa9fc78f4606110f59f89d8e7667bece02"
+sha256 = "97ce10bae54e21d74a4d9eb3bfbc1a85feff616e0d336e124d79baeafce01b02"
+
+[[asset]]
+
+path = "vendor/vello_common/assets/probe.png"
+title = "vello_common probe reference image (rendered from probe.rgba)"
+author = "Linebender vello contributors"
+source = "vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary"
+licence = "MIT OR Apache-2.0"
+licence_file = "vendor/vello_common/LICENSE-MIT"
+kind = "image"
+usage = "Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file"
+sha256 = "78c59a0cfd2c829909d77006f94e34dac1ae5d1f90e7f10f72e84de541fef697"
diff --git a/Cargo.lock b/Cargo.lock
index df58c57d21..53e0f097d5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1142,10 +1142,15 @@ 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"
-source = "git+https://github.com/storytold/craft-libs?rev=f03ec27ab2c405969428512178f746183cd55ca0#f03ec27ab2c405969428512178f746183cd55ca0"
+source = "git+https://github.com/storytold/craft-libs?rev=666c358065261a2816b376fa3bb10538dbf53040#666c358065261a2816b376fa3bb10538dbf53040"
dependencies = [
"miniz_oxide 0.8.9",
"roxmltree",
@@ -1154,6 +1159,18 @@ dependencies = [
"winsafe",
]
+[[package]]
+name = "craft-quit"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=f58bc1a3df490992d633393d0898af0588ce882c#f58bc1a3df490992d633393d0898af0588ce882c"
+dependencies = [
+ "dispatch2",
+ "egui",
+ "muda",
+ "objc2 0.6.4",
+ "objc2-app-kit 0.3.2",
+]
+
[[package]]
name = "craft-ui"
version = "0.1.0"
@@ -1184,6 +1201,15 @@ dependencies = [
"x509-cert",
]
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
+dependencies = [
+ "crossbeam-utils",
+]
+
[[package]]
name = "crossbeam-deque"
version = "0.8.8"
@@ -2067,6 +2093,15 @@ dependencies = [
"serde",
]
+[[package]]
+name = "fontconfig-parser"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
+dependencies = [
+ "roxmltree",
+]
+
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -3005,6 +3040,16 @@ dependencies = [
"cpufeatures 0.3.1",
]
+[[package]]
+name = "keyboard-types"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d"
+dependencies = [
+ "bitflags 2.13.2",
+ "serde",
+]
+
[[package]]
name = "khronos-egl"
version = "6.0.0"
@@ -3248,6 +3293,24 @@ dependencies = [
"pxfm",
]
+[[package]]
+name = "muda"
+version = "0.21.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03"
+dependencies = [
+ "crossbeam-channel",
+ "dpi",
+ "keyboard-types",
+ "objc2 0.6.4",
+ "objc2-app-kit 0.3.2",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+ "png 0.18.1",
+ "thiserror 2.0.21",
+ "windows-sys 0.60.2",
+]
+
[[package]]
name = "multiversion_no_op"
version = "1.0.0"
@@ -3875,8 +3938,9 @@ dependencies = [
[[package]]
name = "pdfcraft"
-version = "0.7.1"
+version = "0.9.0"
dependencies = [
+ "craft-quit",
"eframe",
"egui",
"fmv-macos-events",
@@ -3884,6 +3948,7 @@ dependencies = [
"pdfcraft-engine",
"pdfcraft-ui-egui",
"pollster 1.0.1",
+ "raw-window-handle",
"rustls-native-certs",
"serde_json",
"uds_windows",
@@ -3891,13 +3956,14 @@ dependencies = [
"wayland-client",
"wayland-protocols",
"windows",
+ "winit",
"winresource",
"winsafe",
]
[[package]]
name = "pdfcraft-a11y"
-version = "0.7.1"
+version = "0.9.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3909,7 +3975,7 @@ dependencies = [
[[package]]
name = "pdfcraft-annot"
-version = "0.7.1"
+version = "0.9.0"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
@@ -3920,7 +3986,7 @@ dependencies = [
[[package]]
name = "pdfcraft-audit"
-version = "0.7.1"
+version = "0.9.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3931,7 +3997,7 @@ dependencies = [
[[package]]
name = "pdfcraft-automation"
-version = "0.7.1"
+version = "0.9.0"
dependencies = [
"base64 0.22.1",
"flate2",
@@ -3952,13 +4018,15 @@ dependencies = [
[[package]]
name = "pdfcraft-cli"
-version = "0.7.1"
+version = "0.9.0"
dependencies = [
"base64 0.22.1",
"pdfcraft-audit",
"pdfcraft-automation",
"pdfcraft-cos",
"pdfcraft-engine",
+ "pdfcraft-platform",
+ "pdfcraft-preflight",
"pdfcraft-render",
"rustix 1.1.5",
"serde_json",
@@ -3967,22 +4035,24 @@ dependencies = [
[[package]]
name = "pdfcraft-compare"
-version = "0.7.1"
+version = "0.9.0"
[[package]]
name = "pdfcraft-content"
-version = "0.7.1"
+version = "0.9.0"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-cos"
-version = "0.7.1"
+version = "0.9.0"
dependencies = [
"hayro-syntax",
"pdfcraft-crypt",
"pdfcraft-filters",
+ "pdfcraft-oracle",
+ "pdfcraft-testkit",
"proptest",
"serde_json",
"thiserror 2.0.21",
@@ -3990,7 +4060,7 @@ dependencies = [
[[package]]
name = "pdfcraft-create"
-version = "0.7.1"
+version = "0.9.0"
dependencies = [
"encoding_rs",
"hayro-syntax",
@@ -4006,7 +4076,7 @@ dependencies = [
[[package]]
name = "pdfcraft-crypt"
-version = "0.7.1"
+version = "0.9.0"
dependencies = [
"aes",
"md-5",
@@ -4018,7 +4088,7 @@ dependencies = [
[[package]]
name = "pdfcraft-edit"
-version = "0.7.1"
+version = "0.9.0"
dependencies = [
"hayro-syntax",
"kurbo 0.13.1",
@@ -4033,9 +4103,10 @@ dependencies = [
[[package]]
name = "pdfcraft-engine"
-version = "0.7.1"
+version = "0.9.0"
dependencies = [
"chrono",
+ "craft-docpath",
"icu_properties",
"idna",
"image",
@@ -4065,19 +4136,21 @@ dependencies = [
"pdfcraft-xfdf",
"png 0.18.1",
"thiserror 2.0.21",
+ "tiff",
+ "unicode-segmentation",
"url",
]
[[package]]
name = "pdfcraft-export"
-version = "0.7.1"
+version = "0.9.0"
dependencies = [
"flate2",
]
[[package]]
name = "pdfcraft-filters"
-version = "0.7.1"
+version = "0.9.0"
dependencies = [
"flate2",
"proptest",
@@ -4087,7 +4160,7 @@ dependencies = [
[[package]]
name = "pdfcraft-fonts"
-version = "0.7.1"
+version = "0.9.0"
dependencies = [
"harfrust",
"pdfcraft-cos",
@@ -4097,7 +4170,7 @@ dependencies = [
[[package]]
name = "pdfcraft-forms"
-version = "0.7.1"
+version = "0.9.0"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4109,37 +4182,38 @@ dependencies = [
[[package]]
name = "pdfcraft-geom"
-version = "0.7.1"
+version = "0.9.0"
[[package]]
name = "pdfcraft-js"
-version = "0.7.1"
+version = "0.9.0"
dependencies = [
"boa_engine",
]
[[package]]
name = "pdfcraft-measure"
-version = "0.7.1"
+version = "0.9.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-model",
+ "pdfcraft-testkit",
"serde",
"thiserror 2.0.21",
]
[[package]]
name = "pdfcraft-model"
-version = "0.7.1"
+version = "0.9.0"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-ocr"
-version = "0.7.1"
+version = "0.9.0"
dependencies = [
"ocrs",
"pdfcraft-fonts",
@@ -4149,7 +4223,7 @@ dependencies = [
[[package]]
name = "pdfcraft-optimize"
-version = "0.7.1"
+version = "0.9.0"
dependencies = [
"image",
"pdfcraft-annot",
@@ -4160,36 +4234,43 @@ dependencies = [
"thiserror 2.0.21",
]
+[[package]]
+name = "pdfcraft-oracle"
+version = "0.9.0"
+
[[package]]
name = "pdfcraft-organize"
-version = "0.7.1"
+version = "0.9.0"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
"pdfcraft-cos",
+ "pdfcraft-oracle",
"thiserror 2.0.21",
]
[[package]]
name = "pdfcraft-platform"
-version = "0.7.1"
+version = "0.9.0"
dependencies = [
"async-channel",
"futures-lite",
+ "rustix 1.1.5",
"zbus",
]
[[package]]
name = "pdfcraft-preflight"
-version = "0.7.1"
+version = "0.9.0"
dependencies = [
+ "pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-model",
]
[[package]]
name = "pdfcraft-print"
-version = "0.7.1"
+version = "0.9.0"
dependencies = [
"craft-print",
"pdfcraft-content",
@@ -4200,7 +4281,7 @@ dependencies = [
[[package]]
name = "pdfcraft-redact"
-version = "0.7.1"
+version = "0.9.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4209,12 +4290,14 @@ dependencies = [
"pdfcraft-fonts",
"pdfcraft-forms",
"pdfcraft-model",
+ "pdfcraft-testkit",
+ "pdfcraft-xfa",
"thiserror 2.0.21",
]
[[package]]
name = "pdfcraft-render"
-version = "0.7.1"
+version = "0.9.0"
dependencies = [
"bytemuck",
"flate2",
@@ -4233,7 +4316,7 @@ dependencies = [
[[package]]
name = "pdfcraft-sign"
-version = "0.7.1"
+version = "0.9.0"
dependencies = [
"aes",
"aws-lc-rs",
@@ -4265,18 +4348,24 @@ dependencies = [
"thiserror 2.0.21",
]
+[[package]]
+name = "pdfcraft-testkit"
+version = "0.9.0"
+
[[package]]
name = "pdfcraft-ui-egui"
-version = "0.7.1"
+version = "0.9.0"
dependencies = [
"arboard",
"base64 0.22.1",
"bytemuck",
+ "craft-quit",
"craft-ui",
"eframe",
"egui",
"egui_extras",
"egui_kittest",
+ "fontconfig-parser",
"getrandom 0.4.3",
"image",
"js-sys",
@@ -4309,7 +4398,7 @@ dependencies = [
[[package]]
name = "pdfcraft-web"
-version = "0.7.1"
+version = "0.9.0"
dependencies = [
"eframe",
"egui",
@@ -4321,7 +4410,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfa"
-version = "0.7.1"
+version = "0.9.0"
dependencies = [
"base64 0.22.1",
"image",
@@ -4335,7 +4424,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfdf"
-version = "0.7.1"
+version = "0.9.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-cos",
@@ -5398,9 +5487,9 @@ dependencies = [
[[package]]
name = "rustls"
-version = "0.23.45"
+version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
+checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [
"aws-lc-rs",
"log",
@@ -6868,8 +6957,6 @@ dependencies = [
[[package]]
name = "vello_common"
version = "0.0.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3361bff7f7d82c0c496b92048db83846691f0e844cc28dee92b1c824291b55ee"
dependencies = [
"bytemuck",
"fearless_simd",
@@ -7946,6 +8033,8 @@ version = "0.1.0"
dependencies = [
"anyhow",
"lopdf",
+ "pdfcraft-cos",
+ "pdfcraft-oracle",
"png 0.18.1",
"serde",
"serde_json",
diff --git a/Cargo.toml b/Cargo.toml
index e32b544441..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.7.1"
+version = "0.9.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -14,40 +14,46 @@ repository = "https://github.com/storytold/pdfcraft"
[workspace.dependencies]
# Shared frontend components; engines remain independent of egui.
craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
+# Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`.
+craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f58bc1a3df490992d633393d0898af0588ce882c" }
# Shared system spooler; document composition remains in pdfcraft-print.
-craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f03ec27ab2c405969428512178f746183cd55ca0" }
+craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "666c358065261a2816b376fa3bb10538dbf53040" }
+# 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.7.0" }
-pdfcraft-filters = { path = "crates/filters", version = "0.7.0" }
-pdfcraft-cos = { path = "crates/cos", version = "0.7.0" }
-pdfcraft-crypt = { path = "crates/crypt", version = "0.7.0" }
-pdfcraft-organize = { path = "crates/organize", version = "0.7.0" }
-pdfcraft-measure = { path = "crates/measure", version = "0.7.0" }
-pdfcraft-annot = { path = "crates/annot", version = "0.7.0" }
-pdfcraft-audit = { path = "crates/audit", version = "0.7.0" }
-pdfcraft-fonts = { path = "crates/fonts", version = "0.7.0" }
-pdfcraft-model = { path = "crates/model", version = "0.7.0" }
-pdfcraft-content = { path = "crates/content", version = "0.7.0" }
-pdfcraft-redact = { path = "crates/redact", version = "0.7.0" }
-pdfcraft-print = { path = "crates/print", version = "0.7.0" }
-pdfcraft-xfdf = { path = "crates/xfdf", version = "0.7.0" }
-pdfcraft-sign = { path = "crates/sign", version = "0.7.0" }
-pdfcraft-optimize = { path = "crates/optimize", version = "0.7.0" }
-pdfcraft-a11y = { path = "crates/a11y", version = "0.7.0" }
-pdfcraft-ocr = { path = "crates/ocr", version = "0.7.0" }
-pdfcraft-js = { path = "crates/js", version = "0.7.0" }
-pdfcraft-compare = { path = "crates/compare", version = "0.7.0" }
-pdfcraft-preflight = { path = "crates/preflight", version = "0.7.0" }
-pdfcraft-export = { path = "crates/export", version = "0.7.0" }
-pdfcraft-edit = { path = "crates/edit", version = "0.7.0" }
-pdfcraft-create = { path = "crates/create", version = "0.7.0" }
-pdfcraft-forms = { path = "crates/forms", version = "0.7.0" }
-pdfcraft-xfa = { path = "crates/xfa", version = "0.7.0" }
-pdfcraft-render = { path = "crates/render", version = "0.7.0" }
-pdfcraft-engine = { path = "crates/engine", version = "0.7.0" }
-pdfcraft-platform = { path = "crates/platform", version = "0.7.0" }
-pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.7.0" }
-pdfcraft-automation = { path = "crates/automation", version = "0.7.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"
@@ -110,3 +116,4 @@ hayro-jbig2 = { path = "vendor/hayro-jbig2" }
lopdf = { path = "vendor/lopdf" }
winit = { path = "vendor/winit" }
egui-wgpu = { path = "vendor/egui-wgpu" }
+vello_common = { path = "vendor/vello_common" }
diff --git a/README.md b/README.md
index b20179fff4..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
@@ -172,7 +172,7 @@ Open **Organize pages** to see every page at once:
**Saves you can trust:**
- *Incremental:* the original bytes stay byte-for-byte intact.
- *Atomic:* the file is written to a temporary copy, then swapped in.
-- *Verified:* independently checked with qpdf.
+- *Verified:* independently checked with qpdf, on every CI run.
When open documents exceed the window width, scroll over the tab strip with the mouse wheel or trackpad, or use its horizontal scrollbar. Opening or switching to a document brings its tab into view.
@@ -199,7 +199,7 @@ Every page of a combined document renders pixel-identical to its source.
```sh
pdfcraft-cli combine report.pdf appendix.pdf --out combined.pdf
-pdfcraft-cli extract report.pdf --pages 1,3,5 --out highlights.pdf
+pdfcraft-cli extract report.pdf --pages 1,3,5-8 --out highlights.pdf
pdfcraft-cli split report.pdf --every 10 --out-dir parts/
```
@@ -285,6 +285,7 @@ Every engine feature is reachable without the GUI, through one table of JSON-Sch
pdfcraft-cli run text_find doc=1 query=invoice # key=value; values parse as JSON
pdfcraft-cli run --script review.json # e.g. comment_add {"type": "highlight", "find": "total due"}
pdfcraft-cli run --script steps.json --root ./work # several steps in one session
+ pdfcraft-cli run --script steps.json --report # one JSON document: per step its number, tool, status, result or error
```
With `--root`, every file the script's steps read or write stays in that directory, including the PNG a step saves with `"out"` (the script itself is read from wherever you name it).
@@ -365,7 +366,8 @@ On top of those, two corpus sweeps run over real-world files:
- **Opening and rendering:** of the 983 pdf.js test files, 963 open and render cleanly, with 0 crashes.
- **Open, edit and save round trips:** 958 succeed.
-The output is verified with independent tools: hayro, qpdf and poppler.
+The output is verified against independent implementations: hayro and qpdf on every CI run, and
+poppler over the corpus on demand (`cargo xtask text-oracle`).
PdfCraft is a clean-room implementation. Its behaviour comes from the ISO 32000 specification and black-box observation, never from anyone else's code. Every icon, font and image is openly licensed and listed in [ATTRIBUTION.md](ATTRIBUTION.md).
@@ -415,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).
@@ -451,17 +453,40 @@ logs and crash recovery in a `PdfCraftData` folder next to `pdfcraft.exe`, so no
`%APPDATA%`; delete that file to use the normal per-user folders.
The MSI installs for all users and requires administrator privileges. It adds "Edit with PdfCraft"
-to the right-click menu of PDF files in File Explorer (on Windows 11, under "Show more options"),
-without changing which app opens PDFs by default. For unattended deployment without a desktop
-shortcut, run from an elevated terminal:
+to the right-click menu of PDF files and "Create PDF with PdfCraft…" to the right-click menu of
+images (.png .jpg .jpeg .tif .tiff .gif .bmp .jp2 .j2k .jpx) in File Explorer (on Windows 11, under
+"Show more options"), without changing which app opens them by default. For unattended deployment
+without a desktop shortcut, run from an elevated terminal:
```powershell
msiexec /i "pdfcraft--windows-x64.msi" /qn /norestart INSTALLDESKTOPSHORTCUT=0
```
-Add `INSTALLPDFCONTEXTMENU=0` to leave out the right-click menu entry. Pass both on upgrades too,
-since each install applies its own choices. Use the MSI for your architecture. Per-user
-installation overrides are not supported.
+| Property | `=0` leaves out |
+|---|---|
+| `INSTALLDESKTOPSHORTCUT` | the all-users desktop shortcut |
+| `INSTALLPDFCONTEXTMENU` | "Edit with PdfCraft" on PDF files |
+| `INSTALLIMAGECONTEXTMENU` | "Create PDF with PdfCraft…" on images |
+
+All three are on for a first install. An upgrade keeps what the installed version has: a shortcut
+or menu entry that is there stays, and one that is missing (left out at install, or a desktop
+shortcut someone deleted) stays missing, so you don't need to pass them again. A value on the
+command line always wins: `=1` puts one back and `=0` removes it. Upgrading from a release that
+didn't have a menu yet (0.6.0 and earlier for the PDF menu, 0.2.1 and earlier for the image menu)
+adds it, as a first install would. Use the MSI for your architecture. Per-user installation
+overrides are not supported.
+
+**Pin the checksum, not just the version.** Release candidates of a version share its MSI version
+(MSI versions are numbers only, so 1.2.3-rc.1 and 1.2.3 are both 1.2.3), and the installer lets an
+MSI of the same version replace the installed one without asking. Two different `0.7.1` MSIs can't
+be told apart by version, so if your deployment tool checks the file, pin its SHA-256 from
+`SHA256SUMS.txt` (`Get-FileHash -Algorithm SHA256`).
+
+**Signing identities.** The Windows MSIs and both executables are signed through Azure Trusted
+Signing with a certificate issued to `Learning Machines Inc` (Atlanta, Georgia, US). The macOS app
+and command-line tool are signed with the Developer ID `Learning Machines LLC (DJ6XS33FX8)` and
+notarized. The MSI's manufacturer, shown as the publisher in Installed apps, is
+`Learning Machines LLC`.
### macOS
@@ -556,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 bd879c6a78..0c2b8b5969 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,8 +1,29 @@
# PdfCraft roadmap
-**Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away
+> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3 #1577 branch refresh: upstream integrated, Vietnamese label added, scoped checks and visual comparison pass; estimates unchanged)
-> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 #1577: optional page-edge shadows and viewport-edge fades; native-opening candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
+**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)
+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks keep press/release together on slow frames and preserve text multi-click selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
+> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
+> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4 #1310 #1311 #1312: undo, revert and the next save keep the last saved revision; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
+> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: packed TIFF-predictor RGB rows retain their byte boundaries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -10,6 +31,7 @@
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M5 #1398: stamps read upright on rotated pages; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (gap 6: cloudy comments stay when flatten cannot draw them, a refused text-box edit keeps its properties, arrow defaults stay off lines, a custom match stops at a separator, and the vertical-text redaction error no longer claims an overlap; #1317, #1327, #1302, #1315, #1340; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M7 #1316: replacing a line keeps a neighbouring line whose glyph box overlaps; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
+> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M5: comment submission shortcuts; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M1 #1339: an unchanged incremental save of a classic cross-reference table writes a zero-length subsection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M14 Windows low-DPI font-hinting candidate measured on macOS; no Windows runtime claim; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M3: Windows' Animation effects setting is followed, and Preferences has Animation effects: system, on or off); earlier: exclusive image-crop resize follow-up; M2 short same-line redraws split before placement; estimates unchanged · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -19,6 +41,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
@@ -47,7 +70,7 @@ and add a line to the [progress log](#progress-log); when a gap closes, remove i
|---|---|---|
| **Feature breadth** | **≈ 67%** tier-weighted (56.9% unweighted: 423 shipped + 74 partial of 808; P0 94.2%) | measured, `parity/acrobat-features.toml` |
| Feature breadth, mainstream practitioner | ≈ 70% tier-weighted (63.2% unweighted; P0 95.3%) | measured ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) |
-| **Ready for real work**, full Acrobat Pro | **≈ 49%** (45–53%) | estimated, weighted sum over [dimensions](#by-dimension) |
+| **Ready for real work**, full Acrobat Pro | **≈ 50%** (45–53%) | estimated, weighted sum over [dimensions](#by-dimension) |
| **Ready for real work**, mainstream practitioner | **≈ 55%** (50–60%) | estimated ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) |
| **Ready for real work**, essentials user | **≈ 66%** (60–70%) | estimated ([method](docs/target-app-parity.md#essentials-user)) |
| Remaining to **beta** | ≈ 400–700 Opus 5.5 agent-hours | estimated |
@@ -64,7 +87,7 @@ and 245 open issues keep it far from beta.
| Audience | Ready | Opus 5.5 agent-hours to ≈ 95% | Work that dominates |
|---|---|---|---|
-| Full Acrobat Pro (ready for real work) | ≈ 49% | ≈ 780–1,400 h (≈ 300–550 h wall clock with 3–5 agents; 60–70% parallelizes) | Own renderer and fidelity harness, editing existing content, OCR and Office conversion, Preflight and PDF/A/X/UA, accessibility tagging, signatures for institutions |
+| Full Acrobat Pro (ready for real work) | ≈ 50% | ≈ 780–1,400 h (≈ 300–550 h wall clock with 3–5 agents; 60–70% parallelizes) | Own renderer and fidelity harness, editing existing content, OCR and Office conversion, Preflight and PDF/A/X/UA, accessibility tagging, signatures for institutions |
| Mainstream practitioner | ≈ 55% | ≈ 500–900 h (≈ 200–400 h wall clock; ≈ 60% parallelizes) | Rendering fidelity, editing existing text (CJK, RTL, subsets), conversion (Word layout, Excel, PowerPoint, Office→PDF), OCR languages, the bug backlog |
| Essentials user | ≈ 66% | ≈ 180–330 h (≈ 90–180 h wall clock; ≈ 50% parallelizes) | Rendering fidelity on everyday files, launch and GPU stability, simple text fixes, Word export layout, Chinese UI glyphs |
@@ -79,13 +102,13 @@ Hours are calibrated as in [Effort and calibration](docs/target-app-parity.md#ef
| File formats and conversion | ≈ 40% | 12% | 120–220 (within features) | [file-format-parity.md](docs/file-format-parity.md) |
| PDF specification and standards | ≈ 60% | 8% | 150–260 (within features) | [pdf-spec-parity.md](docs/pdf-spec-parity.md) |
| Hardware | ≈ 35% | 4% | 25–50 | [hardware-parity.md](docs/hardware-parity.md) |
-| Localization | ≈ 59% | 5% | 35–60 | [localization-parity.md](docs/localization-parity.md) |
+| Localization | ≈ 68% | 5% | 35–60 | [localization-parity.md](docs/localization-parity.md) |
| Performance | ≈ 40% | 6% | 25–45 | [gaps.md](docs/gaps.md) gap 13 |
| Stability | ≈ 50% | 8% | 45–80 | [gaps.md](docs/gaps.md) gap 6 |
| Platforms | ≈ 70% | 5% | 15–30 | [hardware-parity.md](docs/hardware-parity.md#platforms) |
| Ecosystem and plug-ins | ≈ 30% | 1% | 10–20 (within features) | [gaps.md](docs/gaps.md) |
| AI features | ≈ 0% (in scope: local providers) | 1% | 20–40 (within features) | [gaps.md](docs/gaps.md) gap 23 |
-| **Total (weighted sum)** | **≈ 49%** | 100% | **≈ 780–1,400** | |
+| **Total (weighted sum)** | **≈ 50%** | 100% | **≈ 780–1,400** | |
## Features
@@ -110,8 +133,8 @@ What's strong and missing in each: [target-app-parity.md](docs/target-app-parity
## Languages
-Measured from `crates/ui-egui/src/i18n/*.tsv` (2,143 keys). Detail and script notes:
-[localization-parity.md](docs/localization-parity.md).
+Twelve key languages (historical % snapshot vs 2,143 keys, except Vietnamese vs the current
+union). Detail and script notes: [localization-parity.md](docs/localization-parity.md).
| Language | Code | Strings | Status |
|---|---|---|---|
@@ -126,10 +149,11 @@ Measured from `crates/ui-egui/src/i18n/*.tsv` (2,143 keys). Detail and script no
| Japanese | `ja` | 99.1% | partial |
| German | `de` | 99.3% | partial |
| Korean | `ko` | 0% | none |
-| Vietnamese | `vi` | 0% | none |
+| Vietnamese | `vi` | 100% of current union | partial (dialogs / tooltips; no help) |
-Also shipped: Traditional Chinese, Russian, Bulgarian, Telugu, Hungarian, Ukrainian, Italian
-(94–100%) and Czech (menus only): **8 more languages**.
+Also shipped: Traditional Chinese, Russian, Bulgarian, Telugu, Hungarian, Ukrainian, Italian,
+Kazakh, Polish, Slovenian, Greek, Turkish, Lithuanian and Czech (menus only): **14 more catalogs**
+(23 interface languages including English).
## Upcoming
@@ -149,10 +173,93 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
+- **2026-10-18 (M3, #1577 refresh):** Integrated upstream `4db6ac4` while preserving selection scrolling, Organize page duplication and persistent redaction preferences. Added the shadow-setting label to the newer Vietnamese catalog; translation hashes refreshed. 181 scoped tests, strict UI all-target Clippy, UI WASM, formatting, assets, layering and parity pass; enabled/disabled renders inspected. Hosted CI must verify the updated PR head.
+
- 2026-10-13 — M3, #1577: optional app-wide page-edge shadows and viewport-edge fades in the viewer and Organize grid; enabled by default, persisted and controllable through the UI channel. PDF rendering and edits are 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-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.
+- **2026-10-12 (M3, drawing markups):** While a comment is being drawn, the pointer in a band along any edge of the view (or just past it) scrolls the view up, down, left or right, so points beyond what is shown can be reached. A polygon, cloud or run of connected lines takes back its last point with Backspace, Delete or Undo (the first point's removal cancels it). Edge scrolling has unit tests of its maths only; it has not been exercised end to end in the app by an automated test.
+- **2026-10-12 (gap 6, #1319, #1305, #1324):** Editing an XFA form keeps a radio item's stored text when it contains a space, so a later layout still selects it. Replace Pages on a split selection replaces those pages and leaves the gap; editing the dialog range still replaces the span. Marking a figure decorative rewrites only that figure's content stream, so a header, added text and unknown stream entries stay. Estimates unchanged.
+- **2026-10-15 (M14, gap 24):** `misc.localization` and `misc.installers` move from planned to partial. Localization: 22 catalogs ship, but Chinese shows tofu in releases, Telugu has no release font and Arabic is not mirrored. Installers: macOS and Windows releases are signed, Linux packages are not. The Acrobat menu diff itself remains open. Estimates unchanged.
+- **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design.
+
+- **2026-10-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.
+- **2026-10-12 (community, M14, NixOS UI fonts):** Linux/Unix interface fallback fonts are also discovered in Fontconfig's configured directories, including Nix store and user font paths. Existing candidate names, glyph checks, size limits and the screenshot opt-out are retained. Synthetic regressions cover included configuration, symlinks, cycles, depth limits and candidate ordering. Estimates unchanged.
+- **2026-10-12 (M7, local text-box candidate):** Add Text opens source text for editing and both text tools retype added items on one click. Draft boxes grow toward the page edge and downwards, with the grown width saved. Single visual rows grow across style boundaries, while multi-row paragraph widths stay fixed. Added-text previews remove only the selected item on a clone, with UI and read-only tool regressions. Arabic shaping, ordering and alignment are outside this candidate. Estimates unchanged.
+- **2026-10-12 (M14, #1395):** XDG desktops use the configuration base for app.ron, safely copying existing preferences without overwriting a destination. Original preferences, logs, recovery and digital IDs are retained; failed migration falls back to the original location. Portable and non-XDG locations remain unchanged; estimates unchanged.
+
+- **2026-10-12 (M10, Windows print progress, follow-up to #944):** While a job spools, the progress card counts its sheets ("Sending sheet 2 of 3 to Office…") as craft-print reports them (`submit_with_progress`, storytold/craft-libs#27), and steps aside for another long job's card. Pins craft-libs 666c358. Estimates unchanged.
+- **2026-10-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.
+- **2026-10-14 (M14, Italian catalog):** Italian gains the 201 entries the other catalogs had and the code looks up: the sentences the accessibility checker, preflight, signature validation and page-range parser word, certificate and calendar labels, digital-ID dialog messages and five plurals. It now has 2,528 entries. 64 keys other catalogs carry but no code looks up (tooltip texts with a fixed shortcut, older wordings, generic labels only Lithuanian has) were left out. AI-assisted, written from the meaning of the English; native review welcome. Estimates unchanged.
+- **2026-10-15 (M3, #2166 part 1):** Save asks before writing over a version of the file the document did not come from (something else wrote it since the open or the last save), with Save as… to keep it, and the changed-on-disk notice says what Reload and Save each do; a file that is open already comes to the front instead of opening twice; a running instance's autosaves are locked, so a second instance neither offers nor discards them. Part 2 (every writer checked and atomic) is #2182.
+- **2026-10-14 (M1, #2166):** No PDF leaves the engine unchecked and no writer truncates: new files from Combine, Extract, Split and Create, signed, timestamped and LTV-embedded files and the OCR batch output reopen with their pages before anyone writes them, as saves already did; the desktop app, the automation tools (with or without `--root`) and the CLI's edit, combine, extract and split all write through the one staging-file writer in `pdfcraft-platform`, so a failed write or `--out` naming the input never leaves a half-written file. Special files such as `/dev/stdout` are still written as given.
+- **2026-10-14 (M3 #2080 candidate):** Existing per-tab page history gains explicit search jumps, rail buttons and control-channel commands. Continuous scrolling and automatic search refresh stay outside history; page-level restoration and estimates unchanged.
+
+- **2026-10-13 (M2, page-unit transform precision):** Preserve small transform coefficients in page marks, added content and print output. Regression cases cover accepted page units of 4096 and 75000; existing page-unit interpretation and estimates are unchanged.
+- **2026-10-14 (M5, PR #1457 branch refresh):** Merged upstream main and reconciled roadmap entries while preserving comment submission shortcuts. Estimates unchanged.
+
+- **2026-10-12 (M3, page-label navigation):** The page box displays logical labels and keeps the current page when its unchanged value is submitted, including duplicate labels. Editable-control regressions also cover numeric fallback, invalid input, active typing and focused same-document replacement with changed labels or fewer pages. Clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Estimates unchanged.
+- **2026-10-13 (community, M2/M6, form-heavy documents):** An image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN spot-colour image, a 1-4-bit gray or indexed one) is converted once per possible pixel value instead of once per pixel (vendored hayro-interpret patch 28). A fillable character sheet whose 3304 × 4105 DeviceN background ran a PostScript tint transform per pixel drew each page in 3.8 s and 430 MB, on opening and again after every field was filled; it now draws in 0.11 s and 100 MB, pixel for pixel the same. The inspector places widgets without `/P` in one pass over the pages (a form of 8,000 such fields opened in 1.17 s), and the Fields panel pairs its rows with the form through one map a frame and draws only the rows in view (8,000 fields: about 15× less CPU a frame with the panel open). Regressions fail before each fix.
+- **2026-10-14 (M9, signature appearance labels):** A visible signature's labels ("Digitally signed by", "Reason: ", …) follow the interface language when Helvetica can draw all of them, and fall back to English as a set otherwise, with a note under the preview; the preview shows the same labels and the signing time. A drawn label the font can't draw is refused naming the label and character, never written as `?`. `sign_document` takes `labels` and `appearance`. Sign, automation and kittest regressions cover German, Chinese and the refusals. Estimates unchanged.
+- **2026-10-13 (M4, #1910 Organize on the current page):** Opening Organize Pages from the toolbar, tool panel, command or `--organize` selects the page being read and scrolls its cell into view, replacing a stale grid selection that did not include it. One UI regression covers a middle page, the last page and reopening after an earlier selection. Estimates unchanged.
+- **2026-10-13 (M5, signature pad undo):** The Draw tab of Create signature/initials has an Undo button and Ctrl/Cmd+Z that take back the last stroke (#1963). Pad is UI-only, so no automation tool. Estimates unchanged.
+- **2026-10-13 (M2, raw crop and page-unit floor):** The renderer applies its existing one-point minimum in physical points for non-nearly-zero raw crop dimensions on scaled pages. Five synthetic regressions cover inspection, crop origins and rotations, reciprocal rounding and bounded raster controls. Existing UserUnit policy, nearly-zero fallback, feature statuses and estimates are unchanged.
+- **2026-10-13 (M5, text box font size, #1908):** Comment Properties has a Font size control for text boxes, typewriter text and callouts (1-400 pt); it writes `/DA` and the rich-text strings, redraws the appearance and grows the box to fit. `comment_edit` takes `font_size`. Annot, tool and UI regressions. Estimates unchanged.
+- **2026-10-13 (M3, Single Page edge paging, #1924):** In Single Page view a page taller than the window pans with the wheel, and one more notch (or a fresh trackpad swipe) at the bottom/top edge shows the next page at its top / the previous page at its bottom. A swipe that panned to the edge, momentum included, does not turn. Estimates unchanged.
+- **2026-10-13 (M3, #1980):** Ctrl+Alt+Q in the digital ID email field no longer requests application exit. Quit requires exact Ctrl+Q/Cmd+Q modifiers and uses the existing unsaved-document confirmation. Synthetic keyboard regressions cover email input and clean/dirty quit requests; estimates unchanged.
+
+- **2026-10-14 (M4, page transitions read, §12.4.4):** `/Trans` and `/Dur` are read into `DocInfo::transitions` and listed by `doc_info`, the data a full-screen presentation needs to play transitions and auto-advance; playing and editing them stay planned.
+- **2026-10-14 (M4, page transitions set, §12.4.4):** `page_transition_set` (one undoable edit) writes or removes `/Trans` and `/Dur` on chosen pages with the values checked first, the counterpart of reading them into `DocInfo::transitions`; playing them and the dialog stay planned.
+- **2026-10-14 (M12, transition geometry):** `pdfcraft_render::transition` gives the picture of any of the twelve page transitions at a moment of its run (which parts of the old and new page to paint, where, how opaque), in page fractions, tested for every style and direction; the full-screen presentation will paint from it once it plays transitions.
+- **2026-10-14 (M3, Open in Full Screen mode honoured):** a document with `/PageMode /FullScreen` now asks (Full Screen / Not now) before the app enters full screen, as Acrobat does; before, the setting was written and read but never acted on when opening.
+- **2026-10-14 (M3, control-click candidate):** Coordinate clicks retain hover warmup and pair press/release in one input pass so slow frames do not turn a click into a hold. Text multi-click selection accepts the actual press event when egui has already cleared its origin. Regression cases cover slow-frame button activation, disabled controls and coalesced text selection; estimates unchanged.
+- **2026-10-14 (community, M7, #128 Hebrew in Add text):** Add text and `page_add_text` draw Hebrew with the craft-fonts Hebr face (Noto Sans Hebrew), through the same shaped path as Arabic and Thai: right-to-left display order with numbers and Latin in place, points and cantillation kept on their letter as one Type 3 glyph, ToUnicode and per-line ActualText so extraction, search and the editor get typing order. Builds without the face refuse Hebrew rather than write `?`. Regular style only. Estimates unchanged.
+- **2026-10-14 (M9, #1787):** Changes after signing are judged by the role an object held when signed for a page's resources too: a rewritten font, XObject, graphics state or resources dictionary is page content however its new version is labelled, so a `/FT` or `/Subtype /Widget` added by the attacker no longer reads as a permitted form fill. A category dictionary that only gained a name is judged as before.
- **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged.
+- **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged.
+- **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged.
+- **2026-10-13 (M6, choice indices):** Multi-select choice fields write ascending `/I` option indices while preserving the caller's `/V` order. Synthetic forms and headless engine regressions cover reverse selection and save/reopen. Estimates unchanged.
+
- **2026-10-12 (M10, Windows printing without PowerShell, #1107):** craft-print now prints on Windows in-process: printers from EnumPrinters/GetDefaultPrinter, jobs drawn as vectors by Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling), so printing works where App Control for Business or AppLocker run PowerShell in Constrained Language Mode. Portrait and landscape sheets still print as one job (turned with /Rotate); a sheet size the driver lacks is refused rather than shrunk. Pins craft-libs f03ec27 (craft-libs#28); deny.toml allows the storytold/winprint.rs fork and adds a webpki-root-certs licence exception and a RUSTSEC-2024-0370 ignore for winprint's build-dependencies. An earlier craft-print revision was checked in Windows Sandbox under enforced WDAC: printers listed, CLI and desktop jobs spooled, and no powershell.exe in the 4688 audit. The final pinned revision passed craft-libs Windows CI, including a real paused-queue job; that Sandbox run has not been repeated. Estimates unchanged.
+- **2026-10-12 (M1, #1338):** A full rewrite used to build a new trailer from `/Root`, `/Info`, `/Encrypt` and `/ID`, so accepted unknown entries such as a custom string or `/Version` disappeared, including when an incremental save was forced to rewrite. Those entries are copied, and objects reachable only from them are written and renumbered with the rest of the file. Ordinary incremental saves are unchanged. Estimates unchanged.
+- **2026-10-12 (M4, #1310, #1311, #1312):** Undo back to the opened file left it marked unsaved, so recovery and close treated unchanged work as dirty. After a save, Revert followed the restored snapshot's older base and the next save dropped the latest saved revision. The last saved file is remembered on its own: undo and redo are clean only when they land on it, Revert opens it, and a later save appends to it. Estimates unchanged.
- **2026-10-12 (M13, CLI robustness scans):** `pdfcraft-cli check` visits a directory once even when input roots overlap or directory symlinks lead back into the tree. PDFs reached through file symlinks remain accepted. Three real CLI regressions cover overlapping roots, a directory cycle and directory/file aliases. Estimates unchanged.
- **2026-10-12 (M4, Type 3 extraction fidelity):** Extract and Split preserve shared image/resource dictionaries reached through Type 3 glyphs, including nested forms, patterns and soft masks. A bounded object-graph walk protects unknown glyph content; unreadable pages/forms retain their glyph resources, and shared explicit fonts are visited once. Separate page dictionaries still prune unused images. Synthetic save/reopen regressions and an opt-in Poppler pixel oracle cover resource ownership, nested forms, cycles, limits and repeated fonts. Estimates unchanged.
- **2026-10-12 (M2, TIFF predictor rows):** Packed RGB images using TIFF horizontal differencing keep every row when samples do not fill the last byte. The renderer aligns each decoded row before moving to the next. Synthetic decoding tests cover 1/2/4-bit samples, horizontal differencing and an 8-bit control; a red/green page-render regression verifies visible output. Estimates unchanged.
@@ -164,6 +271,9 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-12 (gap 6, #1317, #1327, #1302, #1315, #1340):** Flattening keeps a visible comment it cannot draw, such as a cloudy square with no stored appearance, and still draws a solid square, a hidden comment and a cloudy square that already has an appearance. A text box another program drew is left unchanged when a restyle or resize is refused. Make Current Properties Default on an arrow updates the Arrow tool and leaves plain lines alone. A custom text match that ends on a reconstructed word or line separator no longer includes the next glyph. The vertical-writing redaction refusal still fails closed, and its message no longer says the text was under the mark. Estimates unchanged.
- **2026-10-12 (M12, text-only auto-tag):** Automatically tag PDF now writes marked text objects, paragraph tags, a Document element and a parent tree for untagged text-only PDFs. The result is undoable and opens the Tags panel for review. Mixed paint, forms, annotations, existing tags and malformed streams are refused before mutation. Synthetic two-page engine and tool tests check the tree, checker, unchanged rendering and save/reopen. Richer content and role classification remain open. Estimates unchanged.
- **2026-10-12 (M7, #1316):** Replacing a line or paragraph no longer removes a distinct neighbour whose glyph box overlaps the edited line. A second drawing is removed only when it also sits on the same baseline, so fake-bold and invisible copies still go. Synthetic regressions cover 6 pt leading, the same word on the next line, ordinary spacing, a separate paragraph, same-position copies, and incremental and full save/reopen. Estimates unchanged.
+- **2026-10-12 (M5, comment shortcuts):** Ctrl+Enter posts highlight comment text and replies; Shift+Enter adds a newline. Replies keep Enter-to-post, and floating comment composers only submit from their focused editor. Estimates unchanged.
+
+- **2026-10-12 (M7, #1443):** Adding a word to a multi-line paragraph no longer reflows or merges its lines. Two causes: in Chrome- and Skia-made subset fonts, `/W` leaves out glyphs of the `/DW` width, which were treated as missing, so unchanged rows measured too wide and rewrapped (and new letters fell back to Helvetica); and lines drawn turned on the page (a landscape page drawn a quarter turn round) were compared along the page's x axis, so all of them became one line. Lines and rows are now found along each text's own baseline, a turned paragraph is rewritten in its own text space, and that rewrite keeps explicit line breaks. Formatting part of turned text is refused rather than misplaced. Synthetic regressions cover three turns and a default-width subset. Estimates unchanged.
- **2026-10-12 (M1, #1339):** An incremental save of an unchanged classic-table file used to append `xref` and then `trailer` with no subsection, which qpdf reports as a damaged file. The section now has a zero-length subsection, so no object entry changes and a trailer-only edit is still written. Estimates unchanged.
- **2026-10-12 (M12, auto-tag preview):** The engine and `accessibility_autotag_preview` propose paragraph and large-heading roles for bounded text blocks on one page, with locations and reasons. Every proposal needs review; images remain unclassified. No PDF tags are written yet. Synthetic tool tests cover normal text, a larger heading, bounds and unchanged source bytes. Estimates unchanged.
- **2026-10-12 (M10, editing OCR text on scans):** Editing a recognised word on a scanned page now changes what the page shows. Before, the rewrite stayed invisible OCR text, so the scan still showed the old word; on a form, the whole OCR layer also grouped into one paragraph that rewrapped when one word changed. OCR words now group into phrases (a table cell or a tab stop splits them, and lines stay apart); an edit draws the phrase visibly in the scan's sampled ink colour over a cover in the sampled paper colour, keeping each word's width and growing rightwards. The editor covers the scanned word while typing. OCR words are now sized from their letters (a capitals-only word's box spans the cap height, not the full ascender-descender span), so recognised and rewritten text is as tall as the scanned word and sits on its baseline. Five engine tests on synthetic scans and a kittest UI test; `ocr.editable-text-output` is partial.
@@ -177,6 +287,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-12 (M12, XFA scripts turned off):** When a runaway script turns an XFA form's scripts off, the app now says so: a toast as it happens (at open or after a click), and the notice bar from then on, ahead of its other notices, with a JavaScript Console button. Before, the error was only in the console, so calculations and buttons just stopped. A notice bar the user had closed comes back for it; closed again, it stays closed. The engine reports the state (`Document::xfa_scripts_off`, and `xfa_scripts_off` in `doc_info`) instead of the UI reading error text. Two kittest UI tests (a runaway at open; a runaway click after the bar was closed), engine assertions and a `doc_info` tool test. Overall unchanged.
- **2026-10-12 (M14, Windows low-DPI font-hinting candidate):** The Windows policy disables symmetric rendering for embedded JetBrains Mono only. On the locked-font macOS probe, horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150% scaling, and is down 0.79% at 200%; Inter is pixel-identical. Logical layout hashes and 8 MiB atlas size match, repeated fractional offsets add no atlas dimensions, and baseline/candidate time and RSS ranges overlap. Windows runtime and hardware remain unverified; estimates unchanged.
- **2026-10-12 (M7, #1200 candidate):** Bounded local GoToR file specifications open in desktop tabs; explicit numeric page views apply, while named destinations open at the default view with a warning. The automation tool resolves relative to the source and uses existing `--root` confinement. Appearance-only Link Properties edits preserve the existing action. URL/network paths remain refused; `/NewWindow` is a tab hint. Estimates unchanged.
+- **2026-10-12 (M1, #1337):** COS opening repairs exhausted cross-reference entries and records the skipped row; allocation safely falls back to unused numbers for exhausted /Size hints and high edits. Classic-table and stream regressions reproduce the former panic; ordinary, zero-offset and repaired-range controls preserve opening and allocation. Estimates unchanged.
- **2026-10-12 (M7, private image crops):** Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged.
- **2026-10-12 (M12, accessibility finding locations):** Checker findings for figures, annotations and form widgets carry verified page bounds. Selecting one flashes its location; the headless check returns the same PDF-space rectangle. Malformed annotation rectangles remain page-only findings. Guided review and tagging estimates unchanged.
@@ -202,6 +313,7 @@ Newest first: the date, what moved, and the new overall estimate.
- **2026-10-12 (M3, GNOME title bar, #553):** On GNOME's Wayland session, whose compositor draws no window frames (it offers no `zxdg_decoration_manager_v1`), PdfCraft no longer shows winit's fallback title bar: it turns the frame off and the tab strip becomes the header bar, as in GNOME's own apps. Minimize, maximize/restore and close are drawn in Adwaita's style, in the order and on the side GNOME's `button-layout` names (Fedora's default shows only close); the window's edges and corners resize it, and a hairline outlines it while it isn't maximized. Preferences ▸ Use system title bar (shown only there) gives the window back winit's frame at once, without a restart (part of #1001), and `PDFCRAFT_TITLEBAR=app|system` overrides the choice. KDE, Sway, Hyprland, COSMIC and X11 keep the frame their desktop draws. Not done: right-clicking the header bar doesn't open GNOME's window menu (egui can't ask for it), the window has no shadow or rounded corners, the resize edges lie just inside the window rather than outside it, and Windows still uses its system frame (the rest of #1001). Estimates unchanged.
- **2026-10-12 (M0, build label):** A local or patched build can carry a label, set at compile time with `PDFCRAFT_BUILD_LABEL`: `pdfcraft --version`, `pdfcraft-cli --version`, About and the log show `0.7.1 (titlebar)`, and `packaging/linux/package.sh` names the files `pdfcraft-0.7.1-titlebar-…` and makes the .rpm/.deb release `1.titlebar`, which upgrades over the release of the same version (and the next release over it). The version itself is unchanged, so the update check still compares it with published releases. Unlabelled builds are as before. Estimates unchanged.
+- **2026-10-11 (M6, #1208):** Dropdown and list-box Field Properties expose the existing Left/Center/Right alignment picker. Existing alignment stays intact until changed; cancel, undo/redo and save/reopen are covered through the real UI without changing options, values or flags. Estimates unchanged.
- 2026-10-11: Organize Pages shows an editable, synchronized physical page-range selection (#1092), with bounded validation and UI/operation regression coverage; overall estimate unchanged.
- **2026-10-11 (M5.7, scanned signatures, #1110):** Fill & Sign ▸ Add signature has a Scan tab: a phone photo of a signature on paper is framed automatically (all words; table, sheet edge and dust ignored; a sideways signature turned upright), the frame can be moved, resized or redrawn, the photo turned, and an ink-sensitivity slider picks up faint ink. A morphological paper estimate removes shadows and uneven light, and the trimmed ink is saved and reused like an image signature. Photos and images can also be dropped on the Image and Scan tabs. Photos keep the image-signature limits (#1124: a 4 MiB file, up to 32 megapixels decoded, scaled down to 4 megapixels), so a scan never processes more than 4 megapixels. `fill_sign_add` takes `scan` and `sensitivity`. Synthetic engine, UI and automation tests; checked by hand on real phone photos. Milestone and effort estimates unchanged.
@@ -222,6 +334,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.
@@ -427,8 +540,24 @@ 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. |
+| 2026-10-14 | minor | M7/#128: Hebrew in Add text with the craft-fonts Hebr face (display order, points, ToUnicode, ActualText); estimates unchanged. |
| 2026-10-12 | minor | M3/#1364 candidate: absolute local GoToR links require desktop consent or headless allow_absolute; existing relative navigation and validation retained. Estimates unchanged. |
| 2026-10-12 | minor | M8: redaction retires inherited `/Resources` so a parent `/Pages` can't keep a removed XObject reachable; only redacted pages that inherit are pinned (one shared reference), unredacted pages are untouched; estimates unchanged |
| 2026-10-12 | minor | M7/#1200 candidate: bounded local GoToR opening, root-confined automation, and action-preserving appearance edits; named destinations and window behavior remain partial; estimates unchanged. |
@@ -445,5 +574,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 abe2657fbc..bab8c28a88 100644
--- a/apps/pdfcraft-cli/Cargo.toml
+++ b/apps/pdfcraft-cli/Cargo.toml
@@ -18,7 +18,9 @@ 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"
[target.'cfg(unix)'.dependencies]
diff --git a/apps/pdfcraft-cli/src/automation_args.rs b/apps/pdfcraft-cli/src/automation_args.rs
new file mode 100644
index 0000000000..868cdf66a0
--- /dev/null
+++ b/apps/pdfcraft-cli/src/automation_args.rs
@@ -0,0 +1,90 @@
+//! Parse `--automation-read-root` / `--automation-write-root` before the server starts. A malformed
+//! flag must not silently remove a root and leave the server unconfined.
+
+use std::path::PathBuf;
+
+pub struct AutomationArgs {
+ pub read_root: Option,
+ pub write_root: Option,
+ read_given: bool,
+ write_given: bool,
+}
+
+impl AutomationArgs {
+ pub fn new(read_root: Option, write_root: Option) -> Self {
+ Self { read_root, write_root, read_given: false, write_given: false }
+ }
+
+ /// Returns whether this argument is a root flag. A second occurrence of a flag is an error,
+ /// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed.
+ pub fn consume(&mut self, arg: &str, args: &mut impl Iterator) -> Result {
+ let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
+ let (root, given) = match flag {
+ "--automation-read-root" => (&mut self.read_root, &mut self.read_given),
+ "--automation-write-root" => (&mut self.write_root, &mut self.write_given),
+ _ => return Ok(false),
+ };
+ if *given {
+ return Err(format!("{flag} given twice"));
+ }
+ let value = match inline {
+ Some(value) => value.to_owned(),
+ None => {
+ let value = args.next().ok_or_else(|| format!("{flag} needs a folder"))?;
+ if value.starts_with('-') {
+ return Err(format!("{flag} needs a folder; use {flag}= for a folder starting with '-'"));
+ }
+ value
+ }
+ };
+ if value.is_empty() {
+ return Err(format!("{flag} needs a non-empty folder"));
+ }
+ *root = Some(value.into());
+ *given = true;
+ Ok(true)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roots_are_taken_once_and_other_arguments_are_left() {
+ let mut roots = AutomationArgs::new(None, None);
+ let mut args = ["a folder".to_owned(), "report.pdf".to_owned()].into_iter();
+ assert!(roots.consume("--automation-read-root", &mut args).unwrap());
+ assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
+ assert!(!roots.consume("--compact", &mut args).unwrap());
+ assert_eq!(roots.read_root, Some("a folder".into()));
+ assert_eq!(roots.write_root, Some("out".into()));
+ assert_eq!(args.next().as_deref(), Some("report.pdf"));
+ assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
+ assert_eq!(roots.read_root, Some("a folder".into()));
+ }
+
+ #[test]
+ fn invalid_operands_leave_roots_unchanged() {
+ for flag in ["--automation-read-root", "--automation-write-root"] {
+ for operand in [None, Some(""), Some("--version"), Some("--root"), Some("-folder")] {
+ let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
+ let mut args = operand.map(str::to_owned).into_iter();
+ assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
+ assert_eq!(roots.read_root, Some("read".into()));
+ assert_eq!(roots.write_root, Some("write".into()));
+ }
+ let mut roots = AutomationArgs::new(None, None);
+ assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
+ }
+ }
+
+ #[test]
+ fn explicit_dash_folder_and_relative_dash_folder_are_valid() {
+ let mut roots = AutomationArgs::new(None, None);
+ assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
+ assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
+ assert_eq!(roots.read_root, Some("-folder".into()));
+ assert_eq!(roots.write_root, Some("./-folder".into()));
+ }
+}
diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs
index 190cb3da9f..5888e51114 100644
--- a/apps/pdfcraft-cli/src/main.rs
+++ b/apps/pdfcraft-cli/src/main.rs
@@ -17,6 +17,8 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+#[cfg(feature = "mcp")]
+mod automation_args;
mod batch;
use std::io::Write;
@@ -33,8 +35,14 @@ use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind, in
macro_rules! mcp_usage {
() => {
"\
-pdfcraft-cli mcp [--root DIR] [--compact] MCP server on stdin/stdout (opt-in)
+pdfcraft-cli mcp [--root DIR] [--compact] [--automation-read-root DIR] [--automation-write-root DIR]
+ MCP server on stdin/stdout (opt-in)
--compact lists a core set of tools plus tool_search and tool_call
+ --automation-read-root / --automation-write-root confine the files the
+ agent's tools read (open, combine, import, fonts, certificates…) and
+ write (save, export, extract, split, reports…) to DIR, links followed;
+ a root left out grants none of its access. --root, when also given,
+ applies as well
"
};
}
@@ -49,13 +57,14 @@ 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]
[--insert-blank 1] [--title T] [--author A] [--full]
(options run in order; page numbers refer to the document after the earlier options)
pdfcraft-cli combine … --out combined.pdf
-pdfcraft-cli extract --pages 1,3,5 --out out.pdf
+pdfcraft-cli extract --pages 1,3,5-8 --out out.pdf (pages and ranges, in the order given)
pdfcraft-cli split (--every N | --before 3,7) [--out-dir DIR]
pdfcraft-cli check [--timeout 20] [--dpi 36] [--json out.json]
pdfcraft-cli audit [--password PW] find faux redactions (fails when found)
@@ -64,7 +73,8 @@ pdfcraft-cli batch --steps convert,ocr,optimize --out-dir DIR
[--ppi 150] [--quality 60] [--dpi 300] [--language en] [--ocr-all-pages]
pdfcraft-cli tools automation tools and their JSON Schemas
pdfcraft-cli run [key=value …] [--root DIR] [--out image.png]
-pdfcraft-cli run --script steps.json [--root DIR] [{\"tool\": \"doc_open\", \"args\": {…}}, …]
+pdfcraft-cli run --script steps.json [--root DIR] [--report]
+ [{\"tool\": \"doc_open\", \"args\": {…}}, …]; --report prints one JSON document for the run
",
mcp_usage!(),
"\
@@ -161,15 +171,20 @@ fn audit(args: &[String]) -> Result<(), CliError> {
validate_options("audit", args, AUDIT_OPTIONS)?;
let path = *positional(args).first().ok_or("audit: missing file")?;
let bytes = read(path)?;
- let doc = pdfcraft_cos::Document::open_with_password(bytes, flag(args, "--password")).map_err(|e| format!("{path}: {e}"))?;
+ let password = flag(args, "--password");
+ let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map_err(|e| format!("{path}: {e}"))?;
let findings = pdfcraft_audit::audit_redactions(&doc).map_err(|e| format!("{path}: {e}"))?;
+ // Rectangles in points from the top-left of the displayed page, as `doc_audit_redactions`
+ // reports them; the page geometry is read only when there is something to place.
+ let info = if findings.is_empty() { None } else { Some(inspect(bytes, password).map_err(|e| format!("{path}: {e}"))?) };
let items: Vec = findings
.into_iter()
.map(|f| {
+ let rect = f.rect.map(|v| v as f32);
serde_json::json!({
"page": f.page + 1,
"kind": f.kind.id(),
- "rect": f.rect,
+ "rect": info.as_ref().map_or(rect, |i| pdfcraft_automation::view_rect(i, f.page, rect)),
"covered_text": f.covered_text,
})
})
@@ -251,6 +266,7 @@ const RUN_OPTIONS: &[OptionSpec] = &[
OptionSpec { name: "--root", takes_value: true },
OptionSpec { name: "--script", takes_value: true },
OptionSpec { name: "--out", takes_value: true },
+ OptionSpec { name: "--report", takes_value: false },
];
const UI_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--control", takes_value: true }, OptionSpec { name: "--out", takes_value: true }];
@@ -283,28 +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,
});
@@ -332,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
@@ -343,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(),
@@ -376,9 +412,30 @@ fn text(args: &[String]) -> Result<(), CliError> {
}
}
-/// 1-based page list ("1,3,5") → 0-based indices.
+/// The most pages one `A-B` range may name: a bound on what a typed range can allocate.
+const MAX_RANGE: usize = 1_000_000;
+
+/// 1-based pages ("1,3,5-8": single pages and inclusive ranges, in the order given) → 0-based
+/// indices (#1923).
fn page_list(s: &str) -> Result, String> {
- s.split(',').map(|p| p.trim().parse::().ok().filter(|n| *n > 0).map(|n| n - 1).ok_or(format!("bad page number {p:?}"))).collect()
+ let number = |t: &str, item: &str| t.trim().parse::().ok().filter(|n| *n > 0).ok_or_else(|| format!("bad page number {item:?}"));
+ let mut out = Vec::new();
+ for item in s.split(',').map(str::trim) {
+ match item.split_once('-') {
+ Some((from, to)) => {
+ let (from, to) = (number(from, item)?, number(to, item)?);
+ if to < from {
+ return Err(format!("bad page range {item:?}: it ends before it starts"));
+ }
+ if to - from >= MAX_RANGE {
+ return Err(format!("bad page range {item:?}: more than {MAX_RANGE} pages"));
+ }
+ out.extend(from - 1..to);
+ }
+ None => out.push(number(item, item)? - 1),
+ }
+ }
+ Ok(out)
}
/// The options `edit` understands, for its "unknown option" message.
@@ -469,7 +526,16 @@ fn edit(args: &[String]) -> Result<(), CliError> {
session.apply(id, e).map_err(|e| e.to_string())?;
}
let bytes = if full { session.save_full_bytes(id) } else { session.save_bytes(id) }.map_err(|e| e.to_string())?;
- std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}").into())
+ // As the app's Save: the file must reopen with its pages before it replaces anything (#1698).
+ session.check_save(id, &bytes).map_err(|e| e.to_string())?;
+ write_pdf(Path::new(out), &bytes)
+}
+
+/// Write a PDF a command produced, through a staging file beside the target (#2166): a write
+/// that fails, or `--out` naming the input, never leaves a truncated file where a good one was.
+fn write_pdf(out: &Path, bytes: &[u8]) -> Result<(), CliError> {
+ use pdfcraft_platform::staging::{StagingName, write_atomically};
+ write_atomically(out, bytes, StagingName::SuffixThenTag).map_err(|e| format!("{}: {e}", out.display()).into())
}
fn file_stem(path: &str) -> String {
@@ -485,7 +551,7 @@ fn combine(args: &[String]) -> Result<(), CliError> {
}
let sources = inputs.iter().map(|p| Ok((file_stem(p), read(p)?))).collect::, String>>()?;
let bytes = pdfcraft_engine::Session::new().combine(&sources).map_err(|e| e.to_string())?;
- std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}").into())
+ write_pdf(Path::new(out), &bytes)
}
fn extract(args: &[String]) -> Result<(), CliError> {
@@ -496,7 +562,7 @@ fn extract(args: &[String]) -> Result<(), CliError> {
let mut session = pdfcraft_engine::Session::new();
let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?;
let bytes = session.extract(id, &pages).map_err(|e| e.to_string())?;
- std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}").into())
+ write_pdf(Path::new(out), &bytes)
}
fn split(args: &[String]) -> Result<(), CliError> {
@@ -518,7 +584,7 @@ fn split(args: &[String]) -> Result<(), CliError> {
std::fs::create_dir_all(&dir).map_err(|e| format!("split: --out-dir {}: {e}", dir.display()))?;
for (a, b, bytes) in parts {
let name = dir.join(if a == b { format!("{stem}-p{a}.pdf") } else { format!("{stem}-p{a}-{b}.pdf") });
- std::fs::write(&name, bytes.as_slice()).map_err(|e| format!("{}: {e}", name.display()))?;
+ write_pdf(&name, &bytes)?;
stdout_line(format_args!("{}", name.display()))?;
}
Ok(())
@@ -539,16 +605,25 @@ fn render(args: &[String]) -> Result<(), CliError> {
Some("pam") => None,
_ => return Err(format!("render: --out {out}: use a .png, .jpg, .tif or .pam name").into()),
};
- let mut r = PageRenderer::new(
- read(path)?,
- RenderConfig { password: flag(args, "--password").map(Arc::from), reject_oversize: true, ..Default::default() },
- );
+ // 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 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();
@@ -757,22 +832,88 @@ fn print_output(auto: &pdfcraft_automation::Automation, content: Vec stdout_line(format_args!(
- "{}",
- serde_json::json!({ "image": "png", "width": width, "height": height, "bytes": data.len(), "hint": "pass --out file.png to save it" })
- ))?,
+ None => stdout_line(format_args!("{}", png_summary(&data, width, height)))?,
},
}
}
Ok(())
}
+/// What a PNG result is reported as when it isn't written to a file.
+fn png_summary(data: &[u8], width: u32, height: u32) -> serde_json::Value {
+ serde_json::json!({ "image": "png", "width": width, "height": height, "bytes": data.len(), "hint": "pass --out file.png to save it" })
+}
+
+/// A tool's result as JSON values, for the `--report` of a script. Same content as
+/// [`print_output`], collected instead of printed.
+fn output_values(
+ auto: &pdfcraft_automation::Automation,
+ content: Vec,
+ out: Option<&str>,
+) -> Result, String> {
+ let mut values = Vec::new();
+ for c in content {
+ values.push(match c {
+ pdfcraft_automation::Content::Json(v) => v,
+ pdfcraft_automation::Content::Png { data, width, height } => match out {
+ Some(path) => {
+ let written = auto.write_output(path, &data).map_err(|e| e.to_string())?;
+ serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height })
+ }
+ None => png_summary(&data, width, height),
+ },
+ });
+ }
+ Ok(values)
+}
+
+/// Run a script and print one JSON document: per step its number, tool and `ok`/`error`/`skipped`
+/// with the result or the message. The first failing step ends the run, as without `--report`;
+/// the steps after it are listed as skipped and the exit status is the same.
+fn run_script_report(auto: &mut pdfcraft_automation::Automation, steps: &[serde_json::Value]) -> Result<(), CliError> {
+ let mut report = Vec::with_capacity(steps.len());
+ let mut failure: Option = None;
+ for (i, step) in steps.iter().enumerate() {
+ let n = i + 1;
+ let tool = step["tool"].as_str();
+ if failure.is_some() {
+ report.push(serde_json::json!({ "step": n, "tool": tool, "status": "skipped" }));
+ continue;
+ }
+ let outcome = match tool {
+ None => Err(format!("step {n}: missing \"tool\"")),
+ Some(t) => auto
+ .call(t, &step["args"])
+ .map_err(|e| e.to_string())
+ .and_then(|content| output_values(auto, content, step["out"].as_str()))
+ .map_err(|e| format!("step {n} ({t}): {e}")),
+ };
+ match outcome {
+ Ok(result) => report.push(serde_json::json!({ "step": n, "tool": tool, "status": "ok", "result": result })),
+ Err(message) => {
+ report.push(serde_json::json!({ "step": n, "tool": tool, "status": "error", "error": message }));
+ failure = Some(message);
+ }
+ }
+ }
+ let document = serde_json::json!({ "ok": failure.is_none(), "steps": report });
+ stdout_line(format_args!("{}", serde_json::to_string_pretty(&document).unwrap_or_default()))?;
+ failure.map_or(Ok(()), |message| Err(CliError::Message(message)))
+}
+
fn run(args: &[String]) -> Result<(), CliError> {
validate_options("run", args, RUN_OPTIONS)?;
+ let report = args.iter().any(|a| a == "--report");
+ if report && flag(args, "--script").is_none() {
+ return Err("run: --report needs --script".to_string().into());
+ }
let mut auto = automation(args)?;
if let Some(script) = flag(args, "--script") {
let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?;
let steps: Vec = serde_json::from_str(&text).map_err(|e| format!("{script}: {e}"))?;
+ if report {
+ return run_script_report(&mut auto, &steps);
+ }
for (i, step) in steps.iter().enumerate() {
let tool = step["tool"].as_str().ok_or(format!("step {}: missing \"tool\"", i + 1))?;
let content = auto.call(tool, &step["args"]).map_err(|e| format!("step {} ({tool}): {e}", i + 1))?;
@@ -794,11 +935,42 @@ fn run(args: &[String]) -> Result<(), CliError> {
print_output(&auto, content, flag(args, "--out"))
}
+/// Take `--automation-read-root` / `--automation-write-root` (each `--flag DIR` or `--flag=DIR`,
+/// once) out of `args`, and open the roots, which must be existing folders. Returns the arguments
+/// that are left. A malformed root flag is an error, never a root silently left out.
+#[cfg(feature = "mcp")]
+fn automation_roots(command: &str, args: &[String]) -> Result<(Option>, Vec), String> {
+ let mut found = automation_args::AutomationArgs::new(None, None);
+ let mut left = Vec::new();
+ let mut it = args.iter().cloned();
+ while let Some(arg) = it.next() {
+ if !found.consume(&arg, &mut it).map_err(|e| format!("{command}: {e}"))? {
+ left.push(arg);
+ }
+ }
+ let roots = pdfcraft_automation::file_access::AutomationRoots::new(found.read_root.as_deref(), found.write_root.as_deref())
+ .map_err(|e| format!("{command}: {e}"))?;
+ Ok((roots, left))
+}
+
#[cfg(feature = "mcp")]
fn mcp(args: &[String]) -> Result<(), CliError> {
+ let (roots, args) = automation_roots("mcp", args)?;
+ let args = args.as_slice();
validate_options("mcp", args, MCP_OPTIONS)?;
let compact = args.iter().any(|a| a == "--compact");
- let mut server = pdfcraft_automation::mcp::McpServer::new(automation(args)?).with_compact(compact);
+ let mut auto = automation(args)?;
+ if let Some(roots) = roots {
+ let show = |root: Option<&Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
+ let _ = writeln!(
+ std::io::stderr().lock(),
+ "pdfcraft-cli: files confined: read root {}, write root {}",
+ show(roots.read_root()),
+ show(roots.write_root())
+ );
+ auto = auto.with_automation_roots(Some(roots));
+ }
+ let mut server = pdfcraft_automation::mcp::McpServer::new(auto).with_compact(compact);
let _ = writeln!(
std::io::stderr().lock(),
"pdfcraft-cli: MCP server on stdio (protocol {}{}); close stdin to stop",
diff --git a/apps/pdfcraft-cli/tests/audit.rs b/apps/pdfcraft-cli/tests/audit.rs
new file mode 100644
index 0000000000..eb99fa11d5
--- /dev/null
+++ b/apps/pdfcraft-cli/tests/audit.rs
@@ -0,0 +1,66 @@
+//! `audit` reports the rectangles of faux redactions where `doc_audit_redactions` does: in
+//! points from the top-left of the page as displayed, after `/Rotate`.
+
+use std::process::Command;
+
+const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
+
+/// Two US Letter pages with "TOP SECRET" under an opaque `90 688 220 32 re`; the second page is
+/// turned a quarter (`/Rotate 90`).
+fn fixture() -> Vec {
+ let body = "BT /F1 24 Tf 100 700 Td (TOP SECRET) Tj ET\n0 g\n90 688 220 32 re f";
+ let objs: Vec = vec![
+ "<< /Type /Catalog /Pages 2 0 R >>".into(),
+ "<< /Type /Pages /Kids [4 0 R 5 0 R] /Count 2 /MediaBox [0 0 612 792] >>".into(),
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
+ "<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 3 0 R >> >> >>".into(),
+ "<< /Type /Page /Parent 2 0 R /Rotate 90 /Contents 6 0 R /Resources << /Font << /F1 3 0 R >> >> >>".into(),
+ format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
+ ];
+ let mut out = b"%PDF-1.7\n".to_vec();
+ let mut offsets = Vec::new();
+ for (i, o) in objs.iter().enumerate() {
+ offsets.push(out.len());
+ out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
+ }
+ let xref = out.len();
+ out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
+ for o in offsets {
+ out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
+ }
+ out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
+ out
+}
+
+/// The `rect` of each finding, in page order.
+fn rects(report: &serde_json::Value) -> Vec {
+ report["findings"].as_array().expect("findings").iter().map(|f| f["rect"].clone()).collect()
+}
+
+#[test]
+fn audit_rectangles_match_the_tool_in_top_left_points() {
+ let dir = std::env::temp_dir().join(format!("pdfcraft-cli-audit-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ std::fs::write(dir.join("faux.pdf"), fixture()).unwrap();
+
+ let out = Command::new(BIN).arg("audit").arg(dir.join("faux.pdf")).output().unwrap();
+ assert!(!out.status.success(), "findings must fail the audit");
+ let cli: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
+ assert_eq!(cli["count"], 2, "{cli}");
+ // Page 1 upright: y measured down from the top of the 792 pt page. Page 2 turned a quarter
+ // clockwise: 792 pt wide, and the cover's user-space y becomes its x.
+ let expected = [serde_json::json!([90.0, 72.0, 310.0, 104.0]), serde_json::json!([688.0, 90.0, 720.0, 310.0])];
+ assert_eq!(rects(&cli), expected, "{cli}");
+
+ // The tool reports the same rectangles.
+ let steps = serde_json::json!([
+ { "tool": "doc_open", "args": { "path": "faux.pdf" } },
+ { "tool": "doc_audit_redactions", "args": { "doc": 1 } },
+ ]);
+ std::fs::write(dir.join("steps.json"), steps.to_string()).unwrap();
+ let out = Command::new(BIN).arg("run").arg("--script").arg(dir.join("steps.json")).arg("--root").arg(&dir).output().unwrap();
+ assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
+ let stdout = String::from_utf8_lossy(&out.stdout);
+ let tool = serde_json::Deserializer::from_str(&stdout).into_iter::().last().unwrap().unwrap();
+ assert_eq!(rects(&tool), rects(&cli), "{tool}");
+}
diff --git a/apps/pdfcraft-cli/tests/automation_roots.rs b/apps/pdfcraft-cli/tests/automation_roots.rs
new file mode 100644
index 0000000000..162d79bafd
--- /dev/null
+++ b/apps/pdfcraft-cli/tests/automation_roots.rs
@@ -0,0 +1,123 @@
+//! `pdfcraft-cli mcp --automation-read-root DIR --automation-write-root DIR`, on the real binary:
+//! both spellings of the flags confine the server, and a malformed flag stops it instead of
+//! leaving it unconfined.
+#![cfg(feature = "mcp")]
+
+use std::io::Write as _;
+use std::path::{Path, PathBuf};
+use std::process::{Command, Output, Stdio};
+
+use serde_json::{Value, json};
+
+const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
+
+/// `in/a.pdf` and `outside/secret.pdf`, blank one-page PDFs made by the binary itself.
+fn folders(tag: &str) -> PathBuf {
+ let base = std::env::temp_dir().join(format!("pdfcraft-cli-roots-{tag}-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&base);
+ for d in ["in", "out", "outside"] {
+ std::fs::create_dir_all(base.join(d)).unwrap();
+ }
+ for f in ["in/a.pdf", "outside/secret.pdf"] {
+ let script = json!([
+ {"tool": "doc_create", "args": {"from": "blank", "pages": 1, "width": 100, "height": 100}},
+ {"tool": "doc_save", "args": {"doc": 1, "path": f}},
+ ]);
+ std::fs::write(base.join("steps.json"), script.to_string()).unwrap();
+ let out = Command::new(BIN).args(["run", "--script"]).arg(base.join("steps.json")).arg("--root").arg(&base).output().unwrap();
+ assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
+ }
+ base
+}
+
+/// Run `mcp `, send `calls` as `tools/call` requests and return the replies.
+fn session(flags: &[String], calls: &[(&str, Value)]) -> (Vec, String) {
+ let mut child = Command::new(BIN).arg("mcp").args(flags).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
+ let mut input = String::from("{\"jsonrpc\":\"2.0\",\"id\":0,\"method\":\"initialize\",\"params\":{}}\n");
+ for (i, (name, args)) in calls.iter().enumerate() {
+ let r = json!({"jsonrpc": "2.0", "id": i + 1, "method": "tools/call", "params": {"name": name, "arguments": args}});
+ input.push_str(&format!("{r}\n"));
+ }
+ child.stdin.take().unwrap().write_all(input.as_bytes()).unwrap();
+ let out = child.wait_with_output().unwrap();
+ assert!(out.status.success());
+ let replies = String::from_utf8(out.stdout).unwrap().lines().skip(1).map(|l| serde_json::from_str(l).unwrap()).collect();
+ (replies, String::from_utf8(out.stderr).unwrap())
+}
+
+fn s(p: &Path) -> String {
+ p.to_string_lossy().into_owned()
+}
+
+fn flags(spelling: &str, base: &Path) -> Vec {
+ let (r, w) = (s(&base.join("in")), s(&base.join("out")));
+ if spelling == "equals" {
+ vec![format!("--automation-read-root={r}"), format!("--automation-write-root={w}")]
+ } else {
+ vec!["--automation-read-root".into(), r, "--automation-write-root".into(), w]
+ }
+}
+
+fn text(reply: &Value) -> String {
+ reply["result"]["content"][0]["text"].as_str().unwrap_or_default().to_owned()
+}
+
+#[test]
+fn both_spellings_confine_the_server() {
+ for spelling in ["equals", "separate"] {
+ let base = folders(spelling);
+ let calls = [
+ ("doc_open", json!({"path": s(&base.join("in/a.pdf"))})),
+ ("doc_open", json!({"path": s(&base.join("outside/secret.pdf"))})),
+ ("doc_save", json!({"doc": 1, "path": s(&base.join("out/copy.pdf"))})),
+ ("doc_save", json!({"doc": 1, "path": s(&base.join("outside/copy.pdf"))})),
+ ];
+ let (replies, stderr) = session(&flags(spelling, &base), &calls);
+ assert_eq!(replies[0]["result"]["isError"], false, "{spelling}: {replies:?}");
+ assert!(text(&replies[1]).contains("automation path rejected: outside the read root"), "{spelling}: {replies:?}");
+ assert_eq!(replies[2]["result"]["isError"], false, "{spelling}: {replies:?}");
+ assert!(text(&replies[3]).contains("automation path rejected: outside the write root"), "{spelling}: {replies:?}");
+ assert!(base.join("out/copy.pdf").is_file() && !base.join("outside/copy.pdf").exists());
+ assert!(stderr.contains("files confined: read root") && stderr.contains("write root"), "{stderr}");
+ }
+}
+
+#[test]
+fn a_root_left_out_grants_none_of_its_access() {
+ let base = folders("one");
+ let read_only = vec!["--automation-read-root".to_string(), s(&base.join("in"))];
+ let calls = [("doc_open", json!({"path": s(&base.join("in/a.pdf"))})), ("doc_save", json!({"doc": 1, "path": s(&base.join("out/x.pdf"))}))];
+ let (replies, _) = session(&read_only, &calls);
+ assert_eq!(replies[0]["result"]["isError"], false);
+ assert!(text(&replies[1]).contains("write authority is absent"), "{replies:?}");
+ assert!(!base.join("out/x.pdf").exists());
+}
+
+#[test]
+fn no_flags_means_no_confinement() {
+ let base = folders("none");
+ let (replies, stderr) = session(&[], &[("doc_open", json!({"path": s(&base.join("outside/secret.pdf"))}))]);
+ assert_eq!(replies[0]["result"]["isError"], false);
+ assert!(!stderr.contains("confined"), "{stderr}");
+}
+
+fn fails(args: &[&str]) -> String {
+ let out: Output = Command::new(BIN).arg("mcp").args(args).stdin(Stdio::null()).output().unwrap();
+ assert_eq!(out.status.code(), Some(1), "{args:?} should fail");
+ assert!(out.stdout.is_empty(), "{args:?} must not start a server");
+ String::from_utf8(out.stderr).unwrap()
+}
+
+#[test]
+fn malformed_flags_are_errors_not_a_missing_root() {
+ let dir = s(&std::env::temp_dir());
+ assert!(fails(&["--automation-read-root"]).contains("--automation-read-root needs a folder"));
+ assert!(fails(&["--automation-write-root", "--compact"]).contains("--automation-write-root needs a folder; use --automation-write-root="));
+ assert!(fails(&["--automation-read-root="]).contains("--automation-read-root needs a non-empty folder"));
+ assert!(fails(&["--automation-read-root", &dir, "--automation-read-root", &dir]).contains("--automation-read-root given twice"));
+ assert!(fails(&["--automation-write-root", &dir, &format!("--automation-write-root={dir}")]).contains("--automation-write-root given twice"));
+ let missing = fails(&["--automation-read-root", "/definitely/not/a/folder"]);
+ assert!(missing.contains("mcp: cannot open the automation read root"), "{missing}");
+ let file = std::env::current_exe().unwrap();
+ assert!(fails(&["--automation-write-root", &s(&file)]).contains("cannot open the automation write root"));
+}
diff --git a/apps/pdfcraft-cli/tests/edit.rs b/apps/pdfcraft-cli/tests/edit.rs
index c9a5e7e2b3..b35b71dddc 100644
--- a/apps/pdfcraft-cli/tests/edit.rs
+++ b/apps/pdfcraft-cli/tests/edit.rs
@@ -92,6 +92,20 @@ fn the_documented_options_still_edit_and_save() {
}
}
+/// `--out` naming the input replaces it through a staging file (#2166): the result is the
+/// edited, reopenable file, and nothing is left beside it.
+#[test]
+fn editing_in_place_leaves_a_valid_file_and_no_staging_file() {
+ let input = tmp("in-place", "doc.pdf");
+ std::fs::write(&input, fixture()).unwrap();
+ let out = Command::new(BIN).args(["edit", input.to_str().unwrap(), "--out", input.to_str().unwrap(), "--rotate", "1:90"]).output().unwrap();
+ assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
+ assert_eq!(first_page_rotation(&input), 90);
+ let names: Vec =
+ std::fs::read_dir(input.parent().unwrap()).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
+ assert_eq!(names, ["doc.pdf"], "no staging file beside the result");
+}
+
mod one_based_arguments {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
@@ -225,6 +239,102 @@ fn the_append_destination_and_counts_grown_by_earlier_edits_are_accepted() {
}
}
+/// Two pages whose catalog points at a page tree object the file does not have (#1656). The
+/// reader finds the pages by scanning, in file order, so `edit` must work on the same pages.
+/// Object 3 is written before object 2, so file order and object numbers disagree.
+fn fixture_without_page_tree() -> Vec {
+ let stream = |content: &str| format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes();
+ let objs: Vec<(usize, Vec)> = vec![
+ (1, b"<< /Type /Catalog /Pages 6 0 R >>".to_vec()),
+ (3, b"<< /Type /Page /Parent 6 0 R /MediaBox [0 0 200 300] /Contents 4 0 R >>".to_vec()),
+ (2, b"<< /Type /Page /Parent 6 0 R /MediaBox [0 0 300 200] /Contents 5 0 R >>".to_vec()),
+ (4, stream("0 0 m 10 10 l S")),
+ (5, stream("0 0 m 20 20 l S")),
+ ];
+ let mut out = b"%PDF-1.7\n".to_vec();
+ let mut offsets = vec![0; objs.len()];
+ for (num, o) in &objs {
+ offsets[num - 1] = out.len();
+ out.extend_from_slice(format!("{num} 0 obj\n").as_bytes());
+ out.extend_from_slice(o);
+ out.extend_from_slice(b"\nendobj\n");
+ }
+ let xref = out.len();
+ out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
+ for o in offsets {
+ out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
+ }
+ out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
+ out
+}
+
+#[test]
+fn a_document_whose_page_tree_is_missing_edits_the_pages_the_reader_shows() {
+ let input = tmp("no-page-tree", "in.pdf");
+ std::fs::write(&input, fixture_without_page_tree()).unwrap();
+ let before = pdfcraft_render::inspect(std::sync::Arc::new(fixture_without_page_tree()), None).unwrap();
+ assert_eq!(before.pages.iter().map(|p| (p.width, p.height)).collect::>(), [(200.0, 300.0), (300.0, 200.0)]);
+ for (name, args, expected) in [
+ // Width and height are as displayed: the turned page reads 200 x 300.
+ ("rotate.pdf", ["--rotate", "2:90"], &[(200.0, 300.0, 0), (200.0, 300.0, 90)][..]),
+ ("delete.pdf", ["--delete", "1"], &[(300.0, 200.0, 0)][..]),
+ ] {
+ let output = tmp("no-page-tree", name);
+ let mut all = vec!["edit", input.to_str().unwrap(), "--out", output.to_str().unwrap()];
+ all.extend(args);
+ let out = Command::new(BIN).args(&all).output().unwrap();
+ assert!(out.status.success(), "{name}: {}", String::from_utf8_lossy(&out.stderr));
+ let info = pdfcraft_render::inspect(std::sync::Arc::new(std::fs::read(&output).unwrap()), None).unwrap();
+ let pages: Vec<_> = info.pages.iter().map(|p| (p.width, p.height, p.rotation)).collect();
+ assert_eq!(pages, expected, "{name}");
+ }
+}
+
+/// Like [`fixture_without_page_tree`], but the scanning reader shows different pages from the
+/// page objects: it skips the blank 200 x 200 page (no `/Contents`) and shows the Text
+/// annotation (which has `/Contents`) as a page. No page tree may be rebuilt from such a file.
+fn fixture_whose_scan_disagrees() -> Vec {
+ let stream = |content: &str| format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes();
+ let objs: Vec> = vec![
+ b"<< /Type /Catalog /Pages 9 0 R >>".to_vec(),
+ b"<< /Type /Page /Parent 9 0 R /MediaBox [0 0 100 100] /Contents 5 0 R >>".to_vec(),
+ b"<< /Type /Page /Parent 9 0 R /MediaBox [0 0 200 200] >>".to_vec(),
+ b"<< /Type /Page /Parent 9 0 R /MediaBox [0 0 300 300] /Contents 6 0 R /Annots [7 0 R] >>".to_vec(),
+ stream("0 0 m 10 10 l S"),
+ stream("0 0 m 20 20 l S"),
+ b"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (hello) >>".to_vec(),
+ ];
+ let mut out = b"%PDF-1.7\n".to_vec();
+ let mut offsets = Vec::new();
+ for (i, o) in objs.iter().enumerate() {
+ offsets.push(out.len());
+ out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
+ out.extend_from_slice(o);
+ out.extend_from_slice(b"\nendobj\n");
+ }
+ let xref = out.len();
+ out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
+ for o in offsets {
+ out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
+ }
+ out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
+ out
+}
+
+#[test]
+fn a_page_tree_is_not_rebuilt_when_the_reader_shows_other_pages() {
+ let input = tmp("scan-disagrees", "in.pdf");
+ std::fs::write(&input, fixture_whose_scan_disagrees()).unwrap();
+ let shown = pdfcraft_render::inspect(std::sync::Arc::new(fixture_whose_scan_disagrees()), None).unwrap();
+ assert_eq!(shown.pages.len(), 3);
+ assert!(shown.pages.iter().all(|p| p.width != 200.0), "the reader must not show the blank page");
+ let output = tmp("scan-disagrees", "out.pdf");
+ let out = Command::new(BIN).args(["edit", input.to_str().unwrap(), "--out", output.to_str().unwrap(), "--delete", "2"]).output().unwrap();
+ assert_eq!(out.status.code(), Some(1));
+ assert_eq!(String::from_utf8_lossy(&out.stderr), "pdfcraft-cli: the document's page tree lists no pages: the file is damaged\n");
+ assert!(!output.exists(), "no page may be deleted");
+}
+
/// A page past the end names its own option and the page count left by the earlier options (#1550).
#[test]
fn a_page_past_the_end_after_earlier_edits_names_that_option() {
diff --git a/apps/pdfcraft-cli/tests/extract.rs b/apps/pdfcraft-cli/tests/extract.rs
new file mode 100644
index 0000000000..25f1aabc29
--- /dev/null
+++ b/apps/pdfcraft-cli/tests/extract.rs
@@ -0,0 +1,129 @@
+//! `extract --pages`: single pages and ranges (#1923), and an out-of-range page named as the
+//! user typed it (1-based; it was off by one in 0.7.1).
+
+use std::path::PathBuf;
+use std::process::{Command, Output};
+
+const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
+
+/// A PDF with `n` empty 200×300 pt pages.
+fn fixture(n: usize) -> Vec {
+ let mut objs: Vec> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()];
+ let kids: Vec = (0..n).map(|i| format!("{} 0 R", 3 + i)).collect();
+ objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" ")).into_bytes());
+ for _ in 0..n {
+ objs.push(b"<< /Type /Page /Parent 2 0 R >>".to_vec());
+ }
+ let mut out = b"%PDF-1.7
+"
+ .to_vec();
+ let mut offsets = Vec::new();
+ for (i, o) in objs.iter().enumerate() {
+ offsets.push(out.len());
+ out.extend_from_slice(
+ format!(
+ "{} 0 obj
+",
+ i + 1
+ )
+ .as_bytes(),
+ );
+ out.extend_from_slice(o);
+ out.extend_from_slice(
+ b"
+endobj
+",
+ );
+ }
+ let xref = out.len();
+ out.extend_from_slice(
+ format!(
+ "xref
+0 {}
+0000000000 65535 f
+",
+ objs.len() + 1
+ )
+ .as_bytes(),
+ );
+ for o in offsets {
+ out.extend_from_slice(
+ format!(
+ "{o:010} 00000 n
+"
+ )
+ .as_bytes(),
+ );
+ }
+ out.extend_from_slice(
+ format!(
+ "trailer
+<< /Size {} /Root 1 0 R >>
+startxref
+{xref}
+%%EOF
+",
+ objs.len() + 1
+ )
+ .as_bytes(),
+ );
+ out
+}
+
+/// A fresh directory holding `five.pdf` (5 pages).
+fn workdir(test: &str) -> PathBuf {
+ let d = std::env::temp_dir().join(format!("pdfcraft-cli-extract-{test}-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&d);
+ std::fs::create_dir_all(&d).unwrap();
+ std::fs::write(d.join("five.pdf"), fixture(5)).unwrap();
+ d
+}
+
+fn extract(dir: &std::path::Path, pages: &str) -> Output {
+ let out = dir.join(format!("out-{}.pdf", pages.replace(['-', ','], "_")));
+ Command::new(BIN).args(["extract", dir.join("five.pdf").to_str().unwrap(), "--pages", pages, "--out", out.to_str().unwrap()]).output().unwrap()
+}
+
+/// The page count `info` reports for the extracted file.
+fn pages_of(dir: &std::path::Path, pages: &str) -> usize {
+ let out = dir.join(format!("out-{}.pdf", pages.replace(['-', ','], "_")));
+ let info = Command::new(BIN).args(["info", out.to_str().unwrap()]).output().unwrap();
+ let text = String::from_utf8_lossy(&info.stdout);
+ let n = text.split("\"pages\":").nth(1).and_then(|s| s.trim().split(|c: char| !c.is_ascii_digit()).next()).unwrap_or("");
+ n.parse().unwrap_or_else(|_| panic!("no page count in {text}"))
+}
+
+#[test]
+fn pages_and_ranges_are_accepted_in_the_order_given() {
+ let dir = workdir("ranges");
+ for (pages, count) in [("1-3", 3), ("5,1-2", 3), ("2-2", 1), ("1,3,5", 3), (" 4 - 5 , 1 ", 3)] {
+ let out = extract(&dir, pages);
+ assert!(out.status.success(), "{pages:?}: {}", String::from_utf8_lossy(&out.stderr));
+ assert_eq!(pages_of(&dir, pages), count, "{pages:?}");
+ }
+}
+
+#[test]
+fn bad_pages_and_ranges_are_named_as_typed() {
+ let dir = workdir("bad");
+ for (pages, what) in [
+ ("3-1", "bad page range \"3-1\""),
+ ("0", "bad page number \"0\""),
+ ("1-x", "bad page number \"1-x\""),
+ ("2-", "bad page number \"2-\""),
+ ("1-9999999", "bad page range \"1-9999999\""),
+ ] {
+ let out = extract(&dir, pages);
+ assert!(!out.status.success(), "{pages:?} succeeded");
+ let stderr = String::from_utf8_lossy(&out.stderr);
+ assert!(stderr.contains(what), "{pages:?}: {stderr}");
+ }
+ // A page past the end is named as the user counts it (1-based), as a single page and as the
+ // first page a range reaches past the end.
+ for (pages, missing) in [("6", "page 6 does not exist"), ("9", "page 9 does not exist"), ("4-7", "page 6 does not exist")] {
+ let out = extract(&dir, pages);
+ assert!(!out.status.success(), "{pages:?} succeeded");
+ let stderr = String::from_utf8_lossy(&out.stderr);
+ assert!(stderr.contains(missing), "{pages:?}: {stderr}");
+ }
+}
diff --git a/apps/pdfcraft-cli/tests/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/run_report.rs b/apps/pdfcraft-cli/tests/run_report.rs
new file mode 100644
index 0000000000..cf1e6b650d
--- /dev/null
+++ b/apps/pdfcraft-cli/tests/run_report.rs
@@ -0,0 +1,71 @@
+//! `pdfcraft-cli run --script --report`: one JSON document for the whole run (#1891).
+
+use std::process::{Command, Output};
+
+fn run_script(test: &str, steps: &serde_json::Value, extra: &[&str]) -> Output {
+ let dir = std::env::temp_dir().join(format!("pdfcraft-cli-{test}-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ let script = dir.join("steps.json");
+ std::fs::write(&script, steps.to_string()).unwrap();
+ let out =
+ Command::new(env!("CARGO_BIN_EXE_pdfcraft-cli")).current_dir(&dir).arg("run").arg("--script").arg(&script).args(extra).output().unwrap();
+ let _ = std::fs::remove_dir_all(&dir);
+ out
+}
+
+fn blank() -> serde_json::Value {
+ serde_json::json!({ "tool": "doc_create", "args": { "from": "blank", "width": 100, "height": 100 } })
+}
+
+#[test]
+fn a_successful_run_is_one_document_with_a_result_per_step() {
+ let steps = serde_json::json!([blank(), { "tool": "page_render", "args": { "doc": 1, "page": 1, "dpi": 10 } }]);
+ let o = run_script("report-ok", &steps, &["--report"]);
+ assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
+ let report: serde_json::Value = serde_json::from_slice(&o.stdout).expect("stdout is one JSON document");
+ assert_eq!(report["ok"], true);
+ let steps = report["steps"].as_array().unwrap();
+ assert_eq!(steps.len(), 2);
+ assert_eq!((steps[0]["step"].clone(), steps[0]["tool"].clone(), steps[0]["status"].clone()), (1.into(), "doc_create".into(), "ok".into()));
+ assert_eq!(steps[1]["result"][0]["image"], "png", "an image with no `out` is summarised: {report}");
+}
+
+#[test]
+fn a_failing_step_is_named_and_the_rest_are_skipped() {
+ let steps = serde_json::json!([blank(), { "tool": "page_rotate", "args": { "doc": 1, "pages": [999], "degrees": 90 } }, { "tool": "doc_close", "args": { "doc": 1 } }]);
+ let o = run_script("report-fail", &steps, &["--report"]);
+ assert_eq!(o.status.code(), Some(1), "exit status is unchanged");
+ let stderr = String::from_utf8_lossy(&o.stderr);
+ assert!(stderr.contains("step 2 (page_rotate)"), "{stderr}");
+ let report: serde_json::Value = serde_json::from_slice(&o.stdout).expect("stdout is one JSON document");
+ assert_eq!(report["ok"], false);
+ let steps = report["steps"].as_array().unwrap();
+ assert_eq!(steps.iter().map(|s| s["status"].as_str().unwrap()).collect::>(), ["ok", "error", "skipped"]);
+ assert!(steps[1]["error"].as_str().unwrap().contains("step 2 (page_rotate)"), "{report}");
+ assert_eq!(steps[2]["tool"], "doc_close");
+}
+
+#[test]
+fn a_step_without_a_tool_is_an_error_step() {
+ let o = run_script("report-notool", &serde_json::json!([{ "args": {} }]), &["--report"]);
+ assert_eq!(o.status.code(), Some(1));
+ let report: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap();
+ assert_eq!(report["steps"][0]["status"], "error");
+ assert!(report["steps"][0]["tool"].is_null());
+}
+
+#[test]
+fn without_the_flag_the_output_is_unchanged() {
+ let o = run_script("report-off", &serde_json::json!([blank()]), &[]);
+ assert!(o.status.success());
+ let stdout = String::from_utf8_lossy(&o.stdout);
+ assert!(!stdout.contains("\"steps\""), "{stdout}");
+}
+
+#[test]
+fn the_flag_needs_a_script() {
+ let o = Command::new(env!("CARGO_BIN_EXE_pdfcraft-cli")).args(["run", "doc_create", "--report"]).output().unwrap();
+ assert_eq!(o.status.code(), Some(1));
+ assert!(String::from_utf8_lossy(&o.stderr).contains("--report needs --script"));
+}
diff --git a/apps/pdfcraft-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 4a185d1f92..a27401c700 100644
--- a/apps/pdfcraft/Cargo.toml
+++ b/apps/pdfcraft/Cargo.toml
@@ -29,6 +29,9 @@ log = { workspace = true }
# crate registers the handler (Objective-C interop needs unsafe, which our workspace forbids)
# behind a safe API. Same version as PhotoCraft. Pinned exactly: review the diff before bumping.
fmv-macos-events = "=0.1.0"
+# ⌘Q asks about unsaved changes like the close button (#1136): an app menu whose Quit asks the
+# window to close instead of winit's, which ends the process without asking.
+craft-quit = { workspace = true, features = ["menu"] }
[target.'cfg(target_os = "linux")'.dependencies]
# Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback), so the window is
@@ -36,10 +39,19 @@ fmv-macos-events = "=0.1.0"
wayland-client = "0.31"
wayland-protocols = { version = "0.32", features = ["client"] }
+[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
+# Whether the window is on Wayland, where it can't bring itself forward (src/single_instance.rs).
+# Already in the tree through winit and eframe.
+raw-window-handle = "0.6"
+
[dev-dependencies]
# Waits for wgpu's adapter, device and error-scope futures in tests (already in the tree through wgpu).
pollster = "1.0"
+[target.'cfg(target_os = "linux")'.dev-dependencies]
+# Exercise the vendored Wayland scroll policy with the actual winit event type.
+winit = { version = "0.30", default-features = false, features = ["wayland"] }
+
[target.'cfg(target_os = "windows")'.dependencies]
# Which GPU drives which display (EnumDisplayDevices), so the window is drawn on the GPU that
# shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it).
diff --git a/apps/pdfcraft/src/logging.rs b/apps/pdfcraft/src/logging.rs
index a62d300f6b..71ed1d2d2f 100644
--- a/apps/pdfcraft/src/logging.rs
+++ b/apps/pdfcraft/src/logging.rs
@@ -3,7 +3,7 @@
//! Without a logger every `log::warn!`/`log::error!` in the workspace (a failed autosave, a page
//! that would not render, a render worker that could not start, the panic hook's report) vanished.
//! A launch from a desktop menu has no terminal, so the file is what a bug report can attach:
-//! `/logs/pdfcraft.log`, next to `app.ron` (see `settings_dir` in `main.rs`). Each
+//! `/logs/pdfcraft.log` (the PdfCraft eframe storage directory, or portable data). Each
//! start moves the previous log to `pdfcraft.1.log` (and that one to `.2`), so the log of a run
//! that crashed survives the next launch.
//!
diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs
index a1d2ad998d..721b7da1b3 100644
--- a/apps/pdfcraft/src/main.rs
+++ b/apps/pdfcraft/src/main.rs
@@ -37,6 +37,7 @@ mod logging;
#[cfg(target_os = "windows")]
mod motion;
mod reopen;
+mod settings;
mod single_instance;
mod text_scale;
mod updates;
@@ -57,14 +58,24 @@ const APP_ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256
/// The app was called PrintCraft before; settings saved then are under this key.
const LEGACY_STORAGE_KEY: &str = "printcraft";
-/// The settings folder: `app.ron` and the `logs` folder (docs/development.md). In portable mode
-/// it is `PdfCraftData` beside the executable (#157). eframe would otherwise derive it from the
-/// app id; keep it under "PdfCraft".
+/// The preferences folder (`app.ron`, docs/development.md). In portable mode
+/// it is `PdfCraftData` beside the executable (#157). XDG desktops use the configuration base;
+/// other platforms retain eframe's PdfCraft folder. Logs and recovery stay in the data folder.
fn settings_dir() -> Option {
- if let Some(dir) = pdfcraft_ui_egui::portable::data_dir() {
- return Some(dir.to_path_buf());
+ let legacy = eframe::storage_dir("PdfCraft");
+ match settings::directory(
+ cfg!(all(unix, not(target_os = "macos"))),
+ pdfcraft_ui_egui::portable::data_dir(),
+ legacy.clone(),
+ std::env::var_os("XDG_CONFIG_HOME").map(std::path::PathBuf::from),
+ std::env::home_dir(),
+ ) {
+ Ok(dir) => dir,
+ Err(e) => {
+ log::warn!("could not migrate preferences to the XDG configuration folder; using the original location: {e}");
+ legacy
+ }
}
- eframe::storage_dir("PdfCraft")
}
/// Move the settings and crash-recovery folders of the app's former name, PrintCraft, to the new
@@ -75,7 +86,7 @@ fn migrate_legacy_folders() {
if pdfcraft_ui_egui::portable::data_dir().is_some() {
return;
}
- let mut moves = vec![(eframe::storage_dir("PrintCraft"), settings_dir())];
+ let mut moves = vec![(eframe::storage_dir("PrintCraft"), eframe::storage_dir("PdfCraft"))];
// Recovery lives in the settings folder except on Windows, where it is under %LOCALAPPDATA%.
if cfg!(windows) {
let local = std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()).map(std::path::PathBuf::from);
@@ -203,21 +214,27 @@ fn main() -> eframe::Result {
// GNOME on Wayland leaves the window frame to the app (src/decorations.rs).
let window_controls = if cfg!(any(target_os = "macos", target_os = "windows")) { None } else { decorations::app_drawn() };
migrate_legacy_folders();
- // The log file lives in the settings folder; opened after the arguments (so `--version` leaves
+ // The log file stays in the data folder; opened after the arguments (so `--version` leaves
// no file behind) and after the PrintCraft migration (which a fresh folder would block).
// Records logged until now are written to it first.
- if let (Some(logger), Some(dir)) = (logger, settings_dir()) {
+ if let (Some(logger), Some(dir)) =
+ (logger, pdfcraft_ui_egui::portable::data_dir().map(std::path::Path::to_path_buf).or_else(|| eframe::storage_dir("PdfCraft")))
+ {
match logger.attach_dir(&dir.join("logs")) {
Ok(path) => log::info!("PdfCraft {}, log file {}", pdfcraft_engine::build_info::version(), path.display()),
// Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics.
Err(e) => log::warn!("no log file: {e}"),
}
}
- // Windows: a launch that only names files (an Outlook attachment, an Explorer double-click)
- // hands them to the PdfCraft already running and exits (#282, #317). Any option means the
- // caller wants this launch's own window.
+ // Windows, Linux and the BSDs: a launch that only names files (an Outlook attachment, an
+ // Explorer double-click, a file manager or `xdg-open`) hands them to the PdfCraft already
+ // running and exits (#282, #317, #1726). Any option means the caller wants this launch's own
+ // window.
let may_hand_off = !new_window && !files.is_empty() && options.is_empty() && control_file.is_none() && !create_images;
- let instance = match single_instance::claim(&single_instance::absolute(&files), may_hand_off) {
+ // 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,
@@ -332,6 +349,18 @@ struct Launch {
window_controls: Option,
}
+/// Whether the window is a native Wayland one (not X11 or XWayland).
+#[cfg(all(unix, not(target_os = "macos")))]
+fn native_wayland(cc: &eframe::CreationContext<'_>) -> bool {
+ use raw_window_handle::{HasDisplayHandle, RawDisplayHandle};
+ cc.display_handle().is_ok_and(|handle| matches!(handle.as_raw(), RawDisplayHandle::Wayland(_)))
+}
+
+#[cfg(not(all(unix, not(target_os = "macos"))))]
+fn native_wayland(_: &eframe::CreationContext<'_>) -> bool {
+ false
+}
+
fn app_creator<'a>(
launch: Launch,
started: Rc>,
@@ -351,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();
@@ -366,9 +396,14 @@ fn app_creator<'a>(
#[cfg(target_os = "macos")]
{
app.os_events = Some(apple_events.connect(&cc.egui_ctx));
+ // winit's Quit (⌘Q) ends the process without asking about unsaved changes (#1136). This
+ // menu's Quit closes the window instead, so `guard_quit` asks, as for the close button.
+ if let Err(e) = craft_quit::install_app_menu(&cc.egui_ctx, "PdfCraft") {
+ log::warn!("couldn't install the app menu, so ⌘Q quits without asking to save: {e}");
+ }
}
if let Some(server) = instance {
- app.os_events = Some(server.connect(&cc.egui_ctx));
+ app.os_events = Some(server.connect(&cc.egui_ctx, native_wayland(cc)));
}
if let Some(file) = &control_file {
let client = app.attach_control(&cc.egui_ctx);
@@ -531,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| {
@@ -572,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
}
@@ -878,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/settings.rs b/apps/pdfcraft/src/settings.rs
new file mode 100644
index 0000000000..950566a2e0
--- /dev/null
+++ b/apps/pdfcraft/src/settings.rs
@@ -0,0 +1,194 @@
+//! Platform preference location, separate from logs and crash recovery.
+
+use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicU64, Ordering};
+
+static STAGING_ID: AtomicU64 = AtomicU64::new(0);
+
+pub fn directory(
+ xdg: bool,
+ portable: Option<&Path>,
+ legacy: Option,
+ config_home: Option,
+ home: Option,
+) -> std::io::Result