diff --git a/.config/nextest.toml b/.config/nextest.toml
new file mode 100644
index 0000000000..2a4cd2cb93
--- /dev/null
+++ b/.config/nextest.toml
@@ -0,0 +1,3 @@
+# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
+# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
+nextest-version = "0.9.150"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ac276f13c9..94a4c545fe 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -32,6 +32,9 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+ - name: Windows font notice regressions
+ shell: pwsh
+ run: ./packaging/windows/test-font-notices.ps1
- name: Git dependencies come from storytold
run: |
set -euo pipefail
@@ -73,13 +76,13 @@ jobs:
# the org's 5 macOS slots are kept for release builds, and release.yml builds macOS.
os: ${{ (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'full-ci')) && '["ubuntu-24.04","windows-latest"]' || '["ubuntu-24.04"]' }}
# Same craft-fonts commit as release.yml and freebsd.yml.
- craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
+ craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
# egui/winit/rfd, and the independent oracles (crates/oracle).
packages: >-
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
libgtk-3-dev qpdf poppler-utils
# cargo-deny for the deny step of `cargo xtask ci`, which requires it under CI.
- tools: cargo-nextest@0.9.149,cargo-deny@0.20.2
+ tools: cargo-nextest@0.9.150,cargo-deny@0.20.2
# A missing oracle fails the job instead of printing a skip notice that reads as a pass.
linux-env: PDFCRAFT_ORACLES_REQUIRED=1
windows-env: PDFCRAFT_REQUIRE_WINRES=1
diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml
index fba20b1cc7..268da70ca1 100644
--- a/.github/workflows/freebsd.yml
+++ b/.github/workflows/freebsd.yml
@@ -26,4 +26,4 @@ jobs:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
- craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
+ craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c6ee0e1c31..a5c2ba7f7f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -24,7 +24,7 @@ jobs:
with:
app-name: pdfcraft
app-title: PdfCraft
- craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
+ craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 7ca1124ce2..23ebcbbe82 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -4,37 +4,38 @@
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 the SHA-256 of every asset but the translation catalogs, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
-## In this repository (288)
+## In this repository (289)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
-| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/cs.tsv` | Czech 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | 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; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Lao 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/it.tsv` | Italian 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/pl.tsv` | Polish 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Polish 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Kazakh 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/hi.tsv` | Hindi 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/id.tsv` | Indonesian 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/ko.tsv` | Korean 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
+| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Greek UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/cs.tsv` | Czech 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n.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; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n.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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n.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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n.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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/it.tsv` | Italian 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/pl.tsv` | Polish 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n.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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n.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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n.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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n.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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Japanese UI strings, compiled in by crates/ui-egui/src/i18n.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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/fa.tsv` | Persian (Farsi) 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; 2026-10-25 initial catalog covering the current 2,776-entry key union, AI-assisted translations from English, not separately native-reviewed | Persian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/hi.tsv` | Hindi 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/id.tsv` | Indonesian 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/ko.tsv` | Korean 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n.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 |
@@ -285,26 +286,27 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile |
| `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/it/pdf.rs): the legacy adbe.x509.rsa_sha1 format |
| `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
-| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
+| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file |
-| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
+| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
-| `crates/ui-egui/src/i18n/sq.tsv` | Albanian 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/th.tsv` | Thai 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
+| `crates/ui-egui/src/i18n/sq.tsv` | Albanian 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Albanian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/th.tsv` | Thai 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+| `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n.rs |
-## Compiled in through dependencies (5)
+## Compiled in through dependencies (6)
| Crate | File | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|---|
-| `epaint_default_fonts` 0.36.2 | `fonts/Ubuntu-Light.ttf` | Ubuntu Light | Canonical Ltd. / Dalton Maag | Ubuntu-font-1.0 | https://design.ubuntu.com/font | egui fallback fonts (eframe feature default_fonts) |
-| `epaint_default_fonts` 0.36.2 | `fonts/Hack-Regular.ttf` | Hack Regular | Source Foundry Authors (derived from Bitstream Vera / DejaVu) | MIT AND Bitstream-Vera | https://github.com/source-foundry/Hack | egui fallback fonts (eframe feature default_fonts) |
-| `epaint_default_fonts` 0.36.2 | `fonts/NotoEmoji-Regular.ttf` | Noto Emoji (monochrome) | Google Inc. | OFL-1.1 | https://github.com/googlefonts/noto-emoji | egui fallback fonts (eframe feature default_fonts) |
-| `epaint_default_fonts` 0.36.2 | `fonts/emoji-icon-font.ttf` | emoji-icon-font | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | egui fallback fonts (eframe feature default_fonts) |
+| `epaint_default_fonts` 0.37.0 | `fonts/Ubuntu-Light.ttf` | Ubuntu Light | Canonical Ltd. / Dalton Maag | Ubuntu-font-1.0 | https://design.ubuntu.com/font | egui fallback fonts (eframe feature default_fonts) |
+| `epaint_default_fonts` 0.37.0 | `fonts/Hack-Regular.ttf` | Hack Regular | Source Foundry Authors (derived from Bitstream Vera / DejaVu) | MIT AND Bitstream-Vera | https://github.com/source-foundry/Hack | egui fallback fonts (eframe feature default_fonts) |
+| `epaint_default_fonts` 0.37.0 | `fonts/NotoEmoji-Regular.ttf` | Noto Emoji (monochrome) | Google Inc. | OFL-1.1 | https://github.com/googlefonts/noto-emoji | egui monochrome fallback font (egui feature monochrome_emoji_fonts) |
+| `epaint_default_fonts` 0.37.0 | `fonts/emoji-icon-font.ttf` | emoji-icon-font | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | egui monochrome fallback font (egui feature monochrome_emoji_fonts) |
+| `epaint_default_fonts` 0.37.0 | `fonts/egui-icons.ttf` | egui icons (monochrome emoji-icon-font subset) | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE. |
| `hayro-interpret` 0.7.0 | `src/font/generated/metrics.rs` | Standard-14 font metrics and encodings (as Rust tables) (non-visual data, AGENTS.md §1.1) | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | MIT OR Apache-2.0 | ISO 32000-2 Annex D; Adobe Core14 AFM files (via vendored hayro-interpret) | Glyph widths and encodings for non-embedded standard fonts. Non-visual data, allowed by AGENTS.md §1.1 |
## Downloaded at build time (22)
@@ -333,8 +335,8 @@ Fonts are fetched by `cargo xtask demo-pdf` into `target/demo-fonts/`, OCR model
| `NotoColorEmoji-Regular.ttf` | Noto Color Emoji | Google Inc. | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/notocoloremoji | Showcase PDF (cargo xtask demo-pdf) |
| `Inter[opsz,wght].ttf` | Inter (variable) | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/inter | Showcase PDF (cargo xtask demo-pdf) |
| `Inter-Italic[opsz,wght].ttf` | Inter Italic (variable) | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/inter | Showcase PDF (cargo xtask demo-pdf) |
-| `text-detection.rten` | Ocrs text detection model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0) | Scan & OCR: finds words on page images (cargo xtask models) |
-| `text-recognition.rten` | Ocrs text recognition model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0) | Scan & OCR: reads the words it finds (cargo xtask models) |
+| `text-detection.onnx` | Ocrs text detection model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0) | Scan & OCR: finds words on page images (cargo xtask models) |
+| `text-recognition.onnx` | Ocrs text recognition model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0) | Scan & OCR: reads the words it finds (cargo xtask models) |
## Optional build inputs (1)
@@ -342,4 +344,4 @@ Not in this repository and never downloaded by it: compiled in only when the bui
| Input | Option | Title | Author | Licence | Source | Attribution | Used for |
|---|---|---|---|---|---|---|---|
-| `craft-fonts` | `CRAFT_FONTS_DIR` | Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; Arabic font: Noto Sans Arabic | The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), The Noto Project Authors (Noto Sans Arabic) | OFL-1.1 | https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds only BIZ UDPGothic Regular and Noto Sans Arabic. The checkout also holds Noto Sans CJK SC, which crates/fonts/build.rs leaves out for size |
+| `craft-fonts` | `CRAFT_FONTS_DIR` | Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; native Simplified Chinese font: LXGW WenKai Lite; Arabic font: Noto Sans Arabic | The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), LXGW and the original Klee Project Authors / Fontworks (LXGW WenKai Lite), The Noto Project Authors (Noto Sans Arabic) | OFL-1.1 | https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml and .github/workflows/ci.yml); LXGW WenKai Lite from https://github.com/lxgw/LxgwWenKai-Lite/releases/tag/v1.530 (commit b712c83d98acf977bacfb7fb010207802808bda9) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Simplified Chinese interface fallback on native builds only (LXGW WenKai Lite Regular, font SHA-256 f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea, OFL SHA-256 0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e, read from fonts/simplified-chinese-manifest.txt and never embedded on wasm); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds BIZ UDPGothic Regular and the pinned small Arabic, Hebrew, Thai and Devanagari faces; the selected craft-fonts revision contains no Telugu face. Other non-Japanese CJK faces retain the general 4 MiB cap; the reviewed native-only LXGW fallback has a separate 16 MiB cap. Open-source OFL releases are permitted under current AGENTS.md §1.1 |
diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml
index 9ca337e396..489247559d 100644
--- a/ATTRIBUTION.toml
+++ b/ATTRIBUTION.toml
@@ -2,11 +2,11 @@
path = "crates/ui-egui/src/i18n/el.tsv"
title = "Greek interface translation catalog"
author = "Camthalion77 and contributors"
-source = "Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
+usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n.rs"
# ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences.
# ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`).
@@ -26,260 +26,270 @@ usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
path = "crates/ui-egui/src/i18n/zh-hans.tsv"
title = "Simplified Chinese interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
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"
+usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
title = "Traditional Chinese (Taiwan) interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
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"
+usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.tsv"
title = "Czech interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
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"
+usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/pt-br.tsv"
title = "Brazilian Portuguese interface translation catalog"
author = "André Cardoso (andrecards), PdfCraft contributors"
-source = "Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
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"
+usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/es.tsv"
title = "Spanish interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
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"
+usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
title = "French interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
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"
+usage = "French UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
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"
+usage = "German UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/lo.tsv"
title = "Lao interface translation catalog"
author = "@bounkirdni-2025"
-source = "Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
+usage = "Lao UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
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"
+usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/it.tsv"
title = "Italian interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
+usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/pl.tsv"
title = "Polish interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
+usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
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"
+usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
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"
+usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
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"
+usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/kk.tsv"
title = "Kazakh interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
+usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"
title = "Japanese interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
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"
+usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
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"
+usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/ar.tsv"
title = "Arabic interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
+usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/ckb.tsv"
title = "Central Kurdish (Sorani) interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
+usage = "Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n.rs"
+
+[[asset]]
+path = "crates/ui-egui/src/i18n/fa.tsv"
+title = "Persian (Farsi) interface translation catalog"
+author = "PdfCraft contributors"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-25 initial catalog covering the current 2,776-entry key union, AI-assisted translations from English, not separately native-reviewed"
+licence = "MIT OR Apache-2.0"
+licence_file = "LICENSE-MIT"
+kind = "translation"
+usage = "Persian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/tr.tsv"
title = "Turkish interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
+usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/lt.tsv"
title = "Lithuanian interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
+usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/hi.tsv"
title = "Hindi interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
+usage = "Hindi UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/id.tsv"
title = "Indonesian interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
+usage = "Indonesian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/ko.tsv"
title = "Korean interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
+usage = "Korean UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/ro.tsv"
title = "Romanian interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
+usage = "Romanian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/fi.tsv"
title = "Finnish interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
+usage = "Finnish UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "assets/icons/accessibility.svg"
@@ -2429,7 +2439,7 @@ sha256 = "f225e0b044875b8fa6375b6b5ad935f0d99fcc1ad03ecf9f0baae2cedbfca4d2"
[[bundled]]
crate = "epaint_default_fonts"
-version = "0.36.2"
+version = "0.37.0"
path = "fonts/Ubuntu-Light.ttf"
title = "Ubuntu Light"
author = "Canonical Ltd. / Dalton Maag"
@@ -2441,7 +2451,7 @@ sha256 = "80307b8da7649aa4ee4d484b232140e3ce1ec0ca093073d3c53c8f5a5ced7a70"
[[bundled]]
crate = "epaint_default_fonts"
-version = "0.36.2"
+version = "0.37.0"
path = "fonts/Hack-Regular.ttf"
title = "Hack Regular"
author = "Source Foundry Authors (derived from Bitstream Vera / DejaVu)"
@@ -2453,28 +2463,40 @@ sha256 = "15f55cc0c85a2988d2b4b3a8cdb5d77fdfbaf319e1bb5309d725db9818fb7125"
[[bundled]]
crate = "epaint_default_fonts"
-version = "0.36.2"
+version = "0.37.0"
path = "fonts/NotoEmoji-Regular.ttf"
title = "Noto Emoji (monochrome)"
author = "Google Inc."
source = "https://github.com/googlefonts/noto-emoji"
licence = "OFL-1.1"
kind = "font"
-usage = "egui fallback fonts (eframe feature default_fonts)"
+usage = "egui monochrome fallback font (egui feature monochrome_emoji_fonts)"
sha256 = "415dc6290378574135b64c808dc640c1df7531973290c4970c51fdeb849cb0c5"
[[bundled]]
crate = "epaint_default_fonts"
-version = "0.36.2"
+version = "0.37.0"
path = "fonts/emoji-icon-font.ttf"
title = "emoji-icon-font"
author = "John Slegers"
source = "https://github.com/jslegers/emoji-icon-font"
licence = "MIT"
kind = "font"
-usage = "egui fallback fonts (eframe feature default_fonts)"
+usage = "egui monochrome fallback font (egui feature monochrome_emoji_fonts)"
sha256 = "f426bec371f484646d002717e2c555a95cd5145e635d83a48bbef83f3abf18ca"
+[[bundled]]
+crate = "epaint_default_fonts"
+version = "0.37.0"
+path = "fonts/egui-icons.ttf"
+title = "egui icons (monochrome emoji-icon-font subset)"
+author = "John Slegers"
+source = "https://github.com/jslegers/emoji-icon-font"
+licence = "MIT"
+kind = "font"
+usage = "Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE."
+sha256 = "122806555c73df7cef55aa1d945289ae27fbc4dbbd31abbb4204ee0cda829cc8"
+
[[asset]]
path = "vendor/hayro-cmap/assets/cmaps.brotli"
title = "Adobe CMap resources (predefined CJK CMaps)"
@@ -3334,41 +3356,41 @@ usage = "1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packa
sha256 = "f46881724a6621f3686a612a79dbdce4f7dbe9e2025610e44c9a094341334c2c"
[[fetched]]
-file = "text-detection.rten"
-url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.rten"
+file = "text-detection.onnx"
+url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.onnx"
title = "Ocrs text detection model"
author = "Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0)"
-source = "https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0)"
+source = "https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0)"
licence = "CC-BY-SA-4.0"
licence_url = "https://creativecommons.org/licenses/by-sa/4.0/legalcode.txt"
licence_sha256 = "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5"
kind = "model"
usage = "Scan & OCR: finds words on page images (cargo xtask models)"
-sha256 = "f15cfb56bd02c4bf478a20343986504a1f01e1665c2b3a0ad66340f054b1b5ca"
+sha256 = "a917b23dbd9524b465df7e922641b3ff2981623df4ded5a0234004ef2fee7cfe"
[[fetched]]
-file = "text-recognition.rten"
-url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-recognition.rten"
+file = "text-recognition.onnx"
+url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-recognition.onnx"
title = "Ocrs text recognition model"
author = "Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0)"
-source = "https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0)"
+source = "https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0)"
licence = "CC-BY-SA-4.0"
licence_url = "https://creativecommons.org/licenses/by-sa/4.0/legalcode.txt"
licence_sha256 = "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5"
kind = "model"
usage = "Scan & OCR: reads the words it finds (cargo xtask models)"
-sha256 = "e484866d4cce403175bd8d00b128feb08ab42e208de30e42cd9889d8f1735a6e"
+sha256 = "86c145c2edb96c8caed5b1ebb8f44d706408922211451c309c625157dd6061c5"
[[build_input]]
name = "craft-fonts"
option = "CRAFT_FONTS_DIR"
-title = "Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; Arabic font: Noto Sans Arabic"
-author = "The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), The Noto Project Authors (Noto Sans Arabic)"
-source = "https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml)"
+title = "Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; native Simplified Chinese font: LXGW WenKai Lite; Arabic font: Noto Sans Arabic"
+author = "The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), LXGW and the original Klee Project Authors / Fontworks (LXGW WenKai Lite), The Noto Project Authors (Noto Sans Arabic)"
+source = "https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml and .github/workflows/ci.yml); LXGW WenKai Lite from https://github.com/lxgw/LxgwWenKai-Lite/releases/tag/v1.530 (commit b712c83d98acf977bacfb7fb010207802808bda9)"
licence = "OFL-1.1"
attribution = "https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md"
kind = "font"
-usage = "Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds only BIZ UDPGothic Regular and Noto Sans Arabic. The checkout also holds Noto Sans CJK SC, which crates/fonts/build.rs leaves out for size"
+usage = "Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Simplified Chinese interface fallback on native builds only (LXGW WenKai Lite Regular, font SHA-256 f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea, OFL SHA-256 0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e, read from fonts/simplified-chinese-manifest.txt and never embedded on wasm); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds BIZ UDPGothic Regular and the pinned small Arabic, Hebrew, Thai and Devanagari faces; the selected craft-fonts revision contains no Telugu face. Other non-Japanese CJK faces retain the general 4 MiB cap; the reviewed native-only LXGW fallback has a separate 16 MiB cap. Open-source OFL releases are permitted under current AGENTS.md §1.1"
[[asset]]
path = "crates/sign/tests/data/x509-rsa-sha1.pdf"
@@ -3396,21 +3418,21 @@ sha256 = "d1a443233345724859de8e4fb48968ba91e885e12ddbd710701548bd91b39428"
path = "crates/ui-egui/src/i18n/vi.tsv"
title = "Vietnamese interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
+usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/sl.tsv"
title = "Slovene interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
+usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
@@ -3428,11 +3450,11 @@ sha256 = "78c59a0cfd2c829909d77006f94e34dac1ae5d1f90e7f10f72e84de541fef697"
path = "crates/ui-egui/src/i18n/nb.tsv"
title = "Norwegian Bokmål interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
+usage = "Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "assets/icons/superscript.svg"
@@ -3482,28 +3504,28 @@ sha256 = "864ab2f19254e6ac7240bc6a98323197332e8f714d4d3f94831f41309e5ef2f7"
path = "crates/ui-egui/src/i18n/sq.tsv"
title = "Albanian interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
+usage = "Albanian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/th.tsv"
title = "Thai interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
+usage = "Thai UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/ca.tsv"
title = "Catalan interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
-usage = "Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
+usage = "Catalan UI strings, compiled in by crates/ui-egui/src/i18n.rs"
diff --git a/CLAUDE.md b/CLAUDE.md
index 085e8356f6..f51a96d56e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -21,6 +21,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
- See `plan/README.md` §Clean-room and `plan/adr/0001`.
- **Privacy.** When observing Acrobat, use synthetic fixtures only. Never capture the Home view, recent files or account info. Capture by window id (`plan/acrobat/tools/`). Never commit Acrobat outputs, corpus files or personal data.
- **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd (`plan/architecture.md` §3). `cos`/`filters`/`crypt`/`arlington` stay standalone.
+- **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state (`hover_pos`, `interact_pos`, `latest_pos`, `press_origin`, button reads); `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it.
- **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it.
- **Never panic.** Every PDF, script, MCP call, settings file and keystroke is untrusted input, and none of it may crash the app or lose the user's work. This outranks feature work. Return `Result` (the crate's error enum) and propagate with `?`, or fall back leniently and record it. In non-test code:
- No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` unless it is provably infallible, with a comment saying why.
diff --git a/Cargo.lock b/Cargo.lock
index 8b49c9521c..b201da8413 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4,9 +4,9 @@ version = 4
[[package]]
name = "accesskit"
-version = "0.24.1"
+version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320"
+checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932"
dependencies = [
"enumn",
"serde",
@@ -15,57 +15,51 @@ dependencies = [
[[package]]
name = "accesskit_atspi_common"
-version = "0.18.1"
+version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954"
+checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4"
dependencies = [
"accesskit",
- "accesskit_consumer 0.36.0",
+ "accesskit_consumer",
"atspi-common",
- "phf 0.13.1",
+ "phf 0.14.0",
"serde",
"zvariant",
]
[[package]]
name = "accesskit_consumer"
-version = "0.35.0"
+version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10"
+checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344"
dependencies = [
"accesskit",
- "hashbrown 0.16.1",
+ "hashbrown 0.17.1",
]
[[package]]
-name = "accesskit_consumer"
-version = "0.36.0"
+name = "accesskit_ios"
+version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60"
+checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937"
dependencies = [
"accesskit",
- "hashbrown 0.16.1",
-]
-
-[[package]]
-name = "accesskit_consumer"
-version = "0.38.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245"
-dependencies = [
- "accesskit",
- "hashbrown 0.16.1",
+ "accesskit_consumer",
+ "hashbrown 0.17.1",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+ "objc2-ui-kit 0.2.2",
]
[[package]]
name = "accesskit_macos"
-version = "0.26.3"
+version = "0.27.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076"
+checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4"
dependencies = [
"accesskit",
- "accesskit_consumer 0.38.0",
- "hashbrown 0.16.1",
+ "accesskit_consumer",
+ "hashbrown 0.17.1",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
@@ -73,9 +67,9 @@ dependencies = [
[[package]]
name = "accesskit_unix"
-version = "0.21.1"
+version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c"
+checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d"
dependencies = [
"accesskit",
"accesskit_atspi_common",
@@ -91,13 +85,13 @@ dependencies = [
[[package]]
name = "accesskit_windows"
-version = "0.32.1"
+version = "0.35.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21"
+checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb"
dependencies = [
"accesskit",
- "accesskit_consumer 0.35.0",
- "hashbrown 0.16.1",
+ "accesskit_consumer",
+ "hashbrown 0.17.1",
"static_assertions",
"windows",
"windows-core",
@@ -105,11 +99,12 @@ dependencies = [
[[package]]
name = "accesskit_winit"
-version = "0.32.2"
+version = "0.34.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313"
+checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77"
dependencies = [
"accesskit",
+ "accesskit_ios",
"accesskit_macos",
"accesskit_unix",
"accesskit_windows",
@@ -220,21 +215,22 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arboard"
-version = "3.6.1"
+version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
+checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917"
dependencies = [
"clipboard-win",
"image",
"log",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
- "windows-sys 0.60.2",
+ "windows-sys 0.59.0",
+ "wl-clipboard-rs",
"x11rb",
]
@@ -447,9 +443,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "aws-lc-rs"
-version = "1.18.1"
+version = "1.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
+checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13"
dependencies = [
"aws-lc-sys",
"untrusted 0.7.1",
@@ -458,9 +454,9 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
-version = "0.45.0"
+version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
+checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc"
dependencies = [
"cc",
"cmake",
@@ -493,15 +489,6 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
-[[package]]
-name = "bit-set"
-version = "0.8.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
-dependencies = [
- "bit-vec 0.8.0",
-]
-
[[package]]
name = "bit-set"
version = "0.10.0"
@@ -512,10 +499,13 @@ dependencies = [
]
[[package]]
-name = "bit-vec"
-version = "0.8.0"
+name = "bit-set"
+version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c"
+dependencies = [
+ "bit-vec 0.10.1",
+]
[[package]]
name = "bit-vec"
@@ -523,6 +513,16 @@ version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
+[[package]]
+name = "bit-vec"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269"
+dependencies = [
+ "borsh",
+ "serde",
+]
+
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -580,7 +580,7 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
- "objc2 0.6.4",
+ "objc2 0.6.5",
]
[[package]]
@@ -649,7 +649,7 @@ dependencies = [
"oneshot",
"pastey",
"portable-atomic",
- "rand 0.10.3",
+ "rand",
"regress",
"rustc-hash 2.1.3",
"ryu-js",
@@ -740,6 +740,30 @@ dependencies = [
"static_assertions",
]
+[[package]]
+name = "borsh"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
+dependencies = [
+ "borsh-derive",
+ "bytes",
+ "cfg_aliases",
+]
+
+[[package]]
+name = "borsh-derive"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56"
+dependencies = [
+ "once_cell",
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
[[package]]
name = "bp256"
version = "0.14.0"
@@ -999,6 +1023,15 @@ dependencies = [
"x509-cert",
]
+[[package]]
+name = "cobs"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
+dependencies = [
+ "thiserror 2.0.21",
+]
+
[[package]]
name = "codespan-reporting"
version = "0.13.1"
@@ -1145,15 +1178,67 @@ dependencies = [
[[package]]
name = "craft-docpath"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+
+[[package]]
+name = "craft-fonts-build"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+
+[[package]]
+name = "craft-i18n"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+dependencies = [
+ "fixed_decimal",
+ "icu_decimal",
+ "icu_locale_core",
+ "icu_locale_fallback",
+ "icu_normalizer",
+ "icu_plurals",
+ "icu_properties",
+ "icu_provider",
+ "icu_provider_adapters",
+ "icu_provider_blob",
+ "log",
+ "objc2-foundation 0.3.2",
+ "sys-locale",
+ "unicode-bidi",
+ "windows-registry",
+ "writeable",
+]
+
+[[package]]
+name = "craft-keymap"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+dependencies = [
+ "egui",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "craft-menu"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+dependencies = [
+ "craft-keymap",
+ "craft-quit",
+ "egui",
+ "muda",
+ "objc2 0.6.5",
+ "objc2-app-kit 0.3.2",
+]
[[package]]
name = "craft-print"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=666c358065261a2816b376fa3bb10538dbf53040#666c358065261a2816b376fa3bb10538dbf53040"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
- "miniz_oxide 0.8.9",
- "roxmltree",
+ "craft-winprint-gdi",
+ "miniz_oxide 0.9.1",
+ "roxmltree 0.21.2",
"tempfile",
"winprint",
"winsafe",
@@ -1162,22 +1247,66 @@ dependencies = [
[[package]]
name = "craft-quit"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=f58bc1a3df490992d633393d0898af0588ce882c#f58bc1a3df490992d633393d0898af0588ce882c"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"dispatch2",
"egui",
- "muda",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
+ "craft-fonts-build",
+ "craft-i18n",
"egui",
+ "epaint",
+ "js-sys",
+ "log",
+ "objc2 0.6.5",
+ "objc2-app-kit 0.3.2",
+ "objc2-foundation 0.3.2",
"serde",
+ "skrifa 0.44.0",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "craft-update"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+dependencies = [
+ "chrono",
+ "dunce",
+ "egui",
+ "flate2",
+ "getrandom 0.3.4",
+ "log",
+ "plist",
+ "semver",
+ "serde",
+ "serde_json",
+ "sha2 0.10.9",
+ "tar",
+ "tempfile",
+ "ureq",
+ "verifysign",
+ "winreg",
+ "winsafe",
+ "zip",
+]
+
+[[package]]
+name = "craft-winprint-gdi"
+version = "0.1.0"
+source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312"
+dependencies = [
+ "windows-sys 0.61.2",
]
[[package]]
@@ -1439,7 +1568,7 @@ dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
"libc",
- "objc2 0.6.4",
+ "objc2 0.6.5",
]
[[package]]
@@ -1535,9 +1664,8 @@ dependencies = [
[[package]]
name = "ecolor"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
"emath",
@@ -1561,16 +1689,15 @@ checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
dependencies = [
"curve25519-dalek",
"ed25519",
- "sha2 0.11.0",
+ "sha2 0.11.1",
"subtle",
"zeroize",
]
[[package]]
name = "eframe"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1585,7 +1712,7 @@ dependencies = [
"image",
"js-sys",
"log",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"parking_lot",
@@ -1594,6 +1721,7 @@ dependencies = [
"profiling",
"raw-window-handle",
"ron",
+ "send_wrapper",
"serde",
"static_assertions",
"wasm-bindgen",
@@ -1606,9 +1734,8 @@ dependencies = [
[[package]]
name = "egui"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"accesskit",
"ahash",
@@ -1628,7 +1755,8 @@ dependencies = [
[[package]]
name = "egui-wgpu"
-version = "0.36.2"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1646,32 +1774,35 @@ dependencies = [
[[package]]
name = "egui-winit"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"accesskit_winit",
"arboard",
+ "block2 0.6.2",
"bytemuck",
"egui",
+ "image",
"log",
- "objc2 0.6.4",
+ "objc2 0.6.5",
+ "objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"objc2-ui-kit 0.3.2",
+ "parking_lot",
"profiling",
"raw-window-handle",
"serde",
"smithay-clipboard",
"web-time",
"webbrowser",
+ "windows-sys 0.61.2",
"winit",
]
[[package]]
name = "egui_extras"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"egui",
@@ -1684,9 +1815,8 @@ dependencies = [
[[package]]
name = "egui_glow"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
"egui",
@@ -1698,9 +1828,8 @@ dependencies = [
[[package]]
name = "egui_kittest"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"eframe",
"egui",
@@ -1742,9 +1871,8 @@ dependencies = [
[[package]]
name = "emath"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
"serde",
@@ -1772,22 +1900,22 @@ checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enum-map"
-version = "2.7.3"
+version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9"
+checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
dependencies = [
"enum-map-derive",
]
[[package]]
name = "enum-map-derive"
-version = "0.17.0"
+version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb"
+checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.119",
+ "syn 3.0.6",
]
[[package]]
@@ -1824,9 +1952,8 @@ dependencies = [
[[package]]
name = "epaint"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1843,16 +1970,17 @@ dependencies = [
"serde",
"skrifa 0.44.0",
"smallvec",
+ "unicode-bidi",
"unicode-general-category",
+ "unicode-properties",
"unicode-segmentation",
- "vello_cpu 0.1.0",
+ "vello_cpu 0.2.0",
]
[[package]]
name = "epaint_default_fonts"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[package]]
name = "equator"
@@ -1887,7 +2015,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.60.2",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -1990,6 +2118,17 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
+[[package]]
+name = "fixed_decimal"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae"
+dependencies = [
+ "displaydoc",
+ "smallvec",
+ "writeable",
+]
+
[[package]]
name = "fixedbitset"
version = "0.5.7"
@@ -2057,7 +2196,7 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55"
dependencies = [
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
]
@@ -2068,6 +2207,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
[[package]]
name = "foldhash"
version = "0.2.0"
@@ -2099,7 +2244,7 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
dependencies = [
- "roxmltree",
+ "roxmltree 0.20.0",
]
[[package]]
@@ -2318,7 +2463,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e"
dependencies = [
"bytemuck",
- "foldhash",
+ "foldhash 0.2.0",
"hashbrown 0.17.1",
"log",
"peniko",
@@ -2330,18 +2475,18 @@ dependencies = [
[[package]]
name = "glifo"
-version = "0.2.0"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
+checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be"
dependencies = [
"bytemuck",
- "foldhash",
+ "foldhash 0.2.0",
"hashbrown 0.17.1",
"log",
"peniko",
"skrifa 0.44.0",
"smallvec",
- "vello_common 0.1.0",
+ "vello_common 0.2.0",
]
[[package]]
@@ -2370,7 +2515,7 @@ dependencies = [
"glutin_glx_sys",
"glutin_wgl_sys",
"libloading",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
@@ -2505,6 +2650,15 @@ version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash 0.1.5",
+]
+
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -2513,7 +2667,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
- "foldhash",
+ "foldhash 0.2.0",
]
[[package]]
@@ -2524,7 +2678,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"allocator-api2",
"equivalent",
- "foldhash",
+ "foldhash 0.2.0",
]
[[package]]
@@ -2784,6 +2938,23 @@ dependencies = [
"zerovec",
]
+[[package]]
+name = "icu_decimal"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08"
+dependencies = [
+ "displaydoc",
+ "fixed_decimal",
+ "icu_locale_core",
+ "icu_pattern",
+ "icu_plurals",
+ "icu_provider",
+ "serde",
+ "writeable",
+ "zerovec",
+]
+
[[package]]
name = "icu_locale_core"
version = "2.3.0"
@@ -2792,11 +2963,32 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
dependencies = [
"displaydoc",
"litemap",
+ "serde",
"tinystr",
"writeable",
"zerovec",
]
+[[package]]
+name = "icu_locale_fallback"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9"
+dependencies = [
+ "icu_locale_core",
+ "icu_locale_fallback_data",
+ "icu_provider",
+ "potential_utf",
+ "tinystr",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_fallback_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8"
+
[[package]]
name = "icu_normalizer"
version = "2.3.0"
@@ -2809,6 +3001,7 @@ dependencies = [
"icu_provider",
"smallvec",
"utf16_iter",
+ "utf8_iter",
"write16",
"zerovec",
]
@@ -2819,6 +3012,32 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
+[[package]]
+name = "icu_pattern"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b3db611446bd92c246b03e4aa0bafa71ddb194af431ddbb42b9d70e4bfce1a0"
+dependencies = [
+ "displaydoc",
+ "either",
+ "serde",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_plurals"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593"
+dependencies = [
+ "displaydoc",
+ "fixed_decimal",
+ "icu_provider",
+ "serde",
+ "zerovec",
+]
+
[[package]]
name = "icu_properties"
version = "2.3.0"
@@ -2848,6 +3067,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
dependencies = [
"displaydoc",
"icu_locale_core",
+ "postcard",
+ "serde",
+ "stable_deref_trait",
"writeable",
"yoke",
"zerofrom",
@@ -2855,6 +3077,30 @@ dependencies = [
"zerovec",
]
+[[package]]
+name = "icu_provider_adapters"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820"
+dependencies = [
+ "icu_locale_fallback",
+ "icu_provider",
+]
+
+[[package]]
+name = "icu_provider_blob"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e"
+dependencies = [
+ "icu_provider",
+ "postcard",
+ "serde",
+ "writeable",
+ "zerotrie",
+ "zerovec",
+]
+
[[package]]
name = "idna"
version = "1.1.0"
@@ -3065,12 +3311,12 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kittest"
-version = "0.4.0"
+version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5"
+checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75"
dependencies = [
"accesskit",
- "accesskit_consumer 0.35.0",
+ "accesskit_consumer",
]
[[package]]
@@ -3153,6 +3399,9 @@ name = "litemap"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
+dependencies = [
+ "serde_core",
+]
[[package]]
name = "litrs"
@@ -3193,10 +3442,10 @@ dependencies = [
"log",
"md-5",
"nom",
- "rand 0.10.3",
+ "rand",
"rangemap",
"rayon",
- "sha2 0.11.0",
+ "sha2 0.11.1",
"stringprep",
"thiserror 2.0.21",
"weezl 0.2.1",
@@ -3298,13 +3547,13 @@ dependencies = [
"crossbeam-channel",
"dpi",
"keyboard-types",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"png 0.18.1",
"thiserror 2.0.21",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -3381,6 +3630,18 @@ dependencies = [
"jni-sys 0.3.1",
]
+[[package]]
+name = "nix"
+version = "0.31.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
+dependencies = [
+ "bitflags 2.13.2",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+]
+
[[package]]
name = "nohash-hasher"
version = "0.2.0"
@@ -3491,9 +3752,9 @@ dependencies = [
[[package]]
name = "objc2"
-version = "0.6.4"
+version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
+checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
dependencies = [
"objc2-encode",
]
@@ -3522,7 +3783,7 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
@@ -3572,7 +3833,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.13.2",
"dispatch2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
]
[[package]]
@@ -3583,7 +3844,7 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.13.2",
"dispatch2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-io-surface",
]
@@ -3639,7 +3900,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
]
@@ -3650,7 +3911,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags 2.13.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
]
@@ -3686,7 +3947,7 @@ checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-foundation 0.3.2",
]
@@ -3710,7 +3971,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.13.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
@@ -3755,7 +4016,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [
"bitflags 2.13.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
]
@@ -3846,6 +4107,16 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "os_pipe"
+version = "1.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
+dependencies = [
+ "libc",
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "p256"
version = "0.14.0"
@@ -3856,7 +4127,7 @@ dependencies = [
"elliptic-curve",
"primefield",
"primeorder",
- "sha2 0.11.0",
+ "sha2 0.11.1",
]
[[package]]
@@ -3870,7 +4141,7 @@ dependencies = [
"fiat-crypto",
"primefield",
"primeorder",
- "sha2 0.11.0",
+ "sha2 0.11.1",
]
[[package]]
@@ -3884,7 +4155,7 @@ dependencies = [
"elliptic-curve",
"primefield",
"primeorder",
- "sha2 0.11.0",
+ "sha2 0.11.1",
]
[[package]]
@@ -3934,10 +4205,10 @@ dependencies = [
[[package]]
name = "pdfcraft"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"chrono",
- "craft-quit",
+ "craft-update",
"eframe",
"egui",
"fmv-macos-events",
@@ -3946,10 +4217,8 @@ dependencies = [
"pdfcraft-ui-egui",
"pollster 1.0.1",
"raw-window-handle",
- "rustls-native-certs",
"serde_json",
"uds_windows",
- "ureq",
"wayland-client",
"wayland-protocols",
"windows",
@@ -3960,7 +4229,7 @@ dependencies = [
[[package]]
name = "pdfcraft-a11y"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3972,7 +4241,7 @@ dependencies = [
[[package]]
name = "pdfcraft-annot"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
@@ -3983,7 +4252,7 @@ dependencies = [
[[package]]
name = "pdfcraft-audit"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3994,9 +4263,9 @@ dependencies = [
[[package]]
name = "pdfcraft-automation"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
- "base64 0.22.1",
+ "base64 0.23.1",
"flate2",
"image",
"pdfcraft-cos",
@@ -4010,15 +4279,15 @@ dependencies = [
"png 0.18.1",
"rustix 1.1.5",
"serde_json",
- "sha2 0.11.0",
+ "sha2 0.11.1",
"tempfile",
]
[[package]]
name = "pdfcraft-cli"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
- "base64 0.22.1",
+ "base64 0.23.1",
"pdfcraft-audit",
"pdfcraft-automation",
"pdfcraft-cos",
@@ -4034,11 +4303,11 @@ dependencies = [
[[package]]
name = "pdfcraft-compare"
-version = "0.13.0"
+version = "0.14.0"
[[package]]
name = "pdfcraft-content"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
@@ -4046,7 +4315,7 @@ dependencies = [
[[package]]
name = "pdfcraft-cos"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"chrono",
"hayro-syntax",
@@ -4061,7 +4330,7 @@ dependencies = [
[[package]]
name = "pdfcraft-create"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"encoding_rs",
"hayro-syntax",
@@ -4077,19 +4346,19 @@ dependencies = [
[[package]]
name = "pdfcraft-crypt"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"aes",
"md-5",
"proptest",
- "sha2 0.11.0",
+ "sha2 0.11.1",
"stringprep",
"thiserror 2.0.21",
]
[[package]]
name = "pdfcraft-edit"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"hayro-syntax",
"kurbo 0.13.1",
@@ -4104,7 +4373,7 @@ dependencies = [
[[package]]
name = "pdfcraft-engine"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"chrono",
"craft-docpath",
@@ -4145,14 +4414,14 @@ dependencies = [
[[package]]
name = "pdfcraft-export"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"flate2",
]
[[package]]
name = "pdfcraft-filters"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"flate2",
"proptest",
@@ -4162,7 +4431,7 @@ dependencies = [
[[package]]
name = "pdfcraft-fonts"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"harfrust",
"pdfcraft-cos",
@@ -4172,7 +4441,7 @@ dependencies = [
[[package]]
name = "pdfcraft-forms"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4185,11 +4454,11 @@ dependencies = [
[[package]]
name = "pdfcraft-geom"
-version = "0.13.0"
+version = "0.14.0"
[[package]]
name = "pdfcraft-js"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"boa_engine",
"chrono",
@@ -4197,7 +4466,7 @@ dependencies = [
[[package]]
name = "pdfcraft-measure"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4210,24 +4479,25 @@ dependencies = [
[[package]]
name = "pdfcraft-model"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-ocr"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"ocrs",
"pdfcraft-fonts",
"rten",
+ "tempfile",
"thiserror 2.0.21",
]
[[package]]
name = "pdfcraft-optimize"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"image",
"pdfcraft-annot",
@@ -4242,11 +4512,11 @@ dependencies = [
[[package]]
name = "pdfcraft-oracle"
-version = "0.13.0"
+version = "0.14.0"
[[package]]
name = "pdfcraft-organize"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4257,7 +4527,7 @@ dependencies = [
[[package]]
name = "pdfcraft-platform"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"async-channel",
"futures-lite",
@@ -4267,18 +4537,18 @@ dependencies = [
[[package]]
name = "pdfcraft-preflight"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"flate2",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-model",
- "roxmltree",
+ "roxmltree 0.20.0",
]
[[package]]
name = "pdfcraft-print"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"craft-print",
"pdfcraft-content",
@@ -4290,7 +4560,7 @@ dependencies = [
[[package]]
name = "pdfcraft-redact"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4306,7 +4576,7 @@ dependencies = [
[[package]]
name = "pdfcraft-render"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"bytemuck",
"flate2",
@@ -4325,11 +4595,11 @@ dependencies = [
[[package]]
name = "pdfcraft-sign"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"aes",
"aws-lc-rs",
- "base64 0.22.1",
+ "base64 0.23.1",
"bp256",
"bp384",
"cbc",
@@ -4354,26 +4624,29 @@ dependencies = [
"rustls-cng",
"security-framework",
"sha1 0.11.0",
- "sha2 0.11.0",
+ "sha2 0.11.1",
"sha3",
"thiserror 2.0.21",
]
[[package]]
name = "pdfcraft-testkit"
-version = "0.13.0"
+version = "0.14.0"
[[package]]
name = "pdfcraft-ui-egui"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"arboard",
- "base64 0.22.1",
+ "base64 0.23.1",
"block2 0.6.2",
"bytemuck",
"chrono",
+ "craft-keymap",
+ "craft-menu",
"craft-quit",
"craft-ui",
+ "craft-update",
"eframe",
"egui",
"egui_extras",
@@ -4383,7 +4656,7 @@ dependencies = [
"image",
"js-sys",
"log",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"pdfcraft-cos",
@@ -4407,13 +4680,14 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
+ "web-time",
"winsafe",
"xattr",
]
[[package]]
name = "pdfcraft-web"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"eframe",
"egui",
@@ -4425,27 +4699,27 @@ dependencies = [
[[package]]
name = "pdfcraft-xfa"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
- "base64 0.22.1",
+ "base64 0.23.1",
"image",
"pdfcraft-cos",
"pdfcraft-fonts",
"png 0.18.1",
- "roxmltree",
- "sha2 0.11.0",
+ "roxmltree 0.20.0",
+ "sha2 0.11.1",
"thiserror 2.0.21",
]
[[package]]
name = "pdfcraft-xfdf"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-cos",
"pdfcraft-forms",
"pdfcraft-model",
- "roxmltree",
+ "roxmltree 0.20.0",
"thiserror 2.0.21",
]
@@ -4496,6 +4770,17 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+[[package]]
+name = "petgraph"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
+dependencies = [
+ "fixedbitset",
+ "hashbrown 0.15.5",
+ "indexmap",
+]
+
[[package]]
name = "phf"
version = "0.13.1"
@@ -4504,7 +4789,6 @@ checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_macros 0.13.1",
"phf_shared 0.13.1",
- "serde",
]
[[package]]
@@ -4515,6 +4799,7 @@ checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
dependencies = [
"phf_macros 0.14.0",
"phf_shared 0.14.0",
+ "serde",
]
[[package]]
@@ -4668,9 +4953,9 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "plist"
-version = "1.10.2"
+version = "1.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "65dca778f41c79ebbe8b2b2139fee86f01b766b67130d108c7c2b8ffbd949f77"
+checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285"
dependencies = [
"base64 0.23.1",
"indexmap",
@@ -4755,12 +5040,24 @@ dependencies = [
"portable-atomic",
]
+[[package]]
+name = "postcard"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
+dependencies = [
+ "cobs",
+ "serde",
+]
+
[[package]]
name = "potential_utf"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
dependencies = [
+ "serde_core",
+ "writeable",
"zerovec",
]
@@ -4770,15 +5067,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
-[[package]]
-name = "ppv-lite86"
-version = "0.2.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
-dependencies = [
- "zerocopy",
-]
-
[[package]]
name = "presser"
version = "0.3.1"
@@ -4862,16 +5150,17 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "proptest"
-version = "1.11.0"
+version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
+checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0"
dependencies = [
- "bit-set 0.8.0",
- "bit-vec 0.8.0",
+ "bit-set 0.11.1",
+ "bit-vec 0.10.1",
"bitflags 2.13.2",
+ "chacha20",
+ "core_detect",
"num-traits",
- "rand 0.9.5",
- "rand_chacha",
+ "rand",
"rand_xorshift",
"regex-syntax",
"rusty-fork",
@@ -4945,7 +5234,7 @@ dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
- "rand 0.10.3",
+ "rand",
"rand_pcg",
"ring",
"rustc-hash 2.1.3",
@@ -4969,7 +5258,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
- "windows-sys 0.60.2",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -4993,16 +5282,6 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
-[[package]]
-name = "rand"
-version = "0.9.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
-dependencies = [
- "rand_chacha",
- "rand_core 0.9.5",
-]
-
[[package]]
name = "rand"
version = "0.10.3"
@@ -5014,31 +5293,12 @@ dependencies = [
"rand_core 0.10.1",
]
-[[package]]
-name = "rand_chacha"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
-dependencies = [
- "ppv-lite86",
- "rand_core 0.9.5",
-]
-
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
-[[package]]
-name = "rand_core"
-version = "0.9.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
-dependencies = [
- "getrandom 0.3.4",
-]
-
[[package]]
name = "rand_core"
version = "0.10.1"
@@ -5056,11 +5316,11 @@ dependencies = [
[[package]]
name = "rand_xorshift"
-version = "0.4.0"
+version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
+checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf"
dependencies = [
- "rand_core 0.9.5",
+ "rand_core 0.10.1",
]
[[package]]
@@ -5087,7 +5347,7 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135"
dependencies = [
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
@@ -5280,7 +5540,7 @@ dependencies = [
"js-sys",
"libc",
"log",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
@@ -5348,6 +5608,15 @@ version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
+[[package]]
+name = "roxmltree"
+version = "0.21.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "rsa"
version = "0.10.0-rc.18"
@@ -5377,6 +5646,7 @@ dependencies = [
"rten-base",
"rten-gemm",
"rten-model-file",
+ "rten-onnx",
"rten-parallel",
"rten-shape-inference",
"rten-simd",
@@ -5426,6 +5696,12 @@ dependencies = [
"rten-base",
]
+[[package]]
+name = "rten-onnx"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96944374568990b8d1bbc5bae5957585866f9dd8256130460405730948bfd42e"
+
[[package]]
name = "rten-parallel"
version = "0.26.0"
@@ -5506,7 +5782,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.4.15",
- "windows-sys 0.52.0",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -5519,7 +5795,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
- "windows-sys 0.60.2",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -5551,9 +5827,9 @@ dependencies = [
[[package]]
name = "rustls-native-certs"
-version = "0.8.4"
+version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
+checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4"
dependencies = [
"openssl-probe",
"rustls-pki-types",
@@ -5589,7 +5865,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
- "windows-sys 0.60.2",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -5718,6 +5994,16 @@ name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "send_wrapper"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
[[package]]
name = "serde"
@@ -5751,9 +6037,9 @@ dependencies = [
[[package]]
name = "serde_json"
-version = "1.0.151"
+version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
@@ -5850,9 +6136,9 @@ dependencies = [
[[package]]
name = "sha2"
-version = "0.11.0"
+version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
+checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
@@ -6008,7 +6294,7 @@ dependencies = [
"const-oid 0.9.6",
"der 0.7.10",
"hex",
- "rand 0.10.3",
+ "rand",
"reqwest",
"rustls-pki-types",
"rustls-webpki",
@@ -6244,7 +6530,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -6409,7 +6695,10 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
+ "js-sys",
"libc",
+ "wasm-bindgen",
+ "web-sys",
]
[[package]]
@@ -6463,10 +6752,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
- "getrandom 0.3.4",
+ "getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
- "windows-sys 0.60.2",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -6604,6 +6893,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
dependencies = [
"displaydoc",
+ "serde_core",
"zerovec",
]
@@ -6800,6 +7090,17 @@ dependencies = [
"once_cell",
]
+[[package]]
+name = "tree_magic_mini"
+version = "3.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
+dependencies = [
+ "memchr",
+ "nom",
+ "petgraph",
+]
+
[[package]]
name = "try-lock"
version = "0.2.5"
@@ -6815,6 +7116,12 @@ dependencies = [
"rustc-hash 2.1.3",
]
+[[package]]
+name = "typed-path"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
+
[[package]]
name = "typeid"
version = "1.0.3"
@@ -6835,7 +7142,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -6903,24 +7210,26 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
-version = "3.4.2"
+version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
+checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b"
dependencies = [
"base64 0.23.1",
+ "flate2",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
+ "rustls-platform-verifier",
"ureq-proto",
"utf8-zero",
]
[[package]]
name = "ureq-proto"
-version = "0.6.4"
+version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be"
+checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922"
dependencies = [
"base64 0.23.1",
"http",
@@ -6953,7 +7262,7 @@ dependencies = [
"kurbo 0.11.3",
"log",
"pico-args",
- "roxmltree",
+ "roxmltree 0.20.0",
"simplecss",
"siphasher",
"strict-num",
@@ -7025,9 +7334,9 @@ dependencies = [
[[package]]
name = "vello_common"
-version = "0.1.0"
+version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
+checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd"
dependencies = [
"bytemuck",
"fearless_simd",
@@ -7053,14 +7362,24 @@ dependencies = [
[[package]]
name = "vello_cpu"
-version = "0.1.0"
+version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea"
+checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9"
dependencies = [
"bytemuck",
- "glifo 0.2.0",
+ "glifo 0.3.0",
"hashbrown 0.17.1",
- "vello_common 0.1.0",
+ "vello_common 0.2.0",
+]
+
+[[package]]
+name = "verifysign"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51"
+dependencies = [
+ "core-foundation 0.10.1",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -7332,7 +7651,7 @@ dependencies = [
"jni",
"log",
"ndk-context",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"url",
@@ -7488,7 +7807,7 @@ dependencies = [
"naga",
"naga-types",
"ndk-sys",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
@@ -7549,7 +7868,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.60.2",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -7670,16 +7989,16 @@ version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
- "windows-targets 0.52.6",
+ "windows-targets",
]
[[package]]
name = "windows-sys"
-version = "0.60.2"
+version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
- "windows-targets 0.53.5",
+ "windows-targets",
]
[[package]]
@@ -7697,31 +8016,14 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
- "windows_aarch64_gnullvm 0.52.6",
- "windows_aarch64_msvc 0.52.6",
- "windows_i686_gnu 0.52.6",
- "windows_i686_gnullvm 0.52.6",
- "windows_i686_msvc 0.52.6",
- "windows_x86_64_gnu 0.52.6",
- "windows_x86_64_gnullvm 0.52.6",
- "windows_x86_64_msvc 0.52.6",
-]
-
-[[package]]
-name = "windows-targets"
-version = "0.53.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
-dependencies = [
- "windows-link",
- "windows_aarch64_gnullvm 0.53.1",
- "windows_aarch64_msvc 0.53.1",
- "windows_i686_gnu 0.53.1",
- "windows_i686_gnullvm 0.53.1",
- "windows_i686_msvc 0.53.1",
- "windows_x86_64_gnu 0.53.1",
- "windows_x86_64_gnullvm 0.53.1",
- "windows_x86_64_msvc 0.53.1",
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
]
[[package]]
@@ -7739,99 +8041,52 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
-[[package]]
-name = "windows_aarch64_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
-
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
-[[package]]
-name = "windows_aarch64_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
-
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
-[[package]]
-name = "windows_i686_gnu"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
-
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
-[[package]]
-name = "windows_i686_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
-
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
-[[package]]
-name = "windows_i686_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
-
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
-[[package]]
-name = "windows_x86_64_gnu"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
-
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
-[[package]]
-name = "windows_x86_64_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
-
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
-[[package]]
-name = "windows_x86_64_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
-
[[package]]
name = "winit"
version = "0.30.13"
+source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc"
dependencies = [
"ahash",
"android-activity",
@@ -7909,10 +8164,20 @@ dependencies = [
]
[[package]]
-name = "winresource"
-version = "0.1.31"
+name = "winreg"
+version = "0.55.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087"
+checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97"
+dependencies = [
+ "cfg-if",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "winresource"
+version = "0.1.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b"
dependencies = [
"toml",
"version_check",
@@ -7930,6 +8195,24 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+[[package]]
+name = "wl-clipboard-rs"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1"
+dependencies = [
+ "libc",
+ "log",
+ "os_pipe",
+ "rustix 1.1.5",
+ "thiserror 2.0.21",
+ "tree_magic_mini",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-protocols-wlr",
+]
+
[[package]]
name = "wnaf"
version = "0.14.1"
@@ -7953,6 +8236,9 @@ name = "writeable"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
+dependencies = [
+ "either",
+]
[[package]]
name = "x11-dl"
@@ -8076,7 +8362,7 @@ dependencies = [
"png 0.18.1",
"serde",
"serde_json",
- "sha2 0.11.0",
+ "sha2 0.11.1",
"toml",
]
@@ -8277,8 +8563,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
dependencies = [
"displaydoc",
+ "litemap",
+ "serde_core",
"yoke",
"zerofrom",
+ "zerovec",
]
[[package]]
@@ -8287,6 +8576,7 @@ version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
dependencies = [
+ "serde",
"yoke",
"zerofrom",
"zerovec-derive",
@@ -8303,6 +8593,23 @@ dependencies = [
"syn 3.0.6",
]
+[[package]]
+name = "zip"
+version = "9.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704"
+dependencies = [
+ "crc32fast",
+ "flate2",
+ "getrandom 0.4.3",
+ "indexmap",
+ "memchr",
+ "nix",
+ "typed-path",
+ "windows-sys 0.61.2",
+ "zopfli",
+]
+
[[package]]
name = "zlib-rs"
version = "0.6.8"
@@ -8315,6 +8622,18 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+[[package]]
+name = "zopfli"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
+dependencies = [
+ "bumpalo",
+ "crc32fast",
+ "log",
+ "simd-adler32",
+]
+
[[package]]
name = "zune-core"
version = "0.5.3"
@@ -8370,3 +8689,18 @@ dependencies = [
"syn 3.0.6",
"winnow",
]
+
+[[patch.unused]]
+name = "egui_demo_lib"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
+
+[[patch.unused]]
+name = "egui_inspection"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
+
+[[patch.unused]]
+name = "egui_system_font_provider"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
diff --git a/Cargo.toml b/Cargo.toml
index e7acfd31f9..99f9fa251b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,7 +5,7 @@ exclude = ["vendor"]
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
[workspace.package]
-version = "0.13.0"
+version = "0.14.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -13,47 +13,51 @@ repository = "https://github.com/storytold/pdfcraft"
[workspace.dependencies]
# Shared frontend components; engines remain independent of egui.
-craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
+craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
# Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`.
-craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f58bc1a3df490992d633393d0898af0588ce882c" }
+craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
+# One menu definition for the native macOS menu bar and the in-window bar elsewhere.
+craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
+craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", default-features = false }
# Shared system spooler; document composition remains in pdfcraft-print.
-craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "666c358065261a2816b376fa3bb10538dbf53040" }
+craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# Whether a path stored in a document may be opened (GoToR link targets).
-craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
+craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
+craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
# internal
-pdfcraft-geom = { path = "crates/geom", version = "0.13.0" }
-pdfcraft-filters = { path = "crates/filters", version = "0.13.0" }
-pdfcraft-cos = { path = "crates/cos", version = "0.13.0" }
-pdfcraft-crypt = { path = "crates/crypt", version = "0.13.0" }
-pdfcraft-organize = { path = "crates/organize", version = "0.13.0" }
-pdfcraft-measure = { path = "crates/measure", version = "0.13.0" }
-pdfcraft-annot = { path = "crates/annot", version = "0.13.0" }
-pdfcraft-audit = { path = "crates/audit", version = "0.13.0" }
-pdfcraft-fonts = { path = "crates/fonts", version = "0.13.0" }
-pdfcraft-model = { path = "crates/model", version = "0.13.0" }
-pdfcraft-content = { path = "crates/content", version = "0.13.0" }
-pdfcraft-redact = { path = "crates/redact", version = "0.13.0" }
-pdfcraft-print = { path = "crates/print", version = "0.13.0" }
-pdfcraft-xfdf = { path = "crates/xfdf", version = "0.13.0" }
-pdfcraft-sign = { path = "crates/sign", version = "0.13.0" }
-pdfcraft-optimize = { path = "crates/optimize", version = "0.13.0" }
-pdfcraft-a11y = { path = "crates/a11y", version = "0.13.0" }
-pdfcraft-oracle = { path = "crates/oracle", version = "0.13.0" }
-pdfcraft-testkit = { path = "crates/testkit", version = "0.13.0" }
-pdfcraft-ocr = { path = "crates/ocr", version = "0.13.0" }
-pdfcraft-js = { path = "crates/js", version = "0.13.0" }
-pdfcraft-compare = { path = "crates/compare", version = "0.13.0" }
-pdfcraft-preflight = { path = "crates/preflight", version = "0.13.0" }
-pdfcraft-export = { path = "crates/export", version = "0.13.0" }
-pdfcraft-edit = { path = "crates/edit", version = "0.13.0" }
-pdfcraft-create = { path = "crates/create", version = "0.13.0" }
-pdfcraft-forms = { path = "crates/forms", version = "0.13.0" }
-pdfcraft-xfa = { path = "crates/xfa", version = "0.13.0" }
-pdfcraft-render = { path = "crates/render", version = "0.13.0" }
-pdfcraft-engine = { path = "crates/engine", version = "0.13.0" }
-pdfcraft-platform = { path = "crates/platform", version = "0.13.0" }
-pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.13.0" }
-pdfcraft-automation = { path = "crates/automation", version = "0.13.0", default-features = false }
+pdfcraft-geom = { path = "crates/geom", version = "0.14.0" }
+pdfcraft-filters = { path = "crates/filters", version = "0.14.0" }
+pdfcraft-cos = { path = "crates/cos", version = "0.14.0" }
+pdfcraft-crypt = { path = "crates/crypt", version = "0.14.0" }
+pdfcraft-organize = { path = "crates/organize", version = "0.14.0" }
+pdfcraft-measure = { path = "crates/measure", version = "0.14.0" }
+pdfcraft-annot = { path = "crates/annot", version = "0.14.0" }
+pdfcraft-audit = { path = "crates/audit", version = "0.14.0" }
+pdfcraft-fonts = { path = "crates/fonts", version = "0.14.0" }
+pdfcraft-model = { path = "crates/model", version = "0.14.0" }
+pdfcraft-content = { path = "crates/content", version = "0.14.0" }
+pdfcraft-redact = { path = "crates/redact", version = "0.14.0" }
+pdfcraft-print = { path = "crates/print", version = "0.14.0" }
+pdfcraft-xfdf = { path = "crates/xfdf", version = "0.14.0" }
+pdfcraft-sign = { path = "crates/sign", version = "0.14.0" }
+pdfcraft-optimize = { path = "crates/optimize", version = "0.14.0" }
+pdfcraft-a11y = { path = "crates/a11y", version = "0.14.0" }
+pdfcraft-oracle = { path = "crates/oracle", version = "0.14.0" }
+pdfcraft-testkit = { path = "crates/testkit", version = "0.14.0" }
+pdfcraft-ocr = { path = "crates/ocr", version = "0.14.0" }
+pdfcraft-js = { path = "crates/js", version = "0.14.0" }
+pdfcraft-compare = { path = "crates/compare", version = "0.14.0" }
+pdfcraft-preflight = { path = "crates/preflight", version = "0.14.0" }
+pdfcraft-export = { path = "crates/export", version = "0.14.0" }
+pdfcraft-edit = { path = "crates/edit", version = "0.14.0" }
+pdfcraft-create = { path = "crates/create", version = "0.14.0" }
+pdfcraft-forms = { path = "crates/forms", version = "0.14.0" }
+pdfcraft-xfa = { path = "crates/xfa", version = "0.14.0" }
+pdfcraft-render = { path = "crates/render", version = "0.14.0" }
+pdfcraft-engine = { path = "crates/engine", version = "0.14.0" }
+pdfcraft-platform = { path = "crates/platform", version = "0.14.0" }
+pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.14.0" }
+pdfcraft-automation = { path = "crates/automation", version = "0.14.0", default-features = false }
# pdf
hayro = "0.7.1"
@@ -75,11 +79,13 @@ chrono = { version = "0.4.45", default-features = false, features = ["std"] }
proptest = "1"
# ui
-egui = "0.36"
-egui_extras = { version = "0.36", default-features = false, features = ["svg"] }
-eframe = { version = "0.36", default-features = false }
-egui_kittest = "0.36"
+egui = "0.37"
+egui_extras = { version = "0.37", default-features = false, features = ["svg"] }
+eframe = { version = "0.37", default-features = false }
+egui_kittest = "0.37"
rfd = "0.17"
+# Monotonic time for logic-only work on native and web (`App::logic` runs without a UI pass).
+web-time = "1.1"
[workspace.lints.rust]
unsafe_code = "forbid"
@@ -118,6 +124,24 @@ hayro-cmap = { path = "vendor/hayro-cmap" }
hayro-postscript = { path = "vendor/hayro-postscript" }
hayro-jbig2 = { path = "vendor/hayro-jbig2" }
lopdf = { path = "vendor/lopdf" }
-winit = { path = "vendor/winit" }
-egui-wgpu = { path = "vendor/egui-wgpu" }
vello_common = { path = "vendor/vello_common" }
+# The egui and winit crates below are pinned to immutable storytold git revisions (egui
+# 6d518fc799681571963842f95b55a6b68431b15a, winit a517b3846ee8457d84ee4975110bfda6797c44dc).
+# Only the retained `vendor/winit` and `vendor/egui-wgpu` copies are provenance for the app's
+# earlier patches and their source guards, not path patches; the other `vendor/` entries above
+# remain real path patches. See vendor/README.md and the migration plan.
+ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
diff --git a/NOTICE b/NOTICE
index 8cacb0b585..63a8fe672c 100644
--- a/NOTICE
+++ b/NOTICE
@@ -58,3 +58,21 @@ dependencies change (plan/adr/0002, 0006).
Vendored (vendor/, patched; see vendor/README.md):
- hayro 0.7.1 — Apache-2.0 OR MIT, Copyright the hayro authors (licence files kept in vendor/hayro).
- hayro-interpret 0.7.0 — Apache-2.0 OR MIT, Copyright the hayro authors (LICENSE-APACHE, LICENSE-MIT kept in vendor/hayro-interpret).
+
+Bundled egui font icons
+
+The pinned egui dependency includes egui-icons.ttf, a small subset of John Slegers'
+emoji-icon-font, through default_fonts. The parent emoji-icon-font.ttf face is
+included through monochrome_emoji_fonts. Their upstream project is
+https://github.com/jslegers/emoji-icon-font; both use the same exact copyright
+and permission notice below.
+
+MIT License
+
+Copyright (c) 2014 John Slegers
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/README.md b/README.md
index d39bec5aa9..f319ffeadb 100644
--- a/README.md
+++ b/README.md
@@ -332,15 +332,13 @@ pdfcraft-cli ui --control ~/.pdfcraft-control.json inspect focused=true
pdfcraft-cli ui --control ~/.pdfcraft-control.json quit
```
-`ui.dock` arranges the `tools` and `inspector` panels; `canvas` is the protected document view.
-Each per-panel request requires a `panel` ID and an `operation`: `open`, `close`,
-`activate`, `float` with `rect: [x, y, width, height]`, or `move` with `target` and
-`zone: center|left|right|top|bottom`. For example,
-`{"operation":"float","panel":"tools","rect":[100,100,320,440]}` floats Tools.
-`ui.dock.reset` restores the default
-arrangement. The control channel accepts these methods directly; `ui.command` also accepts
-`{"id":"ui.dock","params":{...}}`. Saved preferences retain groups, active tabs, visibility
-and floating geometry. Invalid requests return an error without changing the document or layout.
+`ui.panel` opens, closes or sizes a side panel: `{"panel":"tools"|"side","open":true|false,"width":320}`.
+`tools` is the Tools panel and `side` the panel the rail opens (reopening it shows the one open last).
+`width` is clamped to the panel's limits (Tools 240–520, side panel 260–520 points). `ui.dock.reset`
+resets the side panels: Tools open on the left, default widths. The control channel accepts these
+methods directly; `ui.command` also accepts `{"id":"ui.panel","params":{...}}`. Saved preferences keep
+the Tools side, the open panels and their widths. Invalid requests return an error without changing
+the document or the panels.
`resize` requests a native inner-window size in logical points (each dimension must be between 320 and 8192); the window manager may constrain it. `state` reports the observed `window.width`, `window.height` and `window.pixels_per_point`. `focus` accepts exactly one widget `id` or unique enabled, focusable `label`; subsequent `key` requests traverse or activate the actual widgets. `inspect` includes focusability and accepts a `focused` filter. `quit` takes no parameters: clean documents exit normally, while unsaved edits or typing open the existing Save/Don't save/Cancel prompt and return `quitting: false, needs_confirmation: true`. It never silently discards work. Window resizing and application quit are native-only.
@@ -595,13 +593,4 @@ Forks and modified versions must remove them.
Made by the ArtCraft team and community.
-Tools and Inspector panels can be dragged into tab groups, split beside the PDF, or floated within the app window. Read and full-screen modes keep their existing focused document view. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
-
-Advanced `ui.dock` operations use the same validated transaction as pointer gestures:
-`moveFloating` updates `rect`, `resizeSplit` accepts a boolean child `path` and
-`size` (`{"Ratio":0.5}`, `{"FixedFirst":280}`, or `{"FixedSecond":280}`),
-`setStackOpen` takes `open`, and `resizeStack` takes a point `height` or null.
-A center `move` can include `before` (panel ID or null to append) for tab order.
-The equivalent `{"action": ...}` form accepts a serialized shared docking action;
-this is also the form emitted by the renderer. Protected-canvas and app-specific panel
-rules apply equally to both forms.
+The Tools panel sits on the left, or on the right with Preferences ▸ Tools panel side. The rail stays at the right edge and opens one side panel beside it. Drag a panel's inner edge to resize it; widths are remembered. F4 shows or hides the side panel and Shift+F4 the Tools panel (also in the View menu); View ▸ Reset side panels restores the default arrangement.
diff --git a/ROADMAP.md b/ROADMAP.md
index 72e123df4c..f5de8044e1 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,8 @@
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
-> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M8, #3205 follow-up: labelled Italian international phone numbers with a slash match whole; estimates unchanged)
+> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged)
+> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged)
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged)
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged)
> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: Word table cells use the widths of their own grid columns; estimates unchanged)
@@ -165,10 +166,10 @@ Portuguese and Spanish vs the current union). Detail and script notes: [localiza
| Language | Code | Strings | Status |
|---|---|---|---|
| English | `en` | 100% | full |
-| Simplified Chinese | `zh-hans` | 97.4% | partial (no Hans UI face in releases, #826) |
+| Simplified Chinese | `zh-hans` | 97.4% | partial (native LXGW WenKai Lite; no Hans face in the web build, #826) |
| Spanish | `es` | 100% of current union | partial (dialogs / tooltips; no help) |
| Hindi | `hi` | 0% | none |
-| Arabic | `ar` | 100% | partial (layout not mirrored) |
+| Arabic | `ar` | 100% | partial (logical catalog, explicit RTL labels, mirrored stock chrome; native/browser/IME workflows unqualified) |
| French | `fr` | 99.3% | partial |
| Portuguese (Brazil) | `pt-br` | 100% of current union | partial |
| Indonesian | `id` | 0% | none |
@@ -188,7 +189,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
| Rank | Milestone | Estimate (h) |
|---|---|---|
| 0 | Convert and share: Create PDF from Office, Word export that keeps layout (#773) | 32–55 |
-| 1 | Backlog and platform: crash and wrong-result reports, Chinese UI face (#826) | 50–90 |
+| 1 | Backlog and platform: crash and wrong-result reports, Chinese web UI face (#826) | 50–90 |
| 2 | Fidelity harness against Acrobat and the renderer decision (M2; owner decision on #841) | 110–190 |
| 3 | Editing existing content (M7) | 100–180 |
| 4 | OCR languages and Office export/import (M10) | 95–175 |
@@ -199,6 +200,11 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
+- **2026-10-27 (M10, OCR models):** Model staging downloads and verifies the ONNX pair after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, retains complete RTEN fallback and preserves directory precedence.
+- **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged.
+- **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged.
+- **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged.
+
- **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged.
- **2026-10-23 (M8, #3205 follow-up):** Phone-pattern redaction matches complete labelled Italian geographic numbers such as `Tel. +39 0426/123456`, with synthetic matcher and mark/apply/save/reopen coverage; unlabelled international slash numbers remain unsupported.
- **2026-10-23 (M7, #2636 partial):** A new `PasteContent` edit pastes an added text box or image copied in the same document or another, keeping its style, picture, turns, flips and crop, as one undo step; `content_copy` drives it headlessly. A paste lands beside earlier copies (18 pt steps) and stays on the page. A pasted image reuses its picture object in its own document and after the first paste into another; there a colour profile is dropped and CMYK colours stored losslessly are turned to RGB. The desktop keys and menus follow in separate PRs. Estimates unchanged.
@@ -223,6 +229,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-20 (M3, #3535):** Escape with a menu open closes the menu and keeps the text selection; the next Escape clears it, as before. One keypress used to do both. A synthetic UI regression checks it. Estimates unchanged.
- **2026-10-16 (M3, close prompt keyboard):** The "Save changes?" prompt opens with Save chosen (Enter saves, as before) and the arrow keys and Tab / Shift+Tab move the choice to Cancel or Don't save, shown with a focus ring; Enter or Space press the chosen button and Escape still cancels. Before, Enter always saved whatever was Tab-focused. Estimates unchanged.
- **2026-10-21 (M3, #3808):** Page thumbnails in the Inspector can be resized independently from the document and Organize, with a slider, buttons and `thumbnail-zoom` control option. Selection, page following and reload are covered by regressions. Estimates unchanged.
+- **2026-10-22 (M5, comment threads):** Replies join the comment their `/IRT` references, not every comment sharing its `/NM`; PyMuPDF names each page's first comment `fitz-A0`, so all replies used to land under page 1. The Comments header counts comments without their replies, with the number of replies in small text under it (in every catalogue), and a reply's thread line runs from the avatar above it to its own. A reply to a reply shows in its thread in the panel too (it was listed nowhere), an `/IRT` that leads to no comment (itself, a loop, a pop-up, an unlisted object) leaves its annotation a comment of its own instead of hiding it (a status or checkmark that leads nowhere is listed nowhere), and a Replace Text caret isn't counted as a reply. The comment tools refuse an `id` several comments share instead of acting on the first (page 1's), and take `page` + `index` for those; `comment_list` reads a status or checkmark from the annotation it was set on, as the panel does, and reports a reply's own on the reply. UI, engine and automation regressions cover it. Estimates unchanged.
- **2026-10-22 (M2, #3107 / PR #3438):** Inline-image parsing falls back to the existing EI heuristic when missing declared samples, headers or terminators exceed the content-window budget, instead of waiting for an impossible allocation. Exact-length parsing for available samples and ordinary large streamed images is retained. Estimates unchanged.
- **2026-10-21 (M14, Brazilian Portuguese):** Completed the `pt-br` catalog against the current 2,674-key union, including signature validation and accessibility messages; no build or compilation run. Localization remains partial because help and unpredictable engine/OS error details are not translated.
- **2026-10-21 (M14, #3557):** Preferences: the OK button stays below the scrolling settings instead of being the last thing in them, so it is in reach in a short window.
@@ -316,6 +323,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. Synthetic regression test; the page area's `occur min` (a once-only page of its own) is still not laid out, recorded in `form.xfa-dynamic`. Estimates unchanged.
- **2026-10-14 (M12, reading colours, #2059):** Pending page backgrounds use the document's reading colour immediately, preventing white flashes while scrolling to uncached pages. A headless canvas regression covers three pending pages in every preset and a custom background. Estimates unchanged.
- **2026-10-15 (community, M6, #2608):** In Prepare a form the selected field moves with the arrow keys: a point per press, ten with Shift, as the object nudge and dragging it do. Each press is one undo step ("Change field properties"), and the keys no longer turn the page while a field is selected. Estimates unchanged.
+- **2026-10-15 (M3, closable community card):** The ArtCraft community card on Home has a close button that hides it for the rest of the run; it returns on the next launch, and its links stay in the top bar's Discord button and the Help menu. A UI regression covers closing and the fresh-launch default. Estimates unchanged.
- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
- **2026-10-15 (M14, #2383 candidate):** Device-limit requests now clamp unsupported maxima and minimum alignments before enabling the adapter's full 2D texture extent. This addresses the reported V3D request of 16 shader variables on an adapter supporting 15. Synthetic regressions fail before the clamp and pass afterward, along with the existing HiDPI case and Windows GPU-required application tests. Raspberry Pi startup remains unverified; milestone and overall estimates are unchanged.
- **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged.
@@ -670,10 +678,13 @@ Newest first: the date, what moved, and the new overall estimate.
## Revision history
+**2026-10-25 · minor:** M14 localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Estimates unchanged.
+
**2026-10-15 · minor:** M14/#2383 candidate: GPU device requests clamp unsupported limits while retaining the adapter texture extent. Synthetic V3D/downlevel regressions and Windows GPU-required tests pass; Raspberry Pi hardware remains unverified. Estimates unchanged.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. |
| 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. |
| 2026-10-23 | minor | M11: image downsampling measures physical resolution after page-local UserUnit scaling, including shared nested forms; synthetic measurement and optimization regressions. Estimates unchanged. |
| 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. |
diff --git a/apps/pdfcraft-cli/Cargo.toml b/apps/pdfcraft-cli/Cargo.toml
index 7b5f9a82f3..3d9d87a812 100644
--- a/apps/pdfcraft-cli/Cargo.toml
+++ b/apps/pdfcraft-cli/Cargo.toml
@@ -21,7 +21,7 @@ pdfcraft-cos = { workspace = true }
pdfcraft-preflight = { workspace = true }
pdfcraft-automation = { workspace = true }
pdfcraft-platform = { workspace = true }
-base64 = "0.22"
+base64 = "0.23"
[dev-dependencies]
# Decodes `render` output in tests/it/owner_password.rs; already a dependency of the engine.
diff --git a/apps/pdfcraft-cli/src/check.rs b/apps/pdfcraft-cli/src/check.rs
new file mode 100644
index 0000000000..1821f85826
--- /dev/null
+++ b/apps/pdfcraft-cli/src/check.rs
@@ -0,0 +1,206 @@
+use super::commands::{parse_dpi, read};
+use super::options::{AUDIT_OPTIONS, CHECK_ONE_OPTIONS, CHECK_OPTIONS, flag, positional, validate_options};
+use super::*;
+
+/// Audit a document for faux redactions. Prints the findings as JSON; fails when any are
+/// found so scripts and CI can gate on it, and when the audit itself cannot run.
+pub(crate) fn audit(args: &[String]) -> Result<(), CliError> {
+ validate_options("audit", args, AUDIT_OPTIONS)?;
+ let path = *positional(args, AUDIT_OPTIONS).first().ok_or("audit: missing file")?;
+ let bytes = read(path)?;
+ let password = flag(args, "--password");
+ let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map_err(|e| format!("{path}: {e}"))?;
+ let findings = pdfcraft_audit::audit_redactions(&doc).map_err(|e| format!("{path}: {e}"))?;
+ // Rectangles in points from the top-left of the displayed page, as `doc_audit_redactions`
+ // reports them; the page geometry is read only when there is something to place.
+ let info = if findings.is_empty() {
+ None
+ } else {
+ let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
+ Some(inspect(bytes, render_password.as_deref()).map_err(|e| format!("{path}: {e}"))?)
+ };
+ let items: Vec = findings
+ .into_iter()
+ .map(|f| {
+ let rect = f.rect.map(|v| v as f32);
+ serde_json::json!({
+ "page": f.page + 1,
+ "kind": f.kind.id(),
+ "rect": info.as_ref().map_or(rect, |i| pdfcraft_automation::view_rect(i, f.page, rect)),
+ "covered_text": f.covered_text,
+ })
+ })
+ .collect();
+ println!("{}", serde_json::json!({ "file": path, "findings": items, "count": items.len() }));
+ if items.is_empty() { Ok(()) } else { Err(format!("audit: {} faux redaction(s) found", items.len()).into()) }
+}
+
+/// Child-process body for `check`: prints one JSON line.
+pub(crate) fn check_one(args: &[String]) -> Result<(), CliError> {
+ validate_options("check-one", args, CHECK_ONE_OPTIONS)?;
+ let path = *positional(args, CHECK_ONE_OPTIONS).first().ok_or("check-one: missing file")?;
+ let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("36"))?;
+ let start = Instant::now();
+ let bytes = read(path)?;
+ let (status, pages, failed, detail, warnings) = match inspect(bytes.clone(), None) {
+ Err(e) => (format!("open-error:{}", short(&e.to_string())), 0, 0, e.to_string(), 0),
+ Ok(info) => {
+ let mut r = PageRenderer::new(bytes, RenderConfig::default());
+ let mut failed = Vec::new();
+ for i in 0..info.pages.len().min(500) {
+ let p = r.render(RenderRequest { page: i, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false });
+ if let Some(e) = r.render(RenderRequest { page: i, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }).error {
+ failed.push(format!("p{} text: {e}", i + 1));
+ }
+ if let Some(e) = p.error {
+ failed.push(format!("p{}: {e}", i + 1));
+ }
+ }
+ let status = if failed.is_empty() { "ok".to_string() } else { "page-errors".to_string() };
+ (status, info.pages.len(), failed.len(), failed.join(" | "), info.warnings.len())
+ }
+ };
+ // `--edit` (fuzzing): also run our own object layer end to end, with no panic safety net,
+ // so a panic in cos shows up as a crash of this process.
+ if args.iter().any(|a| a == "--edit") {
+ edit_round_trip(&std::fs::read(path).map_err(|e| e.to_string())?);
+ }
+ let line = serde_json::json!({ "file": path, "status": status, "pages": pages, "failed_pages": failed, "warnings": warnings,
+ "ms": start.elapsed().as_millis() as u64, "detail": detail.chars().take(400).collect::() });
+ stdout_line(format_args!("{line}"))?;
+ Ok(())
+}
+
+/// Open with cos, touch every object, edit the catalog, save incrementally and in full (both
+/// output styles), and reopen each result. Errors are fine; panics, hangs and aborts are bugs.
+fn edit_round_trip(bytes: &[u8]) {
+ use pdfcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental};
+ let Ok(mut doc) = Document::open(Arc::new(bytes.to_vec())) else { return };
+ for num in doc.object_numbers().into_iter().take(20_000) {
+ if let Ok(o) = doc.try_get(num)
+ && let Object::Stream(s) = &*o
+ {
+ let _ = s.decoded();
+ }
+ }
+ if let Some(root) = doc.root() {
+ let _ = doc.update_dict(root, |d| d.set(b"PdfCraftFuzz".to_vec(), Object::Bool(true)));
+ }
+ let classic = SaveOptions { object_streams: false, ..SaveOptions::default() };
+ for out in [write_incremental(&doc, &SaveOptions::default()), write_full(&doc, &SaveOptions::default()), write_full(&doc, &classic)]
+ .into_iter()
+ .flatten()
+ {
+ if let Ok(again) = Document::open(Arc::new(out)) {
+ let _ = again.root().map(|r| again.get(r));
+ }
+ }
+}
+
+fn short(s: &str) -> String {
+ s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).take(4).collect::>().join("-").to_lowercase()
+}
+
+fn collect(paths: &[&str]) -> Vec {
+ let mut out = Vec::new();
+ let mut stack: Vec = paths.iter().map(PathBuf::from).collect();
+ let mut visited = std::collections::HashSet::new();
+ while let Some(p) = stack.pop() {
+ if p.is_dir() {
+ // Directory aliases and symlink cycles must not scan the same tree repeatedly.
+ let identity = match p.canonicalize() {
+ Ok(identity) => identity,
+ Err(error) => {
+ eprintln!("{}: could not resolve directory: {error}", p.display());
+ continue;
+ }
+ };
+ if !visited.insert(identity) {
+ continue;
+ }
+ if let Ok(rd) = std::fs::read_dir(&p) {
+ stack.extend(rd.flatten().map(|e| e.path()));
+ }
+ } else if p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) {
+ out.push(p);
+ }
+ }
+ out.sort();
+ out
+}
+
+pub(crate) fn check(args: &[String]) -> Result<(), CliError> {
+ validate_options("check", args, CHECK_OPTIONS)?;
+ // Every option is checked before the inputs are scanned, so a bad value is one message,
+ // not one problem per file: a zero timeout used to make every file time out, and a bad
+ // dpi used to reach each child, which printed nothing and was reported as a crash.
+ let timeout_value = flag(args, "--timeout").unwrap_or("20");
+ let timeout = Duration::from_secs(
+ timeout_value
+ .parse::()
+ .ok()
+ .filter(|secs| *secs > 0)
+ .ok_or_else(|| format!("bad --timeout: expected a positive whole number of seconds, got {timeout_value:?}"))?,
+ );
+ let dpi = flag(args, "--dpi").unwrap_or("36").to_string();
+ parse_dpi(&dpi)?;
+ let files = collect(&positional(args, CHECK_OPTIONS));
+ if files.is_empty() {
+ return Err("check: no PDF files found".into());
+ }
+ let exe = std::env::current_exe().map_err(|e| e.to_string())?;
+ let mut results = Vec::new();
+ let mut counts: std::collections::BTreeMap = Default::default();
+ for (n, f) in files.iter().enumerate() {
+ let r = run_child(&exe, f, &dpi, timeout);
+ let status = r["status"].as_str().unwrap_or("?").to_string();
+ *counts.entry(status.split(':').next().unwrap_or("?").to_string()).or_default() += 1;
+ if status != "ok" {
+ eprintln!(
+ "[{}/{}] {status:<28} {} {}",
+ n + 1,
+ files.len(),
+ f.display(),
+ r["detail"].as_str().unwrap_or("").chars().take(160).collect::()
+ );
+ }
+ results.push(r);
+ }
+ eprintln!("\nchecked {} files: {:?}", files.len(), counts);
+ if let Some(out) = flag(args, "--json") {
+ std::fs::write(out, serde_json::to_string_pretty(&results).unwrap_or_default()).map_err(|e| e.to_string())?;
+ }
+ Ok(())
+}
+
+fn run_child(exe: &Path, file: &Path, dpi: &str, timeout: Duration) -> serde_json::Value {
+ let file_s = file.to_string_lossy().to_string();
+ let child = Command::new(exe).args(["check-one", &file_s, "--dpi", dpi]).stdout(Stdio::piped()).stderr(Stdio::null()).spawn();
+ let mut child = match child {
+ Ok(c) => c,
+ Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }),
+ };
+ let start = Instant::now();
+ loop {
+ match child.try_wait() {
+ Ok(Some(status)) => {
+ let mut out = String::new();
+ if let Some(mut so) = child.stdout.take() {
+ use std::io::Read;
+ let _ = so.read_to_string(&mut out);
+ }
+ return match serde_json::from_str::(out.trim()) {
+ Ok(v) => v,
+ Err(_) => serde_json::json!({ "file": file_s, "status": "crash", "detail": format!("child exited with {status}") }),
+ };
+ }
+ Ok(None) if start.elapsed() > timeout => {
+ let _ = child.kill();
+ let _ = child.wait();
+ return serde_json::json!({ "file": file_s, "status": "timeout", "detail": format!("exceeded {}s", timeout.as_secs()) });
+ }
+ Ok(None) => std::thread::sleep(Duration::from_millis(20)),
+ Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }),
+ }
+ }
+}
diff --git a/apps/pdfcraft-cli/src/commands.rs b/apps/pdfcraft-cli/src/commands.rs
new file mode 100644
index 0000000000..5efec8331c
--- /dev/null
+++ b/apps/pdfcraft-cli/src/commands.rs
@@ -0,0 +1,413 @@
+use super::options::{
+ COMBINE_OPTIONS, EXTRACT_OPTIONS, INFO_OPTIONS, RENDER_OPTIONS, SPLIT_OPTIONS, TEXT_OPTIONS, can_be_path_value, flag, needs_value, positional,
+ validate_options,
+};
+use super::*;
+
+pub(crate) fn read(path: &str) -> Result>, String> {
+ std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}"))
+}
+
+pub(crate) fn info(args: &[String]) -> Result<(), CliError> {
+ validate_options("info", args, INFO_OPTIONS)?;
+ let path = *positional(args, INFO_OPTIONS).first().ok_or("info: missing file")?;
+ let bytes = read(path)?;
+ let password = flag(args, "--password");
+ // The renderer takes only the user password of R2–R4 files; `Session::open` takes either.
+ let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
+ let mut info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?;
+ let pdf_pages = info.pages.len();
+ let acroform_fields = info.fields.len();
+ // Dynamic XFA forms: say what laying the template out gives (what the app shows).
+ let xfa_layout = (info.xfa == Some(pdfcraft_render::Xfa::Dynamic))
+ .then(|| {
+ let mut s = pdfcraft_engine::Session::new();
+ let id = s.open("info.pdf", None, bytes.clone(), password).ok()?;
+ let d = s.get(id)?;
+ d.xfa.as_ref().map(|x| {
+ info = d.info.clone();
+ // The in-memory layout is an appended revision, not the file being inspected.
+ info.file_size = bytes.len();
+ serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })
+ })
+ })
+ .flatten();
+ // What the document declares as a standard, read the way the Standards panel reads it, so
+ // `info` and the app agree. A file that declares nothing leaves these null and empty.
+ let declared =
+ pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map(|doc| pdfcraft_preflight::declared(&doc)).unwrap_or_default();
+ let json = serde_json::json!({
+ "file": path,
+ "pdf_version": info.pdf_version,
+ "file_size": info.file_size,
+ "pages": info.pages.len(),
+ "pdf_pages": pdf_pages, "acroform_fields": acroform_fields,
+ "first_page_pt": info.pages.first().map(|p| [p.width, p.height]),
+ "title": info.title, "author": info.author, "producer": info.producer, "creator": info.creator,
+ "encrypted": info.encrypted, "tagged": info.tagged, "javascript": info.has_javascript,
+ "pdfa": declared.pdfa.as_ref().map(|(part, conformance)| format!("PDF/A-{part}{}", conformance.to_lowercase())),
+ "pdfua": declared.pdfua,
+ "output_intents": declared.output_intents,
+ "xfa": info.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }),
+ "xfa_layout": xfa_layout,
+ "bookmarks": info.outline.len(), "annotations": info.annotations.len(), "fields": info.fields.len(),
+ "links": info.links.len(), "layers": info.layers.len(), "attachments": info.attachments.len(),
+ "transitions": info.transitions.len(),
+ "custom_properties": info.custom.len(),
+ "page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::>(),
+ "warnings": info.warnings,
+ });
+ stdout_line(format_args!("{}", serde_json::to_string_pretty(&json).unwrap_or_default()))?;
+ Ok(())
+}
+
+pub(crate) fn text(args: &[String]) -> Result<(), CliError> {
+ validate_options("text", args, TEXT_OPTIONS)?;
+ let path = *positional(args, TEXT_OPTIONS).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 mut bytes = read(path)?;
+ // The renderer takes only the user password of R2–R4 files; `Session::open` takes either.
+ // Worked out from the file as read: `bytes` may become the session's display copy below.
+ let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
+ // Printing the text is copying it, so ask what `text_extract` asks (#1645): without the
+ // permissions password, a document that doesn't allow copying gives no text.
+ let mut session = pdfcraft_engine::Session::new();
+ if let Some(doc) = session.open(path, None, bytes.clone(), password).ok().and_then(|id| session.get(id)) {
+ // The engine's hint is about editing in the app; on the command line the way in is
+ // `--password`.
+ doc.check_copying()
+ .map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?;
+ if doc.xfa.is_some() {
+ bytes = doc.display.clone();
+ }
+ }
+ let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() });
+ // A document the renderer could not open (wrong or missing password, unparseable file) has
+ // no pages, and "extract every page" of nothing would print nothing and exit 0 (#132). Ask
+ // `inspect` why instead, so a protected file fails the same way `info` does.
+ if r.page_count() == 0 {
+ let info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| format!("text: {e}"))?;
+ if !info.pages.is_empty() {
+ return Err("text: the document could not be parsed".into());
+ }
+ }
+ 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, preview: false });
+ if let Some(e) = out.error {
+ let _ = writeln!(std::io::stderr().lock(), "page {}: {e}", p + 1);
+ failed.push(p + 1);
+ continue;
+ }
+ if n > 0 {
+ stdout_line(format_args!("\u{c}"))?;
+ }
+ stdout_line(format_args!("{}", out.text.map(|t| t.plain_text()).unwrap_or_default()))?;
+ }
+ if failed.is_empty() {
+ Ok(())
+ } else {
+ Err(format!("text: page(s) {} could not be read", failed.iter().map(usize::to_string).collect::>().join(", ")).into())
+ }
+}
+
+/// The most pages one `A-B` range may name: a bound on what a typed range can allocate.
+const MAX_RANGE: usize = 1_000_000;
+
+/// 1-based pages ("1,3,5-8": single pages and inclusive ranges, in the order given) → 0-based
+/// indices (#1923).
+fn page_list(s: &str) -> Result, String> {
+ let number = |t: &str, item: &str| t.trim().parse::().ok().filter(|n| *n > 0).ok_or_else(|| format!("bad page number {item:?}"));
+ let mut out = Vec::new();
+ for item in s.split(',').map(str::trim) {
+ match item.split_once('-') {
+ Some((from, to)) => {
+ let (from, to) = (number(from, item)?, number(to, item)?);
+ if to < from {
+ return Err(format!("bad page range {item:?}: it ends before it starts"));
+ }
+ if to - from >= MAX_RANGE {
+ return Err(format!("bad page range {item:?}: more than {MAX_RANGE} pages"));
+ }
+ out.extend(from - 1..to);
+ }
+ None => out.push(number(item, item)? - 1),
+ }
+ }
+ Ok(out)
+}
+
+/// The options `edit` understands, for its "unknown option" message.
+const EDIT_OPTIONS: &str = "--out, --password, --full, --rotate, --delete, --move, --insert-blank, --title, --author";
+
+/// Apply page and metadata edits through the engine and save (incrementally unless `--full`).
+///
+/// Every argument is parsed before the file is opened: an option this command does not know
+/// (a typo like `--rotat`) is an error, not something to skip — skipping it would write an
+/// unchanged copy and exit 0, which looks like a successful edit (#133).
+pub(crate) fn edit(args: &[String]) -> Result<(), CliError> {
+ use pdfcraft_engine::{Edit, Session};
+ let mut path: Option<&str> = None;
+ let mut out: Option<&str> = None;
+ let mut password: Option<&str> = None;
+ let mut full = false;
+ let mut edits = Vec::new();
+ let mut i = 0;
+ while let Some(arg) = args.get(i).map(String::as_str) {
+ // Options that take no value, and the input file.
+ match arg {
+ "--full" => {
+ full = true;
+ i += 1;
+ continue;
+ }
+ a if a.starts_with("--") => {}
+ a => {
+ if path.is_some() {
+ return Err(format!("edit: unexpected argument {a:?} (one input file, then options)").into());
+ }
+ path = Some(a);
+ i += 1;
+ continue;
+ }
+ }
+ // `edit` parses its own options (they run in order), so `--out` is checked here in the
+ // same way: it wrote a PDF to a file called `--full`.
+ let value = args.get(i + 1).map(String::as_str);
+ if !can_be_path_value(arg, value) {
+ return Err(needs_value("edit", arg, value).into());
+ }
+ let value = value.ok_or_else(|| format!("edit: {arg} needs a value"))?;
+ match arg {
+ "--out" => out = Some(value),
+ "--password" => password = Some(value),
+ "--rotate" => {
+ let (pages, deg) = value.split_once(':').ok_or("--rotate PAGES:DEGREES")?;
+ let degrees: i64 = deg.parse().map_err(|_| format!("bad --rotate degrees: expected a whole number of degrees, got {deg:?}"))?;
+ // The same rule `rotate_pages` enforces, checked here so a bad angle is named
+ // with its option before any file is opened.
+ if degrees % 90 != 0 {
+ return Err(format!("bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got {degrees}").into());
+ }
+ edits.push(Edit::RotatePages { pages: page_list(pages)?, degrees });
+ }
+ "--delete" => edits.push(Edit::DeletePages { pages: page_list(value)? }),
+ "--move" => {
+ let (pages, to) = value.split_once(':').ok_or("--move PAGES:TO")?;
+ let target: usize = to
+ .parse()
+ .ok()
+ .filter(|target| *target > 0)
+ .ok_or_else(|| format!("bad --move target: expected a page number of at least 1, got {to:?}"))?;
+ edits.push(Edit::MovePages { pages: page_list(pages)?, to: target - 1 });
+ }
+ "--insert-blank" => {
+ let position: usize = value
+ .parse()
+ .ok()
+ .filter(|position| *position > 0)
+ .ok_or_else(|| format!("bad --insert-blank position: expected a page position of at least 1, got {value:?}"))?;
+ edits.push(Edit::InsertBlankPage { at: position - 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() }),
+ other => return Err(format!("edit: unknown option {other:?} (expected one of: {EDIT_OPTIONS})").into()),
+ }
+ i += 2;
+ }
+ let path = path.ok_or("edit: missing file")?;
+ let out = out.ok_or("edit: missing --out")?;
+ let mut session = Session::new();
+ let id = session.open(path, Some(path.to_string()), read(path)?, password).map_err(|e| e.to_string())?;
+ for e in edits {
+ // A destination past the end must fail like `page_move` / `page_insert_blank` do, not
+ // silently become an append (#1330). Checked per edit: earlier edits change the count.
+ let n = session.get(id).map_or(0, |d| d.info.pages.len());
+ let destination = match &e {
+ Edit::MovePages { to, .. } => Some(("--move target", *to)),
+ Edit::InsertBlankPage { at, .. } => Some(("--insert-blank", *at)),
+ _ => None,
+ };
+ if let Some((option, at)) = destination.filter(|(_, at)| *at > n) {
+ return Err(format!("{option} {} is past the end: it must be between 1 and {} (the document has {n} pages)", at + 1, n + 1).into());
+ }
+ // Pages count from the document as the earlier options left it, so a page past the end is
+ // reported with its own option and the current page count (#1550).
+ let listed = match &e {
+ Edit::RotatePages { pages, .. } => Some(("--rotate", pages)),
+ Edit::DeletePages { pages } => Some(("--delete", pages)),
+ Edit::MovePages { pages, .. } => Some(("--move", pages)),
+ _ => None,
+ };
+ if let Some((option, bad)) = listed.and_then(|(option, pages)| pages.iter().find(|p| **p >= n).map(|p| (option, *p))) {
+ return Err(format!("{option}: page {} does not exist; after the earlier options the document has {n} pages", bad + 1).into());
+ }
+ session.apply(id, e).map_err(|e| e.to_string())?;
+ }
+ let bytes = if full { session.save_full_bytes(id) } else { session.save_bytes(id) }.map_err(|e| e.to_string())?;
+ // As the app's Save: the file must reopen with its pages before it replaces anything (#1698).
+ session.check_save(id, &bytes).map_err(|e| e.to_string())?;
+ write_pdf(Path::new(out), &bytes)
+}
+
+/// Write a PDF a command produced, through a staging file beside the target (#2166): a write
+/// that fails, or `--out` naming the input, never leaves a truncated file where a good one was.
+fn write_pdf(out: &Path, bytes: &[u8]) -> Result<(), CliError> {
+ use pdfcraft_platform::staging::{StagingName, write_atomically};
+ write_atomically(out, bytes, StagingName::SuffixThenTag).map_err(|e| format!("{}: {e}", out.display()).into())
+}
+
+fn file_stem(path: &str) -> String {
+ Path::new(path).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string())
+}
+
+pub(crate) fn combine(args: &[String]) -> Result<(), CliError> {
+ validate_options("combine", args, COMBINE_OPTIONS)?;
+ let out = flag(args, "--out").ok_or("combine: missing --out")?;
+ let inputs = positional(args, COMBINE_OPTIONS);
+ if inputs.len() < 2 {
+ return Err("combine: give at least two input files".into());
+ }
+ let sources = inputs.iter().map(|p| Ok((file_stem(p), read(p)?))).collect::, String>>()?;
+ let bytes = pdfcraft_engine::Session::new().combine(&sources).map_err(|e| e.to_string())?;
+ write_pdf(Path::new(out), &bytes)
+}
+
+pub(crate) fn extract(args: &[String]) -> Result<(), CliError> {
+ validate_options("extract", args, EXTRACT_OPTIONS)?;
+ let path = *positional(args, EXTRACT_OPTIONS).first().ok_or("extract: missing file")?;
+ let out = flag(args, "--out").ok_or("extract: missing --out")?;
+ let pages = page_list(flag(args, "--pages").ok_or("extract: missing --pages")?)?;
+ let mut session = pdfcraft_engine::Session::new();
+ let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?;
+ let bytes = session.extract(id, &pages).map_err(|e| e.to_string())?;
+ write_pdf(Path::new(out), &bytes)
+}
+
+pub(crate) fn split(args: &[String]) -> Result<(), CliError> {
+ validate_options("split", args, SPLIT_OPTIONS)?;
+ use pdfcraft_engine::SplitBy;
+ let path = *positional(args, SPLIT_OPTIONS).first().ok_or("split: missing file")?;
+ let by = match (flag(args, "--every"), flag(args, "--before")) {
+ (Some(n), None) => {
+ // A count the splitter cannot hold is refused here, before the file is opened,
+ // rather than silently clamped to 1 further down.
+ let count = n
+ .parse::()
+ .ok()
+ .filter(|count| *count > 0)
+ .ok_or_else(|| format!("bad --every: expected a positive whole number of pages, got {n:?}"))?;
+ SplitBy::PageCount(count)
+ }
+ (None, Some(list)) => SplitBy::Before(page_list(list)?),
+ _ => return Err("split: give either --every N or --before PAGES".into()),
+ };
+ let dir = PathBuf::from(flag(args, "--out-dir").unwrap_or("."));
+ let mut session = pdfcraft_engine::Session::new();
+ let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?;
+ let stem = file_stem(path);
+ let parts = session.split(id, &by).map_err(|e| e.to_string())?;
+ // An output folder that does not exist yet is created (#249); one that can't be is reported
+ // as the folder's problem, not as the first part's.
+ std::fs::create_dir_all(&dir).map_err(|e| format!("split: --out-dir {}: {e}", dir.display()))?;
+ for (a, b, bytes) in parts {
+ let name = dir.join(if a == b { format!("{stem}-p{a}.pdf") } else { format!("{stem}-p{a}-{b}.pdf") });
+ write_pdf(&name, &bytes)?;
+ stdout_line(format_args!("{}", name.display()))?;
+ }
+ Ok(())
+}
+
+/// A `--dpi` the renderer can be asked for: a positive, finite number. `0`, a negative value
+/// or `NaN` parse as an `f32` but reach the renderer as a scale it refuses with its own
+/// internal message (and in `check`, passed on as text, they would turn every file into a
+/// child that printed nothing), so the CLI names the option and the value instead.
+pub(crate) fn parse_dpi(value: &str) -> Result {
+ value
+ .parse::()
+ .ok()
+ .filter(|dpi| dpi.is_finite() && *dpi > 0.0)
+ .ok_or_else(|| format!("bad --dpi: expected a positive number of dots per inch, got {value:?}"))
+}
+
+pub(crate) fn render(args: &[String]) -> Result<(), CliError> {
+ validate_options("render", args, RENDER_OPTIONS)?;
+ let path = *positional(args, RENDER_OPTIONS).first().ok_or("render: missing file")?;
+ let page: usize =
+ flag(args, "--page").unwrap_or("1").parse().ok().filter(|page| *page > 0).ok_or("bad --page: expected a positive page number")?;
+ let page_index = page - 1;
+ let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("96"))?;
+ 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.
+ let format = match Path::new(out).extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).as_deref() {
+ Some("png") => Some(ImageFormat::Png),
+ Some("jpg" | "jpeg") => Some(ImageFormat::Jpeg { quality: 90 }),
+ Some("tif" | "tiff") => Some(ImageFormat::Tiff),
+ Some("pam") => None,
+ _ => return Err(format!("render: --out {out}: use a .png, .jpg, .tif or .pam name").into()),
+ };
+ // The app renders a display copy that draws comments missing their /AP streams; the CLI
+ // must agree with it (and with independent viewers), while keeping the file it reads intact.
+ let bytes = read(path)?;
+ let password = flag(args, "--password");
+ // The renderer takes only the user password of R2–R4 files; `Session::open` and the display
+ // copy take either.
+ let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
+ let display = if inspect(bytes.clone(), render_password.as_deref()).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) {
+ let mut session = pdfcraft_engine::Session::new();
+ let id = session.open(path, None, bytes, password).map_err(|e| e.to_string())?;
+ session.get(id).ok_or("render: the document could not be opened")?.display.clone()
+ } else {
+ pdfcraft_engine::display_bytes_for(bytes, password)
+ };
+ let mut r =
+ PageRenderer::new(display, RenderConfig { password: render_password.as_deref().map(Arc::from), reject_oversize: true, ..Default::default() });
+ let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false });
+ if let Some(e) = p.error {
+ return Err(e.into());
+ }
+ let bytes = match format {
+ // With `reject_oversize` the page is drawn at `--dpi` or not at all, so the file can say so.
+ Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f, f64::from(dpi))?,
+ None => {
+ // PAM (netpbm RGB_ALPHA): the raw premultiplied pixels, for tools that read it.
+ let mut pam = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", p.width, p.height).into_bytes();
+ pam.extend_from_slice(&p.rgba);
+ pam
+ }
+ };
+ std::fs::write(out, bytes).map_err(|e| format!("{out}: {e}"))?;
+ let _ = writeln!(std::io::stderr().lock(), "rendered page {page} at {dpi} dpi: {}×{} px in {} ms", p.width, p.height, p.millis);
+ for warning in &p.warnings {
+ match warning {
+ pdfcraft_render::RenderWarning::ContentTruncated => {
+ let _ = writeln!(std::io::stderr().lock(), "warning: page {page}: content past the page's safety budget was skipped");
+ }
+ pdfcraft_render::RenderWarning::ColourFallback => {
+ let _ =
+ writeln!(std::io::stderr().lock(), "warning: page {page}: a colour that could not be computed was painted in a fallback colour");
+ }
+ }
+ }
+ Ok(())
+}
diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs
index 9ac3b21589..0c7fa9ba22 100644
--- a/apps/pdfcraft-cli/src/main.rs
+++ b/apps/pdfcraft-cli/src/main.rs
@@ -53,6 +53,20 @@ macro_rules! mcp_usage {
};
}
+mod check;
+mod commands;
+mod options;
+mod run;
+mod ui;
+
+use check::{audit, check, check_one};
+use commands::{combine, edit, extract, info, render, split, text};
+use options::{OptionSpec, flag, validate_options};
+#[cfg(feature = "mcp")]
+use run::mcp;
+use run::{automation, print_output, run, tools};
+use ui::ui;
+
/// The full usage, printed by `--help` (also after a command, e.g. `render --help`).
const USAGE: &str = concat!(
"\
@@ -181,1055 +195,3 @@ fn version() -> Result<(), CliError> {
stdout_line(format_args!("Source: {}", pdfcraft_engine::links::GITHUB))?;
Ok(())
}
-
-/// Audit a document for faux redactions. Prints the findings as JSON; fails when any are
-/// found so scripts and CI can gate on it, and when the audit itself cannot run.
-fn audit(args: &[String]) -> Result<(), CliError> {
- validate_options("audit", args, AUDIT_OPTIONS)?;
- let path = *positional(args, AUDIT_OPTIONS).first().ok_or("audit: missing file")?;
- let bytes = read(path)?;
- let password = flag(args, "--password");
- let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map_err(|e| format!("{path}: {e}"))?;
- let findings = pdfcraft_audit::audit_redactions(&doc).map_err(|e| format!("{path}: {e}"))?;
- // Rectangles in points from the top-left of the displayed page, as `doc_audit_redactions`
- // reports them; the page geometry is read only when there is something to place.
- let info = if findings.is_empty() {
- None
- } else {
- let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
- Some(inspect(bytes, render_password.as_deref()).map_err(|e| format!("{path}: {e}"))?)
- };
- let items: Vec = findings
- .into_iter()
- .map(|f| {
- let rect = f.rect.map(|v| v as f32);
- serde_json::json!({
- "page": f.page + 1,
- "kind": f.kind.id(),
- "rect": info.as_ref().map_or(rect, |i| pdfcraft_automation::view_rect(i, f.page, rect)),
- "covered_text": f.covered_text,
- })
- })
- .collect();
- println!("{}", serde_json::json!({ "file": path, "findings": items, "count": items.len() }));
- if items.is_empty() { Ok(()) } else { Err(format!("audit: {} faux redaction(s) found", items.len()).into()) }
-}
-
-fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
- args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str)
-}
-
-#[derive(Clone, Copy)]
-struct OptionSpec {
- name: &'static str,
- takes_value: bool,
-}
-
-/// The options whose value is a file or folder. Only these refuse a value that is another
-/// option: a file named `--password` is never what was meant, while a password can be anything
-/// (`--password --page` is a password of `--page`, which tests/text.rs pins), and so can a title.
-/// The way to name a file that does start with `-` is `./--name`, as `--root` already offered.
-const PATH_VALUE_OPTIONS: [&str; 6] = ["--out", "--out-dir", "--json", "--root", "--control", "--script"];
-
-/// Whether a token can be the value of a path option. Another option cannot: `--out --password`
-/// used to write the PDF to a file called `--password` and exit 0 (photocraft#6944).
-fn can_be_path_value(name: &str, value: Option<&str>) -> bool {
- !PATH_VALUE_OPTIONS.contains(&name) || value.is_some_and(|v| !v.is_empty() && !v.starts_with("--"))
-}
-
-/// The complaint about a path option whose value is missing or is another option.
-fn needs_value(command: &str, name: &str, value: Option<&str>) -> String {
- match value {
- Some(v) if !v.is_empty() => {
- format!("{command}: {name} needs a value and `{v}` is another option; write `./{v}` for a file of that name")
- }
- _ => format!("{command}: {name} needs a value"),
- }
-}
-
-/// Refuse unknown options and malformed confinement flags before any file is read.
-fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Result<(), CliError> {
- if specs.iter().any(|spec| spec.name == "--root") {
- // `flag` searches every token, including another option's value. Match that lookup so
- // a malformed confinement flag cannot be hidden there and fall back to no root.
- let mut root_seen = false;
- for (i, _) in args.iter().enumerate().filter(|(_, arg)| arg.as_str() == "--root") {
- if root_seen {
- return Err(format!("{command}: --root may only be supplied once").into());
- }
- root_seen = true;
- if args.get(i + 1).is_none_or(|value| value.is_empty() || value.starts_with("--")) {
- return Err(format!("{command}: --root needs a directory value").into());
- }
- }
- }
- let mut i = 0;
- while let Some(arg) = args.get(i) {
- if let Some(name) = arg.strip_prefix("--") {
- let Some(spec) = specs.iter().find(|spec| spec.name == arg) else {
- return Err(format!("{command}: unknown option --{name}").into());
- };
- // A file's name is not another option, which also closes the gap the walk left for
- // those: an unknown option in that value position was skipped along with it. Other
- // values, missing or malformed, are left to the command, which reports them in its
- // own terms (`text` and `extract` explain what a page value must be).
- i += if spec.takes_value {
- let value = args.get(i + 1).map(String::as_str);
- if !can_be_path_value(spec.name, value) {
- return Err(needs_value(command, spec.name, value).into());
- }
- 2
- } else {
- 1
- };
- } else {
- i += 1;
- }
- }
- Ok(())
-}
-
-const INFO_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }];
-const AUDIT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }];
-const TEXT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--password", takes_value: true }];
-const COMBINE_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--out", takes_value: true }];
-const EXTRACT_OPTIONS: &[OptionSpec] = &[
- OptionSpec { name: "--out", takes_value: true },
- OptionSpec { name: "--pages", takes_value: true },
- OptionSpec { name: "--password", takes_value: true },
-];
-const SPLIT_OPTIONS: &[OptionSpec] = &[
- OptionSpec { name: "--every", takes_value: true },
- OptionSpec { name: "--before", takes_value: true },
- OptionSpec { name: "--out-dir", takes_value: true },
- OptionSpec { name: "--password", takes_value: true },
-];
-const RENDER_OPTIONS: &[OptionSpec] = &[
- OptionSpec { name: "--page", takes_value: true },
- OptionSpec { name: "--dpi", takes_value: true },
- OptionSpec { name: "--out", takes_value: true },
- OptionSpec { name: "--password", takes_value: true },
-];
-const CHECK_ONE_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--dpi", takes_value: true }, OptionSpec { name: "--edit", takes_value: false }];
-const CHECK_OPTIONS: &[OptionSpec] = &[
- OptionSpec { name: "--timeout", takes_value: true },
- OptionSpec { name: "--dpi", takes_value: true },
- OptionSpec { name: "--json", takes_value: true },
-];
-const RUN_OPTIONS: &[OptionSpec] = &[
- OptionSpec { name: "--root", takes_value: true },
- OptionSpec { name: "--script", takes_value: true },
- OptionSpec { name: "--out", takes_value: true },
- OptionSpec { name: "--report", takes_value: false },
-];
-const UI_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--control", takes_value: true }, OptionSpec { name: "--out", takes_value: true }];
-
-#[cfg(feature = "mcp")]
-const MCP_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--root", takes_value: true }, OptionSpec { name: "--compact", takes_value: false }];
-
-/// The arguments that are not options or their values. Which tokens are values is the spec's
-/// answer: skipping the one after every option made `check-one --edit file.pdf` report "missing
-/// file", the file having been read as `--edit`'s value.
-fn positional<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a str> {
- let mut out = Vec::new();
- let mut i = 0;
- while let Some(a) = args.get(i) {
- match specs.iter().find(|spec| spec.name == a.as_str()) {
- Some(spec) if spec.takes_value => i += 2,
- // An option that takes no value, and an unknown one, which `validate_options` has
- // already refused by the time this runs.
- Some(_) => i += 1,
- None if a.starts_with("--") => i += 1,
- None => {
- out.push(a.as_str());
- i += 1;
- }
- }
- }
- out
-}
-
-fn read(path: &str) -> Result>, String> {
- std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}"))
-}
-
-fn info(args: &[String]) -> Result<(), CliError> {
- validate_options("info", args, INFO_OPTIONS)?;
- let path = *positional(args, INFO_OPTIONS).first().ok_or("info: missing file")?;
- let bytes = read(path)?;
- let password = flag(args, "--password");
- // The renderer takes only the user password of R2–R4 files; `Session::open` takes either.
- let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
- let mut info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?;
- let pdf_pages = info.pages.len();
- let acroform_fields = info.fields.len();
- // Dynamic XFA forms: say what laying the template out gives (what the app shows).
- let xfa_layout = (info.xfa == Some(pdfcraft_render::Xfa::Dynamic))
- .then(|| {
- let mut s = pdfcraft_engine::Session::new();
- let id = s.open("info.pdf", None, bytes.clone(), password).ok()?;
- let d = s.get(id)?;
- d.xfa.as_ref().map(|x| {
- info = d.info.clone();
- // The in-memory layout is an appended revision, not the file being inspected.
- info.file_size = bytes.len();
- serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })
- })
- })
- .flatten();
- // What the document declares as a standard, read the way the Standards panel reads it, so
- // `info` and the app agree. A file that declares nothing leaves these null and empty.
- let declared =
- pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map(|doc| pdfcraft_preflight::declared(&doc)).unwrap_or_default();
- let json = serde_json::json!({
- "file": path,
- "pdf_version": info.pdf_version,
- "file_size": info.file_size,
- "pages": info.pages.len(),
- "pdf_pages": pdf_pages, "acroform_fields": acroform_fields,
- "first_page_pt": info.pages.first().map(|p| [p.width, p.height]),
- "title": info.title, "author": info.author, "producer": info.producer, "creator": info.creator,
- "encrypted": info.encrypted, "tagged": info.tagged, "javascript": info.has_javascript,
- "pdfa": declared.pdfa.as_ref().map(|(part, conformance)| format!("PDF/A-{part}{}", conformance.to_lowercase())),
- "pdfua": declared.pdfua,
- "output_intents": declared.output_intents,
- "xfa": info.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }),
- "xfa_layout": xfa_layout,
- "bookmarks": info.outline.len(), "annotations": info.annotations.len(), "fields": info.fields.len(),
- "links": info.links.len(), "layers": info.layers.len(), "attachments": info.attachments.len(),
- "transitions": info.transitions.len(),
- "custom_properties": info.custom.len(),
- "page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::>(),
- "warnings": info.warnings,
- });
- stdout_line(format_args!("{}", serde_json::to_string_pretty(&json).unwrap_or_default()))?;
- Ok(())
-}
-
-fn text(args: &[String]) -> Result<(), CliError> {
- validate_options("text", args, TEXT_OPTIONS)?;
- let path = *positional(args, TEXT_OPTIONS).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 mut bytes = read(path)?;
- // The renderer takes only the user password of R2–R4 files; `Session::open` takes either.
- // Worked out from the file as read: `bytes` may become the session's display copy below.
- let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
- // Printing the text is copying it, so ask what `text_extract` asks (#1645): without the
- // permissions password, a document that doesn't allow copying gives no text.
- let mut session = pdfcraft_engine::Session::new();
- if let Some(doc) = session.open(path, None, bytes.clone(), password).ok().and_then(|id| session.get(id)) {
- // The engine's hint is about editing in the app; on the command line the way in is
- // `--password`.
- doc.check_copying()
- .map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?;
- if doc.xfa.is_some() {
- bytes = doc.display.clone();
- }
- }
- let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() });
- // A document the renderer could not open (wrong or missing password, unparseable file) has
- // no pages, and "extract every page" of nothing would print nothing and exit 0 (#132). Ask
- // `inspect` why instead, so a protected file fails the same way `info` does.
- if r.page_count() == 0 {
- let info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| format!("text: {e}"))?;
- if !info.pages.is_empty() {
- return Err("text: the document could not be parsed".into());
- }
- }
- 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, preview: false });
- if let Some(e) = out.error {
- let _ = writeln!(std::io::stderr().lock(), "page {}: {e}", p + 1);
- failed.push(p + 1);
- continue;
- }
- if n > 0 {
- stdout_line(format_args!("\u{c}"))?;
- }
- stdout_line(format_args!("{}", out.text.map(|t| t.plain_text()).unwrap_or_default()))?;
- }
- if failed.is_empty() {
- Ok(())
- } else {
- Err(format!("text: page(s) {} could not be read", failed.iter().map(usize::to_string).collect::>().join(", ")).into())
- }
-}
-
-/// The most pages one `A-B` range may name: a bound on what a typed range can allocate.
-const MAX_RANGE: usize = 1_000_000;
-
-/// 1-based pages ("1,3,5-8": single pages and inclusive ranges, in the order given) → 0-based
-/// indices (#1923).
-fn page_list(s: &str) -> Result, String> {
- let number = |t: &str, item: &str| t.trim().parse::().ok().filter(|n| *n > 0).ok_or_else(|| format!("bad page number {item:?}"));
- let mut out = Vec::new();
- for item in s.split(',').map(str::trim) {
- match item.split_once('-') {
- Some((from, to)) => {
- let (from, to) = (number(from, item)?, number(to, item)?);
- if to < from {
- return Err(format!("bad page range {item:?}: it ends before it starts"));
- }
- if to - from >= MAX_RANGE {
- return Err(format!("bad page range {item:?}: more than {MAX_RANGE} pages"));
- }
- out.extend(from - 1..to);
- }
- None => out.push(number(item, item)? - 1),
- }
- }
- Ok(out)
-}
-
-/// The options `edit` understands, for its "unknown option" message.
-const EDIT_OPTIONS: &str = "--out, --password, --full, --rotate, --delete, --move, --insert-blank, --title, --author";
-
-/// Apply page and metadata edits through the engine and save (incrementally unless `--full`).
-///
-/// Every argument is parsed before the file is opened: an option this command does not know
-/// (a typo like `--rotat`) is an error, not something to skip — skipping it would write an
-/// unchanged copy and exit 0, which looks like a successful edit (#133).
-fn edit(args: &[String]) -> Result<(), CliError> {
- use pdfcraft_engine::{Edit, Session};
- let mut path: Option<&str> = None;
- let mut out: Option<&str> = None;
- let mut password: Option<&str> = None;
- let mut full = false;
- let mut edits = Vec::new();
- let mut i = 0;
- while let Some(arg) = args.get(i).map(String::as_str) {
- // Options that take no value, and the input file.
- match arg {
- "--full" => {
- full = true;
- i += 1;
- continue;
- }
- a if a.starts_with("--") => {}
- a => {
- if path.is_some() {
- return Err(format!("edit: unexpected argument {a:?} (one input file, then options)").into());
- }
- path = Some(a);
- i += 1;
- continue;
- }
- }
- // `edit` parses its own options (they run in order), so `--out` is checked here in the
- // same way: it wrote a PDF to a file called `--full`.
- let value = args.get(i + 1).map(String::as_str);
- if !can_be_path_value(arg, value) {
- return Err(needs_value("edit", arg, value).into());
- }
- let value = value.ok_or_else(|| format!("edit: {arg} needs a value"))?;
- match arg {
- "--out" => out = Some(value),
- "--password" => password = Some(value),
- "--rotate" => {
- let (pages, deg) = value.split_once(':').ok_or("--rotate PAGES:DEGREES")?;
- let degrees: i64 = deg.parse().map_err(|_| format!("bad --rotate degrees: expected a whole number of degrees, got {deg:?}"))?;
- // The same rule `rotate_pages` enforces, checked here so a bad angle is named
- // with its option before any file is opened.
- if degrees % 90 != 0 {
- return Err(format!("bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got {degrees}").into());
- }
- edits.push(Edit::RotatePages { pages: page_list(pages)?, degrees });
- }
- "--delete" => edits.push(Edit::DeletePages { pages: page_list(value)? }),
- "--move" => {
- let (pages, to) = value.split_once(':').ok_or("--move PAGES:TO")?;
- let target: usize = to
- .parse()
- .ok()
- .filter(|target| *target > 0)
- .ok_or_else(|| format!("bad --move target: expected a page number of at least 1, got {to:?}"))?;
- edits.push(Edit::MovePages { pages: page_list(pages)?, to: target - 1 });
- }
- "--insert-blank" => {
- let position: usize = value
- .parse()
- .ok()
- .filter(|position| *position > 0)
- .ok_or_else(|| format!("bad --insert-blank position: expected a page position of at least 1, got {value:?}"))?;
- edits.push(Edit::InsertBlankPage { at: position - 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() }),
- other => return Err(format!("edit: unknown option {other:?} (expected one of: {EDIT_OPTIONS})").into()),
- }
- i += 2;
- }
- let path = path.ok_or("edit: missing file")?;
- let out = out.ok_or("edit: missing --out")?;
- let mut session = Session::new();
- let id = session.open(path, Some(path.to_string()), read(path)?, password).map_err(|e| e.to_string())?;
- for e in edits {
- // A destination past the end must fail like `page_move` / `page_insert_blank` do, not
- // silently become an append (#1330). Checked per edit: earlier edits change the count.
- let n = session.get(id).map_or(0, |d| d.info.pages.len());
- let destination = match &e {
- Edit::MovePages { to, .. } => Some(("--move target", *to)),
- Edit::InsertBlankPage { at, .. } => Some(("--insert-blank", *at)),
- _ => None,
- };
- if let Some((option, at)) = destination.filter(|(_, at)| *at > n) {
- return Err(format!("{option} {} is past the end: it must be between 1 and {} (the document has {n} pages)", at + 1, n + 1).into());
- }
- // Pages count from the document as the earlier options left it, so a page past the end is
- // reported with its own option and the current page count (#1550).
- let listed = match &e {
- Edit::RotatePages { pages, .. } => Some(("--rotate", pages)),
- Edit::DeletePages { pages } => Some(("--delete", pages)),
- Edit::MovePages { pages, .. } => Some(("--move", pages)),
- _ => None,
- };
- if let Some((option, bad)) = listed.and_then(|(option, pages)| pages.iter().find(|p| **p >= n).map(|p| (option, *p))) {
- return Err(format!("{option}: page {} does not exist; after the earlier options the document has {n} pages", bad + 1).into());
- }
- session.apply(id, e).map_err(|e| e.to_string())?;
- }
- let bytes = if full { session.save_full_bytes(id) } else { session.save_bytes(id) }.map_err(|e| e.to_string())?;
- // As the app's Save: the file must reopen with its pages before it replaces anything (#1698).
- session.check_save(id, &bytes).map_err(|e| e.to_string())?;
- write_pdf(Path::new(out), &bytes)
-}
-
-/// Write a PDF a command produced, through a staging file beside the target (#2166): a write
-/// that fails, or `--out` naming the input, never leaves a truncated file where a good one was.
-fn write_pdf(out: &Path, bytes: &[u8]) -> Result<(), CliError> {
- use pdfcraft_platform::staging::{StagingName, write_atomically};
- write_atomically(out, bytes, StagingName::SuffixThenTag).map_err(|e| format!("{}: {e}", out.display()).into())
-}
-
-fn file_stem(path: &str) -> String {
- Path::new(path).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string())
-}
-
-fn combine(args: &[String]) -> Result<(), CliError> {
- validate_options("combine", args, COMBINE_OPTIONS)?;
- let out = flag(args, "--out").ok_or("combine: missing --out")?;
- let inputs = positional(args, COMBINE_OPTIONS);
- if inputs.len() < 2 {
- return Err("combine: give at least two input files".into());
- }
- let sources = inputs.iter().map(|p| Ok((file_stem(p), read(p)?))).collect::, String>>()?;
- let bytes = pdfcraft_engine::Session::new().combine(&sources).map_err(|e| e.to_string())?;
- write_pdf(Path::new(out), &bytes)
-}
-
-fn extract(args: &[String]) -> Result<(), CliError> {
- validate_options("extract", args, EXTRACT_OPTIONS)?;
- let path = *positional(args, EXTRACT_OPTIONS).first().ok_or("extract: missing file")?;
- let out = flag(args, "--out").ok_or("extract: missing --out")?;
- let pages = page_list(flag(args, "--pages").ok_or("extract: missing --pages")?)?;
- let mut session = pdfcraft_engine::Session::new();
- let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?;
- let bytes = session.extract(id, &pages).map_err(|e| e.to_string())?;
- write_pdf(Path::new(out), &bytes)
-}
-
-fn split(args: &[String]) -> Result<(), CliError> {
- validate_options("split", args, SPLIT_OPTIONS)?;
- use pdfcraft_engine::SplitBy;
- let path = *positional(args, SPLIT_OPTIONS).first().ok_or("split: missing file")?;
- let by = match (flag(args, "--every"), flag(args, "--before")) {
- (Some(n), None) => {
- // A count the splitter cannot hold is refused here, before the file is opened,
- // rather than silently clamped to 1 further down.
- let count = n
- .parse::()
- .ok()
- .filter(|count| *count > 0)
- .ok_or_else(|| format!("bad --every: expected a positive whole number of pages, got {n:?}"))?;
- SplitBy::PageCount(count)
- }
- (None, Some(list)) => SplitBy::Before(page_list(list)?),
- _ => return Err("split: give either --every N or --before PAGES".into()),
- };
- let dir = PathBuf::from(flag(args, "--out-dir").unwrap_or("."));
- let mut session = pdfcraft_engine::Session::new();
- let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?;
- let stem = file_stem(path);
- let parts = session.split(id, &by).map_err(|e| e.to_string())?;
- // An output folder that does not exist yet is created (#249); one that can't be is reported
- // as the folder's problem, not as the first part's.
- std::fs::create_dir_all(&dir).map_err(|e| format!("split: --out-dir {}: {e}", dir.display()))?;
- for (a, b, bytes) in parts {
- let name = dir.join(if a == b { format!("{stem}-p{a}.pdf") } else { format!("{stem}-p{a}-{b}.pdf") });
- write_pdf(&name, &bytes)?;
- stdout_line(format_args!("{}", name.display()))?;
- }
- Ok(())
-}
-
-/// A `--dpi` the renderer can be asked for: a positive, finite number. `0`, a negative value
-/// or `NaN` parse as an `f32` but reach the renderer as a scale it refuses with its own
-/// internal message (and in `check`, passed on as text, they would turn every file into a
-/// child that printed nothing), so the CLI names the option and the value instead.
-fn parse_dpi(value: &str) -> Result {
- value
- .parse::()
- .ok()
- .filter(|dpi| dpi.is_finite() && *dpi > 0.0)
- .ok_or_else(|| format!("bad --dpi: expected a positive number of dots per inch, got {value:?}"))
-}
-
-fn render(args: &[String]) -> Result<(), CliError> {
- validate_options("render", args, RENDER_OPTIONS)?;
- let path = *positional(args, RENDER_OPTIONS).first().ok_or("render: missing file")?;
- let page: usize =
- flag(args, "--page").unwrap_or("1").parse().ok().filter(|page| *page > 0).ok_or("bad --page: expected a positive page number")?;
- let page_index = page - 1;
- let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("96"))?;
- 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.
- let format = match Path::new(out).extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).as_deref() {
- Some("png") => Some(ImageFormat::Png),
- Some("jpg" | "jpeg") => Some(ImageFormat::Jpeg { quality: 90 }),
- Some("tif" | "tiff") => Some(ImageFormat::Tiff),
- Some("pam") => None,
- _ => return Err(format!("render: --out {out}: use a .png, .jpg, .tif or .pam name").into()),
- };
- // The app renders a display copy that draws comments missing their /AP streams; the CLI
- // must agree with it (and with independent viewers), while keeping the file it reads intact.
- let bytes = read(path)?;
- let password = flag(args, "--password");
- // The renderer takes only the user password of R2–R4 files; `Session::open` and the display
- // copy take either.
- let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
- let display = if inspect(bytes.clone(), render_password.as_deref()).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) {
- let mut session = pdfcraft_engine::Session::new();
- let id = session.open(path, None, bytes, password).map_err(|e| e.to_string())?;
- session.get(id).ok_or("render: the document could not be opened")?.display.clone()
- } else {
- pdfcraft_engine::display_bytes_for(bytes, password)
- };
- let mut r =
- PageRenderer::new(display, RenderConfig { password: render_password.as_deref().map(Arc::from), reject_oversize: true, ..Default::default() });
- let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false });
- if let Some(e) = p.error {
- return Err(e.into());
- }
- let bytes = match format {
- // With `reject_oversize` the page is drawn at `--dpi` or not at all, so the file can say so.
- Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f, f64::from(dpi))?,
- None => {
- // PAM (netpbm RGB_ALPHA): the raw premultiplied pixels, for tools that read it.
- let mut pam = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", p.width, p.height).into_bytes();
- pam.extend_from_slice(&p.rgba);
- pam
- }
- };
- std::fs::write(out, bytes).map_err(|e| format!("{out}: {e}"))?;
- let _ = writeln!(std::io::stderr().lock(), "rendered page {page} at {dpi} dpi: {}×{} px in {} ms", p.width, p.height, p.millis);
- for warning in &p.warnings {
- match warning {
- pdfcraft_render::RenderWarning::ContentTruncated => {
- let _ = writeln!(std::io::stderr().lock(), "warning: page {page}: content past the page's safety budget was skipped");
- }
- pdfcraft_render::RenderWarning::ColourFallback => {
- let _ =
- writeln!(std::io::stderr().lock(), "warning: page {page}: a colour that could not be computed was painted in a fallback colour");
- }
- }
- }
- Ok(())
-}
-
-/// Child-process body for `check`: prints one JSON line.
-fn check_one(args: &[String]) -> Result<(), CliError> {
- validate_options("check-one", args, CHECK_ONE_OPTIONS)?;
- let path = *positional(args, CHECK_ONE_OPTIONS).first().ok_or("check-one: missing file")?;
- let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("36"))?;
- let start = Instant::now();
- let bytes = read(path)?;
- let (status, pages, failed, detail, warnings) = match inspect(bytes.clone(), None) {
- Err(e) => (format!("open-error:{}", short(&e.to_string())), 0, 0, e.to_string(), 0),
- Ok(info) => {
- let mut r = PageRenderer::new(bytes, RenderConfig::default());
- let mut failed = Vec::new();
- for i in 0..info.pages.len().min(500) {
- let p = r.render(RenderRequest { page: i, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false });
- if let Some(e) = r.render(RenderRequest { page: i, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }).error {
- failed.push(format!("p{} text: {e}", i + 1));
- }
- if let Some(e) = p.error {
- failed.push(format!("p{}: {e}", i + 1));
- }
- }
- let status = if failed.is_empty() { "ok".to_string() } else { "page-errors".to_string() };
- (status, info.pages.len(), failed.len(), failed.join(" | "), info.warnings.len())
- }
- };
- // `--edit` (fuzzing): also run our own object layer end to end, with no panic safety net,
- // so a panic in cos shows up as a crash of this process.
- if args.iter().any(|a| a == "--edit") {
- edit_round_trip(&std::fs::read(path).map_err(|e| e.to_string())?);
- }
- let line = serde_json::json!({ "file": path, "status": status, "pages": pages, "failed_pages": failed, "warnings": warnings,
- "ms": start.elapsed().as_millis() as u64, "detail": detail.chars().take(400).collect::() });
- stdout_line(format_args!("{line}"))?;
- Ok(())
-}
-
-/// Open with cos, touch every object, edit the catalog, save incrementally and in full (both
-/// output styles), and reopen each result. Errors are fine; panics, hangs and aborts are bugs.
-fn edit_round_trip(bytes: &[u8]) {
- use pdfcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental};
- let Ok(mut doc) = Document::open(Arc::new(bytes.to_vec())) else { return };
- for num in doc.object_numbers().into_iter().take(20_000) {
- if let Ok(o) = doc.try_get(num)
- && let Object::Stream(s) = &*o
- {
- let _ = s.decoded();
- }
- }
- if let Some(root) = doc.root() {
- let _ = doc.update_dict(root, |d| d.set(b"PdfCraftFuzz".to_vec(), Object::Bool(true)));
- }
- let classic = SaveOptions { object_streams: false, ..SaveOptions::default() };
- for out in [write_incremental(&doc, &SaveOptions::default()), write_full(&doc, &SaveOptions::default()), write_full(&doc, &classic)]
- .into_iter()
- .flatten()
- {
- if let Ok(again) = Document::open(Arc::new(out)) {
- let _ = again.root().map(|r| again.get(r));
- }
- }
-}
-
-fn short(s: &str) -> String {
- s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).take(4).collect::>().join("-").to_lowercase()
-}
-
-fn collect(paths: &[&str]) -> Vec {
- let mut out = Vec::new();
- let mut stack: Vec = paths.iter().map(PathBuf::from).collect();
- let mut visited = std::collections::HashSet::new();
- while let Some(p) = stack.pop() {
- if p.is_dir() {
- // Directory aliases and symlink cycles must not scan the same tree repeatedly.
- let identity = match p.canonicalize() {
- Ok(identity) => identity,
- Err(error) => {
- eprintln!("{}: could not resolve directory: {error}", p.display());
- continue;
- }
- };
- if !visited.insert(identity) {
- continue;
- }
- if let Ok(rd) = std::fs::read_dir(&p) {
- stack.extend(rd.flatten().map(|e| e.path()));
- }
- } else if p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) {
- out.push(p);
- }
- }
- out.sort();
- out
-}
-
-fn check(args: &[String]) -> Result<(), CliError> {
- validate_options("check", args, CHECK_OPTIONS)?;
- // Every option is checked before the inputs are scanned, so a bad value is one message,
- // not one problem per file: a zero timeout used to make every file time out, and a bad
- // dpi used to reach each child, which printed nothing and was reported as a crash.
- let timeout_value = flag(args, "--timeout").unwrap_or("20");
- let timeout = Duration::from_secs(
- timeout_value
- .parse::()
- .ok()
- .filter(|secs| *secs > 0)
- .ok_or_else(|| format!("bad --timeout: expected a positive whole number of seconds, got {timeout_value:?}"))?,
- );
- let dpi = flag(args, "--dpi").unwrap_or("36").to_string();
- parse_dpi(&dpi)?;
- let files = collect(&positional(args, CHECK_OPTIONS));
- if files.is_empty() {
- return Err("check: no PDF files found".into());
- }
- let exe = std::env::current_exe().map_err(|e| e.to_string())?;
- let mut results = Vec::new();
- let mut counts: std::collections::BTreeMap = Default::default();
- for (n, f) in files.iter().enumerate() {
- let r = run_child(&exe, f, &dpi, timeout);
- let status = r["status"].as_str().unwrap_or("?").to_string();
- *counts.entry(status.split(':').next().unwrap_or("?").to_string()).or_default() += 1;
- if status != "ok" {
- eprintln!(
- "[{}/{}] {status:<28} {} {}",
- n + 1,
- files.len(),
- f.display(),
- r["detail"].as_str().unwrap_or("").chars().take(160).collect::()
- );
- }
- results.push(r);
- }
- eprintln!("\nchecked {} files: {:?}", files.len(), counts);
- if let Some(out) = flag(args, "--json") {
- std::fs::write(out, serde_json::to_string_pretty(&results).unwrap_or_default()).map_err(|e| e.to_string())?;
- }
- Ok(())
-}
-
-fn run_child(exe: &Path, file: &Path, dpi: &str, timeout: Duration) -> serde_json::Value {
- let file_s = file.to_string_lossy().to_string();
- let child = Command::new(exe).args(["check-one", &file_s, "--dpi", dpi]).stdout(Stdio::piped()).stderr(Stdio::null()).spawn();
- let mut child = match child {
- Ok(c) => c,
- Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }),
- };
- let start = Instant::now();
- loop {
- match child.try_wait() {
- Ok(Some(status)) => {
- let mut out = String::new();
- if let Some(mut so) = child.stdout.take() {
- use std::io::Read;
- let _ = so.read_to_string(&mut out);
- }
- return match serde_json::from_str::(out.trim()) {
- Ok(v) => v,
- Err(_) => serde_json::json!({ "file": file_s, "status": "crash", "detail": format!("child exited with {status}") }),
- };
- }
- Ok(None) if start.elapsed() > timeout => {
- let _ = child.kill();
- let _ = child.wait();
- return serde_json::json!({ "file": file_s, "status": "timeout", "detail": format!("exceeded {}s", timeout.as_secs()) });
- }
- Ok(None) => std::thread::sleep(Duration::from_millis(20)),
- Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }),
- }
- }
-}
-
-// ---- automation --------------------------------------------------------------------------------
-
-fn automation(args: &[String]) -> Result {
- let a = pdfcraft_automation::Automation::new();
- match flag(args, "--root") {
- Some(root) => a.with_root(root).map_err(|e| format!("--root {root}: {e}")),
- None => Ok(a),
- }
-}
-
-fn tools() -> Result<(), CliError> {
- let list: Vec = pdfcraft_automation::tools()
- .iter()
- .map(|t| serde_json::json!({ "name": t.name, "description": t.description, "read_only": t.read_only, "command": t.command, "input_schema": t.input_schema }))
- .collect();
- stdout_line(format_args!("{}", serde_json::to_string_pretty(&list).unwrap_or_default()))?;
- Ok(())
-}
-
-/// Print a tool's result: JSON as JSON; images go to `--out` (or are summarised). The image is
-/// written like a tool's own output, so `--root` confines it too.
-fn print_output(auto: &pdfcraft_automation::Automation, content: Vec, out: Option<&str>) -> Result<(), CliError> {
- for c in content {
- match c {
- pdfcraft_automation::Content::Json(v) => stdout_line(format_args!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()))?,
- pdfcraft_automation::Content::Png { data, width, height } => match out {
- Some(path) => {
- let written = auto.write_output(path, &data).map_err(|e| e.to_string())?;
- stdout_line(format_args!("{}", serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height })))?;
- }
- None => stdout_line(format_args!("{}", png_summary(&data, width, height)))?,
- },
- }
- }
- Ok(())
-}
-
-/// What a PNG result is reported as when it isn't written to a file.
-fn png_summary(data: &[u8], width: u32, height: u32) -> serde_json::Value {
- serde_json::json!({ "image": "png", "width": width, "height": height, "bytes": data.len(), "hint": "pass --out file.png to save it" })
-}
-
-/// A tool's result as JSON values, for the `--report` of a script. Same content as
-/// [`print_output`], collected instead of printed.
-fn output_values(
- auto: &pdfcraft_automation::Automation,
- content: Vec,
- out: Option<&str>,
-) -> Result, String> {
- let mut values = Vec::new();
- for c in content {
- values.push(match c {
- pdfcraft_automation::Content::Json(v) => v,
- pdfcraft_automation::Content::Png { data, width, height } => match out {
- Some(path) => {
- let written = auto.write_output(path, &data).map_err(|e| e.to_string())?;
- serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height })
- }
- None => png_summary(&data, width, height),
- },
- });
- }
- Ok(values)
-}
-
-/// Run a script and print one JSON document: per step its number, tool and `ok`/`error`/`skipped`
-/// with the result or the message. The first failing step ends the run, as without `--report`;
-/// the steps after it are listed as skipped and the exit status is the same.
-fn run_script_report(auto: &mut pdfcraft_automation::Automation, steps: &[serde_json::Value]) -> Result<(), CliError> {
- let mut report = Vec::with_capacity(steps.len());
- let mut failure: Option = None;
- for (i, step) in steps.iter().enumerate() {
- let n = i + 1;
- let tool = step["tool"].as_str();
- if failure.is_some() {
- report.push(serde_json::json!({ "step": n, "tool": tool, "status": "skipped" }));
- continue;
- }
- let outcome = match tool {
- None => Err(format!("step {n}: missing \"tool\"")),
- Some(t) => auto
- .call(t, &step["args"])
- .map_err(|e| e.to_string())
- .and_then(|content| output_values(auto, content, step["out"].as_str()))
- .map_err(|e| format!("step {n} ({t}): {e}")),
- };
- match outcome {
- Ok(result) => report.push(serde_json::json!({ "step": n, "tool": tool, "status": "ok", "result": result })),
- Err(message) => {
- report.push(serde_json::json!({ "step": n, "tool": tool, "status": "error", "error": message }));
- failure = Some(message);
- }
- }
- }
- let document = serde_json::json!({ "ok": failure.is_none(), "steps": report });
- stdout_line(format_args!("{}", serde_json::to_string_pretty(&document).unwrap_or_default()))?;
- failure.map_or(Ok(()), |message| Err(CliError::Message(message)))
-}
-
-fn run(args: &[String]) -> Result<(), CliError> {
- validate_options("run", args, RUN_OPTIONS)?;
- let report = args.iter().any(|a| a == "--report");
- if report && flag(args, "--script").is_none() {
- return Err("run: --report needs --script".to_string().into());
- }
- let mut auto = automation(args)?;
- if let Some(script) = flag(args, "--script") {
- let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?;
- let steps: Vec = serde_json::from_str(&text).map_err(|e| format!("{script}: {e}"))?;
- if report {
- return run_script_report(&mut auto, &steps);
- }
- for (i, step) in steps.iter().enumerate() {
- let tool = step["tool"].as_str().ok_or(format!("step {}: missing \"tool\"", i + 1))?;
- let content = auto.call(tool, &step["args"]).map_err(|e| format!("step {} ({tool}): {e}", i + 1))?;
- print_output(&auto, content, step["out"].as_str()).map_err(|e| match e {
- CliError::Message(message) => CliError::Message(format!("step {} ({tool}): {message}", i + 1)),
- stdout => stdout,
- })?;
- }
- return Ok(());
- }
- let tool = *positional(args, RUN_OPTIONS).first().ok_or("run: missing tool name (see `pdfcraft-cli tools`)")?;
- let mut obj = serde_json::Map::new();
- for kv in positional(args, RUN_OPTIONS).iter().skip(1) {
- let (k, v) = kv.split_once('=').ok_or(format!("run: expected key=value, got {kv:?}"))?;
- let value = serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string()));
- obj.insert(k.to_string(), value);
- }
- let content = auto.call(tool, &serde_json::Value::Object(obj)).map_err(|e| e.to_string())?;
- print_output(&auto, content, flag(args, "--out"))
-}
-
-/// Take `--automation-read-root` / `--automation-write-root` (each `--flag DIR` or `--flag=DIR`,
-/// once) out of `args`, and open the roots, which must be existing folders. Returns the arguments
-/// that are left. A malformed root flag is an error, never a root silently left out.
-#[cfg(feature = "mcp")]
-fn automation_roots(command: &str, args: &[String]) -> Result<(Option>, Vec), String> {
- let mut found = automation_args::AutomationArgs::new(None, None);
- let mut left = Vec::new();
- let mut it = args.iter().cloned();
- while let Some(arg) = it.next() {
- if !found.consume(&arg, &mut it).map_err(|e| format!("{command}: {e}"))? {
- left.push(arg);
- }
- }
- let roots = pdfcraft_automation::file_access::AutomationRoots::new(found.read_root.as_deref(), found.write_root.as_deref())
- .map_err(|e| format!("{command}: {e}"))?;
- Ok((roots, left))
-}
-
-#[cfg(feature = "mcp")]
-fn mcp(args: &[String]) -> Result<(), CliError> {
- let (roots, args) = automation_roots("mcp", args)?;
- let args = args.as_slice();
- validate_options("mcp", args, MCP_OPTIONS)?;
- let compact = args.iter().any(|a| a == "--compact");
- let mut auto = automation(args)?;
- if let Some(roots) = roots {
- let show = |root: Option<&Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
- let _ = writeln!(
- std::io::stderr().lock(),
- "pdfcraft-cli: files confined: read root {}, write root {}",
- show(roots.read_root()),
- show(roots.write_root())
- );
- auto = auto.with_automation_roots(Some(roots));
- }
- let mut server = pdfcraft_automation::mcp::McpServer::new(auto).with_compact(compact);
- let _ = writeln!(
- std::io::stderr().lock(),
- "pdfcraft-cli: MCP server on stdio (protocol {}{}); close stdin to stop",
- 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()))
-}
-
-// ---- UI control channel client -----------------------------------------------------------------
-
-/// One request to a running app's control channel (`pdfcraft --control FILE`).
-fn ui(args: &[String]) -> Result<(), CliError> {
- validate_options("ui", args, UI_OPTIONS)?;
- use std::io::{BufRead, BufReader, Write as _};
- let file = flag(args, "--control").ok_or("ui: missing --control FILE (start the app with `pdfcraft --control FILE`)")?;
- let info = read_control_file(file)?;
- let port = info["port"].as_u64().ok_or(format!("{file}: no port"))?;
- // Refused rather than truncated, which would quietly reach some other port.
- let port = u16::try_from(port).map_err(|_| format!("{file}: {port} is not a port number"))?;
- let token = info["token"].as_str().ok_or(format!("{file}: no token"))?;
- let pos = positional(args, UI_OPTIONS);
- let method = *pos.first().ok_or("ui: missing method (state, inspect, click, drag, type, key, command, commands, set, open, screenshot)")?;
- let method = if method.starts_with("ui.") { method.to_string() } else { format!("ui.{method}") };
- let mut params = serde_json::Map::new();
- for kv in pos.iter().skip(1) {
- let (k, v) = kv.split_once('=').ok_or(format!("ui: expected key=value, got {kv:?}"))?;
- params.insert(k.to_string(), serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string())));
- }
- let stream =
- std::net::TcpStream::connect(("127.0.0.1", port)).map_err(|e| format!("can't reach the app on port {port}: {e} (is it still running?)"))?;
- stream.set_read_timeout(Some(Duration::from_secs(40))).map_err(|e| e.to_string())?;
- let mut write = stream.try_clone().map_err(|e| e.to_string())?;
- let mut lines = BufReader::new(stream).lines();
- let mut rpc = |id: u64, method: &str, params: serde_json::Value| -> Result {
- writeln!(write, "{}", serde_json::json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })).map_err(|e| e.to_string())?;
- let line = lines.next().ok_or("the app closed the connection")?.map_err(|e| e.to_string())?;
- let reply: serde_json::Value = serde_json::from_str(&line).map_err(|e| e.to_string())?;
- match reply.get("error") {
- Some(e) => Err(e["message"].as_str().unwrap_or("error").to_string()),
- None => Ok(reply["result"].clone()),
- }
- };
- rpc(1, "auth", serde_json::json!({ "token": token }))?;
- let mut result = rpc(2, &method, serde_json::Value::Object(params))?;
- if let (Some(out), Some(data)) = (flag(args, "--out"), result.get("png_base64").and_then(|d| d.as_str())) {
- use base64::Engine as _;
- let png = base64::engine::general_purpose::STANDARD.decode(data).map_err(|e| e.to_string())?;
- std::fs::write(out, png).map_err(|e| format!("{out}: {e}"))?;
- result["png_base64"] = serde_json::json!(format!("written to {out}"));
- }
- stdout_line(format_args!("{}", serde_json::to_string_pretty(&result).unwrap_or_default()))?;
- Ok(())
-}
-
-/// Most a control file may hold. The app writes under 100 bytes.
-const CONTROL_FILE_MAX: u64 = 64 * 1024;
-
-const CONTROL_FILE_ADVICE: &str =
- "Start the app with a control file in a folder only you can write, such as `pdfcraft --control ~/.pdfcraft-control.json`";
-
-/// Read the file written by `pdfcraft --control FILE`.
-///
-/// It names the port that receives the token and every command, typed text included, so it must
-/// be the user's own. In a shared folder such as `/tmp`, another local user can create the file
-/// first; the app then can't write it, and the CLI would talk to the other user's listener.
-/// Refused: a symbolic link, anything but a regular file, and on Unix a file owned by someone
-/// else or open to other users (the app writes it with mode 600).
-fn read_control_file(file: &str) -> Result {
- use std::io::Read as _;
- let at_path = std::fs::symlink_metadata(file).map_err(|e| format!("{file}: {e}"))?;
- if at_path.file_type().is_symlink() {
- return Err(format!("{file}: is a symbolic link, which could point at another user's file. {CONTROL_FILE_ADVICE}"));
- }
- if !at_path.is_file() {
- return Err(format!("{file}: not a regular file. {CONTROL_FILE_ADVICE}"));
- }
- let f = std::fs::File::open(file).map_err(|e| format!("{file}: {e}"))?;
- #[cfg(unix)]
- {
- use std::os::unix::fs::MetadataExt;
- // Checked on the open file, so nothing swapped in after `symlink_metadata` gets through.
- let opened = f.metadata().map_err(|e| format!("{file}: {e}"))?;
- if (opened.dev(), opened.ino()) != (at_path.dev(), at_path.ino()) {
- return Err(format!("{file}: was replaced while being opened. {CONTROL_FILE_ADVICE}"));
- }
- if let Some(problem) = control_file_problem(opened.uid(), opened.mode(), current_uid()) {
- return Err(format!("{file}: {problem}. {CONTROL_FILE_ADVICE}"));
- }
- }
- let mut text = String::new();
- f.take(CONTROL_FILE_MAX.saturating_add(1)).read_to_string(&mut text).map_err(|e| format!("{file}: {e}"))?;
- if u64::try_from(text.len()).unwrap_or(u64::MAX) > CONTROL_FILE_MAX {
- return Err(format!("{file}: too large for a control file (over {} KiB)", CONTROL_FILE_MAX / 1024));
- }
- serde_json::from_str(&text).map_err(|e| format!("{file}: {e}"))
-}
-
-/// Why user `me` can't trust a control file with this owner and mode, if they can't.
-#[cfg(any(test, unix))]
-fn control_file_problem(owner: u32, mode: u32, me: u32) -> Option {
- if owner != me {
- return Some(format!("owned by another user (uid {owner}; you are uid {me}), who may be the one listening for your commands"));
- }
- let permissions = mode & 0o7777;
- (permissions & 0o077 != 0).then(|| format!("other users have access to it (mode {permissions:o}; the app writes it with mode 600)"))
-}
-
-/// The effective user id: the owner of the files this process creates, and so of a control file
-/// the app wrote for this user. std has no `geteuid` and this crate forbids `unsafe`; rustix's is
-/// safe and can't fail.
-#[cfg(unix)]
-fn current_uid() -> u32 {
- rustix::process::geteuid().as_raw()
-}
-
-#[cfg(test)]
-mod tests {
- use super::control_file_problem;
-
- #[test]
- fn a_control_file_must_be_yours_and_private() {
- // Regular file type bits (0o100000) don't matter, only the owner and the permission bits.
- assert_eq!(control_file_problem(1000, 0o100600, 1000), None);
- assert_eq!(control_file_problem(1000, 0o600, 1000), None);
- assert_eq!(control_file_problem(1000, 0o400, 1000), None);
- let other = control_file_problem(1001, 0o600, 1000).unwrap_or_default();
- assert!(other.contains("owned by another user") && other.contains("1001"), "{other}");
- // Root gets no exception: a file a user planted is still theirs.
- assert!(control_file_problem(1000, 0o600, 0).is_some_and(|p| p.contains("owned by another user")));
- for mode in [0o644, 0o640, 0o604, 0o660, 0o606, 0o620, 0o602, 0o610, 0o601, 0o100666] {
- let open = control_file_problem(1000, mode, 1000).unwrap_or_default();
- assert!(open.contains("other users"), "{mode:o}: {open}");
- }
- assert!(control_file_problem(1000, 0o100644, 1000).unwrap_or_default().contains("mode 644"));
- }
-}
diff --git a/apps/pdfcraft-cli/src/options.rs b/apps/pdfcraft-cli/src/options.rs
new file mode 100644
index 0000000000..ab1a3db34b
--- /dev/null
+++ b/apps/pdfcraft-cli/src/options.rs
@@ -0,0 +1,148 @@
+use super::*;
+
+pub(crate) fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
+ args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str)
+}
+
+#[derive(Clone, Copy)]
+pub(crate) struct OptionSpec {
+ pub(crate) name: &'static str,
+ pub(crate) takes_value: bool,
+}
+
+/// The options whose value is a file or folder. Only these refuse a value that is another
+/// option: a file named `--password` is never what was meant, while a password can be anything
+/// (`--password --page` is a password of `--page`, which tests/text.rs pins), and so can a title.
+/// The way to name a file that does start with `-` is `./--name`, as `--root` already offered.
+const PATH_VALUE_OPTIONS: [&str; 6] = ["--out", "--out-dir", "--json", "--root", "--control", "--script"];
+
+/// Whether a token can be the value of a path option. Another option cannot: `--out --password`
+/// used to write the PDF to a file called `--password` and exit 0 (photocraft#6944).
+pub(crate) fn can_be_path_value(name: &str, value: Option<&str>) -> bool {
+ !PATH_VALUE_OPTIONS.contains(&name) || value.is_some_and(|v| !v.is_empty() && !v.starts_with("--"))
+}
+
+/// The complaint about a path option whose value is missing or is another option.
+pub(crate) fn needs_value(command: &str, name: &str, value: Option<&str>) -> String {
+ match value {
+ Some(v) if !v.is_empty() => {
+ format!("{command}: {name} needs a value and `{v}` is another option; write `./{v}` for a file of that name")
+ }
+ _ => format!("{command}: {name} needs a value"),
+ }
+}
+
+/// Refuse unknown options and malformed confinement flags before any file is read.
+pub(crate) fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Result<(), CliError> {
+ if specs.iter().any(|spec| spec.name == "--root") {
+ // `flag` searches every token, including another option's value. Match that lookup so
+ // a malformed confinement flag cannot be hidden there and fall back to no root.
+ let mut root_seen = false;
+ for (i, _) in args.iter().enumerate().filter(|(_, arg)| arg.as_str() == "--root") {
+ if root_seen {
+ return Err(format!("{command}: --root may only be supplied once").into());
+ }
+ root_seen = true;
+ if args.get(i + 1).is_none_or(|value| value.is_empty() || value.starts_with("--")) {
+ return Err(format!("{command}: --root needs a directory value").into());
+ }
+ }
+ }
+ let mut i = 0;
+ while let Some(arg) = args.get(i) {
+ if let Some(name) = arg.strip_prefix("--") {
+ let Some(spec) = specs.iter().find(|spec| spec.name == arg) else {
+ return Err(format!("{command}: unknown option --{name}").into());
+ };
+ // A file's name is not another option, which also closes the gap the walk left for
+ // those: an unknown option in that value position was skipped along with it. Other
+ // values, missing or malformed, are left to the command, which reports them in its
+ // own terms (`text` and `extract` explain what a page value must be).
+ i += if spec.takes_value {
+ let value = args.get(i + 1).map(String::as_str);
+ if !can_be_path_value(spec.name, value) {
+ return Err(needs_value(command, spec.name, value).into());
+ }
+ 2
+ } else {
+ 1
+ };
+ } else {
+ i += 1;
+ }
+ }
+ Ok(())
+}
+
+pub(crate) const INFO_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }];
+
+pub(crate) const AUDIT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }];
+
+pub(crate) const TEXT_OPTIONS: &[OptionSpec] =
+ &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--password", takes_value: true }];
+
+pub(crate) const COMBINE_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--out", takes_value: true }];
+
+pub(crate) const EXTRACT_OPTIONS: &[OptionSpec] = &[
+ OptionSpec { name: "--out", takes_value: true },
+ OptionSpec { name: "--pages", takes_value: true },
+ OptionSpec { name: "--password", takes_value: true },
+];
+
+pub(crate) const SPLIT_OPTIONS: &[OptionSpec] = &[
+ OptionSpec { name: "--every", takes_value: true },
+ OptionSpec { name: "--before", takes_value: true },
+ OptionSpec { name: "--out-dir", takes_value: true },
+ OptionSpec { name: "--password", takes_value: true },
+];
+
+pub(crate) const RENDER_OPTIONS: &[OptionSpec] = &[
+ OptionSpec { name: "--page", takes_value: true },
+ OptionSpec { name: "--dpi", takes_value: true },
+ OptionSpec { name: "--out", takes_value: true },
+ OptionSpec { name: "--password", takes_value: true },
+];
+
+pub(crate) const CHECK_ONE_OPTIONS: &[OptionSpec] =
+ &[OptionSpec { name: "--dpi", takes_value: true }, OptionSpec { name: "--edit", takes_value: false }];
+
+pub(crate) const CHECK_OPTIONS: &[OptionSpec] = &[
+ OptionSpec { name: "--timeout", takes_value: true },
+ OptionSpec { name: "--dpi", takes_value: true },
+ OptionSpec { name: "--json", takes_value: true },
+];
+
+pub(crate) const RUN_OPTIONS: &[OptionSpec] = &[
+ OptionSpec { name: "--root", takes_value: true },
+ OptionSpec { name: "--script", takes_value: true },
+ OptionSpec { name: "--out", takes_value: true },
+ OptionSpec { name: "--report", takes_value: false },
+];
+
+pub(crate) const UI_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--control", takes_value: true }, OptionSpec { name: "--out", takes_value: true }];
+
+#[cfg(feature = "mcp")]
+pub(crate) const MCP_OPTIONS: &[OptionSpec] =
+ &[OptionSpec { name: "--root", takes_value: true }, OptionSpec { name: "--compact", takes_value: false }];
+
+/// The arguments that are not options or their values. Which tokens are values is the spec's
+/// answer: skipping the one after every option made `check-one --edit file.pdf` report "missing
+/// file", the file having been read as `--edit`'s value.
+pub(crate) fn positional<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a str> {
+ let mut out = Vec::new();
+ let mut i = 0;
+ while let Some(a) = args.get(i) {
+ match specs.iter().find(|spec| spec.name == a.as_str()) {
+ Some(spec) if spec.takes_value => i += 2,
+ // An option that takes no value, and an unknown one, which `validate_options` has
+ // already refused by the time this runs.
+ Some(_) => i += 1,
+ None if a.starts_with("--") => i += 1,
+ None => {
+ out.push(a.as_str());
+ i += 1;
+ }
+ }
+ }
+ out
+}
diff --git a/apps/pdfcraft-cli/src/run.rs b/apps/pdfcraft-cli/src/run.rs
new file mode 100644
index 0000000000..19eed21876
--- /dev/null
+++ b/apps/pdfcraft-cli/src/run.rs
@@ -0,0 +1,186 @@
+#[cfg(feature = "mcp")]
+use super::options::MCP_OPTIONS;
+use super::options::{RUN_OPTIONS, flag, positional, validate_options};
+use super::*;
+
+// ---- automation --------------------------------------------------------------------------------
+
+pub(crate) fn automation(args: &[String]) -> Result {
+ let a = pdfcraft_automation::Automation::new();
+ match flag(args, "--root") {
+ Some(root) => a.with_root(root).map_err(|e| format!("--root {root}: {e}")),
+ None => Ok(a),
+ }
+}
+
+pub(crate) fn tools() -> Result<(), CliError> {
+ let list: Vec = pdfcraft_automation::tools()
+ .iter()
+ .map(|t| serde_json::json!({ "name": t.name, "description": t.description, "read_only": t.read_only, "command": t.command, "input_schema": t.input_schema }))
+ .collect();
+ stdout_line(format_args!("{}", serde_json::to_string_pretty(&list).unwrap_or_default()))?;
+ Ok(())
+}
+
+/// Print a tool's result: JSON as JSON; images go to `--out` (or are summarised). The image is
+/// written like a tool's own output, so `--root` confines it too.
+pub(crate) fn print_output(
+ auto: &pdfcraft_automation::Automation,
+ content: Vec,
+ out: Option<&str>,
+) -> Result<(), CliError> {
+ for c in content {
+ match c {
+ pdfcraft_automation::Content::Json(v) => stdout_line(format_args!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()))?,
+ pdfcraft_automation::Content::Png { data, width, height } => match out {
+ Some(path) => {
+ let written = auto.write_output(path, &data).map_err(|e| e.to_string())?;
+ stdout_line(format_args!("{}", serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height })))?;
+ }
+ None => stdout_line(format_args!("{}", png_summary(&data, width, height)))?,
+ },
+ }
+ }
+ Ok(())
+}
+
+/// What a PNG result is reported as when it isn't written to a file.
+fn png_summary(data: &[u8], width: u32, height: u32) -> serde_json::Value {
+ serde_json::json!({ "image": "png", "width": width, "height": height, "bytes": data.len(), "hint": "pass --out file.png to save it" })
+}
+
+/// A tool's result as JSON values, for the `--report` of a script. Same content as
+/// [`print_output`], collected instead of printed.
+fn output_values(
+ auto: &pdfcraft_automation::Automation,
+ content: Vec,
+ out: Option<&str>,
+) -> Result, String> {
+ let mut values = Vec::new();
+ for c in content {
+ values.push(match c {
+ pdfcraft_automation::Content::Json(v) => v,
+ pdfcraft_automation::Content::Png { data, width, height } => match out {
+ Some(path) => {
+ let written = auto.write_output(path, &data).map_err(|e| e.to_string())?;
+ serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height })
+ }
+ None => png_summary(&data, width, height),
+ },
+ });
+ }
+ Ok(values)
+}
+
+/// Run a script and print one JSON document: per step its number, tool and `ok`/`error`/`skipped`
+/// with the result or the message. The first failing step ends the run, as without `--report`;
+/// the steps after it are listed as skipped and the exit status is the same.
+fn run_script_report(auto: &mut pdfcraft_automation::Automation, steps: &[serde_json::Value]) -> Result<(), CliError> {
+ let mut report = Vec::with_capacity(steps.len());
+ let mut failure: Option = None;
+ for (i, step) in steps.iter().enumerate() {
+ let n = i + 1;
+ let tool = step["tool"].as_str();
+ if failure.is_some() {
+ report.push(serde_json::json!({ "step": n, "tool": tool, "status": "skipped" }));
+ continue;
+ }
+ let outcome = match tool {
+ None => Err(format!("step {n}: missing \"tool\"")),
+ Some(t) => auto
+ .call(t, &step["args"])
+ .map_err(|e| e.to_string())
+ .and_then(|content| output_values(auto, content, step["out"].as_str()))
+ .map_err(|e| format!("step {n} ({t}): {e}")),
+ };
+ match outcome {
+ Ok(result) => report.push(serde_json::json!({ "step": n, "tool": tool, "status": "ok", "result": result })),
+ Err(message) => {
+ report.push(serde_json::json!({ "step": n, "tool": tool, "status": "error", "error": message }));
+ failure = Some(message);
+ }
+ }
+ }
+ let document = serde_json::json!({ "ok": failure.is_none(), "steps": report });
+ stdout_line(format_args!("{}", serde_json::to_string_pretty(&document).unwrap_or_default()))?;
+ failure.map_or(Ok(()), |message| Err(CliError::Message(message)))
+}
+
+pub(crate) fn run(args: &[String]) -> Result<(), CliError> {
+ validate_options("run", args, RUN_OPTIONS)?;
+ let report = args.iter().any(|a| a == "--report");
+ if report && flag(args, "--script").is_none() {
+ return Err("run: --report needs --script".to_string().into());
+ }
+ let mut auto = automation(args)?;
+ if let Some(script) = flag(args, "--script") {
+ let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?;
+ let steps: Vec = serde_json::from_str(&text).map_err(|e| format!("{script}: {e}"))?;
+ if report {
+ return run_script_report(&mut auto, &steps);
+ }
+ for (i, step) in steps.iter().enumerate() {
+ let tool = step["tool"].as_str().ok_or(format!("step {}: missing \"tool\"", i + 1))?;
+ let content = auto.call(tool, &step["args"]).map_err(|e| format!("step {} ({tool}): {e}", i + 1))?;
+ print_output(&auto, content, step["out"].as_str()).map_err(|e| match e {
+ CliError::Message(message) => CliError::Message(format!("step {} ({tool}): {message}", i + 1)),
+ stdout => stdout,
+ })?;
+ }
+ return Ok(());
+ }
+ let tool = *positional(args, RUN_OPTIONS).first().ok_or("run: missing tool name (see `pdfcraft-cli tools`)")?;
+ let mut obj = serde_json::Map::new();
+ for kv in positional(args, RUN_OPTIONS).iter().skip(1) {
+ let (k, v) = kv.split_once('=').ok_or(format!("run: expected key=value, got {kv:?}"))?;
+ let value = serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string()));
+ obj.insert(k.to_string(), value);
+ }
+ let content = auto.call(tool, &serde_json::Value::Object(obj)).map_err(|e| e.to_string())?;
+ print_output(&auto, content, flag(args, "--out"))
+}
+
+/// Take `--automation-read-root` / `--automation-write-root` (each `--flag DIR` or `--flag=DIR`,
+/// once) out of `args`, and open the roots, which must be existing folders. Returns the arguments
+/// that are left. A malformed root flag is an error, never a root silently left out.
+#[cfg(feature = "mcp")]
+fn automation_roots(command: &str, args: &[String]) -> Result<(Option>, Vec), String> {
+ let mut found = automation_args::AutomationArgs::new(None, None);
+ let mut left = Vec::new();
+ let mut it = args.iter().cloned();
+ while let Some(arg) = it.next() {
+ if !found.consume(&arg, &mut it).map_err(|e| format!("{command}: {e}"))? {
+ left.push(arg);
+ }
+ }
+ let roots = pdfcraft_automation::file_access::AutomationRoots::new(found.read_root.as_deref(), found.write_root.as_deref())
+ .map_err(|e| format!("{command}: {e}"))?;
+ Ok((roots, left))
+}
+
+#[cfg(feature = "mcp")]
+pub(crate) fn mcp(args: &[String]) -> Result<(), CliError> {
+ let (roots, args) = automation_roots("mcp", args)?;
+ let args = args.as_slice();
+ validate_options("mcp", args, MCP_OPTIONS)?;
+ let compact = args.iter().any(|a| a == "--compact");
+ let mut auto = automation(args)?;
+ if let Some(roots) = roots {
+ let show = |root: Option<&Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
+ let _ = writeln!(
+ std::io::stderr().lock(),
+ "pdfcraft-cli: files confined: read root {}, write root {}",
+ show(roots.read_root()),
+ show(roots.write_root())
+ );
+ auto = auto.with_automation_roots(Some(roots));
+ }
+ let mut server = pdfcraft_automation::mcp::McpServer::new(auto).with_compact(compact);
+ let _ = writeln!(
+ std::io::stderr().lock(),
+ "pdfcraft-cli: MCP server on stdio (protocol {}{}); close stdin to stop",
+ 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/src/ui.rs b/apps/pdfcraft-cli/src/ui.rs
new file mode 100644
index 0000000000..9d12233528
--- /dev/null
+++ b/apps/pdfcraft-cli/src/ui.rs
@@ -0,0 +1,131 @@
+use super::options::{UI_OPTIONS, flag, positional, validate_options};
+use super::*;
+
+// ---- UI control channel client -----------------------------------------------------------------
+
+/// One request to a running app's control channel (`pdfcraft --control FILE`).
+pub(crate) fn ui(args: &[String]) -> Result<(), CliError> {
+ validate_options("ui", args, UI_OPTIONS)?;
+ use std::io::{BufRead, BufReader, Write as _};
+ let file = flag(args, "--control").ok_or("ui: missing --control FILE (start the app with `pdfcraft --control FILE`)")?;
+ let info = read_control_file(file)?;
+ let port = info["port"].as_u64().ok_or(format!("{file}: no port"))?;
+ // Refused rather than truncated, which would quietly reach some other port.
+ let port = u16::try_from(port).map_err(|_| format!("{file}: {port} is not a port number"))?;
+ let token = info["token"].as_str().ok_or(format!("{file}: no token"))?;
+ let pos = positional(args, UI_OPTIONS);
+ let method = *pos.first().ok_or("ui: missing method (state, inspect, click, drag, type, key, command, commands, set, open, screenshot)")?;
+ let method = if method.starts_with("ui.") { method.to_string() } else { format!("ui.{method}") };
+ let mut params = serde_json::Map::new();
+ for kv in pos.iter().skip(1) {
+ let (k, v) = kv.split_once('=').ok_or(format!("ui: expected key=value, got {kv:?}"))?;
+ params.insert(k.to_string(), serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string())));
+ }
+ let stream =
+ std::net::TcpStream::connect(("127.0.0.1", port)).map_err(|e| format!("can't reach the app on port {port}: {e} (is it still running?)"))?;
+ stream.set_read_timeout(Some(Duration::from_secs(40))).map_err(|e| e.to_string())?;
+ let mut write = stream.try_clone().map_err(|e| e.to_string())?;
+ let mut lines = BufReader::new(stream).lines();
+ let mut rpc = |id: u64, method: &str, params: serde_json::Value| -> Result {
+ writeln!(write, "{}", serde_json::json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })).map_err(|e| e.to_string())?;
+ let line = lines.next().ok_or("the app closed the connection")?.map_err(|e| e.to_string())?;
+ let reply: serde_json::Value = serde_json::from_str(&line).map_err(|e| e.to_string())?;
+ match reply.get("error") {
+ Some(e) => Err(e["message"].as_str().unwrap_or("error").to_string()),
+ None => Ok(reply["result"].clone()),
+ }
+ };
+ rpc(1, "auth", serde_json::json!({ "token": token }))?;
+ let mut result = rpc(2, &method, serde_json::Value::Object(params))?;
+ if let (Some(out), Some(data)) = (flag(args, "--out"), result.get("png_base64").and_then(|d| d.as_str())) {
+ use base64::Engine as _;
+ let png = base64::engine::general_purpose::STANDARD.decode(data).map_err(|e| e.to_string())?;
+ std::fs::write(out, png).map_err(|e| format!("{out}: {e}"))?;
+ result["png_base64"] = serde_json::json!(format!("written to {out}"));
+ }
+ stdout_line(format_args!("{}", serde_json::to_string_pretty(&result).unwrap_or_default()))?;
+ Ok(())
+}
+
+/// Most a control file may hold. The app writes under 100 bytes.
+const CONTROL_FILE_MAX: u64 = 64 * 1024;
+
+const CONTROL_FILE_ADVICE: &str =
+ "Start the app with a control file in a folder only you can write, such as `pdfcraft --control ~/.pdfcraft-control.json`";
+
+/// Read the file written by `pdfcraft --control FILE`.
+///
+/// It names the port that receives the token and every command, typed text included, so it must
+/// be the user's own. In a shared folder such as `/tmp`, another local user can create the file
+/// first; the app then can't write it, and the CLI would talk to the other user's listener.
+/// Refused: a symbolic link, anything but a regular file, and on Unix a file owned by someone
+/// else or open to other users (the app writes it with mode 600).
+fn read_control_file(file: &str) -> Result {
+ use std::io::Read as _;
+ let at_path = std::fs::symlink_metadata(file).map_err(|e| format!("{file}: {e}"))?;
+ if at_path.file_type().is_symlink() {
+ return Err(format!("{file}: is a symbolic link, which could point at another user's file. {CONTROL_FILE_ADVICE}"));
+ }
+ if !at_path.is_file() {
+ return Err(format!("{file}: not a regular file. {CONTROL_FILE_ADVICE}"));
+ }
+ let f = std::fs::File::open(file).map_err(|e| format!("{file}: {e}"))?;
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::MetadataExt;
+ // Checked on the open file, so nothing swapped in after `symlink_metadata` gets through.
+ let opened = f.metadata().map_err(|e| format!("{file}: {e}"))?;
+ if (opened.dev(), opened.ino()) != (at_path.dev(), at_path.ino()) {
+ return Err(format!("{file}: was replaced while being opened. {CONTROL_FILE_ADVICE}"));
+ }
+ if let Some(problem) = control_file_problem(opened.uid(), opened.mode(), current_uid()) {
+ return Err(format!("{file}: {problem}. {CONTROL_FILE_ADVICE}"));
+ }
+ }
+ let mut text = String::new();
+ f.take(CONTROL_FILE_MAX.saturating_add(1)).read_to_string(&mut text).map_err(|e| format!("{file}: {e}"))?;
+ if u64::try_from(text.len()).unwrap_or(u64::MAX) > CONTROL_FILE_MAX {
+ return Err(format!("{file}: too large for a control file (over {} KiB)", CONTROL_FILE_MAX / 1024));
+ }
+ serde_json::from_str(&text).map_err(|e| format!("{file}: {e}"))
+}
+
+/// Why user `me` can't trust a control file with this owner and mode, if they can't.
+#[cfg(any(test, unix))]
+fn control_file_problem(owner: u32, mode: u32, me: u32) -> Option {
+ if owner != me {
+ return Some(format!("owned by another user (uid {owner}; you are uid {me}), who may be the one listening for your commands"));
+ }
+ let permissions = mode & 0o7777;
+ (permissions & 0o077 != 0).then(|| format!("other users have access to it (mode {permissions:o}; the app writes it with mode 600)"))
+}
+
+/// The effective user id: the owner of the files this process creates, and so of a control file
+/// the app wrote for this user. std has no `geteuid` and this crate forbids `unsafe`; rustix's is
+/// safe and can't fail.
+#[cfg(unix)]
+fn current_uid() -> u32 {
+ rustix::process::geteuid().as_raw()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::control_file_problem;
+
+ #[test]
+ fn a_control_file_must_be_yours_and_private() {
+ // Regular file type bits (0o100000) don't matter, only the owner and the permission bits.
+ assert_eq!(control_file_problem(1000, 0o100600, 1000), None);
+ assert_eq!(control_file_problem(1000, 0o600, 1000), None);
+ assert_eq!(control_file_problem(1000, 0o400, 1000), None);
+ let other = control_file_problem(1001, 0o600, 1000).unwrap_or_default();
+ assert!(other.contains("owned by another user") && other.contains("1001"), "{other}");
+ // Root gets no exception: a file a user planted is still theirs.
+ assert!(control_file_problem(1000, 0o600, 0).is_some_and(|p| p.contains("owned by another user")));
+ for mode in [0o644, 0o640, 0o604, 0o660, 0o606, 0o620, 0o602, 0o610, 0o601, 0o100666] {
+ let open = control_file_problem(1000, mode, 1000).unwrap_or_default();
+ assert!(open.contains("other users"), "{mode:o}: {open}");
+ }
+ assert!(control_file_problem(1000, 0o100644, 1000).unwrap_or_default().contains("mode 644"));
+ }
+}
diff --git a/apps/pdfcraft-web/Cargo.toml b/apps/pdfcraft-web/Cargo.toml
index a64d729dcd..ad8243d594 100644
--- a/apps/pdfcraft-web/Cargo.toml
+++ b/apps/pdfcraft-web/Cargo.toml
@@ -12,7 +12,7 @@ workspace = true
[dependencies]
pdfcraft-ui-egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "glow", "persistence", "accesskit"] }
-egui = { workspace = true }
+egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen-futures = "0.4"
diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml
index 6943ad6e44..966af354d1 100644
--- a/apps/pdfcraft/Cargo.toml
+++ b/apps/pdfcraft/Cargo.toml
@@ -11,15 +11,13 @@ workspace = true
[dependencies]
# Help ▸ Check for updates (issue #28): one HTTPS request to GitHub, over rustls with the OS's
# trusted roots. No bundled root list (webpki-roots and webpki-root-certs are CDLA-licensed).
-ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "_ring"] }
-rustls-native-certs = "0.8"
pdfcraft-ui-egui = { workspace = true }
# `build_info`: the version and compile-time build label for `--version` and the log.
pdfcraft-engine = { workspace = true }
# 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 }
+eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11", "persistence", "links", "clipboard"] }
+egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
serde_json = { workspace = true }
# Records from every crate go to standard error and the log file (src/logging.rs).
log = { workspace = true }
@@ -31,9 +29,6 @@ chrono = { workspace = true }
# crate registers the handler (Objective-C interop needs unsafe, which our workspace forbids)
# behind a safe API. Same version as PhotoCraft. Pinned exactly: review the diff before bumping.
fmv-macos-events = "=0.1.0"
-# ⌘Q asks about unsaved changes like the close button (#1136): an app menu whose Quit asks the
-# window to close instead of winit's, which ends the process without asking.
-craft-quit = { workspace = true, features = ["menu"] }
[target.'cfg(target_os = "linux")'.dependencies]
# Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback), so the window is
@@ -71,3 +66,6 @@ uds_windows = "=1.2.1"
[build-dependencies]
# Icon + VERSIONINFO for pdfcraft.exe; build.rs only uses it when targeting Windows.
winresource = "0.1"
+
+[target.'cfg(windows)'.dependencies]
+craft-update = { workspace = true }
diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs
index 96725cd8d5..f8e4e5b0d2 100644
--- a/apps/pdfcraft/src/main.rs
+++ b/apps/pdfcraft/src/main.rs
@@ -41,7 +41,6 @@ mod reopen;
mod settings;
mod single_instance;
mod text_scale;
-mod updates;
#[cfg(test)]
#[path = "windows_manifest.rs"]
mod windows_manifest;
@@ -195,6 +194,11 @@ fn rejoin_split_paths(args: Vec, exists: impl Fn(&str) -> bool) -> Vec eframe::Result {
+ // Windows: the update helper is a copy of this program started with `--finish-update`.
+ #[cfg(windows)]
+ if let Some(result) = craft_update::self_update::finish(&craft_update::suite::PDFCRAFT, &std::env::args_os().skip(1).collect::>()) {
+ std::process::exit(i32::from(result.is_err()));
+ }
// First, so the panic hook and every start-up warning are recorded (`logging`).
let logger = logging::install();
// Last-resort guard (AGENTS.md §4): commands, edits, opens and saves catch panics and report
@@ -357,6 +361,9 @@ fn native_options(integrated: bool, app_frame: bool, renderer: eframe::Renderer)
let persistence_path = settings_dir().map(|d| d.join("app.ron"));
// Set explicitly, so the renderer never depends on which one eframe defaults to.
let mut native = eframe::NativeOptions { viewport, persistence_path, renderer, ..Default::default() };
+ // PdfCraft installs its own last-resort system face (`PDFCRAFT_SYSTEM_FONTS`); eframe's
+ // provider would read every installed face, including proprietary ones, into every UI pass.
+ native.system_font_fallback = false;
if renderer == eframe::Renderer::Wgpu {
configure_gpu(&mut native);
}
@@ -416,16 +423,17 @@ fn app_creator<'a>(
{
app.motion.source = Some(motion::source());
}
- app.update_source = Some(std::sync::Arc::new(updates::latest_release));
+ app.set_startup_update_check(true);
app.printer_source = Some(pdfcraft_ui_egui::system_printers());
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));
- // winit's Quit (⌘Q) ends the process without asking about unsaved changes (#1136). This
- // menu's Quit closes the window instead, so `guard_quit` asks, as for the close button.
- if let Err(e) = craft_quit::install_app_menu(&cc.egui_ctx, "PdfCraft") {
- log::warn!("couldn't install the app menu, so ⌘Q quits without asking to save: {e}");
+ // The native menu bar. Its Quit (⌘Q) closes the window rather than ending the process as
+ // winit's does (#1136), so `guard_quit` asks about unsaved changes, as for the close
+ // button.
+ if !app.install_native_menu(&cc.egui_ctx) {
+ log::warn!("couldn't install the macOS menu bar; the menus stay in the window");
}
}
if let Some(server) = instance {
@@ -794,671 +802,4 @@ fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[D
}
#[cfg(test)]
-mod tests {
- use pdfcraft_ui_egui::control::Endpoint;
-
- fn endpoint() -> std::io::Result {
- Ok(Endpoint { port: 4321, token: "0123456789abcdef".into() })
- }
-
- /// Tests that open a real GPU device take this first, so only one of them is inside the
- /// graphics libraries at a time. The test threads otherwise run them together, and on
- /// FreeBSD's CI machine the test process then died with SIGSEGV, in about half the runs:
- /// the first device was still being set up when the second test began its own. The UI
- /// tests take turns the same way (`crates/ui-egui/tests/it/main.rs`). Hold it for the whole
- /// test (declared before the instance, so it's released after the device is dropped).
- static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(());
-
- fn gpu() -> std::sync::MutexGuard<'static, ()> {
- // A test that panicked while holding it leaves nothing to clean up.
- GPU.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
- }
-
- #[test]
- fn a_control_channel_that_cant_be_published_stops_the_app() {
- let dir = std::env::temp_dir().join(format!("pdfcraft-app-control-{}", std::process::id()));
- let _ = std::fs::remove_dir_all(&dir);
- std::fs::create_dir_all(&dir).unwrap();
- let path = |name: &str| dir.join(name).to_str().unwrap().to_owned();
-
- // A folder in the way stands in for a file another user created first: the write fails.
- std::fs::create_dir(dir.join("taken.json")).unwrap();
- let err = super::open_control_channel(&path("taken.json"), endpoint()).unwrap_err();
- assert!(err.contains("--control") && err.contains("taken.json") && err.contains("only you can write"), "{err}");
- // No listener: nothing to publish.
- let err = super::open_control_channel(&path("pc.json"), Err(std::io::Error::other("no loopback"))).unwrap_err();
- assert!(err.contains("no loopback"), "{err}");
- assert!(!dir.join("pc.json").exists());
-
- super::open_control_channel(&path("pc.json"), endpoint()).unwrap();
- let written: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(dir.join("pc.json")).unwrap()).unwrap();
- assert_eq!((written["port"].as_u64(), written["token"].as_str()), (Some(4321), Some("0123456789abcdef")));
- let _ = std::fs::remove_dir_all(&dir);
- }
-
- #[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 unquoted_paths_with_spaces_are_rejoined() {
- let have = ["P:\\Archive\\00042A26 Contract.PDF", "C:\\a b c.pdf", "C:\\a.pdf", "C:\\x y.pdf"];
- let exists = |p: &str| have.contains(&p);
- let args = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>();
- // The case an office program produced: one path, split at its space.
- assert_eq!(
- super::rejoin_split_paths(args(&["P:\\Archive\\00042A26", "Contract.PDF"]), exists),
- args(&["P:\\Archive\\00042A26 Contract.PDF"])
- );
- // The longest run wins; files that exist on their own are left alone.
- assert_eq!(super::rejoin_split_paths(args(&["C:\\a", "b", "c.pdf", "C:\\a.pdf"]), exists), args(&["C:\\a b c.pdf", "C:\\a.pdf"]));
- assert_eq!(super::rejoin_split_paths(args(&["C:\\a.pdf", "C:\\x", "y.pdf"]), exists), args(&["C:\\a.pdf", "C:\\x y.pdf"]));
- // Nothing to join: kept as given, so the missing file is still reported by name.
- assert_eq!(super::rejoin_split_paths(args(&["missing.pdf", "other"]), exists), args(&["missing.pdf", "other"]));
- assert!(super::rejoin_split_paths(Vec::new(), exists).is_empty());
- }
-
- #[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, false, eframe::Renderer::Wgpu).renderer, eframe::Renderer::Wgpu);
- assert_eq!(native_options(false, false, eframe::Renderer::Glow).renderer, eframe::Renderer::Glow);
- }
-
- #[test]
- fn the_desktop_frame_is_turned_off_only_when_the_app_draws_one() {
- use super::native_options;
- assert_eq!(native_options(false, false, eframe::Renderer::Glow).viewport.decorations, None);
- assert_eq!(native_options(false, true, eframe::Renderer::Glow).viewport.decorations, Some(false));
- }
-
- #[test]
- fn a_software_renderer_is_shown_to_the_user() {
- use eframe::wgpu::DeviceType;
- // Issue #519: WARP or llvmpipe is announced in the app, not just in pdfcraft.log.
- let mut app = super::PdfCraftApp::new();
- super::notify_software_renderer(&mut app, DeviceType::Cpu);
- let notice = app.toast.as_ref().map(|(m, _)| m.as_str()).unwrap_or_default();
- assert!(notice.contains("without a graphics processor"), "{notice:?}");
- for gpu in [DeviceType::IntegratedGpu, DeviceType::DiscreteGpu, DeviceType::VirtualGpu, DeviceType::Other] {
- let mut app = super::PdfCraftApp::new();
- super::notify_software_renderer(&mut app, gpu);
- assert!(app.toast.is_none(), "{gpu:?}: {:?}", app.toast);
- }
- }
-
- #[test]
- fn gpu_setup_avoids_vulkan_and_stretched_resizes_on_windows_and_prefers_low_power() {
- let mut native = eframe::NativeOptions::default();
- super::configure_gpu(&mut native);
- let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else {
- panic!("default setup creates its own instance")
- };
- if std::env::var_os("WGPU_POWER_PREF").is_none() {
- assert_eq!(setup.power_preference, eframe::wgpu::PowerPreference::LowPower);
- }
- if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
- let flags = setup.instance_descriptor.flags;
- assert!(!flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL), "Intel Metal can't compile its shader: {flags:?}");
- }
- if cfg!(target_os = "windows") && std::env::var_os("WGPU_BACKEND").is_none() {
- let backends = setup.instance_descriptor.backends;
- assert!(backends.contains(eframe::wgpu::Backends::DX12), "{backends:?}");
- assert!(!backends.contains(eframe::wgpu::Backends::VULKAN), "issue #37: {backends:?}");
- }
- use eframe::wgpu::Dx12SwapchainKind::{DxgiFromHwnd, DxgiFromVisual};
- assert_eq!(super::dx12_presentation(None), DxgiFromVisual);
- assert_eq!(super::dx12_presentation(Some(DxgiFromHwnd)), DxgiFromHwnd, "WGPU_DX12_PRESENTATION_SYSTEM still decides");
- assert_eq!(super::dx12_presentation(Some(DxgiFromVisual)), DxgiFromVisual);
- let presentation = setup.instance_descriptor.backend_options.dx12.presentation_system;
- if cfg!(target_os = "windows") {
- // A composition visual unless WGPU_DX12_PRESENTATION_SYSTEM asks otherwise.
- let asked = eframe::wgpu::Dx12SwapchainKind::from_env();
- assert_eq!(presentation, asked.unwrap_or(DxgiFromVisual), "issue #2738: resizing stretches a window swap chain");
- } else {
- assert_eq!(presentation, eframe::wgpu::Dx12SwapchainKind::default().with_env(), "only Windows changes it");
- }
- }
-
- #[test]
- fn restored_hidpi_window_fits_the_adapters_surface_limit() {
- // The Outlook launch crash: 3440 x 1369 saved points at 250% DPI requested this surface.
- let (width, height) = (8600, 3423);
- let required = eframe::wgpu::Limits::default();
- assert!(width > required.max_texture_dimension_2d);
- let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 16384, ..required.clone() };
- let enabled = super::surface_texture_limits(required, &supported);
- assert!(width <= enabled.max_texture_dimension_2d);
- assert!(height <= enabled.max_texture_dimension_2d);
- }
-
- #[test]
- fn surface_limits_preserve_other_requirements_and_never_overrequest() {
- for required in [eframe::wgpu::Limits::default(), eframe::wgpu::Limits::downlevel_webgl2_defaults()] {
- for extent in [4096, 8192, 16384] {
- let supported = eframe::wgpu::Limits {
- max_texture_dimension_2d: extent,
- max_bind_groups: 8,
- min_uniform_buffer_offset_alignment: 128,
- min_storage_buffer_offset_alignment: 128,
- ..required.clone()
- };
- let enabled = super::surface_texture_limits(required.clone(), &supported);
- let mut expected = required.clone();
- expected.max_texture_dimension_2d = extent;
- assert_eq!(enabled, expected);
- assert_eq!(enabled.max_texture_dimension_2d, supported.max_texture_dimension_2d);
- assert!(enabled.check_limits(&supported));
- }
- }
- }
-
- #[test]
- fn surface_limits_fit_v3d_shader_variable_limit() {
- // Issue #2383: V3D supports 15 variables, below the default request of 16.
- let required = eframe::wgpu::Limits::default();
- let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required.clone() };
- let enabled = super::surface_texture_limits(required.clone(), &supported);
- assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}");
- let expected = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required };
- assert_eq!(enabled, expected);
- }
-
- #[test]
- fn surface_limits_fit_downlevel_maxima_and_minimum_alignments() {
- let required = eframe::wgpu::Limits::downlevel_webgl2_defaults();
- let supported = eframe::wgpu::Limits {
- max_texture_dimension_2d: 2048,
- max_bind_groups: 2,
- max_vertex_attributes: 8,
- min_uniform_buffer_offset_alignment: 512,
- min_storage_buffer_offset_alignment: 512,
- ..required.clone()
- };
- let enabled = super::surface_texture_limits(required, &supported);
- assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}");
- // Larger minimum alignments are weaker capabilities, unlike maximum limits.
- assert_eq!(enabled, supported);
- }
-
- #[test]
- fn a_window_larger_than_the_gpu_limit_gets_a_surface_within_it() {
- use eframe::egui_wgpu::winit::surface_fit;
- // Issue #577: 3440 x 1369 points restored at 250% asked an 8192-pixel device for this.
- let (width, height, scale) = surface_fit(8600, 3423, 8192);
- assert_eq!((width, height), (8192, 3260));
- // Both sides shrink alike and egui draws at that factor: the whole window is drawn.
- assert!((8600.0 * scale - 8192.0).abs() < 0.01, "{scale}");
- assert!((3423.0 * scale - height as f32).abs() < 1.0, "{scale}");
- // Within the limit nothing changes, up to and including the limit itself.
- assert_eq!(surface_fit(8600, 3423, 16384), (8600, 3423, 1.0));
- assert_eq!(surface_fit(8192, 8192, 8192), (8192, 8192, 1.0));
- assert_eq!(surface_fit(0, 0, 8192), (0, 0, 1.0));
- // One side over the limit: the window as it would be stretched across monitors.
- assert_eq!(surface_fit(8193, 600, 8192).0, 8192);
- assert_eq!(surface_fit(600, 8193, 8192).1, 8192);
- }
-
- #[test]
- fn surface_fit_never_empties_or_overflows_a_surface() {
- use eframe::egui_wgpu::winit::surface_fit;
- for (w, h, max) in [
- (1, u32::MAX, 8192),
- (u32::MAX, 1, 2048),
- (u32::MAX, u32::MAX, 16384),
- (0, u32::MAX, 8192),
- (u32::MAX, 0, 8192),
- (5, 7, 0),
- (40_000, 3, 1),
- ] {
- let (fw, fh, scale) = surface_fit(w, h, max);
- assert!(fw <= max.max(1) && fh <= max.max(1), "{w} x {h} in {max}: {fw} x {fh}");
- // A side that wasn't zero stays non-zero (`Surface::configure` rejects an empty one),
- // and a zero side stays zero (egui-wgpu skips configuring it).
- assert_eq!((fw == 0, fh == 0), (w == 0, h == 0), "{w} x {h} in {max}: {fw} x {fh}");
- assert!(scale.is_finite() && scale > 0.0 && scale <= 1.0, "{w} x {h} in {max}: {scale}");
- }
- // The longer side lands exactly on the limit (rounding never leaves it a pixel short),
- // and the shorter side keeps the window's proportions to within a pixel.
- for max in [2048, 8192, 16384] {
- for long in (max + 1..=max * 5).step_by(997).chain([max * 2, max * 4, u32::MAX]) {
- for short in [1, 3, 600, max / 3, max - 1, max, long - 1, long] {
- let (fw, fh, scale) = surface_fit(long, short, max);
- assert_eq!(fw, max, "{long} x {short} in {max}");
- let expected = f64::from(short) * f64::from(max) / f64::from(long);
- assert!((f64::from(fh) - expected).abs() <= 1.0, "{long} x {short} in {max}: {fh}");
- assert_eq!(surface_fit(short, long, max), (fh, fw, scale), "transposed");
- }
- }
- }
- }
-
- #[test]
- fn egui_wgpu_carries_the_surface_size_fit() {
- // Issue #577: egui-wgpu 0.36.2 configures a window's surface at the window's size, and
- // `Surface::configure` panics when that's beyond the device's `max_texture_dimension_2d`
- // (emilk/egui#8361). vendor/egui-wgpu fits it within the limit. A dependency bump that
- // resolves egui-wgpu from crates.io again, or a re-vendored copy without the patch, would
- // bring the crash back: re-apply the patch, or drop the copy once an egui release has a
- // 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 egui_wgpu = lock.split("[[package]]").find(|p| p.contains("\nname = \"egui-wgpu\"\n")).expect("egui-wgpu is in Cargo.lock");
- assert!(!egui_wgpu.contains("\nsource = "), "egui-wgpu must resolve to vendor/egui-wgpu, not:{egui_wgpu}");
- // `surface_fit` is tested above; these keep it in the paths that size a surface.
- let painter = std::fs::read_to_string(root.join("vendor/egui-wgpu/src/winit.rs")).unwrap().replace("\r\n", "\n");
- for patch in [
- "let (width, height, render_scale) = surface_fit(window_width, window_height, max_side);",
- "let (fit_width, fit_height, _) = surface_fit(width, height, self.max_surface_side());",
- "pixels_per_point: pixels_per_point * surface_state.render_scale,",
- "old_state.window_width,\n old_state.window_height,",
- ] {
- assert!(painter.contains(patch), "vendor/egui-wgpu lost its surface size patch: {patch}");
- }
- }
-
- #[test]
- fn a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic() {
- // Issue #519: on a 2015 Intel GPU, wgpu's GL backend couldn't configure the window's
- // surface (`GpuWaitTimeout`), and wgpu's default error handler panicked before the app was
- // created, so PdfCraft never got to retry with OpenGL. vendor/egui-wgpu configures a new
- // window's surface inside `catch_errors`. A real device on PdfCraft's own GPU settings,
- // and an error wgpu raises before anything reaches the driver: a texture one pixel wider
- // than the device allows.
- use eframe::wgpu;
- let _gpu = gpu();
- let native = super::native_options(false, false, eframe::Renderer::Wgpu);
- let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else {
- panic!("default setup creates its own instance")
- };
- let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance());
- let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() };
- // CI runners have a software adapter (WARP, llvmpipe); a machine without any skips.
- let Ok(adapter) = pollster::block_on(instance.request_adapter(&options)) else {
- eprintln!("skipping: no GPU adapter on this machine");
- return;
- };
- let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&(setup.device_descriptor)(&adapter))) else {
- eprintln!("skipping: the adapter gives no device");
- return;
- };
- let texture = |width: u32| {
- device.create_texture(&wgpu::TextureDescriptor {
- label: Some("issue-519"),
- size: wgpu::Extent3d { width, height: 1, depth_or_array_layers: 1 },
- mip_level_count: 1,
- sample_count: 1,
- dimension: wgpu::TextureDimension::D2,
- format: wgpu::TextureFormat::Rgba8Unorm,
- usage: wgpu::TextureUsages::TEXTURE_BINDING,
- view_formats: &[],
- })
- };
- let too_wide = device.limits().max_texture_dimension_2d.saturating_add(1);
- let caught = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(too_wide)));
- assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "{caught:?}");
- // The device stays usable, and nothing is left behind to catch later errors by mistake.
- let fine = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(16)));
- assert!(fine.is_ok(), "{fine:?}");
- assert_eq!(pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || 7)).ok(), Some(7));
- // Nested: the inner call keeps its own error, and the outer one still catches what follows.
- let caught = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || {
- let inner = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(too_wide)));
- assert!(inner.is_err(), "inner: {inner:?}");
- texture(too_wide)
- }));
- assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "outer: {caught:?}");
- }
-
- #[test]
- fn egui_wgpu_returns_a_surface_it_cannot_configure_as_an_error() {
- // Issue #519: `catch_errors` is tested above; this keeps it around the first configure of
- // every new window's surface, and its error reaching eframe as `WgpuError` (eframe then
- // returns `Error::Wgpu`, which `main` retries with OpenGL). Without it a failed configure
- // panics before the app is created, and the OpenGL retry never runs.
- let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
- let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap();
- let egui_wgpu = lock.split("[[package]]").find(|p| p.contains("\nname = \"egui-wgpu\"\n")).expect("egui-wgpu is in Cargo.lock");
- assert!(!egui_wgpu.contains("\nsource = "), "egui-wgpu must resolve to vendor/egui-wgpu, not:{egui_wgpu}");
- let painter = std::fs::read_to_string(root.join("vendor/egui-wgpu/src/winit.rs")).unwrap().replace("\r\n", "\n");
- let add_surface = painter.split("async fn add_surface(").nth(1).and_then(|s| s.split("\n fn ").next()).expect("add_surface");
- for patch in [
- "let installed = catch_errors(&device, || {\n self.install_surface(surface, viewport_id, size.width, size.height, false);\n })",
- "return Err(crate::WgpuError::ConfigureSurface(error));",
- ] {
- assert!(add_surface.contains(patch), "vendor/egui-wgpu lost its surface configure patch: {patch}");
- }
- }
-
- #[test]
- fn pipeline_creation_error_is_returned_and_device_remains_usable() {
- use eframe::{egui_wgpu, wgpu};
- let _gpu = gpu();
- let mut native = super::native_options(false, false, eframe::Renderer::Wgpu);
- let egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") };
- let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance());
- let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() };
- let Ok(adapter) = pollster::block_on(instance.request_adapter(&options)) else {
- assert!(std::env::var_os("PDFCRAFT_TEST_REQUIRE_GPU").is_none(), "a GPU adapter is required for this run");
- eprintln!("skipping pipeline regression: no GPU adapter");
- return;
- };
- let (device, queue) = pollster::block_on(adapter.request_device(&(setup.device_descriptor)(&adapter)))
- .expect("the selected adapter must provide a test device");
- native.wgpu_options.wgpu_setup =
- egui_wgpu::WgpuSetup::Existing(egui_wgpu::WgpuSetupExisting { instance: instance.clone(), adapter, device, queue });
- // Three samples is invalid on every backend, so this reproduces a pipeline error
- // without depending on a particular driver. The unpatched constructor panics.
- for samples in [3, 1, 3, 1] {
- let options = egui_wgpu::RendererOptions { msaa_samples: samples, ..Default::default() };
- let result = pollster::block_on(egui_wgpu::RenderState::create(&native.wgpu_options, &instance, None, options));
- if samples == 3 {
- let error = result.err().expect("invalid pipeline must return an error");
- assert!(error.to_string().contains("egui_pipeline"), "{error}");
- } else {
- assert!(result.is_ok(), "the device must remain usable: {:?}", result.err());
- }
- }
- }
-
- #[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 NVIDIA_3090: (u32, u32) = (0x10de, 0x2204);
- const AMD_IGPU: (u32, u32) = (0x1002, 0x164e);
-
- fn adapters() -> Vec<(u32, u32, eframe::wgpu::DeviceType)> {
- use eframe::wgpu::DeviceType;
- 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, primary: false }
- }
-
- fn panel(pci: (u32, u32)) -> super::DisplayGpu {
- super::DisplayGpu { pci, primary: 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(), &[monitor(NVIDIA)], None), Some(0));
- // Issue #378: the same on Windows, where the Ryzen integrated GPU without a monitor crashed
- // the display driver. Windows also says which display is the primary one.
- assert_eq!(super::pick_adapter(&adapters(), &[panel(NVIDIA)], None), Some(0));
- }
-
- #[test]
- fn pick_adapter_prefers_the_gpu_driving_the_primary_display() {
- // A Windows desktop with a monitor on each GPU: the window opens on the primary display.
- assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), panel(NVIDIA)], None), Some(0));
- assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1));
- }
-
- #[test]
- fn pci_ids_come_from_windows_device_ids() {
- assert_eq!(super::pci_ids("PCI\\VEN_10DE&DEV_2204&SUBSYS_40421458&REV_A1"), Some(NVIDIA_3090));
- assert_eq!(super::pci_ids("PCI\\VEN_1002&DEV_164E&SUBSYS_88771043&REV_C1"), Some(AMD_IGPU));
- assert_eq!(super::pci_ids("pci\\ven_1002&dev_164e"), Some(AMD_IGPU));
- // Remote Desktop and other non-PCI display devices, and malformed ids.
- assert_eq!(super::pci_ids("ROOT\\BasicDisplay\\0000"), None);
- assert_eq!(super::pci_ids("PCI\\VEN_10DE&SUBSYS_40421458"), None);
- assert_eq!(super::pci_ids("PCI\\VEN_10DE&DEV_ZZZZ"), None);
- assert_eq!(super::pci_ids("VEN_&DEV_"), None);
- assert_eq!(super::pci_ids(""), None);
- }
-
- #[cfg(target_os = "windows")]
- #[test]
- fn windows_display_gpus_lists_pci_gpus_with_a_monitor() {
- // Whatever this machine has: every entry is a PCI GPU, at most one drives the primary
- // display, and no GPU is listed twice. (A headless CI runner may list none.)
- let gpus = super::windows_display_gpus();
- assert!(gpus.iter().filter(|g| g.primary).count() <= 1, "{gpus:?}");
- for (i, g) in gpus.iter().enumerate() {
- assert!(g.pci.0 != 0, "{gpus:?}");
- assert!(!gpus[..i].iter().any(|h| h.pci == g.pci), "{gpus:?}");
- }
- }
-
- #[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(), &[monitor(NVIDIA), panel(AMD_IGPU)], None), Some(1));
- assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1));
- assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], None), Some(1));
- assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU)], None), 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)], None), Some(1));
- assert_eq!(super::pick_adapter(&desktop, &[monitor(NVIDIA), monitor(INTEL_IGPU)], None), 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)], None), Some(1));
- }
-
- #[test]
- fn pick_adapter_prefers_the_gpu_the_compositor_draws_with() {
- // A hybrid laptop whose Wayland compositor runs on the discrete GPU (Hyprland with
- // `AQ_DRM_DEVICES` naming it first): the integrated GPU drives the built-in panel, yet the
- // compositor shows nothing it draws, and reports no error either.
- assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some(NVIDIA)), Some(0));
- assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(NVIDIA)), Some(0));
- // The usual hybrid laptop, with the compositor on the integrated GPU: as before (#8).
- assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(AMD_IGPU)), Some(1));
- // No GPU drives a display as far as sysfs says (a container), but the compositor knows.
- assert_eq!(super::pick_adapter(&adapters(), &[], Some(NVIDIA)), Some(0));
- // A compositor GPU no adapter matches (a virtual one) leaves the displays in charge.
- assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some((0x1af4, 0x1050))), Some(1));
- }
-
- #[test]
- fn pick_adapter_falls_back_to_low_power_without_a_match() {
- assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234))], None), Some(1));
- assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234)), monitor((0x8086, 0x5678))], None), Some(1));
- assert_eq!(super::pick_adapter(&[], &[monitor(NVIDIA)], None), 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"))?;
- std::fs::write(dir.join(name).join("device/device"), format!("{device:#06x}\n"))
- };
- let connector = |name: &str, status: &str| -> std::io::Result<()> {
- std::fs::create_dir_all(dir.join(name))?;
- std::fs::write(dir.join(name).join("status"), format!("{status}\n"))
- };
- card("card1", NVIDIA)?;
- card("card2", AMD_IGPU)?;
- connector("card1-DP-3", "connected")?;
- connector("card1-DP-4", "connected")?;
- connector("card2-HDMI-A-1", "disconnected")?;
- connector("card2-Writeback-1", "unknown")?;
- 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!(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);
- }
-
- /// X11 startup notification and docks match a window to its launcher by `StartupWMClass`,
- /// so it has to be the class the window carries: the app id, on Wayland and X11 alike.
- #[test]
- fn the_desktop_entry_names_the_window_class() {
- let entry = include_str!("../../../packaging/linux/ai.storyteller.pdfcraft.desktop");
- let class = entry.lines().find_map(|line| line.strip_prefix("StartupWMClass="));
- assert_eq!(class, Some(super::APP_ID));
- }
-
- // Regression test for #246: `eprintln!` panics when standard error is a broken pipe (a GUI
- // launch on Windows), and a panic inside the panic hook aborts the process. The start-up
- // path and the hook must write to standard error without it.
- #[test]
- fn start_up_and_the_panic_hook_never_use_eprintln() {
- let source = include_str!("main.rs");
- // Everything before the top-level test module is product code.
- let product = source.split("\n#[cfg(test)]\nmod tests {").next().unwrap_or(source);
- assert!(product.len() < source.len(), "found the end of the product code");
- let offenders: Vec<_> = product
- .lines()
- .enumerate()
- .filter(|(_, line)| !line.trim_start().starts_with("//") && line.contains("eprintln!"))
- .map(|(n, line)| format!("main.rs:{}: {}", n + 1, line.trim()))
- .collect();
- assert!(
- offenders.is_empty(),
- "`eprintln!` panics on a broken standard error pipe; use `log::warn!` or `write_fmt` on `stderr()` and ignore the error:\n{}",
- offenders.join("\n")
- );
- }
-}
+mod tests;
diff --git a/apps/pdfcraft/src/tests.rs b/apps/pdfcraft/src/tests.rs
new file mode 100644
index 0000000000..c6e6f36518
--- /dev/null
+++ b/apps/pdfcraft/src/tests.rs
@@ -0,0 +1,686 @@
+/// The lockfile package block for `name`, or a panic naming the missing package.
+fn locked_package(root: &std::path::Path, name: &str) -> String {
+ let lock = std::fs::read_to_string(root.join("Cargo.lock")).expect("Cargo.lock");
+ lock.split("[[package]]")
+ .find(|p| p.contains(&format!("\nname = \"{name}\"\n")))
+ .map(str::to_owned)
+ .unwrap_or_else(|| panic!("{name} is not in Cargo.lock"))
+}
+
+/// Interim graph check for the window stack while the reviewed fork revision is not yet
+/// published. The old `vendor/winit` / `vendor/egui-wgpu` path patches were removed from the
+/// published manifest; local integration supplies the reviewed worktrees through an external
+/// Cargo config patch. A git source must name the public storytold fork; a path source is the
+/// local integration graph. Behaviour (surface fitting, error scopes, scroll phases) is tested
+/// directly elsewhere; the retained vendor copies keep provenance until the fork revision is
+/// pinned. No public revision is invented here.
+fn assert_window_stack_graph(root: &std::path::Path, name: &str, version: &str) -> String {
+ let package = locked_package(root, name);
+ assert!(package.contains(&format!("\nversion = \"{version}\"\n")), "{name} must resolve to {version}: {package}");
+ if let Some(source) = package.lines().find_map(|line| line.strip_prefix("source = ")) {
+ assert!(source.contains("github.com/storytold/"), "{name} must come from the reviewed storytold fork once published, not {source}");
+ assert!(
+ source.split(|c: char| !c.is_ascii_hexdigit()).any(|part| part.len() == 40),
+ "{name} must pin the fork's 40-character revision, not {source}"
+ );
+ }
+ // A path patch has no `source =` in Cargo.lock. The local integration config is the only
+ // allowed path source; a machine-local path must never be in the published manifest,
+ // which the root-manifest assertion below checks. A path entry carries no remote identity
+ // to verify, so this test does not claim the resolved code is the published fork.
+ let manifest = std::fs::read_to_string(root.join("Cargo.toml")).expect("root Cargo.toml");
+ assert!(!manifest.contains(&format!("{name} = {{ path = \"vendor/")), "vendor/{name} must not be a published path patch");
+ package
+}
+
+use pdfcraft_ui_egui::control::Endpoint;
+
+fn endpoint() -> std::io::Result {
+ Ok(Endpoint { port: 4321, token: "0123456789abcdef".into() })
+}
+
+/// Tests that open a real GPU device take this first, so only one of them is inside the
+/// graphics libraries at a time. The test threads otherwise run them together, and on
+/// FreeBSD's CI machine the test process then died with SIGSEGV, in about half the runs:
+/// the first device was still being set up when the second test began its own. The UI
+/// tests take turns the same way (`crates/ui-egui/tests/it/main.rs`). Hold it for the whole
+/// test (declared before the instance, so it's released after the device is dropped).
+static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(());
+
+fn gpu() -> std::sync::MutexGuard<'static, ()> {
+ // A test that panicked while holding it leaves nothing to clean up.
+ GPU.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
+}
+
+#[test]
+fn a_control_channel_that_cant_be_published_stops_the_app() {
+ let dir = std::env::temp_dir().join(format!("pdfcraft-app-control-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = |name: &str| dir.join(name).to_str().unwrap().to_owned();
+
+ // A folder in the way stands in for a file another user created first: the write fails.
+ std::fs::create_dir(dir.join("taken.json")).unwrap();
+ let err = super::open_control_channel(&path("taken.json"), endpoint()).unwrap_err();
+ assert!(err.contains("--control") && err.contains("taken.json") && err.contains("only you can write"), "{err}");
+ // No listener: nothing to publish.
+ let err = super::open_control_channel(&path("pc.json"), Err(std::io::Error::other("no loopback"))).unwrap_err();
+ assert!(err.contains("no loopback"), "{err}");
+ assert!(!dir.join("pc.json").exists());
+
+ super::open_control_channel(&path("pc.json"), endpoint()).unwrap();
+ let written: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(dir.join("pc.json")).unwrap()).unwrap();
+ assert_eq!((written["port"].as_u64(), written["token"].as_str()), (Some(4321), Some("0123456789abcdef")));
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[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 unquoted_paths_with_spaces_are_rejoined() {
+ let have = ["P:\\Archive\\00042A26 Contract.PDF", "C:\\a b c.pdf", "C:\\a.pdf", "C:\\x y.pdf"];
+ let exists = |p: &str| have.contains(&p);
+ let args = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>();
+ // The case an office program produced: one path, split at its space.
+ assert_eq!(super::rejoin_split_paths(args(&["P:\\Archive\\00042A26", "Contract.PDF"]), exists), args(&["P:\\Archive\\00042A26 Contract.PDF"]));
+ // The longest run wins; files that exist on their own are left alone.
+ assert_eq!(super::rejoin_split_paths(args(&["C:\\a", "b", "c.pdf", "C:\\a.pdf"]), exists), args(&["C:\\a b c.pdf", "C:\\a.pdf"]));
+ assert_eq!(super::rejoin_split_paths(args(&["C:\\a.pdf", "C:\\x", "y.pdf"]), exists), args(&["C:\\a.pdf", "C:\\x y.pdf"]));
+ // Nothing to join: kept as given, so the missing file is still reported by name.
+ assert_eq!(super::rejoin_split_paths(args(&["missing.pdf", "other"]), exists), args(&["missing.pdf", "other"]));
+ assert!(super::rejoin_split_paths(Vec::new(), exists).is_empty());
+}
+
+#[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, false, eframe::Renderer::Wgpu).renderer, eframe::Renderer::Wgpu);
+ assert_eq!(native_options(false, false, eframe::Renderer::Glow).renderer, eframe::Renderer::Glow);
+}
+
+#[test]
+fn the_desktop_frame_is_turned_off_only_when_the_app_draws_one() {
+ use super::native_options;
+ assert_eq!(native_options(false, false, eframe::Renderer::Glow).viewport.decorations, None);
+ assert_eq!(native_options(false, true, eframe::Renderer::Glow).viewport.decorations, Some(false));
+}
+
+#[test]
+fn a_software_renderer_is_shown_to_the_user() {
+ use eframe::wgpu::DeviceType;
+ // Issue #519: WARP or llvmpipe is announced in the app, not just in pdfcraft.log.
+ let mut app = super::PdfCraftApp::new();
+ super::notify_software_renderer(&mut app, DeviceType::Cpu);
+ let notice = app.toast.as_ref().map(|(m, _)| m.as_str()).unwrap_or_default();
+ assert!(notice.contains("without a graphics processor"), "{notice:?}");
+ for gpu in [DeviceType::IntegratedGpu, DeviceType::DiscreteGpu, DeviceType::VirtualGpu, DeviceType::Other] {
+ let mut app = super::PdfCraftApp::new();
+ super::notify_software_renderer(&mut app, gpu);
+ assert!(app.toast.is_none(), "{gpu:?}: {:?}", app.toast);
+ }
+}
+
+#[test]
+fn gpu_setup_avoids_vulkan_and_stretched_resizes_on_windows_and_prefers_low_power() {
+ let mut native = eframe::NativeOptions::default();
+ super::configure_gpu(&mut native);
+ let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") };
+ if std::env::var_os("WGPU_POWER_PREF").is_none() {
+ assert_eq!(setup.power_preference, eframe::wgpu::PowerPreference::LowPower);
+ }
+ if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
+ let flags = setup.instance_descriptor.flags;
+ assert!(!flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL), "Intel Metal can't compile its shader: {flags:?}");
+ }
+ if cfg!(target_os = "windows") && std::env::var_os("WGPU_BACKEND").is_none() {
+ let backends = setup.instance_descriptor.backends;
+ assert!(backends.contains(eframe::wgpu::Backends::DX12), "{backends:?}");
+ assert!(!backends.contains(eframe::wgpu::Backends::VULKAN), "issue #37: {backends:?}");
+ }
+ use eframe::wgpu::Dx12SwapchainKind::{DxgiFromHwnd, DxgiFromVisual};
+ assert_eq!(super::dx12_presentation(None), DxgiFromVisual);
+ assert_eq!(super::dx12_presentation(Some(DxgiFromHwnd)), DxgiFromHwnd, "WGPU_DX12_PRESENTATION_SYSTEM still decides");
+ assert_eq!(super::dx12_presentation(Some(DxgiFromVisual)), DxgiFromVisual);
+ let presentation = setup.instance_descriptor.backend_options.dx12.presentation_system;
+ if cfg!(target_os = "windows") {
+ // A composition visual unless WGPU_DX12_PRESENTATION_SYSTEM asks otherwise.
+ let asked = eframe::wgpu::Dx12SwapchainKind::from_env();
+ assert_eq!(presentation, asked.unwrap_or(DxgiFromVisual), "issue #2738: resizing stretches a window swap chain");
+ } else {
+ assert_eq!(presentation, eframe::wgpu::Dx12SwapchainKind::default().with_env(), "only Windows changes it");
+ }
+}
+
+#[test]
+fn restored_hidpi_window_fits_the_adapters_surface_limit() {
+ // The Outlook launch crash: 3440 x 1369 saved points at 250% DPI requested this surface.
+ let (width, height) = (8600, 3423);
+ let required = eframe::wgpu::Limits::default();
+ assert!(width > required.max_texture_dimension_2d);
+ let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 16384, ..required.clone() };
+ let enabled = super::surface_texture_limits(required, &supported);
+ assert!(width <= enabled.max_texture_dimension_2d);
+ assert!(height <= enabled.max_texture_dimension_2d);
+}
+
+#[test]
+fn surface_limits_preserve_other_requirements_and_never_overrequest() {
+ for required in [eframe::wgpu::Limits::default(), eframe::wgpu::Limits::downlevel_webgl2_defaults()] {
+ for extent in [4096, 8192, 16384] {
+ let supported = eframe::wgpu::Limits {
+ max_texture_dimension_2d: extent,
+ max_bind_groups: 8,
+ min_uniform_buffer_offset_alignment: 128,
+ min_storage_buffer_offset_alignment: 128,
+ ..required.clone()
+ };
+ let enabled = super::surface_texture_limits(required.clone(), &supported);
+ let mut expected = required.clone();
+ expected.max_texture_dimension_2d = extent;
+ assert_eq!(enabled, expected);
+ assert_eq!(enabled.max_texture_dimension_2d, supported.max_texture_dimension_2d);
+ assert!(enabled.check_limits(&supported));
+ }
+ }
+}
+
+#[test]
+fn surface_limits_fit_v3d_shader_variable_limit() {
+ // Issue #2383: V3D supports 15 variables, below the default request of 16.
+ let required = eframe::wgpu::Limits::default();
+ let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required.clone() };
+ let enabled = super::surface_texture_limits(required.clone(), &supported);
+ assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}");
+ let expected = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required };
+ assert_eq!(enabled, expected);
+}
+
+#[test]
+fn surface_limits_fit_downlevel_maxima_and_minimum_alignments() {
+ let required = eframe::wgpu::Limits::downlevel_webgl2_defaults();
+ let supported = eframe::wgpu::Limits {
+ max_texture_dimension_2d: 2048,
+ max_bind_groups: 2,
+ max_vertex_attributes: 8,
+ min_uniform_buffer_offset_alignment: 512,
+ min_storage_buffer_offset_alignment: 512,
+ ..required.clone()
+ };
+ let enabled = super::surface_texture_limits(required, &supported);
+ assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}");
+ // Larger minimum alignments are weaker capabilities, unlike maximum limits.
+ assert_eq!(enabled, supported);
+}
+
+#[test]
+fn a_window_larger_than_the_gpu_limit_gets_a_surface_within_it() {
+ use eframe::egui_wgpu::winit::surface_fit;
+ // Issue #577: 3440 x 1369 points restored at 250% asked an 8192-pixel device for this.
+ let (width, height, scale) = surface_fit(8600, 3423, 8192);
+ assert_eq!((width, height), (8192, 3260));
+ // Both sides shrink alike and egui draws at that factor: the whole window is drawn.
+ assert!((8600.0 * scale - 8192.0).abs() < 0.01, "{scale}");
+ assert!((3423.0 * scale - height as f32).abs() < 1.0, "{scale}");
+ // Within the limit nothing changes, up to and including the limit itself.
+ assert_eq!(surface_fit(8600, 3423, 16384), (8600, 3423, 1.0));
+ assert_eq!(surface_fit(8192, 8192, 8192), (8192, 8192, 1.0));
+ assert_eq!(surface_fit(0, 0, 8192), (0, 0, 1.0));
+ // One side over the limit: the window as it would be stretched across monitors.
+ assert_eq!(surface_fit(8193, 600, 8192).0, 8192);
+ assert_eq!(surface_fit(600, 8193, 8192).1, 8192);
+}
+
+#[test]
+fn surface_fit_never_empties_or_overflows_a_surface() {
+ use eframe::egui_wgpu::winit::surface_fit;
+ for (w, h, max) in
+ [(1, u32::MAX, 8192), (u32::MAX, 1, 2048), (u32::MAX, u32::MAX, 16384), (0, u32::MAX, 8192), (u32::MAX, 0, 8192), (5, 7, 0), (40_000, 3, 1)]
+ {
+ let (fw, fh, scale) = surface_fit(w, h, max);
+ assert!(fw <= max.max(1) && fh <= max.max(1), "{w} x {h} in {max}: {fw} x {fh}");
+ // A side that wasn't zero stays non-zero (`Surface::configure` rejects an empty one),
+ // and a zero side stays zero (egui-wgpu skips configuring it).
+ assert_eq!((fw == 0, fh == 0), (w == 0, h == 0), "{w} x {h} in {max}: {fw} x {fh}");
+ assert!(scale.is_finite() && scale > 0.0 && scale <= 1.0, "{w} x {h} in {max}: {scale}");
+ }
+ // The longer side lands exactly on the limit (rounding never leaves it a pixel short),
+ // and the shorter side keeps the window's proportions to within a pixel.
+ for max in [2048, 8192, 16384] {
+ for long in (max + 1..=max * 5).step_by(997).chain([max * 2, max * 4, u32::MAX]) {
+ for short in [1, 3, 600, max / 3, max - 1, max, long - 1, long] {
+ let (fw, fh, scale) = surface_fit(long, short, max);
+ assert_eq!(fw, max, "{long} x {short} in {max}");
+ let expected = f64::from(short) * f64::from(max) / f64::from(long);
+ assert!((f64::from(fh) - expected).abs() <= 1.0, "{long} x {short} in {max}: {fh}");
+ assert_eq!(surface_fit(short, long, max), (fh, fw, scale), "transposed");
+ }
+ }
+ }
+}
+
+/// Regression test: the lockfile resolves egui-wgpu 0.37.0 through an allowed source, the root manifest
+/// does not path-patch the vendor copy, and that copy stays as provenance for the surface-fit patch.
+#[test]
+fn vendor_provenance_egui_wgpu_surface_fit_identity() {
+ // Issue #577: egui-wgpu configured a window's surface at the window's size, and
+ // `Surface::configure` panics when that's beyond the device's `max_texture_dimension_2d`
+ // (emilk/egui#8361). The reviewed egui 0.37 worktree carries E2 (`surface_fit`); the
+ // behavioural test above exercises it. The old 0.36 vendor copy is retained for
+ // provenance only and is no longer a published path patch, so reading it would not prove
+ // what the app runs.
+ let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
+ assert_window_stack_graph(&root, "egui-wgpu", "0.37.0");
+ assert!(root.join("vendor/egui-wgpu/src/winit.rs").exists(), "the vendor copy and its provenance stay until the fork revision is pinned");
+}
+
+#[test]
+fn a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic() {
+ // Issue #519: on a 2015 Intel GPU, wgpu's GL backend couldn't configure the window's
+ // surface (`GpuWaitTimeout`), and wgpu's default error handler panicked before the app was
+ // created, so PdfCraft never got to retry with OpenGL. vendor/egui-wgpu configures a new
+ // window's surface inside `catch_errors`. A real device on PdfCraft's own GPU settings,
+ // and an error wgpu raises before anything reaches the driver: a texture one pixel wider
+ // than the device allows.
+ use eframe::wgpu;
+ let _gpu = gpu();
+ let native = super::native_options(false, false, eframe::Renderer::Wgpu);
+ let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") };
+ let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance());
+ let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() };
+ // CI runners have a software adapter (WARP, llvmpipe); a machine without any skips.
+ let Ok(adapter) = pollster::block_on(instance.request_adapter(&options)) else {
+ eprintln!("skipping: no GPU adapter on this machine");
+ return;
+ };
+ let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&(setup.device_descriptor)(&adapter))) else {
+ eprintln!("skipping: the adapter gives no device");
+ return;
+ };
+ let texture = |width: u32| {
+ device.create_texture(&wgpu::TextureDescriptor {
+ label: Some("issue-519"),
+ size: wgpu::Extent3d { width, height: 1, depth_or_array_layers: 1 },
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension: wgpu::TextureDimension::D2,
+ format: wgpu::TextureFormat::Rgba8Unorm,
+ usage: wgpu::TextureUsages::TEXTURE_BINDING,
+ view_formats: &[],
+ })
+ };
+ let too_wide = device.limits().max_texture_dimension_2d.saturating_add(1);
+ let caught = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(too_wide)));
+ assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "{caught:?}");
+ // The device stays usable, and nothing is left behind to catch later errors by mistake.
+ let fine = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(16)));
+ assert!(fine.is_ok(), "{fine:?}");
+ assert_eq!(pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || 7)).ok(), Some(7));
+ // Nested: the inner call keeps its own error, and the outer one still catches what follows.
+ let caught = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || {
+ let inner = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(too_wide)));
+ assert!(inner.is_err(), "inner: {inner:?}");
+ texture(too_wide)
+ }));
+ assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "outer: {caught:?}");
+}
+
+/// Regression test: the lockfile resolves egui-wgpu 0.37.0 through an allowed source, the root manifest
+/// does not path-patch the vendor copy, and that copy stays as provenance for the catch-errors patch.
+#[test]
+fn vendor_provenance_egui_wgpu_catch_errors_identity() {
+ // Issue #519: E3 (`catch_errors`) is tested above against a real device. The reviewed 0.37
+ // worktree carries it around the first configure of every new window's surface, and its
+ // error reaches eframe as `WgpuError` (eframe then returns `Error::Wgpu`, which `main`
+ // retries with OpenGL). Without it a failed configure panics before the app is created.
+ // `catch_errors` is exercised against a real device in the test above and below; the
+ // graph check makes sure the app runs the reviewed 0.37 worktree (the retained vendor
+ // copy is not read as proof of behaviour).
+ let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
+ assert_window_stack_graph(&root, "egui-wgpu", "0.37.0");
+ assert!(root.join("vendor/egui-wgpu/src/winit.rs").exists(), "the vendor copy and its provenance stay until the fork revision is pinned");
+}
+
+#[test]
+fn pipeline_creation_error_is_returned_and_device_remains_usable() {
+ use eframe::{egui_wgpu, wgpu};
+ let _gpu = gpu();
+ let mut native = super::native_options(false, false, eframe::Renderer::Wgpu);
+ let egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") };
+ let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance());
+ let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() };
+ let Ok(adapter) = pollster::block_on(instance.request_adapter(&options)) else {
+ assert!(std::env::var_os("PDFCRAFT_TEST_REQUIRE_GPU").is_none(), "a GPU adapter is required for this run");
+ eprintln!("skipping pipeline regression: no GPU adapter");
+ return;
+ };
+ let (device, queue) =
+ pollster::block_on(adapter.request_device(&(setup.device_descriptor)(&adapter))).expect("the selected adapter must provide a test device");
+ native.wgpu_options.wgpu_setup =
+ egui_wgpu::WgpuSetup::Existing(egui_wgpu::WgpuSetupExisting { instance: instance.clone(), adapter, device, queue });
+ // Three samples is invalid on every backend, so this reproduces a pipeline error
+ // without depending on a particular driver. The unpatched constructor panics.
+ for samples in [3, 1, 3, 1] {
+ let options = egui_wgpu::RendererOptions { msaa_samples: samples, ..Default::default() };
+ let result = pollster::block_on(egui_wgpu::RenderState::create(&native.wgpu_options, &instance, None, options));
+ if samples == 3 {
+ let error = result.err().expect("invalid pipeline must return an error");
+ assert!(error.to_string().contains("egui_pipeline"), "{error}");
+ } else {
+ assert!(result.is_ok(), "the device must remain usable: {:?}", result.err());
+ }
+ }
+}
+
+/// Regression test: winit resolves to 0.30.13 without a published vendor path patch, the retained copy
+/// still carries the W1 Windows 11 monitor-scale fix, and its upstream base is recorded.
+#[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).
+ // W1 (and W2–W13) were audited against the reviewed winit worktree: the vendor rows in
+ // `vendor/README.md` match the fork's PATCHES.md entries W1–W13. The vendor source is no
+ // longer a published path patch, so this checks the actual graph, not the old copy.
+ let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
+ assert_window_stack_graph(&root, "winit", "0.30.13");
+ let event_loop = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/windows/event_loop.rs")).unwrap();
+ // The audited W1 hunk itself, not merely the file's presence: Windows 11 applies the
+ // rect Windows suggests, while Windows 10 keeps the old adjustment.
+ for marker in [
+ "PdfCraft patch: from Windows 11 (build 22000) on, apply the rect Windows suggests",
+ "if !WIN10_BUILD_VERSION.is_some_and(|build| build < 22000) {",
+ "new_outer_rect = suggested_rect;",
+ ] {
+ assert!(event_loop.contains(marker), "vendor/winit lost the W1 monitor-scale patch: {marker}");
+ }
+ let provenance = std::fs::read_to_string(root.join("vendor/winit/.cargo_vcs_info.json")).unwrap();
+ assert!(provenance.contains("e9809ef54b18499bb4f2cac945719ecc2a61061b"), "vendor/winit keeps its upstream base provenance");
+}
+
+const NVIDIA: (u32, u32) = (0x10de, 0x2684);
+const NVIDIA_3090: (u32, u32) = (0x10de, 0x2204);
+const AMD_IGPU: (u32, u32) = (0x1002, 0x164e);
+
+fn adapters() -> Vec<(u32, u32, eframe::wgpu::DeviceType)> {
+ use eframe::wgpu::DeviceType;
+ 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, primary: false }
+}
+
+fn panel(pci: (u32, u32)) -> super::DisplayGpu {
+ super::DisplayGpu { pci, primary: 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(), &[monitor(NVIDIA)], None), Some(0));
+ // Issue #378: the same on Windows, where the Ryzen integrated GPU without a monitor crashed
+ // the display driver. Windows also says which display is the primary one.
+ assert_eq!(super::pick_adapter(&adapters(), &[panel(NVIDIA)], None), Some(0));
+}
+
+#[test]
+fn pick_adapter_prefers_the_gpu_driving_the_primary_display() {
+ // A Windows desktop with a monitor on each GPU: the window opens on the primary display.
+ assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), panel(NVIDIA)], None), Some(0));
+ assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1));
+}
+
+#[test]
+fn pci_ids_come_from_windows_device_ids() {
+ assert_eq!(super::pci_ids("PCI\\VEN_10DE&DEV_2204&SUBSYS_40421458&REV_A1"), Some(NVIDIA_3090));
+ assert_eq!(super::pci_ids("PCI\\VEN_1002&DEV_164E&SUBSYS_88771043&REV_C1"), Some(AMD_IGPU));
+ assert_eq!(super::pci_ids("pci\\ven_1002&dev_164e"), Some(AMD_IGPU));
+ // Remote Desktop and other non-PCI display devices, and malformed ids.
+ assert_eq!(super::pci_ids("ROOT\\BasicDisplay\\0000"), None);
+ assert_eq!(super::pci_ids("PCI\\VEN_10DE&SUBSYS_40421458"), None);
+ assert_eq!(super::pci_ids("PCI\\VEN_10DE&DEV_ZZZZ"), None);
+ assert_eq!(super::pci_ids("VEN_&DEV_"), None);
+ assert_eq!(super::pci_ids(""), None);
+}
+
+#[cfg(target_os = "windows")]
+#[test]
+fn windows_display_gpus_lists_pci_gpus_with_a_monitor() {
+ // Whatever this machine has: every entry is a PCI GPU, at most one drives the primary
+ // display, and no GPU is listed twice. (A headless CI runner may list none.)
+ let gpus = super::windows_display_gpus();
+ assert!(gpus.iter().filter(|g| g.primary).count() <= 1, "{gpus:?}");
+ for (i, g) in gpus.iter().enumerate() {
+ assert!(g.pci.0 != 0, "{gpus:?}");
+ assert!(!gpus[..i].iter().any(|h| h.pci == g.pci), "{gpus:?}");
+ }
+}
+
+#[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(), &[monitor(NVIDIA), panel(AMD_IGPU)], None), Some(1));
+ assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1));
+ assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], None), Some(1));
+ assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU)], None), 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)], None), Some(1));
+ assert_eq!(super::pick_adapter(&desktop, &[monitor(NVIDIA), monitor(INTEL_IGPU)], None), 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)], None), Some(1));
+}
+
+#[test]
+fn pick_adapter_prefers_the_gpu_the_compositor_draws_with() {
+ // A hybrid laptop whose Wayland compositor runs on the discrete GPU (Hyprland with
+ // `AQ_DRM_DEVICES` naming it first): the integrated GPU drives the built-in panel, yet the
+ // compositor shows nothing it draws, and reports no error either.
+ assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some(NVIDIA)), Some(0));
+ assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(NVIDIA)), Some(0));
+ // The usual hybrid laptop, with the compositor on the integrated GPU: as before (#8).
+ assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(AMD_IGPU)), Some(1));
+ // No GPU drives a display as far as sysfs says (a container), but the compositor knows.
+ assert_eq!(super::pick_adapter(&adapters(), &[], Some(NVIDIA)), Some(0));
+ // A compositor GPU no adapter matches (a virtual one) leaves the displays in charge.
+ assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some((0x1af4, 0x1050))), Some(1));
+}
+
+#[test]
+fn pick_adapter_falls_back_to_low_power_without_a_match() {
+ assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234))], None), Some(1));
+ assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234)), monitor((0x8086, 0x5678))], None), Some(1));
+ assert_eq!(super::pick_adapter(&[], &[monitor(NVIDIA)], None), 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"))?;
+ std::fs::write(dir.join(name).join("device/device"), format!("{device:#06x}\n"))
+ };
+ let connector = |name: &str, status: &str| -> std::io::Result<()> {
+ std::fs::create_dir_all(dir.join(name))?;
+ std::fs::write(dir.join(name).join("status"), format!("{status}\n"))
+ };
+ card("card1", NVIDIA)?;
+ card("card2", AMD_IGPU)?;
+ connector("card1-DP-3", "connected")?;
+ connector("card1-DP-4", "connected")?;
+ connector("card2-HDMI-A-1", "disconnected")?;
+ connector("card2-Writeback-1", "unknown")?;
+ 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!(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);
+}
+
+/// X11 startup notification and docks match a window to its launcher by `StartupWMClass`,
+/// so it has to be the class the window carries: the app id, on Wayland and X11 alike.
+#[test]
+fn the_desktop_entry_names_the_window_class() {
+ let entry = include_str!("../../../packaging/linux/ai.storyteller.pdfcraft.desktop");
+ let class = entry.lines().find_map(|line| line.strip_prefix("StartupWMClass="));
+ assert_eq!(class, Some(super::APP_ID));
+}
+
+// Regression test for #246: `eprintln!` panics when standard error is a broken pipe (a GUI
+// launch on Windows), and a panic inside the panic hook aborts the process. The start-up
+// path and the hook must write to standard error without it.
+#[test]
+fn start_up_and_the_panic_hook_never_use_eprintln() {
+ let source = include_str!("main.rs");
+ // Everything before the top-level test module is product code.
+ let product = source.split("\n#[cfg(test)]\nmod tests;").next().unwrap_or(source);
+ assert!(product.len() < source.len(), "found the end of the product code");
+ let offenders: Vec<_> = product
+ .lines()
+ .enumerate()
+ .filter(|(_, line)| !line.trim_start().starts_with("//") && line.contains("eprintln!"))
+ .map(|(n, line)| format!("main.rs:{}: {}", n + 1, line.trim()))
+ .collect();
+ assert!(
+ offenders.is_empty(),
+ "`eprintln!` panics on a broken standard error pipe; use `log::warn!` or `write_fmt` on `stderr()` and ignore the error:\n{}",
+ offenders.join("\n")
+ );
+}
diff --git a/apps/pdfcraft/src/updates.rs b/apps/pdfcraft/src/updates.rs
deleted file mode 100644
index 686c8cee84..0000000000
--- a/apps/pdfcraft/src/updates.rs
+++ /dev/null
@@ -1,127 +0,0 @@
-//! Asks GitHub for the latest PdfCraft release (Help ▸ Check for updates, issue #28).
-
-use std::time::Duration;
-
-use pdfcraft_ui_egui::updates::{RELEASES_PAGE, Release};
-
-const LATEST: &str = "https://api.github.com/repos/storytold/pdfcraft/releases/latest";
-
-/// The latest release. The answer is untrusted: its size is capped, and only a page under
-/// [`RELEASES_PAGE`] is ever offered for download (anything else falls back to that list).
-pub fn latest_release() -> Result {
- let agent = ureq::Agent::config_builder()
- .timeout_global(Some(Duration::from_secs(10)))
- .tls_config(ureq::tls::TlsConfig::builder().root_certs(os_roots()?).build())
- .build()
- .new_agent();
- let mut response = agent
- .get(LATEST)
- .header("Accept", "application/vnd.github+json")
- .header("User-Agent", concat!("PdfCraft/", env!("CARGO_PKG_VERSION")))
- // Read error statuses ourselves: a 403 from GitHub usually means its anonymous
- // rate limit ran out, and the retry time rides along in a response header.
- .config()
- .http_status_as_error(false)
- .build()
- .call()
- .map_err(|e| format!("couldn't reach GitHub ({e})"))?;
- let status = response.status().as_u16();
- if status == 403 {
- let reset = response.headers().get("x-ratelimit-reset").and_then(|v| v.to_str().ok());
- return Err(rate_limited(reset, now_unix()));
- }
- if !(200..300).contains(&status) {
- return Err(format!("couldn't reach GitHub (http status: {status})"));
- }
- let body = response.body_mut().with_config().limit(1 << 20).read_to_string().map_err(|e| format!("unreadable answer ({e})"))?;
- parse(&body)
-}
-
-/// The certificate authorities the operating system trusts.
-fn os_roots() -> Result {
- let found = rustls_native_certs::load_native_certs();
- let certs: Vec> = found.certs.iter().map(|c| ureq::tls::Certificate::from_der(c.as_ref()).to_owned()).collect();
- if certs.is_empty() {
- return Err("no trusted certificates found on this system".into());
- }
- Ok(ureq::tls::RootCerts::new_with_certs(&certs))
-}
-
-/// The current Unix time in seconds (0 when the clock gives nothing usable).
-fn now_unix() -> u64 {
- std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
-}
-
-/// What to tell the user when GitHub answers 403: its anonymous quota (60 checks an hour,
-/// shared by everyone behind the same address) ran out. `reset` is the `x-ratelimit-reset`
-/// header, the Unix time the quota refills; `now` is the current Unix time. Anything
-/// surprising (no header, not a number, already past) falls back to asking for patience.
-fn rate_limited(reset: Option<&str>, now: u64) -> String {
- const FALLBACK: &str = "GitHub is limiting update checks for now (shared hourly quota); try again later";
- let wait = reset.and_then(|r| r.trim().parse::().ok()).and_then(|reset| reset.checked_sub(now)).filter(|&wait| wait > 0);
- match wait {
- Some(wait) => {
- let minutes = wait.div_ceil(60).max(1);
- let plural = if minutes == 1 { "" } else { "s" };
- format!("GitHub is limiting update checks for now (shared hourly quota); try again in about {minutes} minute{plural}")
- }
- None => FALLBACK.into(),
- }
-}
-
-fn parse(body: &str) -> Result {
- let v: serde_json::Value = serde_json::from_str(body).map_err(|e| format!("unreadable answer ({e})"))?;
- let version = v["tag_name"].as_str().filter(|t| !t.is_empty() && t.len() <= 64).ok_or("no release found")?.to_string();
- let url = v["html_url"]
- .as_str()
- .filter(|u| u.strip_prefix(RELEASES_PAGE).is_some_and(|rest| rest.starts_with('/') && !rest.contains(['?', '#', '\\'])))
- .unwrap_or(RELEASES_PAGE)
- .to_string();
- Ok(Release { version, url })
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn answers_are_read_and_only_our_release_pages_are_offered() {
- let r = parse(r#"{"tag_name":"v0.2.0","html_url":"https://github.com/storytold/pdfcraft/releases/tag/v0.2.0"}"#).unwrap();
- assert_eq!(r, Release { version: "v0.2.0".into(), url: "https://github.com/storytold/pdfcraft/releases/tag/v0.2.0".into() });
- for elsewhere in ["https://example.com/pdfcraft.exe", "https://github.com/storytold/pdfcraft/releases.evil/x", "javascript:alert(1)"] {
- let r = parse(&format!(r#"{{"tag_name":"v9.9.9","html_url":"{elsewhere}"}}"#)).unwrap();
- assert_eq!(r.url, RELEASES_PAGE, "{elsewhere}");
- }
- assert!(parse(r#"{"message":"Not Found"}"#).is_err());
- assert!(parse("").is_err());
- }
-
- #[test]
- fn rate_limit_messages_name_the_wait() {
- let limited = "GitHub is limiting update checks for now (shared hourly quota); try again in about 20 minutes";
- assert_eq!(rate_limited(Some("1800001200"), 1800000000), limited);
- // Rounds up: 61 seconds out reads as 2 minutes.
- assert_eq!(
- rate_limited(Some("1800000061"), 1800000000),
- "GitHub is limiting update checks for now (shared hourly quota); try again in about 2 minutes"
- );
- assert_eq!(
- rate_limited(Some("1800000060"), 1800000000),
- "GitHub is limiting update checks for now (shared hourly quota); try again in about 1 minute"
- );
- // No header, not a number, or already past: ask for patience instead of a time.
- let later = "GitHub is limiting update checks for now (shared hourly quota); try again later";
- for bad in [None, Some(""), Some("soon"), Some("1799999999"), Some("1800000000")] {
- assert_eq!(rate_limited(bad, 1800000000), later);
- }
- }
-
- /// Live: asks GitHub over TLS with the OS's roots (`cargo test -p pdfcraft -- --ignored`).
- #[test]
- #[ignore = "needs network access"]
- fn github_answers_with_the_latest_release() {
- let r = latest_release().unwrap();
- assert!(pdfcraft_ui_egui::updates::is_newer(&r.version, "0.0.0"), "{r:?}");
- assert!(r.url.starts_with(RELEASES_PAGE), "{r:?}");
- }
-}
diff --git a/apps/pdfcraft/tests/it/main.rs b/apps/pdfcraft/tests/it/main.rs
index bc05099a51..b09ace5f40 100644
--- a/apps/pdfcraft/tests/it/main.rs
+++ b/apps/pdfcraft/tests/it/main.rs
@@ -7,6 +7,7 @@ mod event {
pub use winit::event::TouchPhase;
}
+mod vendor_graph;
mod winit_macos_emoji_picker;
mod winit_macos_ime_commit;
mod winit_macos_ime_discard;
diff --git a/apps/pdfcraft/tests/it/vendor_graph.rs b/apps/pdfcraft/tests/it/vendor_graph.rs
new file mode 100644
index 0000000000..a6a4d4b292
--- /dev/null
+++ b/apps/pdfcraft/tests/it/vendor_graph.rs
@@ -0,0 +1,81 @@
+//! Dependency-graph verification for the egui 0.37 window stack.
+//!
+//! The twelve `winit_*` modules next to this one read the retained `vendor/winit` /
+//! `vendor/egui-wgpu` copies. They are provenance for W1–W13 and E2+E3, not proof of what the app
+//! runs: the root manifest pins both crates to immutable storytold git revisions, and the retained
+//! copies are not path patches. These tests check the resolved graph and the retained evidence
+//! instead of treating the copies as the runtime source. The winit fork's own W17 tests own patch
+//! behaviour.
+
+/// Return the workspace root above the PdfCraft app crate.
+fn root() -> std::path::PathBuf {
+ std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
+}
+
+/// Every `[[package]]` block in `Cargo.lock` whose `name` is exactly `name`.
+fn locked_packages(name: &str) -> Vec {
+ let lock = std::fs::read_to_string(root().join("Cargo.lock")).expect("Cargo.lock");
+ let needle = format!("name = \"{name}\"");
+ lock.split("[[package]]").filter(|package| package.lines().any(|line| line.trim() == needle.as_str())).map(str::to_owned).collect()
+}
+
+/// The one resolved package called `name`. A duplicate name is a lockfile problem, not something
+/// to resolve by taking the first match.
+fn locked(name: &str) -> String {
+ let mut packages = locked_packages(name);
+ assert_eq!(packages.len(), 1, "Cargo.lock must contain exactly one `{name}` package, found {}", packages.len());
+ packages.remove(0)
+}
+
+/// The resolved package must be the exact canonical storytold git source at the reviewed
+/// 40-character revision, including the `Cargo.lock` URL and fragment. A path source (no `source`
+/// line) is refused: it would make the graph depend on a developer's checkout. A published path
+/// patch is refused as well.
+fn assert_resolved(name: &str, version: &str, url: &str, rev: &str) {
+ let package = locked(name);
+ let expected_version = format!("version = \"{version}\"");
+ assert!(package.lines().any(|line| line.trim() == expected_version.as_str()), "{name} must resolve to {version}: {package}");
+ let sources: Vec<&str> = package.lines().filter_map(|line| line.trim().strip_prefix("source = ")).collect();
+ assert_eq!(sources.len(), 1, "{name} must have exactly one `source` in Cargo.lock; a local path source is not accepted: {package}");
+ let expected_source = format!("\"git+{url}?rev={rev}#{rev}\"");
+ assert_eq!(sources[0], expected_source.as_str(), "{name} must resolve to the exact canonical git URL, revision and fragment, not {}", sources[0]);
+
+ let manifest = std::fs::read_to_string(root().join("Cargo.toml")).expect("root Cargo.toml");
+ assert!(!manifest.contains(&format!("{name} = {{ path = \"vendor/")), "vendor/{name} cannot stay a published path patch");
+}
+
+/// Regression test: the resolved window stack points at the reviewed forks and the retained vendor provenance
+/// files remain.
+#[test]
+fn vendor_provenance_window_stack_graph_is_resolved() {
+ assert_resolved("winit", "0.30.13", "https://github.com/storytold/winit.git", "a517b3846ee8457d84ee4975110bfda6797c44dc");
+ assert_resolved("egui-wgpu", "0.37.0", "https://github.com/storytold/egui.git", "6d518fc799681571963842f95b55a6b68431b15a");
+ // Retained provenance: the audited copies and their recorded W1–W13 / E2+E3 rows.
+ let readme = std::fs::read_to_string(root().join("vendor/README.md")).expect("vendor/README.md");
+ for marker in ["W1–W13", "surface_fit", "catch_errors", "e9809ef54b18499bb4f2cac945719ecc2a61061b"] {
+ assert!(readme.contains(marker), "vendor/README.md lost window-stack provenance: {marker}");
+ }
+ let vcs = std::fs::read_to_string(root().join("vendor/winit/.cargo_vcs_info.json")).expect("vendor/winit provenance");
+ assert!(vcs.contains("e9809ef54b18499bb4f2cac945719ecc2a61061b"), "vendor/winit keeps its upstream base");
+ for path in ["vendor/winit/src/platform_impl", "vendor/egui-wgpu/src/winit.rs"] {
+ assert!(root().join(path).exists(), "retain {path} as provenance for the pinned fork");
+ }
+}
+
+/// Renderer, security and PostScript vendors are unrelated to the UI port and must stay path patches.
+#[test]
+fn renderer_and_security_vendors_remain() {
+ let manifest = std::fs::read_to_string(root().join("Cargo.toml")).expect("root Cargo.toml");
+ for vendor in [
+ "vendor/hayro",
+ "vendor/hayro-interpret",
+ "vendor/hayro-syntax",
+ "vendor/hayro-cmap",
+ "vendor/hayro-jbig2",
+ "vendor/hayro-postscript",
+ "vendor/lopdf",
+ "vendor/vello_common",
+ ] {
+ assert!(manifest.contains(&format!("path = \"{vendor}\"")), "{vendor} must stay a path patch");
+ }
+}
diff --git a/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs b/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs
index 6cedf57e33..b95058ba94 100644
--- a/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs
+++ b/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs
@@ -1,7 +1,11 @@
-//! vendor/winit patch (8) (vendor/README.md).
+//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
+//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
+//! Retained vendor provenance for winit patch (8) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
+/// Regression test: the vendored winit commits text inserted outside a key press and leaves `selectedRange`
+/// unmarked.
#[test]
-fn winit_commits_macos_text_inserted_outside_a_key_press() {
+fn vendor_provenance_winit_macos_text_inserted_outside_a_key_press() {
// The macOS emoji picker inserts its text with `insertText` outside any key press and without
// a composition. winit 0.30.13 reports `insertText` only as part of a key press or a
// composition, so an emoji picked into a PdfCraft text field was dropped. vendor/winit commits
diff --git a/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs b/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs
index 8701fc20a1..877f9e2a22 100644
--- a/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs
+++ b/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs
@@ -1,7 +1,11 @@
-//! vendor/winit patch (6) (vendor/README.md).
+//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
+//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
+//! Retained vendor provenance for winit patch (6) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
+/// Regression test: the vendored winit commits a composition that ended without marked text and keeps the
+/// marked-text underline.
#[test]
-fn winit_commits_macos_compositions_that_end_without_marked_text() {
+fn vendor_provenance_winit_macos_compositions_that_end_without_marked_text() {
// winit 0.30.13 treats `insertText` as an IME commit only while marked text is left. Input
// methods that end a composition with `unmarkText` first lost the committed text, and Apple
// Korean, which clears its marked text before inserting the key typed after a syllable (`한`
diff --git a/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs b/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs
index 52e65622f6..89efbd3d81 100644
--- a/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs
+++ b/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs
@@ -1,7 +1,10 @@
-//! vendor/winit patch (7) (vendor/README.md).
+//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
+//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
+//! Retained vendor provenance for winit patch (7) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
+/// Regression test: disallowing IME in the vendored winit discards the macOS input method's marked text.
#[test]
-fn winit_discards_the_macos_composition_when_ime_is_disallowed() {
+fn vendor_provenance_winit_discards_the_macos_composition_when_ime_is_disallowed() {
// egui disallows IME when a text field loses focus. winit 0.30.13 then forgets the marked text
// but doesn't tell the input method, which keeps the composition and continues it once IME is
// allowed again: the abandoned syllable comes back in the next field. vendor/winit calls
diff --git a/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs b/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs
index 76bb32ccbd..f18dde0f2a 100644
--- a/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs
+++ b/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs
@@ -1,7 +1,10 @@
-//! vendor/winit patch (10) (vendor/README.md).
+//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
+//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
+//! Retained vendor provenance for winit patch (10) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
+/// Regression test: the vendored winit retries the first Apple Korean key once so it starts a composition.
#[test]
-fn winit_retries_the_first_apple_korean_key_once() {
+fn vendor_provenance_winit_retries_the_first_apple_korean_key_once() {
// On the first key in a freshly started app, Apple Korean can answer with `insertText` and the
// raw compatibility jamo instead of starting a composition, so typing g k s in PdfCraft gave
// ㅎㅏㄴ instead of 한. vendor/winit interprets that one key again before sending it to the
diff --git a/apps/pdfcraft/tests/it/winit_macos_korean_space.rs b/apps/pdfcraft/tests/it/winit_macos_korean_space.rs
index 30c9d4779c..f5f2aada2f 100644
--- a/apps/pdfcraft/tests/it/winit_macos_korean_space.rs
+++ b/apps/pdfcraft/tests/it/winit_macos_korean_space.rs
@@ -1,7 +1,10 @@
-//! vendor/winit patch (9) (vendor/README.md).
+//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
+//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
+//! Retained vendor provenance for winit patch (9) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
+/// Regression test: the vendored winit sends a key committed through macOS IME only once.
#[test]
-fn winit_sends_a_key_committed_through_macos_ime_only_once() {
+fn vendor_provenance_winit_sends_a_key_committed_through_macos_ime_only_once() {
// Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`,
// `insertText(" ")` and `setMarkedText("")`. The last call resets winit 0.30.13's IME state, so
// the key went to the app both as `Ime::Commit(" ")` and as `KeyboardInput` with text " ", and
diff --git a/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs b/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs
index 5422af59b5..5c8e892f25 100644
--- a/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs
+++ b/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs
@@ -1,7 +1,11 @@
-//! vendor/winit patch (12) (vendor/README.md).
+//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
+//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
+//! Retained vendor provenance for winit patch (12) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
+/// Regression test: the vendored winit lets title-bar drags reach the app while the empty tab strip still
+/// moves the window.
#[test]
-fn winit_lets_macos_title_bar_drags_reach_the_app() {
+fn vendor_provenance_winit_lets_macos_title_bar_drags_reach_the_app() {
// On macOS the tab strip is the title bar: a transparent title bar over a full-size content
// view. NSView answers YES to `mouseDownCanMoveWindow` for a view that isn't opaque, so AppKit
// moved the whole window for a drag that began on a document tab, and tabs could not be
diff --git a/apps/pdfcraft/tests/it/winit_wayland_scroll.rs b/apps/pdfcraft/tests/it/winit_wayland_scroll.rs
index 96686f7ecf..8087219b21 100644
--- a/apps/pdfcraft/tests/it/winit_wayland_scroll.rs
+++ b/apps/pdfcraft/tests/it/winit_wayland_scroll.rs
@@ -1,3 +1,5 @@
+//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
+//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
#![cfg(target_os = "linux")]
use egui::{Event, Modifiers, MouseWheelUnit, RawInput, TouchPhase, vec2};
@@ -16,6 +18,7 @@ fn egui_phase(phase: event::TouchPhase) -> TouchPhase {
}
}
+/// Run three wheel frames through egui and return each frame's zoom and smooth-scroll deltas.
fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> {
let ctx = egui::Context::default();
let mut previous = event::TouchPhase::Ended;
@@ -27,7 +30,13 @@ fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> {
let (unit, dy) = if discrete { (MouseWheelUnit::Line, 1.0) } else { (MouseWheelUnit::Point, 24.0) };
let input = RawInput {
time: Some(frame as f64 / 60.0),
- events: vec![Event::MouseWheel { unit, delta: vec2(0.0, dy), phase: egui_phase(phase), modifiers }],
+ events: vec![Event::MouseWheel {
+ unit,
+ delta: vec2(0.0, dy),
+ phase: egui_phase(phase),
+ modifiers,
+ source: egui::MouseWheelSource::Unknown,
+ }],
..Default::default()
};
ctx.run_ui(input, |ui| seen.push(ui.input(|i| (i.zoom_delta(), i.smooth_scroll_delta)))).textures_delta.clear();
@@ -35,8 +44,10 @@ fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> {
seen
}
+/// Regression test: releasing Ctrl after a pixel-wheel zoom makes the next wheel event scroll instead of
+/// zooming.
#[test]
-fn releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() {
+fn vendor_provenance_releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() {
for source in [AxisSource::Wheel, AxisSource::WheelTilt, AxisSource::Continuous] {
let seen = wheel_trace(source, false);
assert!(seen[1].0 > 1.0, "Ctrl must zoom: {source:?}");
@@ -45,16 +56,18 @@ fn releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() {
}
}
+/// Regression test: discrete wheels follow the modifiers of the current event, just like pixel wheels.
#[test]
-fn discrete_wheels_still_follow_current_modifiers() {
+fn vendor_provenance_discrete_wheels_still_follow_current_modifiers() {
let seen = wheel_trace(AxisSource::Wheel, true);
assert!(seen[1].0 > 1.0);
assert_eq!(seen[2].0, 1.0);
assert!(seen[2].1.y > 0.0);
}
+/// Regression test: a finger gesture keeps its Ctrl zoom through momentum until the gesture ends.
#[test]
-fn finger_gestures_keep_their_modifier_until_the_gesture_ends() {
+fn vendor_provenance_finger_gestures_keep_their_modifier_until_the_gesture_ends() {
// A finger scroll's momentum may arrive after Ctrl is released.
let seen = wheel_trace(AxisSource::Finger, false);
assert!(seen[1].0 > 1.0);
@@ -64,8 +77,9 @@ fn finger_gestures_keep_their_modifier_until_the_gesture_ends() {
assert_eq!(scroll::phase(event::TouchPhase::Ended, Some(AxisSource::Finger), false, false), event::TouchPhase::Started);
}
+/// Regression test: wheel sources without a phase keep the existing start, move and end policy.
#[test]
-fn unknown_sources_keep_the_existing_phase_policy() {
+fn vendor_provenance_unknown_sources_keep_the_existing_phase_policy() {
assert_eq!(scroll::phase(event::TouchPhase::Ended, None, false, false), event::TouchPhase::Started);
assert_eq!(scroll::phase(event::TouchPhase::Started, None, false, false), event::TouchPhase::Moved);
assert_eq!(scroll::phase(event::TouchPhase::Ended, None, true, false), event::TouchPhase::Moved);
diff --git a/apps/pdfcraft/tests/it/winit_wayland_text_input.rs b/apps/pdfcraft/tests/it/winit_wayland_text_input.rs
index 691659e5a6..6469354cdc 100644
--- a/apps/pdfcraft/tests/it/winit_wayland_text_input.rs
+++ b/apps/pdfcraft/tests/it/winit_wayland_text_input.rs
@@ -1,7 +1,10 @@
-//! vendor/winit patch (2) (vendor/README.md).
+//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
+//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
+//! Retained vendor provenance for winit patch (2) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
+/// Regression test: the vendored winit keeps a Wayland seat's text input until the seat itself is removed.
#[test]
-fn winit_keeps_the_wayland_text_input_until_the_seat_goes() {
+fn vendor_provenance_winit_keeps_the_wayland_text_input_until_the_seat_goes() {
// winit 0.30.13 destroys a Wayland seat's text input whenever the seat loses any capability (a
// touch screen or tablet unplugged, a remote-desktop session ending) and recreates it only when
// a capability is added, so IME stops working in a running PdfCraft until it is restarted.
diff --git a/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs b/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs
index a1bdd11840..a8009591bf 100644
--- a/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs
+++ b/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs
@@ -1,7 +1,10 @@
-//! vendor/winit patch (4) (vendor/README.md).
+//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
+//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
+//! Retained vendor provenance for winit patch (4) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
+/// Regression test: the vendored winit maximizes a hidden Windows window only when it is shown.
#[test]
-fn winit_maximizes_a_hidden_windows_window_only_when_it_is_shown() {
+fn vendor_provenance_winit_maximizes_a_hidden_windows_window_only_when_it_is_shown() {
// eframe creates PdfCraft's window hidden, restores the saved maximized state and shows the
// window once its first frame is drawn. winit 0.30.13 applies the maximized state with
// `ShowWindow(SW_MAXIMIZE)`, which shows a hidden window, so a window closed maximized came back
diff --git a/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs b/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs
index aef55877d7..1df11d8a68 100644
--- a/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs
+++ b/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs
@@ -1,7 +1,10 @@
-//! vendor/winit patch (3) (vendor/README.md).
+//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
+//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
+//! Retained vendor provenance for winit patch (3) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
+/// Regression test: the vendored winit reads the Windows IME cursor only when the composition reports it.
#[test]
-fn winit_reads_the_windows_ime_cursor_only_when_it_is_reported() {
+fn vendor_provenance_winit_reads_the_windows_ime_cursor_only_when_it_is_reported() {
// winit 0.30.13 reads `GCS_CURSORPOS` for every composition without a target clause, whether
// or not `WM_IME_COMPOSITION` says the IME set it. The Microsoft Korean IME never sets it, so
// the composition's cursor read as 0, before the syllable being composed. vendor/winit puts it
diff --git a/apps/pdfcraft/tests/it/winit_x11_dpi.rs b/apps/pdfcraft/tests/it/winit_x11_dpi.rs
index fed0cf3e42..a9bde6a1ee 100644
--- a/apps/pdfcraft/tests/it/winit_x11_dpi.rs
+++ b/apps/pdfcraft/tests/it/winit_x11_dpi.rs
@@ -1,5 +1,9 @@
+//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
+//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
+/// Regression test: the vendored winit keeps the X11 server-DPI fallback and the desktop file does not
+/// override scaling.
#[test]
-fn winit_carries_the_x11_server_dpi_fallback() {
+fn vendor_provenance_winit_carries_the_x11_server_dpi_fallback() {
// The behavior tests live beside the policy in winit (util::randr::tests).
// This guards PdfCraft's vendored copy, which workspace tests do not test itself.
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
diff --git a/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs b/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs
index f244d1387a..61e5c8ebfa 100644
--- a/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs
+++ b/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs
@@ -1,7 +1,10 @@
-//! vendor/winit patch (5) (vendor/README.md).
+//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
+//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
+//! Retained vendor provenance for winit patch (5) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
+/// Regression test: the vendored winit focuses the X11 input context it replaces.
#[test]
-fn winit_focuses_the_x11_input_context_it_replaces() {
+fn vendor_provenance_winit_focuses_the_x11_input_context_it_replaces() {
// egui allows IME when a text field gains focus and disallows it when the field loses focus.
// On X11, winit 0.30.13 replaces the window's input context each time and leaves focusing the
// new one to the next focus event, which never comes while PdfCraft's window keeps the focus:
diff --git a/assets/demo/showcase.html b/assets/demo/showcase.html
index b26f24565a..3b14ea3b47 100644
--- a/assets/demo/showcase.html
+++ b/assets/demo/showcase.html
@@ -14,27 +14,27 @@
(highlight, sticky note, stamp, ...), so annotation geometry follows the layout.
-->
\n\n\n",
+ esc(title)
+ );
+ for it in items(pages) {
+ match it {
+ Item::Para(runs, lvl) => {
+ let Some(first) = runs.first().map(|r| r.block) else { continue };
+ let mut t = String::new();
+ for r in &runs {
+ let mut run = esc(&r.text);
+ if r.block.italic {
+ run = format!("{run}");
+ }
+ if r.block.bold && lvl == 0 {
+ run = format!("{run}");
+ }
+ // The paragraph has its first run's colour; a run in another colour says so.
+ let colour = rgb8(r.block.color);
+ if colour != rgb8(first.color) {
+ let [red, green, blue] = colour;
+ run = format!("{run}");
+ }
+ t.push_str(&run);
+ }
+ let style = hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\""));
+ match lvl {
+ 0 => s.push_str(&format!("{t}
\n")),
+ l => s.push_str(&format!("{t}\n")),
+ }
+ }
+ Item::Table(t) => {
+ s.push_str("\n");
+ for row in &t.rows {
+ s.push_str("");
+ for c in row {
+ let mut body = esc(&c.text);
+ if c.italic {
+ body = format!("{body}");
+ }
+ if c.bold {
+ body = format!("{body}");
+ }
+ let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() };
+ let style = hex(c.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\""));
+ s.push_str(&format!("| {body} | "));
+ }
+ s.push_str("
\n");
+ }
+ s.push_str("
\n");
+ }
+ Item::Img(im) => {
+ let mime = if im.ext == "jpg" { "image/jpeg" } else { "image/png" };
+ s.push_str(&format!("
\n", base64(&im.bytes)));
+ }
+ Item::PageBreak => s.push_str("
\n"),
+ }
+ }
+ s.push_str("\n