diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7f2730a3..821b7de7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -23,12 +23,14 @@ jobs:
strategy:
fail-fast: false
matrix:
- os: [ubuntu-latest, macos-latest, windows-latest]
+ # No macOS on PRs, main or nightly: the org's 5 macOS slots are kept for release builds,
+ # and release.yml builds macOS (a failing release is fixed and rebuilt).
+ os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v4
# The optional craft-fonts build input (Japanese fonts), so the Japanese font tests run on
- # Linux; macOS and Windows test the build without it. See craftrules standards/fonts.md.
+ # Linux; Windows tests the build without it. See craftrules standards/fonts.md.
- name: Check out craft-fonts (Linux)
if: runner.os == 'Linux'
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 4e3221a9..afe30062 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 (249)
+## In this repository (257)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
@@ -14,7 +14,13 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese 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 | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/es.tsv` | Spanish 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 | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/fr.tsv` | French 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 | French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
+| `crates/ui-egui/src/i18n/de.tsv` | German 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 | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
+| `crates/ui-egui/src/i18n/te.tsv` | Telugu 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 | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
+| `crates/ui-egui/src/i18n/ru.tsv` | Russian 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 | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
+| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian 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 | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
+| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian 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 | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/ja.tsv` | Japanese 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 | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
+| `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 |
| `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 |
@@ -257,6 +263,8 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `crates/sign/tests/data/openssl-signed.pdf` | One-page PDF signed with OpenSSL CMS (adbe.pkcs7.detached) by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl cms -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/pdf.rs): a signature PdfCraft did not make |
| `assets/icons/clipboard-paste.svg` | Lucide icon "clipboard-paste" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clipboard-paste.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/fonts/DancingScript.ttf` | Dancing Script (variable) | The Dancing Script Project Authors (Pablo Impallari) | OFL-1.1 | https://github.com/google/fonts/blob/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/dancingscript/DancingScript%5Bwght%5D.ttf | Fill & Sign typed signatures and initials: the typed name's glyph outlines, drawn as filled paths (the font file itself is not embedded in PDFs) |
+| `packaging/macos/dmg/background.svg` | PdfCraft macOS DMG window background | @XusBadia | MIT OR Apache-2.0 | Original work for this repository, derived from the PdfCraft app icon (assets/app-icon/pdfcraft.svg, referenced via an SVG image element, not copied) | Finder window of the macOS DMG; rendered to background.tiff by packaging/macos/dmg/generate.py |
+| `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 |
## Compiled in through dependencies (6)
diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml
index 36087581..b33510f3 100644
--- a/ATTRIBUTION.toml
+++ b/ATTRIBUTION.toml
@@ -19,7 +19,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 = "5da2aa2944fcad4720972062f0f97800f73b8bfa5dd0fff6c28f4ec0f8ee19f9"
+sha256 = "2d56f8464f3c25efe9737b204db34cef012ea3716f4d78209d9abeaf0ea9393b"
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
@@ -30,7 +30,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 = "39f83ab539f6380df995a855c1c8e8c036bcd060e06b9d1b027771e1e7d6e7ef"
+sha256 = "b0ca648560c45e554447aae8dba37c1c9c636c86b277d00fceeb2a21b474c8f7"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.tsv"
@@ -41,7 +41,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 = "accf181cff1f0ea86bb33528f6158d1aec18c9c3ff54de62dc758536a24cb677"
+sha256 = "8bab483a3f5ba3a724365c2a5a892291a10f41fdc443eaceb66f49c8a20cae20"
[[asset]]
path = "crates/ui-egui/src/i18n/pt-br.tsv"
@@ -52,7 +52,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 = "0ee4d2f630623ef7e06413a7c34a4a0269a2600fba682f06988a1346be4d07a5"
+sha256 = "2cd5dcc6cf7e94914b955ee0f2f5c3e8951b16e26ab49fcb7111246a6e31ee2a"
[[asset]]
path = "crates/ui-egui/src/i18n/es.tsv"
@@ -63,7 +63,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 = "4c409434bd3385b56b561c36ff819e19e574bda8979d16cd53232cb5bea3c65f"
+sha256 = "e9a9bad1252afdcb42c36b24ca85411f8c63df7debada94d4423bd48761ecb05"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
@@ -74,7 +74,62 @@ 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 = "d322ac956218961d22828726a7fe0752b0a68603679c2dcf17c5aa840995e124"
+sha256 = "0351382a91d2df51da3d45e25a09af87a081bc88e3496c3512ad89f5d4460a3a"
+
+[[asset]]
+path = "crates/ui-egui/src/i18n/de.tsv"
+title = "German interface translation catalog"
+author = "PdfCraft contributors"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
+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 = "57f52e3671a322938a160ac11b73b1f55fb6311140f20d89ae12c9a91a0500d6"
+
+[[asset]]
+path = "crates/ui-egui/src/i18n/te.tsv"
+title = "Telugu interface translation catalog"
+author = "PdfCraft contributors"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
+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 = "d3630f989e9725c22444dc27765142674b66f72436502ca969b2851fc38d703d"
+
+[[asset]]
+path = "crates/ui-egui/src/i18n/ru.tsv"
+title = "Russian interface translation catalog"
+author = "PdfCraft contributors"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
+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 = "e505c00bcc8b8af6aec27abf84bebac7a1925c632130411000a6c57241816193"
+
+[[asset]]
+path = "crates/ui-egui/src/i18n/hu.tsv"
+title = "Hungarian interface translation catalog"
+author = "PdfCraft contributors"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
+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 = "c198f1de831d7f9c853baa6977894ccc34d94e6b36c772c2e53fc5b0e0fe708b"
+
+[[asset]]
+path = "crates/ui-egui/src/i18n/bg.tsv"
+title = "Bulgarian interface translation catalog"
+author = "PdfCraft contributors"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
+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 = "9682b2c3c28449dc63637cb5962b309df3ad7db7e8d96bf8bb0f0bc386c9c611"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"
@@ -85,7 +140,18 @@ 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 = "4b14f85bc0890823c30bff37723ecb05bc5ea5a8583334644168b08043ab6bde"
+sha256 = "319b032d2c50097ec14e2c7eab92e8696d33e198aeac3585ff46e98c61dec90f"
+
+[[asset]]
+path = "crates/ui-egui/src/i18n/uk.tsv"
+title = "Ukrainian interface translation catalog"
+author = "PdfCraft contributors"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
+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 = "f3a03fc466c1f956cbd44a5cad101c53db5ffc80b3b7551a273ac41f43263dd5"
[[asset]]
path = "assets/icons/accessibility.svg"
@@ -3083,6 +3149,29 @@ kind = "font"
usage = "Fill & Sign typed signatures and initials: the typed name's glyph outlines, drawn as filled paths (the font file itself is not embedded in PDFs)"
sha256 = "21808625578fe8d8cd10cb684be546dca077b27cd03a53a2f1ec11dc743c924c"
+[[asset]]
+path = "packaging/macos/dmg/background.svg"
+title = "PdfCraft macOS DMG window background"
+author = "@XusBadia"
+source = "Original work for this repository, derived from the PdfCraft app icon (assets/app-icon/pdfcraft.svg, referenced via an SVG image element, not copied)"
+licence = "MIT OR Apache-2.0"
+licence_file = "assets/app-icon/LICENSE.txt"
+kind = "image"
+usage = "Finder window of the macOS DMG; rendered to background.tiff by packaging/macos/dmg/generate.py"
+sha256 = "e8b9ea9d5e64b7b741c977543c1693825e77d5c4c579eeb674bf5dcd3764cd8a"
+
+
+[[asset]]
+path = "packaging/macos/dmg/background.tiff"
+title = "PdfCraft macOS DMG window background (rendered)"
+author = "@XusBadia"
+source = "Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py"
+licence = "MIT OR Apache-2.0"
+licence_file = "assets/app-icon/LICENSE.txt"
+kind = "image"
+usage = "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"
+sha256 = "f46881724a6621f3686a612a79dbdce4f7dbe9e2025610e44c9a094341334c2c"
+
[[fetched]]
file = "text-detection.rten"
url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.rten"
diff --git a/Cargo.lock b/Cargo.lock
index 2520b60a..879c1cfd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3477,6 +3477,7 @@ dependencies = [
"hayro-syntax",
"pdfcraft-cos",
"pdfcraft-fonts",
+ "pdfcraft-model",
"thiserror 2.0.21",
]
@@ -3486,8 +3487,11 @@ version = "0.4.0"
dependencies = [
"base64 0.22.1",
"image",
+ "pdfcraft-cos",
"pdfcraft-engine",
+ "pdfcraft-model",
"pdfcraft-organize",
+ "pdfcraft-platform",
"pdfcraft-render",
"pdfcraft-xfa",
"png 0.18.1",
@@ -3566,6 +3570,7 @@ dependencies = [
"pdfcraft-fonts",
"pdfcraft-model",
"thiserror 2.0.21",
+ "unicode-bidi",
]
[[package]]
@@ -3573,6 +3578,8 @@ name = "pdfcraft-engine"
version = "0.4.0"
dependencies = [
"chrono",
+ "icu_properties",
+ "idna",
"image",
"pdfcraft-a11y",
"pdfcraft-annot",
@@ -3598,6 +3605,7 @@ dependencies = [
"pdfcraft-xfdf",
"png 0.18.1",
"thiserror 2.0.21",
+ "url",
]
[[package]]
@@ -3621,6 +3629,7 @@ dependencies = [
name = "pdfcraft-fonts"
version = "0.4.0"
dependencies = [
+ "harfrust",
"pdfcraft-cos",
"skrifa 0.44.0",
]
@@ -3634,6 +3643,7 @@ dependencies = [
"pdfcraft-cos",
"pdfcraft-fonts",
"pdfcraft-model",
+ "pdfcraft-render",
"thiserror 2.0.21",
]
@@ -3700,6 +3710,10 @@ dependencies = [
"thiserror 2.0.21",
]
+[[package]]
+name = "pdfcraft-platform"
+version = "0.4.0"
+
[[package]]
name = "pdfcraft-preflight"
version = "0.4.0"
@@ -3736,12 +3750,15 @@ dependencies = [
name = "pdfcraft-render"
version = "0.4.0"
dependencies = [
+ "bytemuck",
"getrandom 0.4.3",
"hayro",
"hayro-syntax",
"kurbo 0.13.1",
"log",
"lopdf",
+ "pdfcraft-fonts",
+ "skrifa 0.44.0",
"thiserror 2.0.21",
]
@@ -3777,6 +3794,7 @@ version = "0.4.0"
dependencies = [
"arboard",
"base64 0.22.1",
+ "bytemuck",
"eframe",
"egui",
"egui_extras",
@@ -3785,10 +3803,14 @@ dependencies = [
"image",
"js-sys",
"log",
+ "objc2 0.6.4",
+ "objc2-app-kit 0.3.2",
+ "objc2-foundation 0.3.2",
"pdfcraft-cos",
"pdfcraft-engine",
"pdfcraft-fonts",
"pdfcraft-model",
+ "pdfcraft-platform",
"pdfcraft-render",
"pdfcraft-xfa",
"png 0.18.1",
@@ -3822,9 +3844,12 @@ name = "pdfcraft-xfa"
version = "0.4.0"
dependencies = [
"base64 0.22.1",
+ "image",
"pdfcraft-cos",
"pdfcraft-fonts",
+ "png 0.18.1",
"roxmltree",
+ "sha2",
"thiserror 2.0.21",
]
@@ -6571,8 +6596,6 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winit"
version = "0.30.13"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
dependencies = [
"ahash",
"android-activity",
diff --git a/Cargo.toml b/Cargo.toml
index 8a1c3b65..e80a0b46 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -40,6 +40,7 @@ pdfcraft-forms = { path = "crates/forms", version = "0.4.0" }
pdfcraft-xfa = { path = "crates/xfa", version = "0.4.0" }
pdfcraft-render = { path = "crates/render", version = "0.4.0" }
pdfcraft-engine = { path = "crates/engine", version = "0.4.0" }
+pdfcraft-platform = { path = "crates/platform", version = "0.4.0" }
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.4.0" }
pdfcraft-automation = { path = "crates/automation", version = "0.4.0", default-features = false }
@@ -74,8 +75,14 @@ unsafe_code = "forbid"
dbg_macro = "warn"
todo = "warn"
+# `"*"` matches every non-workspace package: crates.io dependencies and the
+# vendored crates under `vendor/` (excluded from the workspace). Our own crates
+# keep full debug info. Dependencies keep only line tables, so backtraces still
+# show file and line, but debug info for the whole dependency graph stays out of
+# every test binary (each UI test binary had a 500-600 MB PDB on Windows, #454).
[profile.dev.package."*"]
opt-level = 2
+debug = "line-tables-only"
[profile.dev]
opt-level = 1
@@ -91,3 +98,4 @@ hayro = { path = "vendor/hayro" }
hayro-syntax = { path = "vendor/hayro-syntax" }
hayro-jbig2 = { path = "vendor/hayro-jbig2" }
lopdf = { path = "vendor/lopdf" }
+winit = { path = "vendor/winit" }
diff --git a/README.md b/README.md
index e08d5551..43342478 100644
--- a/README.md
+++ b/README.md
@@ -133,7 +133,7 @@ Search the whole document as you type, step through matches with ⌘G
## Navigate long documents
-Bookmarks, page thumbnails and the document's own page labels (i, ii, 1, 2…) keep you oriented in long documents.
+Bookmarks, page thumbnails and the document's own page labels (i, ii, 1, 2…) keep you oriented in long documents. Search bookmark titles in the Bookmarks panel to find nested entries even when their parents are collapsed. Matches keep their ancestors for context; Clear restores the unfiltered tree without changing its expansion state.
@@ -172,6 +172,8 @@ Open **Organize pages** to see every page at once:
- *Atomic:* the file is written to a temporary copy, then swapped in.
- *Verified:* independently checked with qpdf.
+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.
+
Unsaved documents carry a dot on their tab, and closing or quitting asks before anything is lost. Changes are autosaved every minute. If PdfCraft ever quits unexpectedly, it offers to recover your work the next time it opens. Encrypted documents stay encrypted on disk.
@@ -291,6 +293,8 @@ Every engine feature is reachable without the GUI, through one table of JSON-Sch
{ "mcpServers": { "pdfcraft": { "command": "pdfcraft-cli", "args": ["mcp", "--root", "/path/to/your/pdfs"] } } }
```
+ `--compact` shrinks the tool list the agent has to read: `tools/list` returns about ten core tools plus `tool_search` and `tool_call`, which find and run every other tool, so the list costs far fewer tokens. Every tool still works.
+
`--root` confines every file the agent can read or write to one directory. Builds that should not include the server at all can use `cargo build -p pdfcraft-cli --no-default-features`.
- **The Rust API** (`pdfcraft_automation::Automation::call`), for embedding.
@@ -418,6 +422,10 @@ The honest assessment by area, what's lacking and where we're going are in **[RO
Installers and executables are code-signed.
+The portable zip runs from any folder, a USB stick included. Its `portable.txt` keeps the settings,
+logs and crash recovery in a `PdfCraftData` folder next to `pdfcraft.exe`, so nothing is written to
+`%APPDATA%`; delete that file to use the normal per-user folders.
+
The MSI installs for all users and requires administrator privileges. For unattended deployment
without a desktop shortcut, run from an elevated terminal:
diff --git a/ROADMAP.md b/ROADMAP.md
index ecd96d48..ce510d9c 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -79,7 +79,8 @@ The gaps that matter most, in priority order. Agents: prefer these over adding P
3. **Measure fidelity against Acrobat.** Build a side-by-side harness (render, text, form behaviour) over synthetic fixtures (`plan/acrobat/`, clean-room rules apply). Until then, parity numbers count features, not quality.
4. **Editing existing content (M7).** Fonts, subsets, reflow and CJK. The most visible gap for Pro users.
5. **Pro workflows.** Signatures with timestamps and LTV (M9), OCR beyond Latin (M10), Office import/export (M10), preflight and PDF/A/X/UA (M11), XFA (M12).
-6. **1.0 polish (M14).** Code-signed installers, localization (the catalog system is in place and French, Spanish, Japanese and both Chinese catalogs cover the UI; Czech and Brazilian Portuguese still cover menus only), keyboard-only operation, a full screen-reader audit.
+6. **1.0 polish (M14).** Code-signed installers, localization (the catalog system is in place and French, German, Spanish, Japanese, Russian and both Chinese catalogs cover the UI; Czech and Brazilian Portuguese still cover menus only), keyboard-only operation, a full screen-reader audit.
+6. **1.0 polish (M14).** Code-signed installers, localization (the catalog system is in place and French, Spanish, Japanese, Russian, Ukrainian and both Chinese catalogs cover the UI; Czech and Brazilian Portuguese still cover menus only), keyboard-only operation, a full screen-reader audit.
Decided against for now (owner, 2026-10-05): self-installing updates and an update check at start. Help ▸ Check for updates is manual only.
@@ -90,20 +91,21 @@ Hours are for a single agent (low–high). "Done" is the estimated fraction of t
| M | Milestone | Est. hours | Done | Remaining (h) | Notes |
|---|---|---|---|---|---|
| M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 92% | 2–4 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates |
-| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 78% | 25–45 | Done: - filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption; - cos: parse and repair (including invalid xref object-number ranges), decrypt on load, re-encrypt on save, incremental and full writing. Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open. Full saves now pack objects into compressed object streams. Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs |
-| M2 | Model, render, text | 200–350 | 15% | 170–300 | hayro bootstrap renderer (vendored patches). Text extraction reaches word-F1 0.98 against pdftotext. Missing: model crate, fonts, DisplayList, renderer independent of hayro |
+| M2 | Model, render, text | 200–350 | 15% | 170–300 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input. Text extraction reaches word-F1 0.98 against pdftotext. Missing: model crate, fonts, DisplayList, renderer independent of hayro |
+| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 78% | 25–45 | Done: - filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption; - cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing. Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open. Full saves now pack objects into compressed object streams. Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs |
| M3 | Viewer app (native + web) | 80–150 | 88% | 10–19 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel. |
-| M4 | Engine, history, save, organize | 100–180 | 95% | 9–17 | Done: - command registry (menus, shortcuts and palette all use it); - undo/redo; incremental, atomic and encrypted saves; - autosave and crash recovery; - organize, combine, extract, split and insert-from-file, with identical fonts and images stored once; - CLI `edit/combine/extract/split`. Done since: bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize. Missing: Replace pages, page transitions, recovery on the web |
-| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 15–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export; flatten; Fill & Sign; agent tools. Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters. Missing: custom stamps, replace-text proposals, summary layouts with the page |
+| M4 | Engine, history, save, organize | 100–180 | 95% | 9–17 | Done: - command registry (menus, shortcuts and palette all use it); - undo/redo; incremental, atomic and encrypted saves; - autosave and crash recovery; - organize, combine, extract, split and insert-from-file, with identical fonts and images stored once; - CLI `edit/combine/extract/split`. Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize. Missing: Replace pages, page transitions, recovery on the web |
+| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 15–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export; flatten; Fill & Sign; agent tools. Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page |
| M6 | Forms + JavaScript | 160–320 | 45% | 95–185 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done. Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 17% | 210–420 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
| M8 | Security + redaction | 100–180 | 66% | 35–65 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize. Missing: certificate security, redaction codes and pattern locales, DCT re-encoding |
| M9 | Signatures (PAdES, validation) | 160–280 | 55% | 70–125 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL. Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
-| M10 | OCR, create, export, print | 200–350 | 33% | 130–235 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, CUPS spooler, print-ready PDF). Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print. Missing: OCR languages beyond Latin, editable-text OCR output, Office export/import, Windows/web printing |
+| M10 | OCR, create, export, print | 200–350 | 33% | 130–235 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, CUPS spooler, print-ready PDF). Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; Create PDF from multiple files (PDFs, images and text, edited as pages in the grid). Missing: OCR languages beyond Latin, editable-text OCR output, Office export/import, Windows/web printing |
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 165–290 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 23% | 155–295 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export. Missing: autotag, Tags/Order/Content panels, Reading Order tool, alt-text workflow, compare, geospatial/3D measurement, search index, XFA |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 33–66 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers |
-| M14 | 1.0 polish: performance, localization, installers | 120–250 | 5% | 115–240 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`; Japanese, Traditional Chinese, Simplified Chinese, Spanish and French catalogs have ≈1,800 entries each; Simplified Chinese and French coverage is checked against the UI source; Czech and Brazilian Portuguese for menus. French locales (`fr`, `fr_FR`, `fr_CA`) follow the French catalog. Missing: more catalogs filled in, installers, performance budgets |
+| M14 | 1.0 polish: performance, localization, installers | 120–250 | 5% | 115–240 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`; Japanese, Traditional Chinese, Simplified Chinese, Russian, German, Spanish and French catalogs have ≈1,800 entries each; Simplified Chinese, French and German coverage is checked against the UI source; Czech and Brazilian Portuguese for menus. French locales (`fr`, `fr_FR`, `fr_CA`) follow the French catalog. Missing: more catalogs filled in, installers, performance budgets |
+| M14 | 1.0 polish: performance, localization, installers | 120–250 | 5% | 115–240 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`; Japanese, Traditional Chinese, Simplified Chinese, Russian, Spanish, French and Ukrainian catalogs have ≈1,800–2,000 entries each; Simplified Chinese, French and Ukrainian coverage is checked against the UI source; Czech and Brazilian Portuguese for menus. French locales (`fr`, `fr_FR`, `fr_CA`) follow the French catalog; Ukrainian locales (`uk`, `uk_UA`) follow the Ukrainian one. Missing: more catalogs filled in, installers, performance budgets |
| | **Total (original plan sizing)** | **2,085–3,840** | **≈ 35%** | **≈ 1,350–2,500 at the planned rate; ≈ 600–1,100 at the measured rate** | |
**Overall progress: about 30–35% of the effort (51.0% of features shipped).** The viewer and the core are far ahead of the editing features, because the viewer was built first so progress could be seen; rendering itself is still borrowed from `hayro`. See [Honest assessment](#honest-assessment-2026-10-05).
@@ -125,14 +127,48 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
## Log
+- **2026-10-09 (M5, line endings):** Lines, connected lines and callouts draw all ten standard line-ending styles. `comment_add` accepts `endings`, and Comment properties can change them in one undo step. An unknown name is left undrawn, and a non-finite direction is rejected. Cloudy borders remain polygon-only, so `comment.line-endings` stays partial. Overall estimate unchanged (about 30–35%).
+- **2026-10-09 (M6, field rotation):** Regenerated form appearances honour `/MK /R` (0, 90, 180, 270). Filling or restyling a rotated field keeps the text, check mark and button caption turned, and Field Properties ▸ Position and `form_set_props` `rotation` set it, swapping width and height around the field's center when the axis changes. `form.appearance-regeneration` stays partial (Helvetica metrics are still approximate). Overall estimate unchanged (about 30–35%).
+- **2026-10-09 (M5.7, #434 partial):** Natural-size image stamps placed at a click or through `stamp_custom` remain upright on rotated pages, with a displayed-space-sized rectangle and a counterrotated standard appearance matrix. Move, resize, restyle and undo/redo preserve that orientation. Native and external Poppler save/reopen rendering cover all four quarter-turns; UI tests verify the displayed bounds. Explicit rectangle placement, image signatures, PDF-page stamps and image-file orientation metadata remain outside this repair. #434 stays open; milestone table and effort estimates remain unchanged.
+- **2026-10-09 (M1, raw-stream decoding limits):** COS checks unfiltered decoded data against the caller and per-stream limits before copying it, including empty filter arrays and streams whose Crypt filter was applied on load. Strict decoding uses the same raw-data guard. Tiny synthetic regressions cover exceeded, exact and zero-byte limits while retaining the filtered decoder behavior. Milestone and overall effort estimates unchanged (about 30–35%).
+
+- **2026-10-09 (M7, grouped artwork):** Edit text/images selects page-drawn Form XObjects as whole figures, including nested raster/vector/text content. Their BBox and optional Matrix determine the selection; move/resize/rotate/flip/delete rewrite only the selected page placement, preserving shared Forms and other placements. Engine, automation and UI core regressions cover geometry, rendered movement, save/reopen, undo and selection/drag/Delete. Bitmap replacement/extraction remain raster-only; inline images and individual elements inside a Form are not editable through this tool. Overall estimate unchanged (about 30–35%).
+
+- **2026-10-08 (M5.7, signature resize proportions):** Image signature corner handles preserve the embedded image's original aspect ratio and anchor the opposite corner; edge midpoint handles stretch width or height independently. The live preview and committed edit share the constrained geometry, including page/view rotation and reopened PDFs whose rectangles were stretched previously. Synthetic UI tests cover all corners, edge resizing, undo and redo. Milestone and effort estimates unchanged.
+- **2026-10-08 (M5.7, live signature adjustment):** Embedded image signatures resize and move with every pointer frame. Movement hides the frame and handles, which return on release; Escape cancels without committing. A cached background preserves the page beneath the image, and only the completed gesture enters undo history. The read-only engine preview and `comment_image_preview` tool share bounded image decoding and render layers. Synthetic pixel tests cover live geometry, alpha, coloured page content, rotation, cancellation and encrypted save/reopen. Milestone and effort estimates unchanged.
+- **2026-10-08 (M10/M4, create from multiple files):** Create a PDF ▸ Multiple files is live. The picked PDFs, images and `.txt` files open as one unsaved document in the Organize page grid, where pages are removed and reordered and Save pages writes what is shown. The grid gained a "+" at every gap that inserts files right there, and on desktop files dropped on the grid go to the gap under the pointer; Insert from file now takes several files in order, converts images and text, and selects what it inserted. File-type detection moved from the shell into `pdfcraft-create` (`source_kind`), with one engine entry point (`Session::convert_to_pdf`) shared by Open, Insert, Create and the new `doc_create_multiple` tool (which can also write one PDF per file). Not yet: Office/HTML/Markdown inputs, PDF Portfolios, progress for big batches, dropping onto the grid in the web build. Overall estimate unchanged (about 30–35%).
+
+- **2026-10-08 (M5.7, signature placement):** Image signatures and initials preview at the pointer at their actual page size, including zoom and document/view rotation. A successful placement switches to Select with the new annotation selected; stamp resize handles use the existing engine edit, automation tool and undo history while preserving the appearance. Synthetic rendered UI and automation tests verify positioning, one placement per selection, resizing, undo/redo and save/reopen. Milestone and overall effort estimates unchanged.
+- **2026-10-08 (M5.7, image signatures):** Fill & Sign imports local PNG/JPEG signatures and initials through the Image tab, with previews, local persistence and undoable placement. The shared engine validates bounded images and preserves transparency and orientation; `fill_sign_add` accepts an image path. Synthetic UI and automation tests cover cancellation, invalid input, settings, undo/redo and save/reopen rendering. Overall effort estimate unchanged (about 30–35%).
+
- **2026-10-08 (M9, #179):** Windows Current User Personal certificate store identities join file-based digital IDs, signing through CNG without exporting keys. RSA-2048 and ECDSA P-256 round trips use temporary certificates with cleanup guards; enumeration, missing identities, automation errors and the password-free UI have regression coverage. Smart cards and PKCS #11 remain planned; overall effort estimate unchanged.
Newest first. One line per session: the date, what moved, and the new overall percentage.
+- **2026-10-09 (M14, German interface follow-up):** Follow the merged German catalog with shorter search/action labels, corrected callout/accessibility wording and a distinct certificate Subject context. Localized tool names retain their capitalization and comment composer titles go through the catalog. Search hints reserve shortcut space and Home cards bound text with full hover text; layout, context and German Preferences/search control regression tests cover these changes. Overall effort estimate remains about 30–35%.
+
+- **2026-10-09 (community, destinations):** Bookmarks and links go to their destination's position, not just its page (ISO 32000-2 §12.3.2.2): `/XYZ` puts (left, top) at the window's top-left and applies a non-zero zoom; `/Fit`, `/FitH` and `/FitV` switch to fit page, width and height with `top` or `left` at the window's edge; `/FitR` zooms to fit the rectangle, centred. Named destinations resolve the same way; crop-box origin, `/Rotate` and view rotation are accounted for; malformed or non-finite operands fall back to the top of the page. Not yet: the `/FitB*` content box (read as the page). Overall estimate unchanged (about 30–35%).
+
+- **2026-10-09 (community, M14, Hungarian):** Magyar (`hu`) is a complete interface catalog (2,036 entries) covering every command, All tools entry, dialog, panel and notice, selected automatically for `hu` and `hu_HU`, and from Preferences. Informal "te"; commands are nouns (Mentés, Megnyitás). After a numeral the noun stays singular, so both plural forms match. Coverage tests check commands, All tools labels and every `tl!` literal; history labels keep captured names; the bundled Latin faces cover Hungarian diacritics including ő and ű. Overall estimate unchanged (about 30–35%).
+- **2026-10-09 (M14, Windows Auto):** Neutral launcher locales (`C`, `C.UTF-8`, `POSIX`) no longer select English ahead of the Windows preferred display-language list. Explicit supported locale overrides retain their priority. Regression tests use existing catalogs and cover neutral/mixed overrides, unsupported first display tags and multiple preferred Windows languages. Catalogs and other platforms are unchanged; overall effort estimate remains about 30–35%.
+
+- **2026-10-09 (community, M14, German):** Deutsch (`de`) is a complete interface catalog (2,035 entries) covering every command, All tools entry, dialog, panel and notice, selected automatically for `de`, `de_DE`, `de_AT`, `de_CH` and other German locales, and from Preferences. Informal "du", infinitive for commands. Coverage tests check commands, All tools labels and every `tl!` literal; history labels keep captured names; the bundled Latin faces cover umlauts, ß and German quotes. Overall estimate unchanged (about 30–35%).
+- **2026-10-09 (community, M7, Arabic added text):** Add text writes Arabic into the PDF instead of `?`. The text is shaped with the craft-fonts `Arab` face through harfrust (already in the tree via egui): joining forms, ligatures and mark positions. Each line is laid out in display order by the Unicode bidirectional algorithm, so Arabic mixed with Latin words, European or Arabic-Indic digits and brackets reads correctly; justified right-to-left paragraphs end at the right edge. Each cluster (a letter with its dots or marks) becomes one glyph of a Type 3 font built from the face's outlines, with a ToUnicode entry, so `text_extract`, copy and search get every letter once and no font file is embedded. Wrapping measures shaped words. Each item has its own fonts under names free on the page (a full save renumbers objects but not resource names); updating or deleting the item removes them. Without the face, adding Arabic text returns a clear error and changes nothing, while an existing item holding Arabic still moves and retypes, drawn as `?` as before. Tests cover shaping, display order, ToUnicode, font names and clean-up on one page, and odd input (empty lines, direction controls, lone marks, 18,000 characters). Not yet: bold/italic Arabic, Hebrew, right-to-left typing in the box (misc.rtl-ui), editing existing Arabic text (edit.rtl-cjk-editing). The craft-fonts revision pinned for releases predates its Arabic face, so release builds report the missing font until the pin moves. Overall estimate unchanged (about 30–35%).
+- **2026-10-09 (M2, predictor hardening):** Empty PNG predictor frames return without row buffers. Packed TIFF Predictor 2 rows reject overflowing dimensions and size per-component scratch to the samples present in the input, aligned with upstream lopdf#589. Synthetic regressions cover malformed dimensions, partial rows, 1/2/4-bit decoding and padding. Overall estimate unchanged (about 30–35%).
+- **2026-10-09 (community, document inspection):** Annotation dates preserve non-date Unicode text instead of panicking while opening; failed auxiliary structure inspection keeps renderer page geometry and reports a warning. Synthetic UTF16BE annotation, UTF8-boundary and independent panic regressions cover the fix. Overall estimate unchanged (about 30–35%).
+
+- **2026-10-09 (community, M14, #503):** Ukrainian (Українська, `uk`) adds 2,047 original catalog entries covering commands, tools, dialogs, panels, notices, history and contributor controls. Auto resolves `uk`/`uk_UA`; the manual preference persists. Integer plurals distinguish one/few/many, including 11–14 and large counts; fixed-count templates use count-neutral wording. Calendar dates use genitive month names; Home cards fit wrapped text and the search hint keeps clear of its shortcut. Coverage checks scan UI literals, and font checks use bundled Inter and JetBrains Mono, including ґ/є/і/ї. Control tests check translated Preferences, command search, error framing and language switching with unsaved documents. Overall estimate unchanged (about 30–35%).
+
+- **2026-10-09 (community, Home pinned folders):** Home gets a Folders section: File ▸ Pin folder to Home… (or "Pin a folder…" on Home) pins up to 8 folders, and each lists its newest PDFs (name, how long ago, size; 8 at first, up to 100 with Show more), one click to open, Unpin to remove. Folders are read on a worker thread and listed again every few seconds while Home shows, so new scans appear without reopening anything; a folder that can't be read (moved, offline synced drive) stays pinned and says so. Each folder is described by where it is ("iCloud Drive", "OneDrive · Contoso", "Documents › Work") rather than its full path, so two folders called "Scans" can be told apart. Cloud drives work through their local synced folder; nothing is read through a provider's API. Settings keep only absolute paths, without duplicates. Command label translated in every catalog; Home strings in French, Spanish, Japanese and both Chinese catalogs. Not yet: subfolders, search inside a pinned folder, the web build. Overall estimate unchanged (about 30–35%).
+
+- **2026-10-09 (community, M14, Telugu):** Telugu (తెలుగు, `te`) as a complete catalog of 1,984 entries covering every command, All tools entry, dialog, panel and notice; `te` and `te_IN` locales follow it. Telugu text is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, storytold/craft-fonts#10), kept in the web build too, after the CJK and Arabic faces; egui shapes its conjuncts. Commands use the plain imperative and sentences the polite form. The craft-fonts revision pinned for releases has no Telugu face yet, so release builds show Telugu as replacement boxes until the pin moves. Overall estimate unchanged (about 30–35%).
+
- **2026-10-09 (M14, Simplified Chinese About):** The About tabs, contributor controls and statistics, and model table now have Simplified Chinese translations, along with multiline link-privacy and cut-and-stack guidance. Coverage checks include direct `i18n::t` calls, multiline literals and dynamic credit labels; UI control checks exercise the translated About tabs and tables. Chinese font delivery remains incomplete; overall ≈ 30–35% (unchanged).
- **2026-10-09 (M14, #305):** Windows MSI rejects per-user installation overrides while allowing removal of legacy per-user installs and keeps its progress message visible. README documents unattended deployment without a desktop shortcut. Compiled x64 MSI checks pass; ARM64 install regressions added for CI. Overall estimate unchanged (about 30–35%).
+- **2026-10-08 (M4, Combine files):** Combine files is now its own tab instead of a dialog: a table of the files (name, pages, size, modified, warnings) whose columns sort, resize and move, with the layout kept in the settings. Files, folders (optionally with subfolders) and open documents can be added, or dropped on the tab; rows select like a file manager (Ctrl/Shift), move as a block by toolbar, Alt+arrows or drag, and every change undoes with ⌘Z. Problems show before Combine: password-protected files, security settings that withhold page assembly, bad page ranges (checked as typed), duplicates; protected files unlock with their open or permissions password, also headlessly (`doc_combine` `passwords`). The result is never encrypted, and the list says so. `organize.combine-sort` shipped; `organize.combine-options` partial (open files). Still missing: protecting the result in the same step, file-size and PDF/A options. Overall estimate unchanged (about 30–35%).
+
- **2026-10-08 (M14, French):** Français (`fr`) is a complete interface catalog (about 1,880 entries), selected automatically for `fr`, `fr_FR`, `fr_CA` and other French locales, and from Preferences. Coverage tests check commands, All tools labels and every `tl!` literal; history labels keep captured names. Latin UI faces cover the catalog. Localization remains partial for Czech and Brazilian Portuguese; overall ≈ 30–35% (unchanged).
- **2026-10-08 (M14, right-to-left file names):** A tab with an Arabic or Hebrew file name showed replacement boxes. The interface now takes an `Arab` face from craft-fonts when the build input has one, and on desktop falls back to one font installed on the machine for characters no embedded face has (`PDFCRAFT_SYSTEM_FONTS=0` turns that off; README screenshots do). File names and paths in the tab strip, the recent-files list and Properties are put in display order, since egui joins the letters but does not reorder runs. Still missing: multi-line text (bookmarks, comments, search results), text input and a mirrored layout, so `misc.rtl-ui` stays planned; the craft-fonts revision pinned for releases predates its Arabic face, so release builds rely on the installed font until the pin moves. Overall estimate unchanged (about 30–35%).
@@ -238,3 +274,5 @@ Newest first. One line per session: the date, what moved, and the new overall pe
- Robustness sweep (963 of 983 files open, 0 crashes), text layer, find and select, tiles, web build, polish.
- Overall ≈ 3–4%.
- **2026-09-30 (session 1):** planning complete; viewer vertical slice.
+
+- M13: common command, batch, inspection and bounded preview tools share the existing confined automation path. MCP tools carry complete hints and reject unknown top-level keys as invalid parameters.
diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs
index 011d8320..1940d63e 100644
--- a/apps/pdfcraft-cli/src/main.rs
+++ b/apps/pdfcraft-cli/src/main.rs
@@ -1,22 +1,6 @@
//! pdfcraft-cli — headless PdfCraft.
//!
-//! ```text
-//! pdfcraft-cli info [--password PW] document summary as JSON
-//! 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]
-//! pdfcraft-cli combine … --out combined.pdf
-//! pdfcraft-cli extract --pages 1,3,5 --out out.pdf
-//! pdfcraft-cli split (--every N | --before 3,7) [--out-dir DIR]
-//! pdfcraft-cli check [--timeout 20] [--dpi 36] [--json out.json]
-//! 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 mcp [--root DIR] MCP server on stdin/stdout (opt-in)
-//! pdfcraft-cli ui --control FILE [key=value …] [--out shot.png]
-//! drive a running app started with --control FILE
-//! ```
+//! Usage: `pdfcraft-cli --help`, which prints `USAGE` below.
//!
//! `run` and `mcp` drive the same tool table (`pdfcraft-automation`). In `run`, values parse as
//! JSON when they can (`pages=[1,3]`, `degrees=90`) and are strings otherwise. A script runs its
@@ -42,28 +26,75 @@ use std::time::{Duration, Instant};
use pdfcraft_engine::export::ImageFormat;
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind, inspect};
+/// The `mcp` lines of [`USAGE`]: only a build with the `mcp` feature has the command.
+#[cfg(feature = "mcp")]
+macro_rules! mcp_usage {
+ () => {
+ "\
+pdfcraft-cli mcp [--root DIR] [--compact] MCP server on stdin/stdout (opt-in)
+ --compact lists a core set of tools plus tool_search and tool_call
+"
+ };
+}
+#[cfg(not(feature = "mcp"))]
+macro_rules! mcp_usage {
+ () => {
+ ""
+ };
+}
+
+/// The full usage, printed by `--help` (also after a command, e.g. `render --help`).
+const USAGE: &str = concat!(
+ "\
+pdfcraft-cli info [--password PW] document summary as JSON
+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]
+pdfcraft-cli combine … --out combined.pdf
+pdfcraft-cli extract --pages 1,3,5 --out out.pdf
+pdfcraft-cli split (--every N | --before 3,7) [--out-dir DIR]
+pdfcraft-cli check [--timeout 20] [--dpi 36] [--json out.json]
+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\": {…}}, …]
+",
+ mcp_usage!(),
+ "\
+pdfcraft-cli ui --control FILE [key=value …] [--out shot.png]
+ drive a running app started with --control FILE
+help and feedback: https://discord.gg/artcraft"
+);
+
fn main() -> ExitCode {
let args: Vec = std::env::args().skip(1).collect();
- let result =
- match args.first().map(String::as_str) {
- Some("info") => info(&args[1..]),
- Some("render") => render(&args[1..]),
- Some("text") => text(&args[1..]),
- Some("edit") => edit(&args[1..]),
- Some("combine") => combine(&args[1..]),
- Some("extract") => extract(&args[1..]),
- Some("split") => split(&args[1..]),
- Some("check") => check(&args[1..]),
- Some("check-one") => check_one(&args[1..]),
- Some("tools") => tools(),
- Some("run") => run(&args[1..]),
- Some("ui") => ui(&args[1..]),
- #[cfg(feature = "mcp")]
- Some("mcp") => mcp(&args[1..]),
- Some("--version") => version(),
- _ => Err("usage: pdfcraft-cli … (see source header for options)\nhelp and feedback: https://discord.gg/artcraft"
- .into()),
- };
+ // ` --help` too; only right after the command, so a later `-h` (a value) still reaches it.
+ let command = match args.get(1).map(String::as_str) {
+ Some("--help" | "-h") => Some("--help"),
+ _ => args.first().map(String::as_str),
+ };
+ let result = match command {
+ Some("--help" | "-h" | "help") => stdout_line(format_args!("{USAGE}")),
+ None => Err(format!("usage:\n{USAGE}").into()),
+ Some("info") => info(&args[1..]),
+ Some("render") => render(&args[1..]),
+ Some("text") => text(&args[1..]),
+ Some("edit") => edit(&args[1..]),
+ Some("combine") => combine(&args[1..]),
+ Some("extract") => extract(&args[1..]),
+ Some("split") => split(&args[1..]),
+ Some("check") => check(&args[1..]),
+ Some("check-one") => check_one(&args[1..]),
+ Some("tools") => tools(),
+ Some("run") => run(&args[1..]),
+ Some("ui") => ui(&args[1..]),
+ #[cfg(feature = "mcp")]
+ Some("mcp") => mcp(&args[1..]),
+ #[cfg(not(feature = "mcp"))]
+ Some("mcp") => Err("mcp: this build leaves out the MCP server (built without the `mcp` feature)".into()),
+ Some("--version") => version(),
+ Some(other) => Err(format!("unknown command '{other}'; see pdfcraft-cli --help").into()),
+ };
match result {
Ok(()) => ExitCode::SUCCESS,
Err(CliError::Stdout(e)) if e.kind() == std::io::ErrorKind::BrokenPipe => ExitCode::SUCCESS,
@@ -175,6 +206,22 @@ fn info(args: &[String]) -> Result<(), CliError> {
fn text(args: &[String]) -> Result<(), CliError> {
let path = *positional(args).first().ok_or("text: missing file")?;
+ let mut selected_page = None;
+ // Validate every supplied value before reading, retaining the first valid selection.
+ let mut options = args.iter();
+ while let Some(option) = options.next() {
+ if option == "--page" {
+ let page = options
+ .next()
+ .and_then(|value| value.parse::().ok())
+ .filter(|page| *page > 0)
+ .ok_or("bad --page: expected a positive page number")?;
+ selected_page.get_or_insert(page - 1);
+ } else if option.starts_with("--") {
+ // Match positional(): an option's operand is not itself another option.
+ options.next();
+ }
+ }
let password = flag(args, "--password");
let bytes = read(path)?;
let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: password.map(Arc::from), ..Default::default() });
@@ -187,15 +234,15 @@ fn text(args: &[String]) -> Result<(), CliError> {
return Err("text: the document could not be parsed".into());
}
}
- let pages: Vec = match flag(args, "--page") {
- Some(p) => vec![p.parse::().map_err(|_| "bad --page")?.saturating_sub(1)],
+ let pages: Vec = match selected_page {
+ Some(page) => vec![page],
None => (0..r.page_count()).collect(),
};
let mut failed: Vec = Vec::new();
for (n, p) in pages.iter().enumerate() {
let out = r.render(RenderRequest { page: *p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0 });
if let Some(e) = out.error {
- eprintln!("page {}: {e}", p + 1);
+ let _ = writeln!(std::io::stderr().lock(), "page {}: {e}", p + 1);
failed.push(p + 1);
continue;
}
@@ -262,11 +309,11 @@ fn edit(args: &[String]) -> Result<(), CliError> {
"--move" => {
let (pages, to) = value.split_once(':').ok_or("--move PAGES:TO")?;
let to: usize = to.parse().map_err(|_| "bad target")?;
- edits.push(Edit::MovePages { pages: page_list(pages)?, to: to.saturating_sub(1) });
+ edits.push(Edit::MovePages { pages: page_list(pages)?, to: to.checked_sub(1).ok_or("--move target must be at least 1")? });
}
"--insert-blank" => {
let at: usize = value.parse().map_err(|_| "bad position")?;
- edits.push(Edit::InsertBlankPage { at: at.saturating_sub(1), width: 612.0, height: 792.0 });
+ edits.push(Edit::InsertBlankPage { at: at.checked_sub(1).ok_or("--insert-blank must be at least 1")?, width: 612.0, height: 792.0 });
}
"--title" => edits.push(Edit::SetInfo { key: "Title".into(), value: value.into() }),
"--author" => edits.push(Edit::SetInfo { key: "Author".into(), value: value.into() }),
@@ -337,6 +384,7 @@ fn split(args: &[String]) -> Result<(), CliError> {
fn render(args: &[String]) -> Result<(), CliError> {
let path = *positional(args).first().ok_or("render: missing file")?;
let page: usize = flag(args, "--page").unwrap_or("1").parse().map_err(|_| "bad --page")?;
+ let page_index = page.checked_sub(1).ok_or("--page must be at least 1")?;
let dpi: f32 = flag(args, "--dpi").unwrap_or("96").parse().map_err(|_| "bad --dpi")?;
let out = flag(args, "--out").ok_or("render: missing --out (.png, .jpg, .tif or .pam)")?;
// The file is what its name says (#248): a `.png` used to get a netpbm PAM stream.
@@ -348,7 +396,7 @@ fn render(args: &[String]) -> Result<(), CliError> {
_ => 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), ..Default::default() });
- let p = r.render(RenderRequest { page: page.saturating_sub(1), kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0 });
+ let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0 });
if let Some(e) = p.error {
return Err(e.into());
}
@@ -362,7 +410,7 @@ fn render(args: &[String]) -> Result<(), CliError> {
}
};
std::fs::write(out, bytes).map_err(|e| format!("{out}: {e}"))?;
- eprintln!("rendered page {page} at {dpi} dpi: {}×{} px in {} ms", p.width, p.height, p.millis);
+ let _ = writeln!(std::io::stderr().lock(), "rendered page {page} at {dpi} dpi: {}×{} px in {} ms", p.width, p.height, p.millis);
Ok(())
}
@@ -579,8 +627,14 @@ fn run(args: &[String]) -> Result<(), CliError> {
#[cfg(feature = "mcp")]
fn mcp(args: &[String]) -> Result<(), CliError> {
- let mut server = pdfcraft_automation::mcp::McpServer::new(automation(args)?);
- eprintln!("pdfcraft-cli: MCP server on stdio (protocol {}); close stdin to stop", pdfcraft_automation::mcp::PROTOCOL_VERSIONS[0]);
+ let compact = args.iter().any(|a| a == "--compact");
+ let mut server = pdfcraft_automation::mcp::McpServer::new(automation(args)?).with_compact(compact);
+ let _ = writeln!(
+ std::io::stderr().lock(),
+ "pdfcraft-cli: MCP server on stdio (protocol {}{}); close stdin to stop",
+ pdfcraft_automation::mcp::PROTOCOL_VERSIONS[0],
+ if compact { ", compact tool list" } else { "" }
+ );
server.serve(std::io::stdin().lock(), std::io::stdout().lock()).map_err(|e| CliError::Message(e.to_string()))
}
diff --git a/apps/pdfcraft-cli/tests/edit.rs b/apps/pdfcraft-cli/tests/edit.rs
index 8cd47cfb..15fdb68c 100644
--- a/apps/pdfcraft-cli/tests/edit.rs
+++ b/apps/pdfcraft-cli/tests/edit.rs
@@ -91,3 +91,91 @@ fn the_documented_options_still_edit_and_save() {
assert_eq!(info.title.as_deref(), Some("Rotated"), "{name}");
}
}
+
+mod one_based_arguments {
+ use super::*;
+ use std::sync::atomic::{AtomicU64, Ordering};
+
+ struct TestDir(PathBuf);
+ impl TestDir {
+ fn new() -> Self {
+ static NEXT: AtomicU64 = AtomicU64::new(0);
+ for _ in 0..128 {
+ let path =
+ std::env::temp_dir().join(format!("pdfcraft-cli-one-based-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
+ match std::fs::create_dir(&path) {
+ Ok(()) => return Self(path),
+ Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
+ Err(e) => panic!("cannot create test directory: {e}"),
+ }
+ }
+ panic!("cannot reserve test directory");
+ }
+ }
+ impl Drop for TestDir {
+ fn drop(&mut self) {
+ let _ = std::fs::remove_dir_all(&self.0);
+ }
+ }
+
+ #[test]
+ fn zero_render_page_and_edit_positions_fail_before_io() {
+ let dir = TestDir::new();
+ let source = fixture();
+ std::fs::write(dir.0.join("input.pdf"), &source).unwrap();
+ for input in ["input.pdf", "missing.pdf"] {
+ for destination in ["output.pam", "new.pam", "input.pdf"] {
+ std::fs::write(dir.0.join("output.pam"), b"keep destination").unwrap();
+ for (command, option, value, diagnostic) in [
+ ("render", "--page", "0", "--page must be at least 1"),
+ ("edit", "--move", "1:0", "--move target must be at least 1"),
+ ("edit", "--insert-blank", "0", "--insert-blank must be at least 1"),
+ ] {
+ let out = Command::new(BIN).current_dir(&dir.0).args([command, input, "--out", destination, option, value]).output().unwrap();
+ assert_eq!(out.status.code(), Some(1), "{command} {input} {option} {value}");
+ assert_eq!(out.stderr, format!("pdfcraft-cli: {diagnostic}\n").as_bytes());
+ assert!(out.stdout.is_empty());
+ assert_eq!(std::fs::read(dir.0.join("input.pdf")).unwrap(), source);
+ assert_eq!(std::fs::read(dir.0.join("output.pam")).unwrap(), b"keep destination");
+ assert!(!dir.0.join("new.pam").exists());
+ assert!(!dir.0.join("missing.pdf").exists());
+ }
+ }
+ }
+ assert_eq!(std::fs::read_dir(&dir.0).unwrap().count(), 2);
+ }
+
+ #[test]
+ fn first_render_page_move_target_and_insertion_position_remain_usable() {
+ let dir = TestDir::new();
+ let source = fixture();
+ std::fs::write(dir.0.join("input.pdf"), &source).unwrap();
+ for output in ["page.pam", "page.png"] {
+ let out =
+ Command::new(BIN).current_dir(&dir.0).args(["render", "input.pdf", "--page", "1", "--dpi", "18", "--out", output]).output().unwrap();
+ assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
+ let bytes = std::fs::read(dir.0.join(output)).unwrap();
+ if output.ends_with(".pam") {
+ let header = b"P7\nWIDTH 50\nHEIGHT 75\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n";
+ assert!(bytes.starts_with(header));
+ assert_eq!(bytes.len(), header.len() + 50 * 75 * 4);
+ } else {
+ assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n"));
+ assert_eq!(u32::from_be_bytes(bytes[16..20].try_into().unwrap()), 50);
+ assert_eq!(u32::from_be_bytes(bytes[20..24].try_into().unwrap()), 75);
+ }
+ }
+ let out = Command::new(BIN)
+ .current_dir(&dir.0)
+ .args(["edit", "input.pdf", "--out", "edited.pdf", "--move", "1:1", "--rotate", "1:90", "--insert-blank", "1"])
+ .output()
+ .unwrap();
+ assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
+ assert!(out.stdout.is_empty() && out.stderr.is_empty());
+ let info = pdfcraft_render::inspect(std::sync::Arc::new(std::fs::read(dir.0.join("edited.pdf")).unwrap()), None).unwrap();
+ assert_eq!(info.pages.len(), 2);
+ assert_eq!((info.pages[0].width, info.pages[0].height, info.pages[0].rotation), (612.0, 792.0, 0));
+ assert_eq!((info.pages[1].width, info.pages[1].height, info.pages[1].rotation), (300.0, 200.0, 90));
+ assert_eq!(std::fs::read(dir.0.join("input.pdf")).unwrap(), source);
+ }
+}
diff --git a/apps/pdfcraft-cli/tests/help.rs b/apps/pdfcraft-cli/tests/help.rs
new file mode 100644
index 00000000..9a86150f
--- /dev/null
+++ b/apps/pdfcraft-cli/tests/help.rs
@@ -0,0 +1,65 @@
+//! `--help` prints the full usage to stdout and succeeds; a missing or unknown command fails with
+//! the usage (or a pointer to `--help`) on stderr.
+
+use std::process::{Command, Output};
+
+const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
+
+fn run(args: &[&str]) -> Output {
+ Command::new(BIN).args(args).output().unwrap()
+}
+
+fn text(bytes: &[u8]) -> String {
+ String::from_utf8_lossy(bytes).into_owned()
+}
+
+#[test]
+fn help_flags_print_full_usage_to_stdout() {
+ for args in [&["--help"][..], &["-h"], &["help"], &["render", "--help"], &["check", "-h"]] {
+ let out = run(args);
+ assert!(out.status.success(), "{args:?} exited {:?}", out.status);
+ let stdout = text(&out.stdout);
+ // Per-command options, not just the command list.
+ assert!(stdout.contains("pdfcraft-cli render --page N"), "{args:?}: {stdout}");
+ assert!(stdout.contains("--every N"), "{args:?}: {stdout}");
+ assert!(out.stderr.is_empty(), "{args:?}: {}", text(&out.stderr));
+ }
+}
+
+#[test]
+fn no_command_fails_with_usage_on_stderr() {
+ let out = run(&[]);
+ assert!(!out.status.success());
+ assert!(out.stdout.is_empty());
+ assert!(text(&out.stderr).contains("pdfcraft-cli render --page N"));
+}
+
+/// `mcp` is listed only when the build has it (`--no-default-features` leaves it out).
+#[test]
+fn usage_lists_mcp_only_when_built_with_it() {
+ let out = run(&["--help"]);
+ let stdout = text(&out.stdout);
+ assert_eq!(stdout.contains("pdfcraft-cli mcp"), cfg!(feature = "mcp"), "{stdout}");
+ assert_eq!(stdout.contains("--compact"), cfg!(feature = "mcp"), "{stdout}");
+ // The lines around it are still there.
+ assert!(stdout.contains("pdfcraft-cli run --script steps.json"), "{stdout}");
+ assert!(stdout.contains("pdfcraft-cli ui --control FILE"), "{stdout}");
+}
+
+#[cfg(not(feature = "mcp"))]
+#[test]
+fn mcp_without_the_feature_says_it_was_left_out() {
+ let out = run(&["mcp"]);
+ assert!(!out.status.success());
+ let stderr = text(&out.stderr);
+ assert!(stderr.contains("without the `mcp` feature"), "{stderr}");
+}
+
+#[test]
+fn unknown_command_names_it_and_points_to_help() {
+ let out = run(&["frobnicate"]);
+ assert!(!out.status.success());
+ let stderr = text(&out.stderr);
+ assert!(stderr.contains("unknown command 'frobnicate'"), "{stderr}");
+ assert!(stderr.contains("pdfcraft-cli --help"), "{stderr}");
+}
diff --git a/apps/pdfcraft-cli/tests/mcp_compact.rs b/apps/pdfcraft-cli/tests/mcp_compact.rs
new file mode 100644
index 00000000..48692068
--- /dev/null
+++ b/apps/pdfcraft-cli/tests/mcp_compact.rs
@@ -0,0 +1,76 @@
+//! `pdfcraft-cli mcp --compact`: the flag reaches the server, `--root` is still honoured, and plain `mcp` still lists every tool.
+#![cfg(feature = "mcp")]
+
+use std::io::Write as _;
+use std::process::{Command, Stdio};
+
+use serde_json::{Value, json};
+
+/// Run `pdfcraft-cli mcp ` over an initialize request plus `requests` and return the replies to `requests`.
+fn session(flags: &[&str], requests: &[Value]) -> Vec {
+ let mut child = Command::new(env!("CARGO_BIN_EXE_pdfcraft-cli"))
+ .arg("mcp")
+ .args(flags)
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::null())
+ .spawn()
+ .unwrap();
+ let mut input = String::from("{\"jsonrpc\":\"2.0\",\"id\":0,\"method\":\"initialize\",\"params\":{}}\n");
+ for r in requests {
+ input.push_str(&r.to_string());
+ input.push('\n');
+ }
+ child.stdin.take().unwrap().write_all(input.as_bytes()).unwrap();
+ let out = child.wait_with_output().unwrap();
+ assert!(out.status.success());
+ String::from_utf8(out.stdout).unwrap().lines().skip(1).map(|l| serde_json::from_str(l).unwrap()).collect()
+}
+
+fn request(id: u64, method: &str, params: Value) -> Value {
+ json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })
+}
+
+/// The `tools/list` tool names of `pdfcraft-cli mcp `.
+fn listed_tools(flags: &[&str]) -> Vec {
+ let reply = session(flags, &[request(1, "tools/list", json!({}))]).remove(0);
+ reply["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap().to_string()).collect()
+}
+
+#[test]
+fn compact_flag_shortens_the_tool_list() {
+ let full = listed_tools(&[]);
+ let compact = listed_tools(&["--compact"]);
+ assert_eq!(compact.len(), 17, "{compact:?}");
+ assert!(compact.iter().any(|n| n == "tool_search") && compact.iter().any(|n| n == "tool_call"));
+ assert!(compact.iter().all(|n| full.contains(n) || n == "tool_search" || n == "tool_call"));
+ assert!(full.len() > 100 && !full.iter().any(|n| n == "tool_search"), "{} tools", full.len());
+ // The flag can sit on either side of --root.
+ assert_eq!(listed_tools(&["--root", ".", "--compact"]), compact);
+ assert_eq!(listed_tools(&["--compact", "--root", "."]), compact);
+}
+
+#[test]
+fn compact_flag_keeps_the_root() {
+ let root = std::env::temp_dir().join(format!("pdfcraft-cli-compact-root-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&root);
+ std::fs::create_dir_all(&root).unwrap();
+ let outside = std::env::temp_dir().join(format!("pdfcraft-cli-compact-outside-{}.pdf", std::process::id()));
+ let outside = outside.to_string_lossy().into_owned();
+ let root_arg = root.to_string_lossy().into_owned();
+ let open_outside =
+ request(1, "tools/call", json!({ "name": "tool_call", "arguments": { "name": "doc_open", "arguments": { "path": outside } } }));
+ let text = |reply: &Value| reply["result"]["content"][0]["text"].as_str().unwrap_or_default().to_string();
+
+ // With the root, in either flag order, a path outside it is refused with the root's own message.
+ for flags in [vec!["--root", root_arg.as_str(), "--compact"], vec!["--compact", "--root", root_arg.as_str()]] {
+ let reply = session(&flags, std::slice::from_ref(&open_outside)).remove(0);
+ assert_eq!(reply["result"]["isError"], true, "{flags:?}: {reply}");
+ assert!(text(&reply).contains("is outside the allowed directory"), "{flags:?}: {}", text(&reply));
+ }
+ // Without a root the same call gets past that check (the file does not exist, so it fails for that reason instead).
+ let reply = session(&["--compact"], std::slice::from_ref(&open_outside)).remove(0);
+ assert_eq!(reply["result"]["isError"], true, "{reply}");
+ assert!(!text(&reply).contains("is outside the allowed directory"), "{}", text(&reply));
+ let _ = std::fs::remove_dir_all(&root);
+}
diff --git a/apps/pdfcraft-cli/tests/run_root.rs b/apps/pdfcraft-cli/tests/run_root.rs
index 2f9fb69b..511621cc 100644
--- a/apps/pdfcraft-cli/tests/run_root.rs
+++ b/apps/pdfcraft-cli/tests/run_root.rs
@@ -61,11 +61,19 @@ fn script_image_output_is_confined_to_the_root() {
assert!(!base.join("outside/escaped.png").exists() && !base.join("outside/new").exists());
assert_eq!(std::fs::read_to_string(base.join("outside/existing.txt")).unwrap(), "SENTINEL\n");
- // "." is the root itself: refused, without staging a temporary file beside the root.
+ // "." is the root itself: refused, without staging a temporary file beside the root. The
+ // file at the old fixed staging name and the listing are canaries; staging names are random.
std::fs::write(base.join(".root.pdfcraft-tmp"), "SENTINEL\n").unwrap();
+ let listing = || {
+ let mut names: Vec<_> = std::fs::read_dir(&base).unwrap().map(|e| e.unwrap().file_name()).collect();
+ names.sort();
+ names
+ };
+ let before = listing();
let o = render_to(&base, &base, Some(&root), ".");
assert!(!o.status.success() && String::from_utf8_lossy(&o.stderr).contains("is a folder"), "{}", String::from_utf8_lossy(&o.stderr));
assert_eq!(std::fs::read_to_string(base.join(".root.pdfcraft-tmp")).unwrap(), "SENTINEL\n");
+ assert_eq!(listing(), before, "nothing was left beside the root");
// A relative path resolves inside the root, not in the working directory.
let o = render_to(&base, &base, Some(&root), "relative.png");
diff --git a/apps/pdfcraft-cli/tests/stderr.rs b/apps/pdfcraft-cli/tests/stderr.rs
new file mode 100644
index 00000000..9991bc1c
--- /dev/null
+++ b/apps/pdfcraft-cli/tests/stderr.rs
@@ -0,0 +1,132 @@
+//! Diagnostic stderr failures must not replace a command's real result.
+#![cfg(target_os = "linux")]
+
+use std::io::Write as _;
+use std::path::{Path, PathBuf};
+use std::process::{Child, Command, Output, Stdio};
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::time::{Duration, Instant};
+
+const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
+
+struct Scratch(PathBuf);
+impl Scratch {
+ fn new() -> Self {
+ static NEXT: AtomicU64 = AtomicU64::new(0);
+ for _ in 0..128 {
+ let path = std::env::temp_dir().join(format!("pdfcraft-stderr-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
+ match std::fs::create_dir(&path) {
+ Ok(()) => return Self(path),
+ Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
+ Err(error) => panic!("creating stderr test directory: {error}"),
+ }
+ }
+ panic!("no unused stderr test directory after 128 attempts");
+ }
+}
+impl Drop for Scratch {
+ fn drop(&mut self) {
+ let _ = std::fs::remove_dir_all(&self.0);
+ }
+}
+
+// Only this test's newly spawned child is ever terminated, including assertion unwinding.
+struct OwnedChild(Option);
+impl Drop for OwnedChild {
+ fn drop(&mut self) {
+ if let Some(child) = &mut self.0 {
+ let _ = child.kill();
+ let _ = child.wait();
+ }
+ }
+}
+fn run(command: &mut Command, input: Option<&str>) -> Output {
+ command.stdin(if input.is_some() { Stdio::piped() } else { Stdio::null() });
+ let mut owned = OwnedChild(Some(command.spawn().unwrap()));
+ if let Some(input) = input {
+ owned.0.as_mut().unwrap().stdin.take().unwrap().write_all(input.as_bytes()).unwrap();
+ }
+ // Every fixture emits only a few short lines, below the pipe capacity.
+ let deadline = Instant::now() + Duration::from_secs(30);
+ while owned.0.as_mut().unwrap().try_wait().unwrap().is_none() {
+ assert!(Instant::now() < deadline, "owned CLI child exceeded thirty seconds");
+ std::thread::sleep(Duration::from_millis(10));
+ }
+ owned.0.take().unwrap().wait_with_output().unwrap()
+}
+fn full() -> Stdio {
+ std::fs::OpenOptions::new().write(true).open("/dev/full").unwrap().into()
+}
+fn render(pdf: &Path, out: &Path, stderr: Stdio) -> Output {
+ run(Command::new(BIN).arg("render").arg(pdf).args(["--page", "1", "--dpi", "18", "--out"]).arg(out).stdout(Stdio::piped()).stderr(stderr), None)
+}
+
+#[test]
+fn failing_diagnostics_preserve_render_success_and_real_text_or_file_errors() {
+ let dir = Scratch::new();
+ let bytes = pdfcraft_engine::Session::new().create_from_text("Diagnostic test", "Ordinary diagnostic text").unwrap();
+ let pdf = dir.0.join("input.pdf");
+ std::fs::write(&pdf, bytes.as_slice()).unwrap();
+ let image_path = dir.0.join("page.pam");
+ let failed_sink = render(&pdf, &image_path, full());
+ assert!(failed_sink.status.success(), "completed render must survive failed diagnostics: {}", failed_sink.status);
+ assert!(failed_sink.stdout.is_empty());
+ let image = std::fs::read(&image_path).unwrap();
+ assert!(image.starts_with(b"P7\nWIDTH ") && image.windows(7).any(|part| part == b"ENDHDR\n"));
+ let ordinary = render(&pdf, &image_path, Stdio::piped());
+ assert!(ordinary.status.success());
+ assert!(String::from_utf8_lossy(&ordinary.stderr).contains("rendered page 1 at 18 dpi"));
+ assert_eq!(std::fs::read(&image_path).unwrap(), image, "diagnostic destination must not change rendered bytes");
+
+ let invalid = run(Command::new(BIN).arg("text").arg(&pdf).args(["--page", "2"]).stdout(Stdio::piped()).stderr(full()), None);
+ assert_eq!(invalid.status.code(), Some(1), "page errors remain normal failures rather than stderr panics");
+ assert!(invalid.stdout.is_empty());
+ let valid = run(Command::new(BIN).arg("text").arg(&pdf).stdout(Stdio::piped()).stderr(full()), None);
+ assert!(valid.status.success());
+ assert!(String::from_utf8_lossy(&valid.stdout).contains("Ordinary diagnostic text"));
+
+ // An existing directory with a supported extension reaches the real file writer.
+ // Using /dev/full as the render path would only test upstream's extension check.
+ let folder = dir.0.join("folder.pam");
+ std::fs::create_dir(&folder).unwrap();
+ std::fs::write(folder.join("keep"), b"sentinel").unwrap();
+ assert_eq!(render(&pdf, &folder, full()).status.code(), Some(1));
+ assert_eq!(std::fs::read(folder.join("keep")).unwrap(), b"sentinel");
+ assert_eq!(std::fs::read(&image_path).unwrap(), image);
+ assert_eq!(std::fs::read(&pdf).unwrap(), bytes.as_slice());
+}
+
+#[cfg(feature = "mcp")]
+#[test]
+fn failing_startup_diagnostics_preserve_mcp_replies_and_protocol_output_errors() {
+ let requests = [
+ serde_json::json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18" } }),
+ serde_json::json!({ "jsonrpc": "2.0", "id": 2, "method": "ping" }),
+ serde_json::json!({ "jsonrpc": "2.0", "id": 3, "method": "ordinary-unknown-method" }),
+ ];
+ let input = requests.iter().map(|request| format!("{request}\n")).collect::();
+ let output = run(Command::new(BIN).arg("mcp").stdout(Stdio::piped()).stderr(full()), Some(&input));
+ assert!(output.status.success(), "failed startup diagnostics must not stop stdio: {}", output.status);
+ let replies: Vec = String::from_utf8(output.stdout).unwrap().lines().map(|line| serde_json::from_str(line).unwrap()).collect();
+ assert_eq!(replies.len(), 3);
+ assert_eq!(replies[0]["id"], 1);
+ assert_eq!(replies[0]["result"]["protocolVersion"], "2025-06-18");
+ assert_eq!(replies[1]["id"], 2);
+ assert_eq!(replies[1]["result"], serde_json::json!({}));
+ assert_eq!(replies[2]["id"], 3);
+ assert_eq!(replies[2]["error"]["code"], -32601);
+ // Upstream's compact mode still reaches stdio even when its expanded diagnostic fails.
+ let ping = "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"ping\"}\n";
+ for (stderr, diagnostic_expected) in [(full(), false), (Stdio::piped(), true)] {
+ let output = run(Command::new(BIN).args(["mcp", "--compact"]).stdout(Stdio::piped()).stderr(stderr), Some(ping));
+ assert!(output.status.success());
+ let reply: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
+ assert_eq!(reply["id"], 4);
+ assert_eq!(reply["result"], serde_json::json!({}));
+ if diagnostic_expected {
+ assert!(String::from_utf8_lossy(&output.stderr).contains(", compact tool list); close stdin to stop"));
+ }
+ }
+ let output = run(Command::new(BIN).arg("mcp").stdout(full()).stderr(full()), Some("{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"ping\"}\n"));
+ assert_eq!(output.status.code(), Some(1), "actual protocol output failure must still fail");
+}
diff --git a/apps/pdfcraft-cli/tests/text.rs b/apps/pdfcraft-cli/tests/text.rs
index c573393b..5ac2e07c 100644
--- a/apps/pdfcraft-cli/tests/text.rs
+++ b/apps/pdfcraft-cli/tests/text.rs
@@ -95,3 +95,117 @@ fn text_of_a_protected_document_with_the_password_succeeds() {
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("Page 1") && stdout.contains("Page 3"), "{stdout}");
}
+
+mod page_argument_tests {
+ use super::*;
+ use std::path::PathBuf;
+ use std::sync::atomic::{AtomicU64, Ordering};
+
+ struct PageDir(PathBuf);
+
+ impl PageDir {
+ fn new() -> Self {
+ static NEXT: AtomicU64 = AtomicU64::new(0);
+ for _ in 0..128 {
+ let serial = NEXT.fetch_add(1, Ordering::Relaxed);
+ let path = std::env::temp_dir().join(format!("pdfcraft-page-{}-{serial}", std::process::id()));
+ match std::fs::create_dir(&path) {
+ Ok(()) => return Self(path),
+ Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
+ Err(error) => panic!("creating page test directory: {error}"),
+ }
+ }
+ panic!("no unused page test directory after 128 attempts");
+ }
+ }
+
+ impl Drop for PageDir {
+ fn drop(&mut self) {
+ let _ = std::fs::remove_dir_all(&self.0);
+ }
+ }
+
+ #[test]
+ fn malformed_page_values_fail_before_reading_or_printing_document_text() {
+ let dir = PageDir::new();
+ let path = dir.0.join("three.pdf");
+ let bytes = fixture(3);
+ std::fs::write(&path, &bytes).unwrap();
+ let missing = dir.0.join("missing.pdf");
+ let invalid = [
+ vec!["--page", "0"],
+ vec!["--page", ""],
+ vec!["--page", "bad"],
+ vec!["--page", "-1"],
+ vec!["--page", "1.5"],
+ vec!["--page", "18446744073709551616"],
+ vec!["--page"],
+ vec!["--page", "--password", "unused"],
+ vec!["--page", "2", "--page"],
+ ];
+ for input in [&path, &missing] {
+ for args in &invalid {
+ let out = Command::new(BIN).arg("text").arg(input).args(args).output().unwrap();
+ assert_eq!(out.status.code(), Some(1), "{args:?}: {out:?}");
+ assert!(out.stdout.is_empty(), "{args:?}: no text may be printed");
+ assert_eq!(String::from_utf8(out.stderr).unwrap().trim(), "pdfcraft-cli: bad --page: expected a positive page number");
+ assert_eq!(std::fs::read(&path).unwrap(), bytes);
+ assert!(!missing.exists());
+ }
+ }
+ }
+
+ #[test]
+ fn valid_page_values_preserve_all_page_selection_and_first_duplicate_semantics() {
+ let dir = PageDir::new();
+ let path = dir.0.join("three.pdf");
+ let bytes = fixture(3);
+ std::fs::write(&path, &bytes).unwrap();
+ for (args, expected) in [
+ (vec![], vec!["Page 1", "Page 2", "Page 3"]),
+ (vec!["--page", "1"], vec!["Page 1"]),
+ (vec!["--page", "2"], vec!["Page 2"]),
+ (vec!["--page", "3"], vec!["Page 3"]),
+ (vec!["--page", "2", "--page", "1"], vec!["Page 2"]),
+ ] {
+ let out = Command::new(BIN).arg("text").arg(&path).args(args).output().unwrap();
+ assert!(out.status.success(), "{out:?}");
+ assert!(out.stderr.is_empty());
+ let text = String::from_utf8(out.stdout).unwrap();
+ assert_eq!(text.split('').map(str::trim).collect::>(), expected);
+ assert_eq!(std::fs::read(&path).unwrap(), bytes);
+ }
+ }
+
+ #[test]
+ fn an_option_shaped_password_is_not_parsed_as_a_page_flag() {
+ let dir = PageDir::new();
+ let source = dir.0.join("three.pdf");
+ let bytes = fixture(3);
+ std::fs::write(&source, &bytes).unwrap();
+ let protected = dir.0.join("protected.pdf");
+ let script = dir.0.join("protect.json");
+ let steps = serde_json::json!([
+ { "tool": "doc_open", "args": { "path": source } },
+ { "tool": "doc_protect", "args": { "doc": 1, "open_password": "--page" } },
+ { "tool": "doc_save", "args": { "doc": 1, "path": protected } },
+ ]);
+ std::fs::write(&script, steps.to_string()).unwrap();
+ let out = Command::new(BIN).args(["run", "--script"]).arg(&script).output().unwrap();
+ assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
+ let protected_bytes = std::fs::read(&protected).unwrap();
+ for (args, expected) in [
+ (vec!["--password", "--page"], vec!["Page 1", "Page 2", "Page 3"]),
+ (vec!["--page", "2", "--password", "--page"], vec!["Page 2"]),
+ (vec!["--password", "--page", "--page", "2"], vec!["Page 2"]),
+ ] {
+ let out = Command::new(BIN).arg("text").arg(&protected).args(args).output().unwrap();
+ assert!(out.status.success(), "{out:?}");
+ assert!(out.stderr.is_empty());
+ let text = String::from_utf8(out.stdout).unwrap();
+ assert_eq!(text.split('').map(str::trim).collect::>(), expected);
+ assert_eq!(std::fs::read(&protected).unwrap(), protected_bytes);
+ assert_eq!(std::fs::read(&source).unwrap(), bytes);
+ }
+ }
+}
diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml
index 78d9fb5c..2400fe9e 100644
--- a/apps/pdfcraft/Cargo.toml
+++ b/apps/pdfcraft/Cargo.toml
@@ -14,7 +14,9 @@ workspace = true
ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "_ring"] }
rustls-native-certs = "0.8"
pdfcraft-ui-egui = { workspace = true }
-eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "persistence", "links"] }
+# wgpu draws; OpenGL (glow) is the fallback when wgpu can't start on an old or unusual GPU or
+# driver (#461, #435, #392), and can be chosen with PDFCRAFT_RENDERER=gl.
+eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11", "persistence", "links"] }
egui = { workspace = true }
serde_json = { workspace = true }
# Records from every crate go to standard error and the log file (src/logging.rs).
diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs
index 8fae5b58..5d567b81 100644
--- a/apps/pdfcraft/src/main.rs
+++ b/apps/pdfcraft/src/main.rs
@@ -19,6 +19,9 @@
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+use std::cell::Cell;
+use std::rc::Rc;
+
use pdfcraft_ui_egui::PdfCraftApp;
#[cfg(target_os = "macos")]
@@ -39,16 +42,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). eframe would
-/// otherwise derive it from the app id; keep it under "PdfCraft".
+/// 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".
fn settings_dir() -> Option {
+ if let Some(dir) = pdfcraft_ui_egui::portable::data_dir() {
+ return Some(dir.to_path_buf());
+ }
eframe::storage_dir("PdfCraft")
}
/// Move the settings and crash-recovery folders of the app's former name, PrintCraft, to the new
/// name once, so an upgrade keeps recent files, preferences and unsaved work. Best effort: a
-/// folder is left alone when the new one already exists or the move fails.
+/// folder is left alone when the new one already exists or the move fails. A portable copy leaves
+/// the per-user folders alone.
fn migrate_legacy_folders() {
+ if pdfcraft_ui_egui::portable::data_dir().is_some() {
+ return;
+ }
let mut moves = vec![(eframe::storage_dir("PrintCraft"), settings_dir())];
// Recovery lives in the settings folder except on Windows, where it is under %LOCALAPPDATA%.
if cfg!(windows) {
@@ -70,6 +81,37 @@ fn migrate_legacy_folders() {
}
}
+/// Desktop launchers (GNOME Files, KDE Dolphin…) only recognise an app as the default handler for a
+/// mime type if its `Exec` takes URIs (`%u`/`%U`), not just paths (`%F`); the packaged `.desktop`
+/// file uses `%U` accordingly (packaging/linux/ai.storyteller.pdfcraft.desktop). Decode a local
+/// `file://` argument (`file:///path` or `file://localhost/path`) to a plain path here so the rest
+/// of the app, which only ever opens paths, is unaffected. Other schemes (`http://`, `mailto:`…),
+/// URIs naming another host, and plain paths pass through untouched.
+fn path_from_arg(arg: String) -> String {
+ let Some(rest) = arg.strip_prefix("file://") else { return arg };
+ let rest = rest.strip_prefix("localhost").unwrap_or(rest);
+ if !rest.starts_with('/') {
+ return arg;
+ }
+ // `file:///C:/x.pdf` on Windows names `C:/x.pdf`. `rest` starts with the one-byte '/', so
+ // byte 1 is a char boundary.
+ let rest = if cfg!(windows) && rest.as_bytes().get(2) == Some(&b':') { &rest[1..] } else { rest };
+ let mut out = Vec::with_capacity(rest.len());
+ let mut bytes = rest.bytes();
+ while let Some(b) = bytes.next() {
+ if b == b'%' {
+ let hex = bytes.clone().take(2).collect::>();
+ if let Some(byte) = std::str::from_utf8(&hex).ok().filter(|h| h.len() == 2).and_then(|h| u8::from_str_radix(h, 16).ok()) {
+ out.push(byte);
+ bytes.nth(1);
+ continue;
+ }
+ }
+ out.push(b);
+ }
+ String::from_utf8(out).unwrap_or(arg)
+}
+
fn main() -> eframe::Result {
// First, so the panic hook and every start-up warning are recorded (`logging`).
let logger = logging::install();
@@ -107,10 +149,89 @@ fn main() -> eframe::Result {
let value = args.next().unwrap_or_default();
options.push((flag.trim_start_matches("--").to_string(), value));
}
- _ => files.push(a),
+ _ => files.push(path_from_arg(a)),
}
}
let integrated = cfg!(target_os = "macos");
+ migrate_legacy_folders();
+ // The log file lives in the settings 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()) {
+ match logger.attach_dir(&dir.join("logs")) {
+ Ok(path) => log::info!("PdfCraft {}, log file {}", env!("CARGO_PKG_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}"),
+ }
+ }
+ let choice = renderer_choice(std::env::var("PDFCRAFT_RENDERER").ok().as_deref());
+ let launch = Launch { files, options, control_file, create_images, integrated };
+ // Finder, Open With and the Dock deliver files as Apple events, not arguments; catch the one
+ // that launched us as well as later ones. Lives until the event loop returns.
+ #[cfg(target_os = "macos")]
+ let apple_events = apple_events::AppleEvents::install();
+ // Set once the app is created, which is after the renderer has started.
+ let started = Rc::new(Cell::new(false));
+ let first = if choice == RendererChoice::Gl { eframe::Renderer::Glow } else { eframe::Renderer::Wgpu };
+ let result = eframe::run_native(
+ "PdfCraft",
+ native_options(integrated, first),
+ app_creator(
+ launch.clone(),
+ Rc::clone(&started),
+ #[cfg(target_os = "macos")]
+ &apple_events,
+ ),
+ );
+ match result {
+ // Only a renderer that couldn't start: without a window or display at all, OpenGL can't
+ // help either, and winit's own error says more.
+ Err(e @ eframe::Error::Wgpu(_)) if retry_with_gl(choice, started.get()) => {
+ // Old or unusual GPUs and drivers (#461, #435, #392) can't give wgpu a device; OpenGL
+ // usually still works there, so that's better than quitting.
+ log::error!("the GPU renderer (wgpu) didn't start: {e}. Starting with OpenGL instead; set PDFCRAFT_RENDERER=gl to skip wgpu.");
+ eframe::run_native(
+ "PdfCraft",
+ native_options(integrated, eframe::Renderer::Glow),
+ app_creator(
+ launch,
+ started,
+ #[cfg(target_os = "macos")]
+ &apple_events,
+ ),
+ )
+ }
+ other => other,
+ }
+}
+
+/// Which renderer to start with, from `PDFCRAFT_RENDERER`.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum RendererChoice {
+ /// wgpu, then OpenGL if wgpu can't start (unset, or anything unrecognised).
+ Auto,
+ /// wgpu only (`wgpu`): a failure is reported, not worked around.
+ Wgpu,
+ /// OpenGL only (`gl`, `opengl` or `glow`): for drivers where wgpu starts but misbehaves.
+ Gl,
+}
+
+fn renderer_choice(value: Option<&str>) -> RendererChoice {
+ match value.map(|v| v.trim().to_ascii_lowercase()).as_deref() {
+ Some("wgpu") => RendererChoice::Wgpu,
+ Some("gl" | "opengl" | "glow") => RendererChoice::Gl,
+ _ => RendererChoice::Auto,
+ }
+}
+
+/// Whether a failed run should be retried with OpenGL: only when wgpu was tried first by choice of
+/// nobody, and it failed before the app was created (so while starting the renderer, not later).
+fn retry_with_gl(choice: RendererChoice, app_started: bool) -> bool {
+ choice == RendererChoice::Auto && !app_started
+}
+
+/// The window and renderer settings for one run.
+fn native_options(integrated: bool, renderer: eframe::Renderer) -> eframe::NativeOptions {
let mut viewport = egui::ViewportBuilder::default()
.with_title("PdfCraft")
.with_inner_size([1440.0, 920.0])
@@ -126,90 +247,130 @@ fn main() -> eframe::Result {
if integrated {
viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false);
}
- migrate_legacy_folders();
- // The log file lives in the settings 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()) {
- match logger.attach_dir(&dir.join("logs")) {
- Ok(path) => log::info!("PdfCraft {}, log file {}", env!("CARGO_PKG_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}"),
- }
- }
let persistence_path = settings_dir().map(|d| d.join("app.ron"));
- let mut native = eframe::NativeOptions { viewport, persistence_path, ..Default::default() };
- configure_gpu(&mut native);
- // Finder, Open With and the Dock deliver files as Apple events, not arguments; catch the one
- // that launched us as well as later ones. Lives until the event loop returns.
- #[cfg(target_os = "macos")]
- let apple_events = apple_events::AppleEvents::install();
- #[cfg(target_os = "macos")]
- let apple_events = &apple_events;
- eframe::run_native(
- "PdfCraft",
- native,
- Box::new(move |cc| {
- let mut app = PdfCraftApp::new();
- if let Some(json) = cc.storage.and_then(|s| s.get_string("pdfcraft").or_else(|| s.get_string(LEGACY_STORAGE_KEY))) {
- app.restore(&json);
+ // Set explicitly, so the renderer never depends on which one eframe defaults to.
+ let mut native = eframe::NativeOptions { viewport, persistence_path, renderer, ..Default::default() };
+ if renderer == eframe::Renderer::Wgpu {
+ configure_gpu(&mut native);
+ }
+ native
+}
+
+/// What the command line asked for, kept so a second run (with OpenGL) can start the same way.
+#[derive(Clone)]
+struct Launch {
+ files: Vec,
+ options: Vec<(String, String)>,
+ control_file: Option,
+ create_images: bool,
+ integrated: bool,
+}
+
+fn app_creator<'a>(
+ launch: Launch,
+ started: Rc>,
+ #[cfg(target_os = "macos")] apple_events: &'a apple_events::AppleEvents,
+) -> eframe::AppCreator<'a> {
+ let Launch { files, options, control_file, create_images, integrated } = launch;
+ Box::new(move |cc| {
+ started.set(true);
+ let mut app = PdfCraftApp::new();
+ 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.integrated_titlebar = integrated;
+ app.update_source = Some(std::sync::Arc::new(updates::latest_release));
+ app.os_key_store_ids = cfg!(any(target_os = "macos", target_os = "windows"));
+ #[cfg(target_os = "macos")]
+ {
+ app.os_events = Some(apple_events.connect(&cc.egui_ctx));
+ }
+ if let Some(file) = &control_file {
+ let client = app.attach_control(&cc.egui_ctx);
+ match pdfcraft_ui_egui::control::serve(client).and_then(|ep| write_control_file(file, ep.port, &ep.token).map(|()| ep.port)) {
+ // Never the token (AGENTS.md §3): it stays in the owner-only file.
+ Ok(port) => log::info!("UI control channel on 127.0.0.1:{port} (connection details in {file})"),
+ Err(e) => log::error!("--control {file}: {e}"),
}
- app.integrated_titlebar = integrated;
- app.update_source = Some(std::sync::Arc::new(updates::latest_release));
- app.os_key_store_ids = cfg!(any(target_os = "macos", target_os = "windows"));
- #[cfg(target_os = "macos")]
- {
- app.os_events = Some(apple_events.connect(&cc.egui_ctx));
+ }
+ // Autosave unsaved changes; offer to recover documents a crashed session left behind.
+ if let Some(dir) = pdfcraft_ui_egui::RecoveryStore::default_dir() {
+ app.enable_recovery(pdfcraft_ui_egui::RecoveryStore::new(dir));
+ }
+ // A portable marker whose data folder can't be written (#157): say where settings went.
+ if let Some(w) = &pdfcraft_ui_egui::portable::current().unwritable {
+ app.notify_fmt(
+ "Portable mode is off: {folder} can't be written ({error}). Settings are kept in your user folder instead.",
+ &[("folder", &w.folder.display().to_string()), ("error", &w.error)],
+ );
+ }
+ if create_images {
+ if let Err(e) = app.begin_image_import_paths(&files) {
+ app.notify(e);
}
- if let Some(file) = &control_file {
- let client = app.attach_control(&cc.egui_ctx);
- match pdfcraft_ui_egui::control::serve(client).and_then(|ep| write_control_file(file, ep.port, &ep.token).map(|()| ep.port)) {
- // Never the token (AGENTS.md §3): it stays in the owner-only file.
- Ok(port) => log::info!("UI control channel on 127.0.0.1:{port} (connection details in {file})"),
- Err(e) => log::error!("--control {file}: {e}"),
- }
+ } else {
+ // With the preference on, last session's files come back first; files named on
+ // the command line open after them, in front (#442).
+ app.reopen_last_files(&files);
+ for f in files {
+ app.open_path(&f);
}
- // Autosave unsaved changes; offer to recover documents a crashed session left behind.
- if let Some(dir) = pdfcraft_ui_egui::RecoveryStore::default_dir() {
- app.enable_recovery(pdfcraft_ui_egui::RecoveryStore::new(dir));
+ }
+ for (k, v) in options {
+ if let Err(e) = app.set_option(&k, &v) {
+ log::warn!("--{k} {v}: {e}");
}
- if create_images {
- if let Err(e) = app.begin_image_import_paths(&files) {
- app.notify(e);
- }
- } else {
- for f in files {
- app.open_path(&f);
- }
- }
- for (k, v) in options {
- if let Err(e) = app.set_option(&k, &v) {
- log::warn!("--{k} {v}: {e}");
- }
- }
- Ok(Box::new(app))
- }),
- )
+ }
+ Ok(Box::new(app))
+ })
}
/// Write the control endpoint so that only the current user can read the token.
+///
+/// The JSON goes to a new file next to `path`, created fresh (owner-only on Unix), which then
+/// replaces `path`. Opening `path` itself would follow a link planted there and truncate whatever
+/// it points at; a rename replaces the link and leaves its target alone. A reader polling for the
+/// file also never sees it half-written.
fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()> {
+ use std::io::{ErrorKind, Write};
let json = serde_json::json!({ "port": port, "token": token, "pid": std::process::id() }).to_string();
- let mut opts = std::fs::OpenOptions::new();
- opts.write(true).create(true).truncate(true);
- #[cfg(unix)]
- {
- use std::os::unix::fs::OpenOptionsExt;
- opts.mode(0o600);
+ let path = std::path::Path::new(path);
+ let name = path.file_name().ok_or_else(|| std::io::Error::new(ErrorKind::InvalidInput, "not a file name"))?;
+ // Unpredictable suffixes, so the name can't be planted in advance. `RandomState` is keyed from
+ // the operating system's random source.
+ let random = std::hash::RandomState::new();
+ for i in 0..16u32 {
+ let mut staged = std::ffi::OsString::from(".");
+ staged.push(name);
+ staged.push(format!(".{:016x}.tmp", std::hash::BuildHasher::hash_one(&random, i)));
+ let staged = path.with_file_name(staged);
+ let mut opts = std::fs::OpenOptions::new();
+ opts.write(true).create_new(true);
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::OpenOptionsExt;
+ opts.mode(0o600);
+ }
+ let file = match opts.open(&staged) {
+ Ok(f) => f,
+ Err(e) if e.kind() == ErrorKind::AlreadyExists => continue,
+ // Windows refuses `create_new` on a folder with "access denied"; the name is taken all
+ // the same. A folder we can't write to, with nothing at the name, still fails here.
+ Err(e) if e.kind() == ErrorKind::PermissionDenied && staged.symlink_metadata().is_ok() => continue,
+ Err(e) => return Err(e),
+ };
+ // The block closes the file before it is renamed.
+ let written = {
+ let mut file = file;
+ file.write_all(json.as_bytes())
+ };
+ let done = written.and_then(|()| std::fs::rename(&staged, path));
+ if done.is_err() {
+ let _ = std::fs::remove_file(&staged);
+ }
+ return done;
}
- use std::io::Write;
- let mut f = opts.open(path)?;
- #[cfg(unix)]
- {
- use std::os::unix::fs::PermissionsExt;
- f.set_permissions(std::fs::Permissions::from_mode(0o600))?;
- }
- f.write_all(json.as_bytes())
+ Err(std::io::Error::new(ErrorKind::AlreadyExists, "no free name for the control file's temporary copy"))
}
/// How wgpu finds a GPU. Each choice yields to its wgpu environment variable.
@@ -220,7 +381,9 @@ fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()>
/// It also saves battery. Machines with one GPU are unaffected.
/// - On Linux, draw on a GPU that a monitor is plugged into. On a desktop whose monitors all hang
/// off the discrete GPU, drawing on the integrated one leaves the window black under Wayland
-/// compositors on NVIDIA. Among the GPUs that drive a display, the integrated one still wins.
+/// compositors on NVIDIA. When several GPUs drive a display, the one driving a built-in panel
+/// wins (a hybrid laptop, issue #8); without a built-in panel (a desktop with a monitor on each
+/// GPU, issue #445) the discrete one wins, as the integrated one may fail to present there.
/// - On Windows, use Direct3D 12, falling back to OpenGL, and never load Vulkan drivers unless
/// `WGPU_BACKEND` asks for them. Creating a Vulkan instance loads every installed Vulkan driver
/// into the process, and a faulty one (an Intel driver in issue #37) crashed PdfCraft before
@@ -256,58 +419,125 @@ fn configure_gpu(native: &mut eframe::NativeOptions) {
}
}
-/// PCI `(vendor, device)` ids of the GPUs with a connected monitor, read from the DRM connectors
-/// under `drm` (`card1-DP-3/status` is `connected`, `card1/device/{vendor,device}` hold `0x10de`).
+/// A GPU with a connected monitor.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
+struct DisplayGpu {
+ /// PCI `(vendor, device)` ids.
+ pci: (u32, u32),
+ /// Whether one of its connected connectors is a built-in panel (`eDP`, `LVDS` or `DSI`).
+ internal_panel: bool,
+}
+
+/// Whether a DRM connector name (`eDP-1`, `LVDS-1`, `DSI-1`) is a laptop's built-in panel.
+#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
+fn is_internal_panel(connector: &str) -> bool {
+ ["eDP", "LVDS", "DSI"].iter().any(|kind| connector.starts_with(kind))
+}
+
+/// The GPUs with a connected monitor, read from the DRM connectors under `drm` (`card1-DP-3/status`
+/// is `connected`, `card1/device/{vendor,device}` hold `0x10de`).
#[cfg(target_os = "linux")]
-fn linux_display_gpus(drm: &std::path::Path) -> Vec<(u32, u32)> {
+fn linux_display_gpus(drm: &std::path::Path) -> Vec {
let read_hex = |p: std::path::PathBuf| -> Option {
let s = std::fs::read_to_string(p).ok()?;
u32::from_str_radix(s.trim().trim_start_matches("0x"), 16).ok()
};
- let mut gpus = Vec::new();
+ let mut gpus: Vec = Vec::new();
let Ok(entries) = std::fs::read_dir(drm) else { return gpus };
// A machine has a handful of connectors; the cap only bounds a pathological sysfs.
for entry in entries.flatten().take(256) {
let name = entry.file_name();
- let Some((card, _connector)) = name.to_str().and_then(|n| n.split_once('-')) else { continue };
+ let Some((card, connector)) = name.to_str().and_then(|n| n.split_once('-')) else { continue };
let connected = std::fs::read_to_string(entry.path().join("status")).is_ok_and(|s| s.trim() == "connected");
if !connected {
continue;
}
let device = drm.join(card).join("device");
- if let (Some(v), Some(d)) = (read_hex(device.join("vendor")), read_hex(device.join("device")))
- && !gpus.contains(&(v, d))
- {
- gpus.push((v, d));
+ let (Some(v), Some(d)) = (read_hex(device.join("vendor")), read_hex(device.join("device"))) else { continue };
+ let internal_panel = is_internal_panel(connector);
+ match gpus.iter_mut().find(|g| g.pci == (v, d)) {
+ Some(gpu) => gpu.internal_panel |= internal_panel,
+ None => gpus.push(DisplayGpu { pci: (v, d), internal_panel }),
}
}
gpus
}
-/// Index of the adapter to draw with: one that drives a display (by PCI ids) first, then the most
-/// frugal kind — integrated, discrete, other, virtual, software.
+/// Index of the adapter to draw with: one that drives a display (by PCI ids) first. Among several
+/// of those, the one driving a built-in panel (a hybrid laptop, #8), or else, with no built-in
+/// panel anywhere (a desktop with a monitor on each GPU, #445), the discrete one. Otherwise the
+/// most frugal kind: integrated, discrete, other, virtual, software.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
-fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[(u32, u32)]) -> Option {
+fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[DisplayGpu]) -> Option {
use eframe::wgpu::DeviceType;
+ let display = |pci: (u32, u32)| displays.iter().find(|g| g.pci == pci);
+ let multi_gpu_desktop = displays.len() > 1 && !displays.iter().any(|g| g.internal_panel);
adapters
.iter()
.enumerate()
.min_by_key(|(_, (vendor, device, kind))| {
- let drives_display = displays.contains(&(*vendor, *device));
- let frugality = match kind {
- DeviceType::IntegratedGpu => 0,
- DeviceType::DiscreteGpu => 1,
- DeviceType::Other => 2,
- DeviceType::VirtualGpu => 3,
- DeviceType::Cpu => 4,
+ let shown = display((*vendor, *device));
+ let drives_display = shown.is_some();
+ let drives_panel = shown.is_some_and(|g| g.internal_panel);
+ let rank = match kind {
+ // On a desktop with monitors on both, the integrated GPU can accept the window and
+ // still fail to present to it (#445).
+ DeviceType::DiscreteGpu if drives_display && multi_gpu_desktop => 0,
+ DeviceType::IntegratedGpu => 1,
+ DeviceType::DiscreteGpu => 2,
+ DeviceType::Other => 3,
+ DeviceType::VirtualGpu => 4,
+ DeviceType::Cpu => 5,
};
- (!drives_display, frugality)
+ (!drives_display, !drives_panel, rank)
})
.map(|(i, _)| i)
}
#[cfg(test)]
mod tests {
+ #[test]
+ fn path_from_arg_decodes_file_uris() {
+ assert_eq!(super::path_from_arg("file:///home/alice/report.pdf".to_string()), "/home/alice/report.pdf");
+ assert_eq!(super::path_from_arg("file:///home/alice/my%20report.pdf".to_string()), "/home/alice/my report.pdf");
+ assert_eq!(super::path_from_arg("file://localhost/tmp/a%C3%A9.pdf".to_string()), "/tmp/aé.pdf");
+ // Malformed or truncated escapes are kept as written; bytes that aren't UTF-8 keep the URI.
+ assert_eq!(super::path_from_arg("file:///tmp/100%.pdf".to_string()), "/tmp/100%.pdf");
+ assert_eq!(super::path_from_arg("file:///tmp/a%2".to_string()), "/tmp/a%2");
+ assert_eq!(super::path_from_arg("file:///tmp/%zz%".to_string()), "/tmp/%zz%");
+ assert_eq!(super::path_from_arg("file:///tmp/%FF.pdf".to_string()), "file:///tmp/%FF.pdf");
+ }
+
+ #[test]
+ fn path_from_arg_leaves_plain_paths_and_other_schemes_alone() {
+ assert_eq!(super::path_from_arg("report.pdf".to_string()), "report.pdf");
+ assert_eq!(super::path_from_arg("/home/alice/report.pdf".to_string()), "/home/alice/report.pdf");
+ assert_eq!(super::path_from_arg("https://example.com/report.pdf".to_string()), "https://example.com/report.pdf");
+ assert_eq!(super::path_from_arg("file://server/share/a.pdf".to_string()), "file://server/share/a.pdf");
+ assert_eq!(super::path_from_arg("file://".to_string()), "file://");
+ }
+
+ #[test]
+ fn opengl_is_the_fallback_unless_a_renderer_was_chosen() {
+ use super::{RendererChoice, native_options, renderer_choice, retry_with_gl};
+ assert_eq!(renderer_choice(None), RendererChoice::Auto);
+ assert_eq!(renderer_choice(Some("")), RendererChoice::Auto);
+ assert_eq!(renderer_choice(Some("vulkan")), RendererChoice::Auto);
+ assert_eq!(renderer_choice(Some(" WGPU ")), RendererChoice::Wgpu);
+ for gl in ["gl", "OpenGL", "glow"] {
+ assert_eq!(renderer_choice(Some(gl)), RendererChoice::Gl);
+ }
+ // Retried with OpenGL only when wgpu failed to start by default, never after the app ran.
+ assert!(retry_with_gl(RendererChoice::Auto, false));
+ assert!(!retry_with_gl(RendererChoice::Auto, true));
+ assert!(!retry_with_gl(RendererChoice::Wgpu, false));
+ assert!(!retry_with_gl(RendererChoice::Gl, false));
+ // Each run states its renderer rather than relying on eframe's default.
+ assert_eq!(native_options(false, eframe::Renderer::Wgpu).renderer, eframe::Renderer::Wgpu);
+ assert_eq!(native_options(false, eframe::Renderer::Glow).renderer, eframe::Renderer::Glow);
+ }
+
#[test]
fn gpu_backends_avoid_vulkan_on_windows_and_prefer_low_power() {
let mut native = eframe::NativeOptions::default();
@@ -325,6 +555,23 @@ mod tests {
}
}
+ #[test]
+ fn winit_carries_the_windows_11_monitor_scale_fix() {
+ // Issue #324: winit 0.30.13 as released nudges a window dragged onto a monitor with another
+ // scale factor back onto the one it is leaving, so on Windows 11 it ends up on the wrong
+ // monitor, at the wrong size and scale. vendor/winit carries the fix from winit master. A
+ // dependency bump that resolves winit from crates.io again, or a re-vendored copy without
+ // the patch, would silently bring the bug back: re-apply the patch, or drop the copy once a
+ // winit 0.30 release has the fix (vendor/README.md).
+ let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
+ let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap();
+ let winit = lock.split("[[package]]").find(|p| p.contains("\nname = \"winit\"\n")).expect("winit is in Cargo.lock");
+ assert!(!winit.contains("\nsource = "), "winit must resolve to vendor/winit, not:{winit}");
+ let dpi_changed = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/windows/event_loop.rs")).unwrap();
+ let patch = "if !WIN10_BUILD_VERSION.is_some_and(|build| build < 22000) {\n new_outer_rect = suggested_rect;";
+ assert!(dpi_changed.contains(patch), "vendor/winit lost its WM_DPICHANGED patch");
+ }
+
const NVIDIA: (u32, u32) = (0x10de, 0x2684);
const AMD_IGPU: (u32, u32) = (0x1002, 0x164e);
@@ -333,29 +580,65 @@ mod tests {
vec![(NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (AMD_IGPU.0, AMD_IGPU.1, DeviceType::IntegratedGpu), (0, 0, DeviceType::Cpu)]
}
+ const INTEL_IGPU: (u32, u32) = (0x8086, 0xa780);
+
+ fn monitor(pci: (u32, u32)) -> super::DisplayGpu {
+ super::DisplayGpu { pci, internal_panel: false }
+ }
+
+ fn panel(pci: (u32, u32)) -> super::DisplayGpu {
+ super::DisplayGpu { pci, internal_panel: true }
+ }
+
#[test]
fn pick_adapter_prefers_the_gpu_driving_the_monitors() {
// A desktop whose monitors are all on the discrete GPU: the integrated one shows black.
- assert_eq!(super::pick_adapter(&adapters(), &[NVIDIA]), Some(0));
+ assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA)]), Some(0));
}
#[test]
fn pick_adapter_keeps_the_integrated_gpu_on_hybrid_laptops() {
// Issue #8: the panel is on the integrated GPU, an external monitor on the discrete one.
- assert_eq!(super::pick_adapter(&adapters(), &[NVIDIA, AMD_IGPU]), Some(1));
- assert_eq!(super::pick_adapter(&adapters(), &[AMD_IGPU]), Some(1));
+ assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA), panel(AMD_IGPU)]), Some(1));
+ assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)]), Some(1));
+ assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)]), Some(1));
+ assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU)]), Some(1));
+ }
+
+ #[test]
+ fn pick_adapter_prefers_the_discrete_gpu_on_desktops_with_a_monitor_on_each() {
+ // Issue #445: one monitor on the Intel iGPU, one on the NVIDIA card, no built-in panel.
+ use eframe::wgpu::DeviceType;
+ let desktop =
+ [(INTEL_IGPU.0, INTEL_IGPU.1, DeviceType::IntegratedGpu), (NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (0, 0, DeviceType::Cpu)];
+ assert_eq!(super::pick_adapter(&desktop, &[monitor(INTEL_IGPU), monitor(NVIDIA)]), Some(1));
+ assert_eq!(super::pick_adapter(&desktop, &[monitor(NVIDIA), monitor(INTEL_IGPU)]), Some(1));
+ // A GPU driving no display still loses to one that does.
+ assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), monitor(INTEL_IGPU)]), Some(1));
}
#[test]
fn pick_adapter_falls_back_to_low_power_without_a_match() {
- assert_eq!(super::pick_adapter(&adapters(), &[(0x8086, 0x1234)]), Some(1));
- assert_eq!(super::pick_adapter(&[], &[NVIDIA]), None);
+ assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234))]), Some(1));
+ assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234)), monitor((0x8086, 0x5678))]), Some(1));
+ assert_eq!(super::pick_adapter(&[], &[monitor(NVIDIA)]), None);
+ }
+
+ #[test]
+ fn internal_panels_are_edp_lvds_and_dsi_connectors() {
+ for name in ["eDP-1", "LVDS-1", "DSI-1"] {
+ assert!(super::is_internal_panel(name), "{name}");
+ }
+ for name in ["DP-3", "HDMI-A-1", "DVI-D-1", "VGA-1", "Writeback-1", ""] {
+ assert!(!super::is_internal_panel(name), "{name}");
+ }
}
#[cfg(target_os = "linux")]
#[test]
fn linux_display_gpus_reads_connected_connectors() -> std::io::Result<()> {
let dir = std::env::temp_dir().join(format!("pdfcraft-drm-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> {
std::fs::create_dir_all(dir.join(name).join("device"))?;
std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?;
@@ -371,10 +654,92 @@ mod tests {
connector("card1-DP-4", "connected")?;
connector("card2-HDMI-A-1", "disconnected")?;
connector("card2-Writeback-1", "unknown")?;
- let gpus = super::linux_display_gpus(&dir);
+ let desktop = super::linux_display_gpus(&dir);
+ // Issue #445: a monitor on the integrated GPU too, still no built-in panel.
+ connector("card2-HDMI-A-1", "connected")?;
+ let mut both = super::linux_display_gpus(&dir);
+ // A hybrid laptop: the integrated GPU drives the built-in panel as well.
+ connector("card2-eDP-1", "connected")?;
+ let mut laptop = super::linux_display_gpus(&dir);
std::fs::remove_dir_all(&dir)?;
- assert_eq!(gpus, vec![NVIDIA]);
+ assert_eq!(desktop, vec![monitor(NVIDIA)]);
+ both.sort_by_key(|g| g.pci);
+ assert_eq!(both, vec![monitor(AMD_IGPU), monitor(NVIDIA)]);
+ laptop.sort_by_key(|g| g.pci);
+ assert_eq!(laptop, vec![panel(AMD_IGPU), monitor(NVIDIA)]);
assert!(super::linux_display_gpus(std::path::Path::new("/nonexistent/drm")).is_empty());
Ok(())
}
+
+ /// A fresh folder per call: tests run in parallel.
+ fn scratch() -> std::path::PathBuf {
+ static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
+ let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ let d = std::env::temp_dir().join(format!("pdfcraft-control-file-{}-{n}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&d);
+ std::fs::create_dir_all(&d).unwrap();
+ d
+ }
+
+ /// The control file's port and token, and every name in its folder.
+ fn written(dir: &std::path::Path) -> (serde_json::Value, Vec) {
+ let json = serde_json::from_str(&std::fs::read_to_string(dir.join("ctl.json")).unwrap()).unwrap();
+ let mut names: Vec = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
+ names.sort();
+ (json, names)
+ }
+
+ #[test]
+ fn the_control_file_replaces_a_hard_link_instead_of_writing_through_it() {
+ // The file used to be opened with create + truncate, so a link planted at the path had its
+ // target truncated and overwritten with the token.
+ let dir = scratch();
+ std::fs::write(dir.join("victim.txt"), "keep me").unwrap();
+ std::fs::hard_link(dir.join("victim.txt"), dir.join("ctl.json")).unwrap();
+ super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap();
+ assert_eq!(std::fs::read_to_string(dir.join("victim.txt")).unwrap(), "keep me");
+ let (json, names) = written(&dir);
+ assert_eq!((json["port"].as_u64(), json["token"].as_str()), (Some(4242), Some("t0ken")));
+ assert_eq!(names, ["ctl.json", "victim.txt"], "no temporary file left behind");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn the_control_file_replaces_a_symlink_instead_of_writing_through_it() {
+ let dir = scratch();
+ let (link, target) = (dir.join("ctl.json"), dir.join("victim.txt"));
+ std::fs::write(&target, "keep me").unwrap();
+ #[cfg(unix)]
+ let made = std::os::unix::fs::symlink(&target, &link);
+ #[cfg(windows)]
+ let made = std::os::windows::fs::symlink_file(&target, &link);
+ if let Err(e) = made {
+ // Windows needs Developer Mode (or admin) for symlinks.
+ eprintln!("skipped: can't create a symlink here: {e}");
+ let _ = std::fs::remove_dir_all(&dir);
+ return;
+ }
+ super::write_control_file(&link.to_string_lossy(), 4242, "t0ken").unwrap();
+ assert_eq!(std::fs::read_to_string(&target).unwrap(), "keep me");
+ assert!(!std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
+ assert_eq!(written(&dir).0["token"].as_str(), Some("t0ken"));
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn the_control_file_replaces_the_one_a_previous_run_left() {
+ let dir = scratch();
+ std::fs::write(dir.join("ctl.json"), r#"{"port":1,"token":"old"}"#).unwrap();
+ super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap();
+ let (json, names) = written(&dir);
+ assert_eq!(json["token"].as_str(), Some("t0ken"));
+ assert_eq!(names, ["ctl.json"]);
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let mode = std::fs::metadata(dir.join("ctl.json")).unwrap().permissions().mode();
+ assert_eq!(mode & 0o077, 0, "only the owner can read the token: {mode:o}");
+ }
+ let _ = std::fs::remove_dir_all(&dir);
+ }
}
diff --git a/contributors/people.toml b/contributors/people.toml
index c0549e9d..ade46dac 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -22,6 +22,11 @@ real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
+[people.jcl-bit]
+real_name = "James Locke"
+display_name = "James Locke"
+sync_github_name = false
+
[people.switch87]
real_name = "Gert Pellin"
display_name = "Gert Pellin"
@@ -36,3 +41,26 @@ sync_github_name = false
real_name = "Hal Goff"
display_name = "Hal Goff, HG Studio"
sync_github_name = false
+
+[people.mlkonrad]
+real_name = "Marlon Konrad"
+display_name = "Marlon Konrad"
+sync_github_name = false
+
+[people.chiranjeevi-max]
+real_name = "Chiranjeevi Uddanti"
+display_name = "Chiranjeevi U"
+sync_github_name = false
+
+[people.cyfung1031]
+real_name = "Chester Fung"
+display_name = "Chester Fung"
+sync_github_name = true
+
+[people.amitabhthakur]
+sync_github_name = true
+
+[people.chent11]
+real_name = "Tao Chen"
+display_name = "Tao Chen"
+sync_github_name = false
diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs
index 95e272d3..38f0c9f2 100644
--- a/crates/a11y/src/alt.rs
+++ b/crates/a11y/src/alt.rs
@@ -144,10 +144,8 @@ fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64;
let top = ctm.last().copied().unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
match op.op.as_slice() {
b"q" => ctm.push(top),
- b"Q" => {
- if ctm.len() > 1 {
- ctm.pop();
- }
+ b"Q" if ctm.len() > 1 => {
+ ctm.pop();
}
b"cm" => {
if let Some(m) = Matrix::from_operands(&op.operands)
diff --git a/crates/annot/Cargo.toml b/crates/annot/Cargo.toml
index 9603e572..8a313814 100644
--- a/crates/annot/Cargo.toml
+++ b/crates/annot/Cargo.toml
@@ -11,6 +11,7 @@ workspace = true
[dependencies]
pdfcraft-cos = { workspace = true }
pdfcraft-fonts = { workspace = true }
+pdfcraft-model = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
diff --git a/crates/annot/README.md b/crates/annot/README.md
index b3224b7b..4d0fa7c9 100644
--- a/crates/annot/README.md
+++ b/crates/annot/README.md
@@ -1,6 +1,6 @@
# pdfcraft-annot
-Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends only on `pdfcraft-cos`.
+Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends on `pdfcraft-cos`, `pdfcraft-fonts` and `pdfcraft-model`.
## What it does
@@ -23,6 +23,7 @@ set_review_state(&mut doc, page, index, ReviewState::Accepted, "Ada", &meta)?;
move_annotation(&mut doc, page, index, dx, dy, &meta)?;
set_style(&mut doc, page, index, Some(rgb), Some(0.5), Some(2.0), &meta)?;
delete_annotation(&mut doc, page, index)?;
+signature_image(&doc, page, index)?; // embedded image XObject of Fill & Sign signature/initials
```
A comment is addressed by `(page, index in /Annots)`, the same pair
@@ -37,6 +38,12 @@ deterministic; the engine supplies them.
cloudy borders or callouts, instead of leaving a stale appearance.
- Replies get the parent's `/Rect` and an empty appearance: they appear in comment lists but
never paint a second icon.
+- Resizing a stamp changes its `/Rect` and preserves its original appearance and resources;
+ viewers scale that appearance into the new rectangle. Locked stamps refuse the edit.
+- Natural-size image stamps placed by the engine counterrotate their appearance on rotated
+ pages. Image restyling retains that appearance matrix. Explicit rectangles, image signatures
+ and PDF-page stamps still use their existing placement behavior; page-rotation support for
+ those paths is not complete.
## Not yet
diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs
index ecb8549b..9a026069 100644
--- a/crates/annot/src/appearance.rs
+++ b/crates/annot/src/appearance.rs
@@ -26,23 +26,98 @@ pub fn cloud_radius(w: f64) -> f64 {
6.0 + 1.5 * w.max(0.0)
}
-/// Draw a line ending (`/LE`) at `tip`, for a line arriving from `from`. Open and closed arrows only.
-fn line_end(c: &mut String, kind: &[u8], tip: (f64, f64), from: (f64, f64), w: f64) {
+/// One of the ten `/LE` names (ISO 32000-2 Table 217). Unknown names are not drawn.
+pub(crate) fn known_ending(kind: &[u8]) -> bool {
+ std::str::from_utf8(kind).ok().and_then(crate::LineEnding::parse).is_some()
+}
+
+/// Draw a line ending (`/LE`) at `tip`, for a line arriving from `from`.
+///
+/// Returns false for an unknown style or a non-finite direction, so the caller keeps the
+/// previous appearance instead of writing `NaN`. `None` and a zero-length direction draw nothing.
+fn line_end(c: &mut String, kind: &[u8], tip: (f64, f64), from: (f64, f64), w: f64) -> bool {
if kind == b"None" {
- return;
+ return true;
+ }
+ if !known_ending(kind) {
+ return false;
}
let (dx, dy) = (from.0 - tip.0, from.1 - tip.1);
- let len = dx.hypot(dy);
- if len == 0.0 {
- return;
+ if !dx.is_finite() || !dy.is_finite() {
+ return false;
+ }
+ let len = dx.hypot(dy);
+ if !len.is_finite() {
+ return false;
+ }
+ if len == 0.0 {
+ return true;
}
- let (ux, uy) = (dx / len, dy / len);
let s = arrow_size(w);
- let (cos, sin) = (30f64.to_radians().cos(), 30f64.to_radians().sin());
- let a = (tip.0 + s * (ux * cos - uy * sin), tip.1 + s * (ux * sin + uy * cos));
- let b = (tip.0 + s * (ux * cos + uy * sin), tip.1 + s * (-ux * sin + uy * cos));
- let op = if kind == b"ClosedArrow" { "h B" } else { "S" };
- c.push_str(&format!("{} {} m {} {} l {} {} l {op}\n", n(a.0), n(a.1), n(tip.0), n(tip.1), n(b.0), n(b.1)));
+ if !s.is_finite() {
+ return false;
+ }
+ let (ix, iy) = (dx / len, dy / len);
+ match kind {
+ b"OpenArrow" | b"ClosedArrow" | b"ROpenArrow" | b"RClosedArrow" => {
+ // Open and closed arrows keep their original geometry. A reverse arrow uses the same
+ // head, pointing back along the line.
+ let (ux, uy) = if kind.starts_with(b"R") { (-ix, -iy) } else { (ix, iy) };
+ let (cos, sin) = (30f64.to_radians().cos(), 30f64.to_radians().sin());
+ let a = (tip.0 + s * (ux * cos - uy * sin), tip.1 + s * (ux * sin + uy * cos));
+ let b = (tip.0 + s * (ux * cos + uy * sin), tip.1 + s * (-ux * sin + uy * cos));
+ let op = if kind.ends_with(b"ClosedArrow") { "h B" } else { "S" };
+ c.push_str(&format!("{} {} m {} {} l {} {} l {op}\n", n(a.0), n(a.1), n(tip.0), n(tip.1), n(b.0), n(b.1)));
+ }
+ b"Square" | b"Circle" | b"Diamond" => {
+ let h = s / 2.0;
+ if kind == b"Square" {
+ c.push_str(&format!("{} {} {} {} re h B\n", n(tip.0 - h), n(tip.1 - h), n(s), n(s)));
+ } else if kind == b"Circle" {
+ c.push_str(&ellipse(tip.0 - h, tip.1 - h, tip.0 + h, tip.1 + h));
+ c.push_str("B\n");
+ } else {
+ let (px, py) = (-iy, ix);
+ let v = |along: f64, across: f64| (tip.0 + ix * along + px * across, tip.1 + iy * along + py * across);
+ let (a, b, d, e) = (v(h, 0.0), v(0.0, h), v(-h, 0.0), v(0.0, -h));
+ c.push_str(&format!("{} {} m {} {} l {} {} l {} {} l h B\n", n(a.0), n(a.1), n(b.0), n(b.1), n(d.0), n(d.1), n(e.0), n(e.1)));
+ }
+ }
+ _ => {
+ // Butt is perpendicular to the line. Slash is that stroke turned 45 degrees.
+ let (px, py) = (-iy, ix);
+ let (sx, sy) = if kind == b"Slash" {
+ let q = std::f64::consts::FRAC_1_SQRT_2;
+ (px * q - py * q, px * q + py * q)
+ } else {
+ (px, py)
+ };
+ let h = s / 2.0;
+ c.push_str(&format!("{} {} m {} {} l S\n", n(tip.0 - sx * h), n(tip.1 - sy * h), n(tip.0 + sx * h), n(tip.1 + sy * h)));
+ }
+ }
+ true
+}
+
+/// The cubic Bézier segments (first control point, second control point, end) of a smooth curve
+/// through every point of `pts`, from the first: a Catmull-Rom spline, so the curve leaves each
+/// point parallel to the chord between its neighbours (an end uses itself as the missing
+/// neighbour). Ink is drawn so, as Acrobat draws it, rather than as straight segments. The curve
+/// stays within its points and these control points.
+pub(crate) fn smooth_segments(pts: &[(f64, f64)]) -> Vec<[(f64, f64); 3]> {
+ let Some(&last) = pts.last() else { return Vec::new() };
+ let at = |i: usize| pts.get(i).copied().unwrap_or(last);
+ (0..pts.len().saturating_sub(1))
+ .map(|i| {
+ let (p0, p1, p2, p3) = (at(i.saturating_sub(1)), at(i), at(i + 1), at(i + 2));
+ [(p1.0 + (p2.0 - p0.0) / 6.0, p1.1 + (p2.1 - p0.1) / 6.0), (p2.0 - (p3.0 - p1.0) / 6.0, p2.1 - (p3.1 - p1.1) / 6.0), p2]
+ })
+ .collect()
+}
+
+/// `c` operators for [`smooth_segments`] (the path already starts at the first point).
+fn smooth_curve(pts: &[(f64, f64)]) -> String {
+ smooth_segments(pts).iter().map(|[a, b, e]| format!("{} {} {} {} {} {} c\n", n(a.0), n(a.1), n(b.0), n(b.1), n(e.0), n(e.1))).collect()
}
/// A closed cloudy outline through `pts` (§12.5.4 `/BE /S /C`): each edge becomes a row of
@@ -179,6 +254,12 @@ fn form(bbox: [f64; 4], content: &[u8], resources: Dict) -> Stream {
Stream::flate(d, content)
}
+/// The page `/Rotate` an image signature's picture is turned back by (`/PCPictureRotate`, written
+/// when the signature is added to a turned page): 0, 90, 180 or 270.
+pub(crate) fn picture_turn(d: &Dict) -> i64 {
+ d.get(b"PCPictureRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90
+}
+
/// Draw the normal appearance of an annotation, or `None` if this subtype/variant isn't supported.
pub fn build(d: &Dict) -> Option {
// Imported measurement appearances may include leaders, captions and formatting that
@@ -313,32 +394,45 @@ pub fn build(d: &Dict) -> Option {
});
}
b"Line" => {
- let l = nums(d, b"L").filter(|l| l.len() == 4)?;
+ let l = nums(d, b"L").filter(|l| l.len() == 4 && l.iter().all(|x| x.is_finite()))?;
let col = stroke?;
let ends: Vec> = match d.get(b"LE") {
None => vec![b"None".to_vec(), b"None".to_vec()],
Some(o) => o.as_array()?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::