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 0f56341b..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 (251)
+## In this repository (257)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
@@ -14,9 +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 |
@@ -259,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 3a1801a2..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 = "50ec1133889f3a3948a3901a9fa40b3499f3c549b605e55848138483946d74da"
+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 = "3746a7d87fb98c9f3aeb41c63ce50371c42dfebca64b67ccbb599abb36a93ba8"
+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 = "2c09a121e493fb123d9f886529f62268119ca580ad5d5300eca61d4a39216827"
+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 = "0ccf62e34424981d5980eeaee8799db2653a4c186807df143326ef9881959006"
+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 = "0b579afad6e52b98cef5e61e07e238d9cf76cc7f295395f7e1c855400e5fbaf9"
+sha256 = "e9a9bad1252afdcb42c36b24ca85411f8c63df7debada94d4423bd48761ecb05"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
@@ -74,7 +74,18 @@ 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 = "956753e05b5a9e1d4c4a63ee57667cc9fa5bb95288c3d46c6b15435b83d6ed8d"
+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"
@@ -85,7 +96,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "e8b96edd718a1cd212b7440c4ab100f2555c97dccb0cda289bdb1775d5f3aa61"
+sha256 = "d3630f989e9725c22444dc27765142674b66f72436502ca969b2851fc38d703d"
[[asset]]
path = "crates/ui-egui/src/i18n/ru.tsv"
@@ -96,7 +107,29 @@ 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 = "136a9ae206c4ee5aad29d248613c568799080b4b3ee9cd197233072ee9a2983a"
+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"
@@ -107,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 = "d2e7114c70da3c52200cf68e9023dc32ff462439795784e49c2ae771531240e2"
+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"
@@ -3105,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 d05e13d8..bc613bb8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3477,6 +3477,7 @@ dependencies = [
"hayro-syntax",
"pdfcraft-cos",
"pdfcraft-fonts",
+ "pdfcraft-model",
"thiserror 2.0.21",
]
@@ -3569,6 +3570,7 @@ dependencies = [
"pdfcraft-fonts",
"pdfcraft-model",
"thiserror 2.0.21",
+ "unicode-bidi",
]
[[package]]
@@ -3627,6 +3629,7 @@ dependencies = [
name = "pdfcraft-fonts"
version = "0.4.0"
dependencies = [
+ "harfrust",
"pdfcraft-cos",
"skrifa 0.44.0",
]
@@ -3640,6 +3643,7 @@ dependencies = [
"pdfcraft-cos",
"pdfcraft-fonts",
"pdfcraft-model",
+ "pdfcraft-render",
"thiserror 2.0.21",
]
@@ -3746,6 +3750,7 @@ dependencies = [
name = "pdfcraft-render"
version = "0.4.0"
dependencies = [
+ "bytemuck",
"getrandom 0.4.3",
"hayro",
"hayro-syntax",
@@ -3753,6 +3758,7 @@ dependencies = [
"log",
"lopdf",
"pdfcraft-fonts",
+ "skrifa 0.44.0",
"thiserror 2.0.21",
]
@@ -3788,6 +3794,7 @@ version = "0.4.0"
dependencies = [
"arboard",
"base64 0.22.1",
+ "bytemuck",
"eframe",
"egui",
"egui_extras",
@@ -6586,8 +6593,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 37f8fe30..e80a0b46 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -75,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
@@ -92,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 24db83c6..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.
diff --git a/ROADMAP.md b/ROADMAP.md
index 9d6d7412..41ace172 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, 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, 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,8 +91,8 @@ 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: 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 |
@@ -103,7 +104,8 @@ Hours are for a single agent (low–high). "Done" is the estimated fraction of t
| 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, Russian, 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,6 +127,11 @@ 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.
@@ -138,6 +145,20 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
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%).
diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs
index 3fe28824..1940d63e 100644
--- a/apps/pdfcraft-cli/src/main.rs
+++ b/apps/pdfcraft-cli/src/main.rs
@@ -1,23 +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] [--compact] MCP server on stdin/stdout (opt-in)
-//! --compact lists a core set of tools plus tool_search and tool_call
-//! 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
@@ -43,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,
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/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 e40607a8..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")]
@@ -150,6 +153,85 @@ fn main() -> eframe::Result {
}
}
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])
@@ -165,77 +247,82 @@ 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}");
}
- // 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);
- }
- } 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.
@@ -294,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
@@ -330,52 +419,78 @@ 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)
}
@@ -403,6 +518,26 @@ mod tests {
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();
@@ -420,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);
@@ -428,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"))?;
@@ -466,9 +654,19 @@ 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(())
}
diff --git a/contributors/people.toml b/contributors/people.toml
index 00beb658..ade46dac 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -59,3 +59,8 @@ 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/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 c98cb6d2..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
@@ -40,6 +40,10 @@ deterministic; the engine supplies them.
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 6045743f..9a026069 100644
--- a/crates/annot/src/appearance.rs
+++ b/crates/annot/src/appearance.rs
@@ -26,23 +26,77 @@ 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
@@ -200,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
@@ -334,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::