diff --git a/.coderabbit.yaml b/.coderabbit.yaml
new file mode 100644
index 000000000..2408084cf
--- /dev/null
+++ b/.coderabbit.yaml
@@ -0,0 +1,6 @@
+# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
+reviews:
+ auto_review:
+ # Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
+ ignore_usernames:
+ - "dependabot[bot]"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 388b44f03..a21cbdfd6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -123,7 +123,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - uses: EmbarkStudios/cargo-deny-action@v2
+ - uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1
with:
command: check
diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml
index ce817da12..01cb9cd66 100644
--- a/.github/workflows/nix.yml
+++ b/.github/workflows/nix.yml
@@ -18,16 +18,6 @@ concurrency:
cancel-in-progress: true
jobs:
- eval:
- runs-on: ubuntu-latest
- timeout-minutes: 15
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - uses: cachix/install-nix-action@448cde739abec1eaf0646249e3d25bb8d9bc7702 # v31.11.2
- - name: nix flake check --no-build
- run: |
- if [ -f flake.nix ]; then
- nix flake check --no-build --all-systems
- else
- echo "No flake.nix present in this repository; skipping check."
- fi
+ nix:
+ # 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/nix.yml@main
diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml
new file mode 100644
index 000000000..ecee45a6d
--- /dev/null
+++ b/.github/workflows/sonarcloud.yml
@@ -0,0 +1,20 @@
+name: SonarCloud
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: sonarcloud-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ sonarcloud:
+ # Tracked at @main so org-wide CI improvements propagate without PR churn.
+ uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
+ secrets: inherit
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 5cfee8d55..5b1695604 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -4,30 +4,37 @@
Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
-## In this repository (272)
+## In this repository (288)
| 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 | 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 | 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 | 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 | 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 | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/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 | 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 | 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 | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/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 | 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 | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
+| `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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
@@ -223,6 +230,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `vendor/hayro-interpret/assets/FoxitSymbol.pfb` | FoxitSymbol (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) |
| `vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc` | CGATS001Compat v2 micro ICC profile | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | CC0-1.0 | https://github.com/saucecontrol/Compact-ICC-Profiles | Default CMYK colour conversion (vendored hayro-interpret) |
| `crates/fonts/src/std14_metrics.rs` | Standard-14 font advance widths and encodings (as Rust tables) | Adobe (Core14 AFM metrics, PDF specification encodings); tables generated by the PdfCraft contributors | MIT OR Apache-2.0 | ISO 32000-2 Annex D and the Adobe Core14 AFM metrics. Generated by joining the published WinAnsi, Symbol and ZapfDingbats encodings (code to glyph name) with the per-glyph advance widths, then spot-checked against the specification by hand in the tests of crates/fonts/src/std14.rs. Data only: the code that reads these tables lives in std14.rs, so that ordinary edits do not churn this file's hash. | Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree. |
+| `vendor/hayro-cmap/assets/cmaps.brotli` | Adobe CMap resources (predefined CJK CMaps) | Adobe | BSD-3-Clause | https://github.com/adobe-type-tools/cmap-resources (via hayro-cmap 0.1.0 assets/) | Character-code mapping tables required to read CJK PDFs. Non-visual data, allowed by AGENTS.md §1.1 |
| `docs/brand/artcraft-logo-white.png` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
| `docs/brand/artcraft-logo.png` | ArtCraft brand: artcraft-logo | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
@@ -277,11 +285,19 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile |
| `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/pdf.rs): the legacy adbe.x509.rsa_sha1 format |
| `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
-| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
+| `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 | 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 | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file |
+| `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 | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.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 | 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 | 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 | Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-## Compiled in through dependencies (6)
+## Compiled in through dependencies (5)
| Crate | File | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|---|
@@ -289,7 +305,6 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `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) |
-| `hayro-cmap` 0.1.0 | `assets/cmaps.brotli` | Adobe CMap resources (predefined CJK CMaps) (non-visual data, AGENTS.md §1.1) | Adobe | BSD-3-Clause | https://github.com/adobe-type-tools/cmap-resources (via hayro-cmap) | Character-code mapping tables required to read CJK PDFs. Non-visual data, allowed by AGENTS.md §1.1 |
| `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)
diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml
index db3dd6124..6c9c88a2e 100644
--- a/ATTRIBUTION.toml
+++ b/ATTRIBUTION.toml
@@ -2,12 +2,12 @@
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"
+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"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "6eee0b34db6529f26235bfc07fa1fdb9030b8e7b0b10ede271ca9242cb491919"
+sha256 = "1902f1c31698e6adfdaf65b35b1fe1628f5cd2bd0182dd3b99bcf5e21720f394"
# 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`).
@@ -25,211 +25,286 @@ sha256 = "6eee0b34db6529f26235bfc07fa1fdb9030b8e7b0b10ede271ca9242cb491919"
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"
+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"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "9870c0fb9f7198155be16adee7f45d36cb9a254af90b545a3ce5b04cbbbf24d4"
+sha256 = "2e23c41fcd5b9118cfde03b3a45aa2f988f7eeba650677b6f1f6664e543c7a59"
[[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"
+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"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "fc31e40877ce115cfb7033c57dd6acabcae3725b249364387e3fe23e04d94e5e"
+sha256 = "105703f2fbc07c0663308ebd6852199de12a5851a37507d7e51e477eb5e57218"
[[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"
+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"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "5de19834f293efaea68cb6b7f51a71d320391f53345f56cfebcd73dab828b665"
+sha256 = "7358300715a54e72a477c5888dad53f426ddf76da141a6202bec74594eb5ff0a"
[[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"
+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"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "cef0d22341d0722f029c6543fc846fce164331b7ad8f693a6754b7567b9d9e95"
+sha256 = "265083ea7dfa129fcae0b9e401aef8f7c84982011ba91fb2023c52705b81e639"
[[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"
+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"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "c0ca7761cbabd8096bc9af54cdf6b88b3ae0cd8210003db8b3356e2706e44fc8"
+sha256 = "ffcf42859cfe38ae13c6fe4219cbe6225a20b4572c202db9e82ee38bfb433799"
[[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"
+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"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "a8afd51c3296f44b0446d6b06492b1348444b3e3c448a326ad213d18ce7af714"
+sha256 = "3f31e5c9abdb0208d48d8c68aa81437d9d1e10aeb615dedf811fefa539d93279"
[[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"
+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"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "7676a2dfaa7d66e2e9ef608d2d9de7f863557dcecbef6d94bafa981097405e6d"
+sha256 = "815a3a672421cc3590fb1f267755316659cdb2046e30bbb3ad8b31e96f08b20a"
+
+[[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"
+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"
+sha256 = "6bd67286795ed487eedf2ce549249c8a83286cb4273490c1349996050b540fed"
[[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"
+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"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "9a22996c0308ea5b20ec5e328504cd83cbb560147763faaf0e6987f974844c29"
+sha256 = "9cd63425705fb9a3aa029ff3a5110d0877a34f9038f1d64a2102ccd6c3cb35a6"
[[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"
+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"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "eacc972d65a2d951e94e1a8cf944638e8328f73f6c05759de6059a425f159cd7"
+sha256 = "76f138293fc12ea3584568a81f6f90a1cedfff6256e78b4f60cb21b1b66bbba4"
[[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"
+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"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "dfa4aa540cc7d61fa22a27a949e6eec0f78dcc5b883d0e4e004d695e460b95aa"
+sha256 = "7bca90240f873fcec687bea84e9ea5835f0903827501d0e73ec26b98b82e8a52"
[[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"
+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"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "27e02e7bb3d378bceca76988a9dceaa68d974f57cbee962954deca6184cf62aa"
+sha256 = "3d539a28afcf8c2e36b7087ac23334b50bac7971e03da20a62b6e7e400763a87"
[[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"
+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"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "9675edd36c5ae54d9893fd10c4325aab664941bf714c08fbfeef9134fa42056c"
+sha256 = "71b7e8180fc759aebad4660953ab8b55383def0572c1ace2288cd8bfeb1b82dc"
[[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"
+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"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "ce18fc96f8516aa917e4a8bcfd290edec13a9800b3d1ee102e4b2a71b8467d2b"
+sha256 = "e689fb24a8acfa7e85f0f60644f7e2da1225595e6cd6a94109fbe86082926244"
[[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"
+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"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "286a1e2e051748eb4bcee3906918dc78d0704f8a37bde22dc50d3376d5e75017"
+sha256 = "c601ea117ec2a3cdba606fdc9a62220fcffbd0c20c1133bacb653a979cf046a5"
[[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"
+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"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "77593b7497c67f4a79cc31fa43fdd6eae11601dd87c079a8e666b25cdd9117ba"
+sha256 = "006867b891ae2f995c7a711e55b6401db29d20ab493889c6284dc3671f9faa2d"
[[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"
+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"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "d7d93a03f22ab380696f69b20b12104bf30e78c4440b683df63594af716894e2"
+sha256 = "9e5b18a09c209525cf08ef53a242ed457cfa346295eef7facde482a75cf72f52"
[[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"
+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"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "889a0d0a9ea53ca0a818c2ba672c053c232d2cca140f8d63127353e6f36505fe"
+sha256 = "2306dbbe6a73671fd20df116adb159a7f66c85fbbd1bae5ecef5f6e882d6b7f1"
+
+[[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"
+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"
+sha256 = "864db8aa0a5e443c92e7e388f89349eb0f0e7beda35a0b81695d864dcec51b72"
[[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"
+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"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "bae8b21651ae2a69c71c91b69ba79727254dbfb078c1be312a13383332934927"
+sha256 = "463a2d28bd5c5ac8df31a5b5c68400d7a61ce51237a58d9337078c973e6f9e3d"
[[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"
+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"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "7fa70f68a9fa24d0b1cac05fe3aaabaeaca9750c57416124bd317a4eda806d5a"
+sha256 = "dea5076ce9c19f054812b678241931ad5e7f85dee08acf1c1e12c7ecaaa5d0f5"
+[[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"
+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"
+sha256 = "09d2840c02b40a6049b86c2cb68151d863913e8c91c22226f25d07496c6366aa"
+[[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"
+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"
+sha256 = "0dc1abdb262e4568f19ffb2233bfeb3686ae42df8a8d8bec2951351a50e6a886"
+[[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"
+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"
+sha256 = "a3e67cee4f3fff776e816523f810b2bb6353235dbdf85181c3f12896604a3339"
+
+[[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"
+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"
+sha256 = "18d4beda8495d7f0b91f0dc0a52c2248d9ce6fb3f4e3de0d51f42235eaab7aa8"
+
+
+[[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"
+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"
+sha256 = "7b13e11191a3f2c0e38f1940996ae62da16e3bdb057215b2737f3a9c6a840000"
[[asset]]
path = "assets/icons/accessibility.svg"
@@ -2425,14 +2500,13 @@ kind = "font"
usage = "egui fallback fonts (eframe feature default_fonts)"
sha256 = "f426bec371f484646d002717e2c555a95cd5145e635d83a48bbef83f3abf18ca"
-[[bundled]]
-crate = "hayro-cmap"
-version = "0.1.0"
-path = "assets/cmaps.brotli"
+[[asset]]
+path = "vendor/hayro-cmap/assets/cmaps.brotli"
title = "Adobe CMap resources (predefined CJK CMaps)"
author = "Adobe"
-source = "https://github.com/adobe-type-tools/cmap-resources (via hayro-cmap)"
+source = "https://github.com/adobe-type-tools/cmap-resources (via hayro-cmap 0.1.0 assets/)"
licence = "BSD-3-Clause"
+licence_file = "vendor/hayro-cmap/assets/LICENSE.txt"
kind = "data"
adobe_data = true
usage = "Character-code mapping tables required to read CJK PDFs. Non-visual data, allowed by AGENTS.md §1.1"
@@ -3347,23 +3421,23 @@ 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"
+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"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "95b5fc6d10f7bc8371688772741870448de716fa2dd9af62fdfe4e009d269b45"
+sha256 = "1c73f9d51b599923d704bf00c8e56b23696de16141b7a2c20aae6f60f88927a5"
[[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"
+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"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "3ac2b113045b7e399dd6d9ddb7773e5b0874c8b43c2ba4cca5854598a2a7ba92"
+sha256 = "a3b8351f0da80c2d394221dbe5d62e794bb019c74c2adfc5d933d999f7d3236f"
[[asset]]
@@ -3376,3 +3450,91 @@ licence_file = "vendor/vello_common/LICENSE-MIT"
kind = "image"
usage = "Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file"
sha256 = "78c59a0cfd2c829909d77006f94e34dac1ae5d1f90e7f10f72e84de541fef697"
+
+[[asset]]
+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"
+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"
+sha256 = "03477c4ab2d0d52543cb91b3aec6f27b2689e6b9bb7c95bbb8463664c75a9dc0"
+
+[[asset]]
+path = "assets/icons/superscript.svg"
+title = "Lucide icon \"superscript\""
+author = "Lucide Contributors (portions Cole Bemis, Feather)"
+source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg)"
+licence = "ISC"
+licence_file = "assets/icons/LICENSE-lucide.txt"
+kind = "icon"
+usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
+sha256 = "34af289365aaa19295f874e4fda3b56196e203785d6fe1d9c3c929ae290518a0"
+
+[[asset]]
+path = "assets/icons/subscript.svg"
+title = "Lucide icon \"subscript\""
+author = "Lucide Contributors (portions Cole Bemis, Feather)"
+source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg)"
+licence = "ISC"
+licence_file = "assets/icons/LICENSE-lucide.txt"
+kind = "icon"
+usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
+sha256 = "06462f57e87a14e6496da9216eee9906b83ded0ff99b70498efa37ae88a5cba4"
+
+[[asset]]
+path = "assets/icons/pilcrow.svg"
+title = "Lucide icon \"pilcrow\""
+author = "Lucide Contributors (portions Cole Bemis, Feather)"
+source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg)"
+licence = "ISC"
+licence_file = "assets/icons/LICENSE-lucide.txt"
+kind = "icon"
+usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
+sha256 = "d344d9f9222f6c1b698c0c82a753a2212d9bd7785f06c00cd6e4910fe2fee8f8"
+
+[[asset]]
+path = "assets/icons/move-horizontal.svg"
+title = "Lucide icon \"move-horizontal\""
+author = "Lucide Contributors (portions Cole Bemis, Feather)"
+source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg)"
+licence = "ISC"
+licence_file = "assets/icons/LICENSE-lucide.txt"
+kind = "icon"
+usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
+sha256 = "864ab2f19254e6ac7240bc6a98323197332e8f714d4d3f94831f41309e5ef2f7"
+
+[[asset]]
+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"
+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"
+sha256 = "ebd177262d8fe20f960f06df084f8a03c9504bcd9990dded3a006e35bd8ef7c9"
+
+[[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"
+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"
+sha256 = "1dbb66c05af0aec06d5a6fa154e89f1684203dde32c8e8aedd9aeec6c2d5ae02"
+
+[[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"
+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"
+sha256 = "cde106ae68eea0a2d6229863ea21bbea235e4c3c5e3e728d52689d54041f0593"
diff --git a/CLAUDE.md b/CLAUDE.md
index 12779b1e3..085e8356f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -31,6 +31,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
- No `unsafe` (`unsafe_code = "forbid"`).
- Every crash fix gets a synthetic regression test. See `AGENTS.md` §4 (and, for team members, the internal `craftrules/standards/never-crash.md`).
- **Rust only** in the product and build (`xtask`). No handwritten JS/TS.
+- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Review every update as you make it, at four zooms.** Not an end-of-task pass — a per-update habit, so a defect never gets built on. **Clause:** checked arithmetic, `get()` not `[i]`, char-boundary slicing, the right error through `?`, `<` against `<=`, the 1-based/0-based seam, capped allocations. **Function:** is it total — name an input that panics; walk empty, zero, max, malformed, cyclic; is the return type honest. **File/module:** two sources of truth, dead or duplicated code, is the public surface still smallest and the doc comment still true. **Project:** layering, wasm, undo/redo, save fidelity, encryption and permissions, a matching `automation` tool or registry entry, and whether a `parity/` status or note just became untrue. Scale the zoom to the change, and fix what a level turns up before widening.
- Tests mirror the zooms: property → unit → crate integration → end-to-end.
- **Prove a test fails before the fix by actually reverting the fix.** A test nobody has seen fail is not evidence. This has caught two worthless tests in this repo: one confinement test passed with path resolution switched off entirely, because the tools failed on a nonexistent document id before reaching the path.
@@ -38,6 +39,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
- **External PRs are held to safety, security and completeness before style.** Provenance first (no Adobe-derived asset, no committed font, no copied GPL/AGPL code, behaviour from the spec and not from reading another implementation), then no new panic path or `unsafe`, then `--root` confinement on every path including ones reached by nested dispatch, opt-in-only MCP and control channel, no new outbound network, then a headless `automation` tool, tests at the right zoom and an honest `parity/` entry. Provenance is the part a contributor cannot be expected to know, and the only part that cannot be undone after merge.
- **Quality gates** before every commit: `cargo fmt --check`, clippy with `-D warnings`, the tests, `cargo xtask layers`, `cargo xtask assets`, and the wasm check once `xtask ci` exists (M0).
- **Scope builds and tests to the crates you touched** — `cargo clippy -p -j 2 --all-targets` and `cargo test -p -j 2`, under a `timeout`. A full `--workspace` build has locked up a development machine's CPU; it is CI's job and CI runs it on every PR. Run the workspace sweep locally only when you have a reason to and the machine is free.
+- **Check for existing work before starting a fix or feature.** Search the issues and PRs, open and closed, with `gh issue list -R storytold/pdfcraft --state all --search ""` and the same with `gh pr list` (try a few phrasings), and check whether a merged fix is already on `main` (`git merge-base --is-ancestor origin/main`). If one is, the report is likely from an older build: say so rather than fixing it again. If an open PR covers it, review or build on that PR instead of opening a competing one. If an issue exists, reference it in the PR (`Fixes #123`).
- **Commits and PRs:** `main` is the only integration branch and is **never pushed to directly**. Branch from an up-to-date `main` as `/` (`feat/`, `fix/`, `docs/`, `chore/`, `refactor/`), one task id per commit (e.g. `M1.4: xref stream reader`), commit only green states, and open a PR into `main` using `.github/pull_request_template.md`. Merge only when CI is green.
- **`main` moves fast enough that PRs rot.** It took 396 commits in the three days to 2026-10-12, and most open PRs are conflicting at any given time. Rebase your own branch the same day `main` moves under it, and ask the author to rebase theirs — never rewrite a contributor's branch. Conflict risk tracks how *contested* the files are, not how large the change is: tooling, tests and docs are nearly free, while `crates/fonts`, `ui-egui` and the i18n catalogues are where several people collide at once.
diff --git a/Cargo.lock b/Cargo.lock
index 68a758c2f..a95014924 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1887,7 +1887,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -2549,8 +2549,6 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
[[package]]
name = "hayro-cmap"
version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6"
dependencies = [
"brotli",
"hayro-postscript",
@@ -3938,8 +3936,9 @@ dependencies = [
[[package]]
name = "pdfcraft"
-version = "0.9.0"
+version = "0.11.0"
dependencies = [
+ "chrono",
"craft-quit",
"eframe",
"egui",
@@ -3963,7 +3962,7 @@ dependencies = [
[[package]]
name = "pdfcraft-a11y"
-version = "0.9.0"
+version = "0.11.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3975,7 +3974,7 @@ dependencies = [
[[package]]
name = "pdfcraft-annot"
-version = "0.9.0"
+version = "0.11.0"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
@@ -3986,7 +3985,7 @@ dependencies = [
[[package]]
name = "pdfcraft-audit"
-version = "0.9.0"
+version = "0.11.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3997,7 +3996,7 @@ dependencies = [
[[package]]
name = "pdfcraft-automation"
-version = "0.9.0"
+version = "0.11.0"
dependencies = [
"base64 0.22.1",
"flate2",
@@ -4011,6 +4010,7 @@ dependencies = [
"pdfcraft-render",
"pdfcraft-xfa",
"png 0.18.1",
+ "rustix 1.1.5",
"serde_json",
"sha2 0.11.0",
"tempfile",
@@ -4018,7 +4018,7 @@ dependencies = [
[[package]]
name = "pdfcraft-cli"
-version = "0.9.0"
+version = "0.11.0"
dependencies = [
"base64 0.22.1",
"pdfcraft-audit",
@@ -4028,6 +4028,7 @@ dependencies = [
"pdfcraft-platform",
"pdfcraft-preflight",
"pdfcraft-render",
+ "png 0.18.1",
"rustix 1.1.5",
"serde_json",
"winresource",
@@ -4035,19 +4036,20 @@ dependencies = [
[[package]]
name = "pdfcraft-compare"
-version = "0.9.0"
+version = "0.11.0"
[[package]]
name = "pdfcraft-content"
-version = "0.9.0"
+version = "0.11.0"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-cos"
-version = "0.9.0"
+version = "0.11.0"
dependencies = [
+ "chrono",
"hayro-syntax",
"pdfcraft-crypt",
"pdfcraft-filters",
@@ -4060,7 +4062,7 @@ dependencies = [
[[package]]
name = "pdfcraft-create"
-version = "0.9.0"
+version = "0.11.0"
dependencies = [
"encoding_rs",
"hayro-syntax",
@@ -4076,7 +4078,7 @@ dependencies = [
[[package]]
name = "pdfcraft-crypt"
-version = "0.9.0"
+version = "0.11.0"
dependencies = [
"aes",
"md-5",
@@ -4088,7 +4090,7 @@ dependencies = [
[[package]]
name = "pdfcraft-edit"
-version = "0.9.0"
+version = "0.11.0"
dependencies = [
"hayro-syntax",
"kurbo 0.13.1",
@@ -4103,7 +4105,7 @@ dependencies = [
[[package]]
name = "pdfcraft-engine"
-version = "0.9.0"
+version = "0.11.0"
dependencies = [
"chrono",
"craft-docpath",
@@ -4132,6 +4134,7 @@ dependencies = [
"pdfcraft-redact",
"pdfcraft-render",
"pdfcraft-sign",
+ "pdfcraft-testkit",
"pdfcraft-xfa",
"pdfcraft-xfdf",
"png 0.18.1",
@@ -4143,14 +4146,14 @@ dependencies = [
[[package]]
name = "pdfcraft-export"
-version = "0.9.0"
+version = "0.11.0"
dependencies = [
"flate2",
]
[[package]]
name = "pdfcraft-filters"
-version = "0.9.0"
+version = "0.11.0"
dependencies = [
"flate2",
"proptest",
@@ -4160,7 +4163,7 @@ dependencies = [
[[package]]
name = "pdfcraft-fonts"
-version = "0.9.0"
+version = "0.11.0"
dependencies = [
"harfrust",
"pdfcraft-cos",
@@ -4170,30 +4173,32 @@ dependencies = [
[[package]]
name = "pdfcraft-forms"
-version = "0.9.0"
+version = "0.11.0"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-fonts",
"pdfcraft-model",
+ "proptest",
"thiserror 2.0.21",
]
[[package]]
name = "pdfcraft-geom"
-version = "0.9.0"
+version = "0.11.0"
[[package]]
name = "pdfcraft-js"
-version = "0.9.0"
+version = "0.11.0"
dependencies = [
"boa_engine",
+ "chrono",
]
[[package]]
name = "pdfcraft-measure"
-version = "0.9.0"
+version = "0.11.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4206,14 +4211,14 @@ dependencies = [
[[package]]
name = "pdfcraft-model"
-version = "0.9.0"
+version = "0.11.0"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-ocr"
-version = "0.9.0"
+version = "0.11.0"
dependencies = [
"ocrs",
"pdfcraft-fonts",
@@ -4223,7 +4228,7 @@ dependencies = [
[[package]]
name = "pdfcraft-optimize"
-version = "0.9.0"
+version = "0.11.0"
dependencies = [
"image",
"pdfcraft-annot",
@@ -4236,11 +4241,11 @@ dependencies = [
[[package]]
name = "pdfcraft-oracle"
-version = "0.9.0"
+version = "0.11.0"
[[package]]
name = "pdfcraft-organize"
-version = "0.9.0"
+version = "0.11.0"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4251,7 +4256,7 @@ dependencies = [
[[package]]
name = "pdfcraft-platform"
-version = "0.9.0"
+version = "0.11.0"
dependencies = [
"async-channel",
"futures-lite",
@@ -4261,7 +4266,7 @@ dependencies = [
[[package]]
name = "pdfcraft-preflight"
-version = "0.9.0"
+version = "0.11.0"
dependencies = [
"flate2",
"pdfcraft-content",
@@ -4272,18 +4277,19 @@ dependencies = [
[[package]]
name = "pdfcraft-print"
-version = "0.9.0"
+version = "0.11.0"
dependencies = [
"craft-print",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-model",
+ "pdfcraft-render",
"thiserror 2.0.21",
]
[[package]]
name = "pdfcraft-redact"
-version = "0.9.0"
+version = "0.11.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4299,7 +4305,7 @@ dependencies = [
[[package]]
name = "pdfcraft-render"
-version = "0.9.0"
+version = "0.11.0"
dependencies = [
"bytemuck",
"flate2",
@@ -4318,7 +4324,7 @@ dependencies = [
[[package]]
name = "pdfcraft-sign"
-version = "0.9.0"
+version = "0.11.0"
dependencies = [
"aes",
"aws-lc-rs",
@@ -4326,6 +4332,7 @@ dependencies = [
"bp256",
"bp384",
"cbc",
+ "chrono",
"crypto-bigint",
"des",
"ecdsa",
@@ -4352,15 +4359,16 @@ dependencies = [
[[package]]
name = "pdfcraft-testkit"
-version = "0.9.0"
+version = "0.11.0"
[[package]]
name = "pdfcraft-ui-egui"
-version = "0.9.0"
+version = "0.11.0"
dependencies = [
"arboard",
"base64 0.22.1",
"bytemuck",
+ "chrono",
"craft-quit",
"craft-ui",
"eframe",
@@ -4400,7 +4408,7 @@ dependencies = [
[[package]]
name = "pdfcraft-web"
-version = "0.9.0"
+version = "0.11.0"
dependencies = [
"eframe",
"egui",
@@ -4412,7 +4420,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfa"
-version = "0.9.0"
+version = "0.11.0"
dependencies = [
"base64 0.22.1",
"image",
@@ -4426,7 +4434,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfdf"
-version = "0.9.0"
+version = "0.11.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-cos",
@@ -4934,7 +4942,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
- "windows-sys 0.52.0",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -5484,7 +5492,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
- "windows-sys 0.52.0",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -5554,7 +5562,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
- "windows-sys 0.52.0",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -5565,9 +5573,9 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
[[package]]
name = "rustls-webpki"
-version = "0.103.15"
+version = "0.103.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
+checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [
"aws-lc-rs",
"ring",
@@ -6431,7 +6439,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.5",
- "windows-sys 0.52.0",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -7306,9 +7314,9 @@ dependencies = [
[[package]]
name = "webpki-root-certs"
-version = "1.0.9"
+version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
+checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb"
dependencies = [
"rustls-pki-types",
]
@@ -7514,7 +7522,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.52.0",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -8034,6 +8042,7 @@ name = "xtask"
version = "0.1.0"
dependencies = [
"anyhow",
+ "chrono",
"lopdf",
"pdfcraft-cos",
"pdfcraft-oracle",
diff --git a/Cargo.toml b/Cargo.toml
index ec8909802..201240314 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.9.0"
+version = "0.11.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -21,39 +21,39 @@ craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.
# Whether a path stored in a document may be opened (GoToR link targets).
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
# internal
-pdfcraft-geom = { path = "crates/geom", version = "0.9.0" }
-pdfcraft-filters = { path = "crates/filters", version = "0.9.0" }
-pdfcraft-cos = { path = "crates/cos", version = "0.9.0" }
-pdfcraft-crypt = { path = "crates/crypt", version = "0.9.0" }
-pdfcraft-organize = { path = "crates/organize", version = "0.9.0" }
-pdfcraft-measure = { path = "crates/measure", version = "0.9.0" }
-pdfcraft-annot = { path = "crates/annot", version = "0.9.0" }
-pdfcraft-audit = { path = "crates/audit", version = "0.9.0" }
-pdfcraft-fonts = { path = "crates/fonts", version = "0.9.0" }
-pdfcraft-model = { path = "crates/model", version = "0.9.0" }
-pdfcraft-content = { path = "crates/content", version = "0.9.0" }
-pdfcraft-redact = { path = "crates/redact", version = "0.9.0" }
-pdfcraft-print = { path = "crates/print", version = "0.9.0" }
-pdfcraft-xfdf = { path = "crates/xfdf", version = "0.9.0" }
-pdfcraft-sign = { path = "crates/sign", version = "0.9.0" }
-pdfcraft-optimize = { path = "crates/optimize", version = "0.9.0" }
-pdfcraft-a11y = { path = "crates/a11y", version = "0.9.0" }
-pdfcraft-oracle = { path = "crates/oracle", version = "0.9.0" }
-pdfcraft-testkit = { path = "crates/testkit", version = "0.9.0" }
-pdfcraft-ocr = { path = "crates/ocr", version = "0.9.0" }
-pdfcraft-js = { path = "crates/js", version = "0.9.0" }
-pdfcraft-compare = { path = "crates/compare", version = "0.9.0" }
-pdfcraft-preflight = { path = "crates/preflight", version = "0.9.0" }
-pdfcraft-export = { path = "crates/export", version = "0.9.0" }
-pdfcraft-edit = { path = "crates/edit", version = "0.9.0" }
-pdfcraft-create = { path = "crates/create", version = "0.9.0" }
-pdfcraft-forms = { path = "crates/forms", version = "0.9.0" }
-pdfcraft-xfa = { path = "crates/xfa", version = "0.9.0" }
-pdfcraft-render = { path = "crates/render", version = "0.9.0" }
-pdfcraft-engine = { path = "crates/engine", version = "0.9.0" }
-pdfcraft-platform = { path = "crates/platform", version = "0.9.0" }
-pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.9.0" }
-pdfcraft-automation = { path = "crates/automation", version = "0.9.0", default-features = false }
+pdfcraft-geom = { path = "crates/geom", version = "0.11.0" }
+pdfcraft-filters = { path = "crates/filters", version = "0.11.0" }
+pdfcraft-cos = { path = "crates/cos", version = "0.11.0" }
+pdfcraft-crypt = { path = "crates/crypt", version = "0.11.0" }
+pdfcraft-organize = { path = "crates/organize", version = "0.11.0" }
+pdfcraft-measure = { path = "crates/measure", version = "0.11.0" }
+pdfcraft-annot = { path = "crates/annot", version = "0.11.0" }
+pdfcraft-audit = { path = "crates/audit", version = "0.11.0" }
+pdfcraft-fonts = { path = "crates/fonts", version = "0.11.0" }
+pdfcraft-model = { path = "crates/model", version = "0.11.0" }
+pdfcraft-content = { path = "crates/content", version = "0.11.0" }
+pdfcraft-redact = { path = "crates/redact", version = "0.11.0" }
+pdfcraft-print = { path = "crates/print", version = "0.11.0" }
+pdfcraft-xfdf = { path = "crates/xfdf", version = "0.11.0" }
+pdfcraft-sign = { path = "crates/sign", version = "0.11.0" }
+pdfcraft-optimize = { path = "crates/optimize", version = "0.11.0" }
+pdfcraft-a11y = { path = "crates/a11y", version = "0.11.0" }
+pdfcraft-oracle = { path = "crates/oracle", version = "0.11.0" }
+pdfcraft-testkit = { path = "crates/testkit", version = "0.11.0" }
+pdfcraft-ocr = { path = "crates/ocr", version = "0.11.0" }
+pdfcraft-js = { path = "crates/js", version = "0.11.0" }
+pdfcraft-compare = { path = "crates/compare", version = "0.11.0" }
+pdfcraft-preflight = { path = "crates/preflight", version = "0.11.0" }
+pdfcraft-export = { path = "crates/export", version = "0.11.0" }
+pdfcraft-edit = { path = "crates/edit", version = "0.11.0" }
+pdfcraft-create = { path = "crates/create", version = "0.11.0" }
+pdfcraft-forms = { path = "crates/forms", version = "0.11.0" }
+pdfcraft-xfa = { path = "crates/xfa", version = "0.11.0" }
+pdfcraft-render = { path = "crates/render", version = "0.11.0" }
+pdfcraft-engine = { path = "crates/engine", version = "0.11.0" }
+pdfcraft-platform = { path = "crates/platform", version = "0.11.0" }
+pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.11.0" }
+pdfcraft-automation = { path = "crates/automation", version = "0.11.0", default-features = false }
# pdf
hayro = "0.7.1"
@@ -68,6 +68,8 @@ serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
log = "0.4"
kurbo = "0.13"
+# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
+chrono = { version = "0.4.45", default-features = false, features = ["std"] }
# testing
proptest = "1"
@@ -112,6 +114,7 @@ codegen-units = 1
hayro-interpret = { path = "vendor/hayro-interpret" }
hayro = { path = "vendor/hayro" }
hayro-syntax = { path = "vendor/hayro-syntax" }
+hayro-cmap = { path = "vendor/hayro-cmap" }
hayro-jbig2 = { path = "vendor/hayro-jbig2" }
lopdf = { path = "vendor/lopdf" }
winit = { path = "vendor/winit" }
diff --git a/README.md b/README.md
index 2a65b6645..7d3069258 100644
--- a/README.md
+++ b/README.md
@@ -17,10 +17,13 @@
-
-
-
-
+
+
+
+
+
+
+
@@ -137,6 +140,8 @@ Right-click a PDF tab to close it, reopen the last closed file-backed tab, or re
Bookmarks, page thumbnails and the document's own page labels (i, ii, 1, 2…) keep you oriented in long documents. Search bookmark titles in the Bookmarks panel to find nested entries even when their parents are collapsed. Matches keep their ancestors for context; Clear restores the unfiltered tree without changing its expansion state.
+In **Inspector > Pages**, use **Thumbnail size** and the smaller/larger buttons to show more pages or inspect larger previews (50–150%). This changes only the thumbnails, keeping multi-page selection and the current page in view. The size belongs to the open document view and survives reloading its file; it is not remembered after restarting. Agents can set it with `ui.set {"key":"thumbnail-zoom","value":"75"}` and read `active.thumbnail_zoom` from `ui.state`.
+
diff --git a/ROADMAP.md b/ROADMAP.md
index b1283e775..bc057209c 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,17 @@
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
-> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged)
+> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged.)
+> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M11 #3507: image merging preserves content stream boundaries; estimates unchanged)
+> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged)
+> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M8, gap 6 #2662: the redaction audit counts a cover over one word of a long line; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
+> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M12 #3673: Character encoding accepts Identity-H/V with Adobe CJK collections; estimates unchanged)
+> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M2, #3601: missing spacing glyphs in non-embedded Japanese CID substitutes draw as blanks while PDF widths and Unicode stay unchanged; estimates unchanged)
+> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M3, #3541/#3620: comment hover follows individual marked lines and original annotation paint order; estimates unchanged)
+> **Last reviewed:** 2026-10-20 - **Last updated:** 2026-10-20 - **Change:** minor (M6, #3502: form detection uses the existing content-stream boundary policy; estimates unchanged)
+> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M2, #3574: named form properties retain page origin and own dictionary boundaries; estimates unchanged)
+> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M4: initial-view options resolve indirect boolean preferences; estimates unchanged)
+> **Last reviewed:** 2026-10-16; **Last updated:** 2026-10-16; **Change:** minor (M5 #2669, item 5: reply groups and page root counts per frame; estimates unchanged)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged)
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged)
@@ -16,10 +26,12 @@
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M6: preserve XML/XFDF attribute whitespace; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-16 | **Last updated:** 2026-10-16 | **Change:** minor (M10 #1201 partial: lossless image Decode; estimates unchanged) | **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
+> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-16 · **Change:** minor (M14: Czech catalog completed and reviewed; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M4 #1910: Organize Pages opens on the current page; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks keep press/release together on slow frames and preserve text multi-click selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M10 #2156 candidate: remember selected print paper by canonical name; estimates unchanged)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4 #1310 #1311 #1312: undo, revert and the next save keep the last saved revision; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -41,6 +53,12 @@
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced: #774, #756 and the #799–#821 batch; earlier: localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M6 #2892: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged)
+> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M7 #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Estimates unchanged)
+> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Estimates unchanged)
+> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Estimates unchanged)
+> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Estimates unchanged)
+> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M4 #3309: bounded iterative named-destination pruning on page deletion; estimates unchanged)
+> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M1 #2658: macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. Estimates unchanged)
PdfCraft's one-page summary: how close it is to Adobe Acrobat Pro's offline feature set, and what
comes next. Adobe's cloud services (Document Cloud, Acrobat Sign requests, shared review, the AI
@@ -132,18 +150,18 @@ What's strong and missing in each: [target-app-parity.md](docs/target-app-parity
## Languages
-Twelve key languages (historical % snapshot vs 2,143 keys, except Vietnamese vs the current
-union). Detail and script notes: [localization-parity.md](docs/localization-parity.md).
+Twelve key languages (historical % snapshot vs 2,143 keys, except Vietnamese, Brazilian
+Portuguese and Spanish vs the current union). Detail and script notes: [localization-parity.md](docs/localization-parity.md).
| Language | Code | Strings | Status |
|---|---|---|---|
| English | `en` | 100% | full |
| Simplified Chinese | `zh-hans` | 97.4% | partial (no Hans UI face in releases, #826) |
-| Spanish | `es` | 95.5% | partial |
+| Spanish | `es` | 100% of current union | partial (dialogs / tooltips; no help) |
| Hindi | `hi` | 0% | none |
| Arabic | `ar` | 100% | partial (layout not mirrored) |
| French | `fr` | 99.3% | partial |
-| Portuguese (Brazil) | `pt-br` | 99.4% | partial |
+| Portuguese (Brazil) | `pt-br` | 100% of current union | partial |
| Indonesian | `id` | 0% | none |
| Japanese | `ja` | 99.1% | partial |
| German | `de` | 99.3% | partial |
@@ -151,8 +169,8 @@ union). Detail and script notes: [localization-parity.md](docs/localization-pari
| Vietnamese | `vi` | 100% of current union | partial (dialogs / tooltips; no help) |
Also shipped: Traditional Chinese, Russian, Bulgarian, Telugu, Hungarian, Ukrainian, Italian,
-Kazakh, Polish, Slovenian, Greek, Turkish, Lithuanian and Czech (menus only): **14 more catalogs**
-(23 interface languages including English).
+Kazakh, Polish, Slovenian, Greek, Turkish, Lithuanian, Romanian and Czech: **15 more catalogs**
+(24 interface languages including English).
## Upcoming
@@ -171,20 +189,67 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
## Progress log
+- **2026-10-13 (M7, free image rotation):** A selected page image has a rotate knob (Shift: 15 degree steps) and `image_edit` a `rotate_by` action; the angle is written as one undoable `cm`. Snapping to page edges while moving is in #1740; resize snapping is not done. Estimates unchanged.
+
+- **2026-10-20 (M4, initial view):** Window and interface options read indirect boolean ViewerPreferences values. Synthetic regressions cover true/false, direct/indirect preference dictionaries and save/reopen; malformed entries retain false defaults. Estimates unchanged.
+- **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 (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.
+
+- **2026-10-19 (M11, #2178):** Optimize checks image and soft-mask dimensions and byte counts before decoding; hostile-dimension regressions pass. Estimates unchanged.
+
+- **2026-10-20 (M11, #3507):** Image merging keeps trailing comments, split operands and graphics state across content streams. Synthetic tool tests check saved text, rendered regions and exact image rows.
+
+- **2026-10-17 (M8, gap 6, #2662):** A filled cover over a long line counts when it hides half of any one word, not only 40% of the line's words or box, so a black box over one number in a long line — or an unapplied redaction mark over it — no longer audits clean while the number is still extractable; a sliver under half of every word it touches and under 40% of the line is still not flagged
+- **2026-10-13 (M10, #1757):** Office export keeps each dotted-leader contents entry in one paragraph, with its page number, instead of inferring table cells from its separate text objects.
+- **2026-10-20 (M6, calculation order page):** Prepare a form ▸ Calculation order, a page of its own, lists the calculated fields in the order they calculate with their formulas and values, moved with Up/Down arrows (as Acrobat's Set Field Calculation Order), Alt+↑/↓ or by dragging. A card says what is wrong with the order and holds Fix order; a loop has a card of its own that opens one of its formulas. On the page, each calculated field shows its step, and arrows show what the field in focus reads and what reads it. Estimates unchanged.
+- **2026-10-20 (M6, calculation order):** The order a form's fields calculate in (`/CO`) is read, checked, fixed and set headlessly with `form_calculation_order`: it says which fields read one calculated later (they show stale values), which read each other in a loop, and which the PDF's order leaves out; Fix order moves only what must move, in one undo step, and the form recalculates. Editing a formula no longer moves its field to the end of the order, and a field the order lists twice calculates once. Estimates unchanged.
+- **2026-10-20 (M2, #3574):** Forms without Resources use page Properties for ActualText and layer visibility. Synthetic tests cover boundaries, nested/shared forms, and save/reopen. Estimates unchanged.
+- **2026-10-20 (M2, PR #3348 review):** Tracking samples retain short repeated punctuation (up to four glyphs), keeping tracked ellipses intact and searchable while longer TOC leaders still preserve title word spaces. Synthetic regressions cover isolated and surrounded ellipses. Estimates unchanged.
+- **2026-10-20 (M2, #3601):** A missing spacing glyph in an OpenType substitute uses its ordinary-space glyph. Synthetic Japanese CID renderer tests check blank spaces, `/W` and `/DW` placement, and unchanged Unicode extraction. Font selection and embedded fonts are unchanged. Estimates unchanged.
+- **2026-10-20 (M12, #3673):** Character encoding accepts Identity-H/V fonts with Adobe CJK collections and recognizes predefined non-Identity CMaps independently. Unknown CMaps and foreign registries remain refused; synthetic checker and read-only automation regressions passed after failing before the fix. Per-glyph mapping validation and embedded-font extraction remain separate. Estimates unchanged.
+- **2026-10-19 (M6, renames keep formulas):** Renaming a field renames it in every calculation that reads it, in the same undo step: `AFSimple_Calculate` field lists, simplified field notation (the comment and the JavaScript after it) and `getField` calls naming it in one string literal; the rest of each script is kept as written. A sum used to drop the renamed field, notation read it as 0 and a script kept a stale value. Field Properties says how many formulas changed and `form_set_props` lists them as `formulas_updated`. Names built at run time and other events' scripts are not rewritten. Estimates unchanged.
+- **2026-10-19 (M6, text field table):** Prepare a form ▸ Text field table drags out an area and adds rows and columns of named text fields as one undoable edit, laid out on the page as displayed, with a live preview, per-column name patterns and widths, and refusal before anything is added when a cell would be too small or a name taken; `form_add_field_table` does the same headlessly. `form.multiple-copies` is partial: copying an existing field into a grid is still missing. Estimates unchanged.
+
+- **2026-10-16 (M7, #2516):** A paragraph number from `text_paragraphs` keeps naming the paragraph it listed after edits regroup the page: `text_edit`, `text_edit_layout` and `page_render`'s `hide_text_block` find it again by its text and box, and refuse one that was merged into a neighbour (the edited paragraph included) instead of rewriting the paragraph that took its number (`stale_paragraphs` lists the numbers gone). The inline editor's switch-paragraph click holds the same key. No stable identity across save and reopen yet (#2508).
+- **2026-10-19 (M7, #3591):** A background added to a page that already has one is painted over the old one instead of going under it and staying hidden. Behind the content, backgrounds come first, in the order added, and then behind-content watermarks, so a watermark added after a background no longer disappears. Synthetic regressions in `crates/edit` check the order. Estimates unchanged.
+- **2026-10-19 (M2, radial luminosity masks):** Added synthetic radial-fade coverage for the existing masked-gradient fix (#2132): centre, midpoint, antialiased clip and per-pixel shading reference, including previews, scales and tiles. Removing the existing native-gradient mask guard reproduces an opaque midpoint; restoring it passes. No new renderer behavior or estimate change.
+- **2026-10-20 (M6, #3502):** Form detection preserves comments across content streams and does not add fields over rectangles hidden by those comments. Synthetic tests cover visible controls, graphics state, and saved field inventories. Estimates unchanged.
+- **2026-10-20 (M6, #2273 / PR #2279):** Rebased the field-highlight default and Appearance preference onto main, retaining secured-form parity evidence and updating secured AcroForm/XFA toggle expectations. Build and test validation delegated to GitHub CI. Estimates unchanged.
+- **2026-10-20 (M5, reshape lines):** A line or arrow could only be moved after it was drawn; its length and angle stayed. A selected line, connected lines, polygon or cloud now has a handle on each end or corner: dragging one reshapes it (Shift holds it to 45°), as one undo step, with the new shape drawn while dragging. `pdfcraft_annot::set_points`, `Edit::ReshapeAnnotation`, `comment_edit points`; comment lists carry the points. Estimates unchanged.
+- **2026-10-20 (M3, #3620):** Overlapping comment hover uses the original annotation index independently of spatial inspection order. A synthetic UI regression covers different annotation upper edges. Estimates unchanged.
+
+- **2026-10-20 (M3, #3541/#3620):** Comment hover follows the individual marked lines instead of the enclosing multiline rectangle, and overlapping comments follow paint order. Synthetic UI regressions cover all four text-markup kinds, page/view rotations, neighboring highlights, legacy rectangle fallback and unchanged PDF bytes. Estimates unchanged.
+- **2026-10-20 (M10, #3532 partial):** Vector printing retains existing widget artwork when its appearance lacks Subtype. A print-owned copy adds Form dispatch metadata. The source stream and all explicit subtypes stay unchanged. Synthetic source/output raster checks cover resources, selected/shared appearances, placement, flags and content modes. Appearance regeneration and physical printer validation remain outside this fix. Estimates unchanged.
- **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged.
- **2026-10-19 (M3, #3387):** Save-prompt shortcuts yield to later consent and utility dialogs; a synthetic UI regression checks that an edited document stays open. Estimates unchanged.
+- **2026-10-18 (M3, PR #3101 review):** Gap changes in Organize Pages preserve document scroll position; menu and control changes apply to both comparison panes without changing unrelated tabs. Three synthetic UI regressions were observed failing before the fixes. Readiness estimates unchanged.
+
+- **2026-10-18 (M3, PR #3101):** Optional gapless continuous page display with persisted defaults and UI control support; both new labels translated in all 22 catalogs. Rebased onto current main, preserving navigation and rendering fixes. Readiness estimates unchanged.
+
- **2026-10-18 (M10, #3120):** Export to Word, PowerPoint, HTML and RTF reads text from form XObjects nested as deep as the renderer draws them (50 levels), and up to 50 000 forms a page (the renderer's budget for nested ones); it stopped at 12 levels and 4 096 forms and left the rest out silently. A page past those limits is listed in `doc_export_office`'s `truncated_pages`, with a warning. Estimates unchanged.
- **2026-10-18 (M5, PR #1971 review):** Nested FDF annotation values that identify a source `/Page` or `/Pages` dictionary become null at every copy depth. A synthetic three-page PDF saved and reopened before export verifies that destination actions cannot pull source page content into the FDF, while unrelated nested metadata and shared references remain intact. FDF import semantics, copied-graph limits and estimates are unchanged.
- **2026-10-15 (M5, PR #1971 refresh):** Nested FDF annotation values retain shared or cyclic copied graphs. Discovery borrows direct values and records annotation positions with lazy array owners; retained entries are budgeted before cloning. Synthetic stress fixtures include repeated aliases and saved-PDF cache eviction. Limits cover the copied graph; source parsing and page discovery retain their existing limits, with no total source-memory bound. FDF import semantics and estimates are unchanged.
- **2026-10-13 (M5, nested FDF values):** Comment export copies and renumbers nested indirect values, retaining geometry, colours, borders and shared graphs. Graph/data limits return an error before the destination is written; synthetic saved-PDF and automation exchange regressions. Estimates unchanged.
+- **2026-10-18 (M2, #3086):** Text extraction estimates letter tracking without repeated punctuation gaps so dotted TOC leaders do not swallow word spaces. Synthetic PDF regressions cover leader lengths, phrase search and selection; strongly tracked text stays intact. Estimates unchanged.
- **2026-10-18 (M6, #2892):** Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged.
- **2026-10-18 (M7, #3212):** Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, with original content preserved.
+- **2026-10-18 (M7, #3187):** Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Estimates unchanged.
+- **2026-10-18 (M12, #2569):** Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Estimates unchanged.
+- **2026-10-18 (M12, #2572):** Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Estimates unchanged.
+- **2026-10-18 (M12, #2570):** Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Estimates unchanged.
+- **2026-10-19 (M4, #3309 review):** Page deletion keeps working with 60,000 named destinations, without a name-count traversal limit. Synthetic regressions verify every surviving target after deletion and restoration of the deleted page and its destinations by undo/redo, full/incremental save/reopen and unchanged rendering. Estimates unchanged.
+- **2026-10-18 (M4, #3309):** Deleting a page prunes named destinations without recursive calls. Cyclic and shared indirect nodes are visited once. Synthetic deep/cyclic graph, save/reopen and automation regressions cover the change. Estimates unchanged.
+- **2026-10-18 (M1, #2658):** macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. Estimates unchanged.
- **2026-10-15 (M7, Edit text caret):** In a text region that mixes sizes (a 24 pt title over 12 pt lines), the inline editor's caret on a small row was as tall as the region's first size and reached into the row below: egui draws the caret at least one row of the text field's font tall, and that font had the paragraph's first size. The field's font is now half the region's smallest size, so each row's own height decides. Kittest regression; checked in the app on a real document. Estimates unchanged.
- **2026-10-16 (M3, #2781):** The Pages panel follows the current page, as Acrobat's page thumbnails do: going to a page whose thumbnail is out of sight (scrolling, Page Down, Go to page, a bookmark) scrolls the panel just enough to show it. A thumbnail already in view, or clicked, leaves the panel where it is, and a page change while the user scrolls the panel or drags thumbnails does not move it. Estimates unchanged.
- **2026-10-16 (M12, #1645):** CLI info, text and render use the engine's dynamic XFA layout rather than placeholder pages. Synthetic two-page and encrypted-form regressions cover counts, text, pixels, copy permissions and unchanged input bytes. Estimates unchanged.
@@ -201,6 +266,10 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-16 (M10, #1201 partial):** Lossless image export applies each component's Decode pair. Indexed values round and clip before palette lookup. Color-key masks keep raw samples. The existing 16-bit precision limit remains. JPEG and mask Decode, ICC transforms, and calibrated colors remain outside this fix. Estimates unchanged.
- **2026-10-16 (M3, #2739):** Wrapped revision buttons stay above Binding in Document Properties. A synthetic regression checks control bounds and opens a saved revision at two viewport widths. Estimates unchanged.
- **2026-10-19 (M7, tracked text):** Editable paragraphs and object labels distinguish repeated glyph tracking from word gaps in TJ arrays and separately positioned glyphs. Synthetic regressions cover character spacing, horizontal scaling, rotated baselines, larger word gaps, and rows of one-glyph words or cells (inferred tracking is capped at half an em, so `a b c` and digit or Y/N columns keep their spaces). Estimates unchanged.
+- **2026-10-16 (M5, #2669, item 5):** The Comments panel groups replies by name and counts all page roots once per frame. Estimates unchanged.
+- **2026-10-16 (M7, #2140):** Header & footer (and Bates numbering, which uses the same dialog) can shrink the page content so the header and footer don't cover it: the original content is scaled evenly into the band they leave free, centred across, also on rotated pages; Remove and Update take the scaling away, and margins that leave no room are refused. Dialog checkbox with a preview of the band, `shrink` on `doc_header_footer`, all catalogs. Annotations and added text and images are not moved yet. Estimates unchanged.
+- **2026-10-20 (M14, Spanish catalog gaps):** `es.tsv` gains the 143 `(context, English)` keys the other catalogs carried and it was missing, so Spanish covers the whole bundled key union like Lithuanian and Vietnamese: calendar months and weekdays, the `certificate` context, X.509 key usages, signature-revision and digital-ID lines, password validation, eight plural messages, the combine-from-folder strings, the rendering and stale-file notices and the shortcut-spelled tooltips. Counts in `localization-parity.md` re-measured against the current union (`vi` restated against it for the same reason). Status stays `partial`: no translated help, and OS details stay outside the catalog. Estimates unchanged.
+- **2026-10-16 (M14, localization):** Czech goes from menus-and-dialogs to the whole surface: 2,674 entries after adding the ~220 keys the other catalogs had -- the sentences the engine words (signature details, accessibility findings and report, PDF/A problems, pages to print, history labels), the date picker's weekday heads, the certificate viewer's fields and shared buttons. "Tag" is *tag* throughout (the Tags panel said *značky*, which is also a redaction mark), and "{n} tags" no longer reads "1 značek". Two new catalog tests. Estimates unchanged.
- **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged.
- **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged.
- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of a form, with its button, comes last). Acrobat Pro also puts that page last, and when printing keeps it but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet; both re-observed on a synthetic fixture, see the 2026-10-19 line. Synthetic regression tests. Estimates unchanged.
@@ -210,14 +279,21 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged.
- **2026-10-13 (M6, data exchange fidelity):** XML and XFDF exports preserve tabs and line breaks in field names and comment metadata using character references. Saved-PDF regressions cover distinct names that XML would normalize to the same attribute value, restored field values and replacement of the original comment identities. Estimates unchanged.
- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. Synthetic regression test; the page area's `occur min` (a once-only page of its own) is still not laid out, recorded in `form.xfa-dynamic`. Estimates unchanged.
+- **2026-10-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-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
- **2026-10-15 (M14, #2383 candidate):** Device-limit requests now clamp unsupported maxima and minimum alignments before enabling the adapter's full 2D texture extent. This addresses the reported V3D request of 16 shader variables on an adapter supporting 15. Synthetic regressions fail before the clamp and pass afterward, along with the existing HiDPI case and Windows GPU-required application tests. Raspberry Pi startup remains unverified; milestone and overall estimates are unchanged.
- **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged.
- **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor).
+- **2026-10-15 (M14, Norwegian Bokmål localization):** Added Norwegian Bokmål (`nb`) as an interface and date language: a catalog covering every key the other catalogs have, Norwegian key names, month and weekday names for Fill & Sign dates, and `no`/`nn` locales resolving to it until a Nynorsk catalog exists. Written from the meaning of the English labels; terminology reviewed by a native-speaking contributor, not yet every line. Estimates unchanged.
- **2026-10-12 (M2, inline-image boundaries):** Unfiltered inline images with known sample layouts keep pixel bytes that look like the `EI` terminator when content is rewritten. Regression tests cover row padding, colour-space and parameter aliases, masks and malformed sizes; classic and packed saved PDFs retain the pixels in independent readers and renderers. Filtered and resource-defined layouts retain the existing tolerant heuristic. Estimates unchanged.
- **2026-10-12 (M3, drawing markups):** While a comment is being drawn, the pointer in a band along any edge of the view (or just past it) scrolls the view up, down, left or right, so points beyond what is shown can be reached. A polygon, cloud or run of connected lines takes back its last point with Backspace, Delete or Undo (the first point's removal cancels it). Edge scrolling has unit tests of its maths only; it has not been exercised end to end in the app by an automated test.
+- **2026-10-15 (M2, large object streams):** An object stream's index of `objnum offset` pairs is read once per stream instead of once per object fetched from it (vendored hayro-syntax patch 22), so reading every object of a stream of N objects no longer takes N² steps. A Firefox page whose 65,542 objects share one stream (pdf.js corpus `bug1978317.pdf`) drew in 37 s and extracted its text in 40 s; it now takes 0.5 s and 1.9 s, and leaves the robustness baseline's known timeouts. 40,000 pages in one stream open in 0.2 s instead of 54 s. The regression fails before the fix. Estimates unchanged.
- **2026-10-12 (gap 6, #1319, #1305, #1324):** Editing an XFA form keeps a radio item's stored text when it contains a space, so a later layout still selects it. Replace Pages on a split selection replaces those pages and leaves the gap; editing the dialog range still replaces the span. Marking a figure decorative rewrites only that figure's content stream, so a header, added text and unknown stream entries stay. Estimates unchanged.
- **2026-10-15 (M14, gap 24):** `misc.localization` and `misc.installers` move from planned to partial. Localization: 22 catalogs ship, but Chinese shows tofu in releases, Telugu has no release font and Arabic is not mirrored. Installers: macOS and Windows releases are signed, Linux packages are not. The Acrobat menu diff itself remains open. Estimates unchanged.
+- **2026-10-15 (M14, PNG export):** Export ▸ Image as PNG, `pdfcraft-cli render` and the `page_render` automation/MCP tool write an opaque page as RGB at deflate level 3 instead of RGBA at the default level 6: the files stay the same size and are written 3–5× faster (a 300-dpi scan drawn at 220 dpi: 805 → 167 ms; a text page at 220 dpi: 70 → 40 ms; an annual-report page at 150 dpi: 179 → 64 ms). A page with translucent pixels is still written as RGBA. The automation tools use the engine's encoder instead of a copy of it. Estimates unchanged.
+- **2026-10-15 (G14, Romanian localization):** Added Romanian (`ro`, Română) as an interface and date language with a 2,673-entry catalog covering every bundled key, and a CLDR plural rule (`plural_ro`: one / few / other, "20 de pagini"). Locale tags `ro` / `ro-RO` / `ro_RO.UTF-8` / `ro-MD` resolve, so Auto picks it on Romanian Windows. Coverage and plural tests added. Status `partial` like the other catalogs (no translated help).
+- **2026-10-22 (M14, JPEG and TIFF export):** Export ▸ Image as JPEG or TIFF refuses a raster that is not exactly width × height pixels, as PNG export has since #2425. A raster a pixel short panicked inside the JPEG and TIFF encoders, and a partial pixel at the end was dropped and the rest written as a valid image. Rendered pages are always the right size, so what exports is unchanged. Estimates unchanged.
- **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design.
- **2026-10-13 (M3, typed zoom):** The right rail's zoom percentage is an editable field: type 150, 150% or 62.5 % and press Enter, Escape discards, and nonsense keeps the current zoom. The control channel's zoom option shares its parser, which now refuses zero, negative and non-finite values.
@@ -260,6 +336,13 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-14 (M3, control-click candidate):** Coordinate clicks retain hover warmup and pair press/release in one input pass so slow frames do not turn a click into a hold. Text multi-click selection accepts the actual press event when egui has already cleared its origin. Regression cases cover slow-frame button activation, disabled controls and coalesced text selection; estimates unchanged.
- **2026-10-14 (community, M7, #128 Hebrew in Add text):** Add text and `page_add_text` draw Hebrew with the craft-fonts Hebr face (Noto Sans Hebrew), through the same shaped path as Arabic and Thai: right-to-left display order with numbers and Latin in place, points and cantillation kept on their letter as one Type 3 glyph, ToUnicode and per-line ActualText so extraction, search and the editor get typing order. Builds without the face refuse Hebrew rather than write `?`. Regular style only. Estimates unchanged.
- **2026-10-14 (M9, #1787):** Changes after signing are judged by the role an object held when signed for a page's resources too: a rewritten font, XObject, graphics state or resources dictionary is page content however its new version is labelled, so a `/FT` or `/Subtype /Widget` added by the attacker no longer reads as a permitted form fill. A category dictionary that only gained a name is judged as before.
+- **2026-10-14 (M3, full screen as presentation, #379/#1459/#1353):** Full Screen Mode shows one page (or one spread) at a time fitted to the screen on black, with no tools; click, Space, Enter and the arrows turn pages; Esc restores the display from before. Transitions, auto-advance and loop remain planned.
+- **2026-10-14 (M10, #1818):** Properties… reads a CUPS printer's driver options off the GUI thread, the dialog saying so meanwhile, and `lpoptions` is given ten seconds before it is killed and the PPD's defaults stand; a read that dies leaves the panel with nothing rather than a frozen window. The bounded wait is tested with a stand-in command that never answers.
+- **2026-10-14 (M10 #2156 candidate):** Print paper selection survives Cancel and settings restoration; canonical names avoid preset-index dependence. Other print preferences and estimates unchanged.
+
+- **2026-10-15 (community, M14, Polish signature panel):** A Polish signature panel listed its details in English: the catalog lacked every signature wording, the changes after signing they name and 34 UI literals. All 101 are translated; `polish_covers_ui_literals` and `polish_says_what_a_signature_says` keep them so. Estimates unchanged.
+- **2026-10-14 (M14, Finnish localization):** Added Finnish as another interface and date language, with a 2,674-entry catalog covering every key of the other bundled catalogs and two-form plurals (partitive after numbers other than 1). Calendar dates use partitive month names (`14. lokakuuta 2026`). Coverage tests check commands/tools, scanned UI literals and bundled catalog keys. AI-assisted; native proofreading pending.
+- **2026-10-14 (M14, Catalan localization):** Added Catalan as another interface and date language, with a 2,592-entry clean-room catalog, one/other plurals and coverage tests for commands, tools, UI literals and every key in the other catalogs. A native Catalan speaker (the contributor) has reviewed the translation. Estimates unchanged.
- **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged.
- **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged.
- **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged.
@@ -552,6 +635,10 @@ Newest first: the date, what moved, and the new overall estimate.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-20 | minor | M4: initial-view window and interface options resolve indirect boolean ViewerPreferences values; synthetic true/false and save/reopen regressions retain defaults for malformed entries. Estimates unchanged. |
+| 2026-10-20 | minor | M6, #3502: form detection preserves comments across content streams; estimates unchanged. |
+| 2026-10-20 | minor | M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged. |
+| 2026-10-20 | minor | M11 #3507: image merging preserves comments and state across content streams. Estimates unchanged. |
| 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. |
| 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. |
| 2026-10-15 | minor | M7: the Edit text caret is as tall as the row it is on in a region with several sizes; estimates unchanged. |
@@ -560,6 +647,7 @@ Newest first: the date, what moved, and the new overall estimate.
| 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. |
| 2026-10-13 | minor | M6: XML/XFDF attribute whitespace survives form and comment export/import; estimates unchanged |
| 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. |
+| 2026-10-16 | minor | M14: Czech catalog completed (2,674 entries) and "tag" terminology unified. Estimates unchanged. |
| 2026-10-13 | minor | M9: separate page validation overlays preserve signature artwork, and snapshot tabs reuse instead of multiplying; baked captions remain unchanged (#79 open); estimates unchanged |
| 2026-10-16 | minor | M10 #1201 partial: lossless image export applies component Decode pairs and Indexed mapping. Estimates unchanged. |
| 2026-10-15 | minor | M6: CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Estimates unchanged. |
@@ -591,3 +679,8 @@ Newest first: the date, what moved, and the new overall estimate.
- **2026-10-13 (M7):** Edit a PDF ▸ Add content: Header and footer, Watermark and Background open an Add / Update / Remove menu (Update and Remove enabled only when the document has that mark), as Acrobat's Watermark control does. The catalogue gains `ToolItem::actions`, so any tool can list more commands (#1192).
- **2026-10-18 (M8, #1350):** Published the history-preserving integration; simplified the inherited-form resource guard for Rust 1.96 Clippy. Formatting, 64 redaction tests, scoped Clippy, layering, assets and the scoped wasm check pass locally. CI remains pending; estimates unchanged.
+- **2026-10-12 (i18n):** Albanian (`sq`, Shqip) added: 2,343-entry catalog, month and weekday names, Preferences entry; not yet reviewed by a native speaker.
+
+- **2026-10-21 (M4, #3865):** A new bookmark records where the reader is: the top-left of the selected text, or the top of the window, as `/XYZ left top null` (zoom kept), instead of the page top (`Edit::AddBookmark` takes `left` and `top`; `bookmark_add` takes `top` and `left`; `PageInfo::xyz_for_view`).
+
+- **2026-10-18 (renderer, hayro patches 19/35):** A mesh shading (types 4–7) is sampled over its own triangles instead of the whole path: a BricsCAD plan with 310 one-triangle `sh` fills took 2 s per 1024 px tile at every zoom (and hit the 20 s watchdog on a busy machine); now about 70 ms, so zooming into dense topology drawings stays fluid. Estimates unchanged.
diff --git a/apps/pdfcraft-cli/Cargo.toml b/apps/pdfcraft-cli/Cargo.toml
index bab8c28a8..991cbf98f 100644
--- a/apps/pdfcraft-cli/Cargo.toml
+++ b/apps/pdfcraft-cli/Cargo.toml
@@ -23,6 +23,10 @@ pdfcraft-automation = { workspace = true }
pdfcraft-platform = { workspace = true }
base64 = "0.22"
+[dev-dependencies]
+# Decodes `render` output in tests/owner_password.rs; already a dependency of the engine.
+png = "0.18"
+
[target.'cfg(unix)'.dependencies]
# `geteuid` for `ui`'s control-file owner check: safe, so no `unsafe` libc call here.
# Already in Cargo.lock through the GUI crates.
diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs
index 5888e5111..14500a384 100644
--- a/apps/pdfcraft-cli/src/main.rs
+++ b/apps/pdfcraft-cli/src/main.rs
@@ -176,7 +176,12 @@ fn audit(args: &[String]) -> Result<(), CliError> {
let findings = pdfcraft_audit::audit_redactions(&doc).map_err(|e| format!("{path}: {e}"))?;
// Rectangles in points from the top-left of the displayed page, as `doc_audit_redactions`
// reports them; the page geometry is read only when there is something to place.
- let info = if findings.is_empty() { None } else { Some(inspect(bytes, password).map_err(|e| format!("{path}: {e}"))?) };
+ let 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| {
@@ -299,7 +304,9 @@ fn info(args: &[String]) -> Result<(), CliError> {
let path = *positional(args).first().ok_or("info: missing file")?;
let bytes = read(path)?;
let password = flag(args, "--password");
- let mut info = inspect(bytes.clone(), password).map_err(|e| e.to_string())?;
+ // 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).
@@ -366,6 +373,9 @@ fn text(args: &[String]) -> Result<(), CliError> {
}
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();
@@ -378,12 +388,12 @@ fn text(args: &[String]) -> Result<(), CliError> {
bytes = doc.display.clone();
}
}
- let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: password.map(Arc::from), ..Default::default() });
+ 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(), password).map_err(|e| format!("text: {e}"))?;
+ 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());
}
@@ -478,17 +488,31 @@ fn edit(args: &[String]) -> Result<(), CliError> {
"--password" => password = Some(value),
"--rotate" => {
let (pages, deg) = value.split_once(':').ok_or("--rotate PAGES:DEGREES")?;
- edits.push(Edit::RotatePages { pages: page_list(pages)?, degrees: deg.parse().map_err(|_| "bad 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 to: usize = to.parse().map_err(|_| "bad target")?;
- edits.push(Edit::MovePages { pages: page_list(pages)?, to: to.checked_sub(1).ok_or("--move target must be at least 1")? });
+ 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 at: usize = value.parse().map_err(|_| "bad position")?;
- edits.push(Edit::InsertBlankPage { at: at.checked_sub(1).ok_or("--insert-blank must be at least 1")?, width: 612.0, height: 792.0 });
+ 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() }),
@@ -570,7 +594,16 @@ fn split(args: &[String]) -> Result<(), CliError> {
use pdfcraft_engine::SplitBy;
let path = *positional(args).first().ok_or("split: missing file")?;
let by = match (flag(args, "--every"), flag(args, "--before")) {
- (Some(n), None) => SplitBy::PageCount(n.parse().map_err(|_| "bad --every")?),
+ (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()),
};
@@ -590,12 +623,25 @@ fn split(args: &[String]) -> Result<(), CliError> {
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).first().ok_or("render: missing file")?;
- let page: usize = flag(args, "--page").unwrap_or("1").parse().map_err(|_| "bad --page")?;
- let page_index = page.checked_sub(1).ok_or("--page must be at least 1")?;
- let dpi: f32 = flag(args, "--dpi").unwrap_or("96").parse().map_err(|_| "bad --dpi")?;
+ let 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(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() {
@@ -609,14 +655,18 @@ fn render(args: &[String]) -> Result<(), CliError> {
// must agree with it (and with independent viewers), while keeping the file it reads intact.
let bytes = read(path)?;
let password = flag(args, "--password");
- let display = if inspect(bytes.clone(), password).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) {
+ // 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: password.map(Arc::from), reject_oversize: true, ..Default::default() });
+ 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());
@@ -647,7 +697,7 @@ fn render(args: &[String]) -> Result<(), CliError> {
fn check_one(args: &[String]) -> Result<(), CliError> {
validate_options("check-one", args, CHECK_ONE_OPTIONS)?;
let path = *positional(args).first().ok_or("check-one: missing file")?;
- let dpi: f32 = flag(args, "--dpi").unwrap_or("36").parse().map_err(|_| "bad --dpi")?;
+ 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) {
@@ -739,12 +789,23 @@ fn collect(paths: &[&str]) -> Vec {
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));
if files.is_empty() {
return Err("check: no PDF files found".into());
}
- let timeout = Duration::from_secs(flag(args, "--timeout").unwrap_or("20").parse().map_err(|_| "bad --timeout")?);
- let dpi = flag(args, "--dpi").unwrap_or("36").to_string();
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();
diff --git a/apps/pdfcraft-cli/tests/check.rs b/apps/pdfcraft-cli/tests/check.rs
index a206df591..7a6236419 100644
--- a/apps/pdfcraft-cli/tests/check.rs
+++ b/apps/pdfcraft-cli/tests/check.rs
@@ -1,4 +1,5 @@
//! The robustness harness visits each directory once, including symlink aliases and cycles.
+//! Its `--timeout` and `--dpi` options are checked before anything is scanned.
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
@@ -109,3 +110,22 @@ fn check_follows_directory_and_file_symlinks() {
assert_eq!(results[0]["status"], "ok");
assert_eq!(results[0]["file"], file_alias.to_string_lossy().as_ref());
}
+
+/// `--timeout` and `--dpi` are checked before any input is scanned: a zero timeout used to
+/// make every file time out at once, and a dpi that is not a positive finite number used to
+/// reach each child, which printed nothing and was reported as a crash per file.
+#[test]
+fn a_bad_timeout_or_dpi_is_refused_before_scanning() {
+ let out = Command::new(BIN).args(["check", "no-such-input", "--timeout", "0"]).output().unwrap();
+ assert_eq!(out.status.code(), Some(1));
+ assert_eq!(out.stderr, b"pdfcraft-cli: bad --timeout: expected a positive whole number of seconds, got \"0\"\n");
+ for value in ["abc", "0", "-3", "NaN"] {
+ let out = Command::new(BIN).args(["check", "no-such-input", "--dpi", value]).output().unwrap();
+ assert_eq!(out.status.code(), Some(1), "--dpi {value}");
+ assert_eq!(
+ out.stderr,
+ format!("pdfcraft-cli: bad --dpi: expected a positive number of dots per inch, got {value:?}\n").as_bytes(),
+ "--dpi {value}"
+ );
+ }
+}
diff --git a/apps/pdfcraft-cli/tests/edit.rs b/apps/pdfcraft-cli/tests/edit.rs
index b35b71ddd..3cd1442d8 100644
--- a/apps/pdfcraft-cli/tests/edit.rs
+++ b/apps/pdfcraft-cli/tests/edit.rs
@@ -141,9 +141,9 @@ mod one_based_arguments {
for destination in ["output.pam", "new.pam", "input.pdf"] {
std::fs::write(dir.0.join("output.pam"), b"keep destination").unwrap();
for (command, option, value, diagnostic) in [
- ("render", "--page", "0", "--page must be at least 1"),
- ("edit", "--move", "1:0", "--move target must be at least 1"),
- ("edit", "--insert-blank", "0", "--insert-blank must be at least 1"),
+ ("render", "--page", "0", "bad --page: expected a positive page number"),
+ ("edit", "--move", "1:0", "bad --move target: expected a page number of at least 1, got \"0\""),
+ ("edit", "--insert-blank", "0", "bad --insert-blank position: expected a page position of at least 1, got \"0\""),
] {
let out = Command::new(BIN).current_dir(&dir.0).args([command, input, "--out", destination, option, value]).output().unwrap();
assert_eq!(out.status.code(), Some(1), "{command} {input} {option} {value}");
@@ -194,6 +194,33 @@ mod one_based_arguments {
}
}
+/// A value the option cannot hold names the option and the value, and fails before the input
+/// file is opened: a missing input still gets the option's own message.
+#[test]
+fn a_value_an_option_cannot_hold_is_named_before_the_file_is_opened() {
+ let dir = tmp("values", "");
+ for (option, value, diagnostic) in [
+ ("--rotate", "1:abc", "bad --rotate degrees: expected a whole number of degrees, got \"abc\""),
+ ("--rotate", "1:45", "bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got 45"),
+ ("--rotate", "1:-45", "bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got -45"),
+ ("--move", "1:abc", "bad --move target: expected a page number of at least 1, got \"abc\""),
+ ("--move", "1:0", "bad --move target: expected a page number of at least 1, got \"0\""),
+ ("--insert-blank", "abc", "bad --insert-blank position: expected a page position of at least 1, got \"abc\""),
+ ("--insert-blank", "0", "bad --insert-blank position: expected a page position of at least 1, got \"0\""),
+ ] {
+ let out = Command::new(BIN).current_dir(&dir).args(["edit", "missing.pdf", "--out", "out.pdf", option, value]).output().unwrap();
+ assert_eq!(out.status.code(), Some(1), "{option} {value}");
+ assert_eq!(out.stderr, format!("pdfcraft-cli: {diagnostic}\n").as_bytes(), "{option} {value}");
+ assert!(out.stdout.is_empty(), "{option} {value}");
+ assert!(!dir.join("out.pdf").exists(), "{option} {value}: no output may be written");
+ }
+ // A negative multiple of 90 is still a rotation the engine accepts: the refusal above is
+ // the multiple-of-90 rule, not a sign problem (the missing input is the next error).
+ let out = Command::new(BIN).current_dir(&dir).args(["edit", "missing.pdf", "--out", "out.pdf", "--rotate", "1:-90"]).output().unwrap();
+ assert_eq!(out.status.code(), Some(1));
+ assert!(!String::from_utf8_lossy(&out.stderr).contains("--rotate degrees"), "{}", String::from_utf8_lossy(&out.stderr));
+}
+
/// A destination past the end must fail, not silently become an append (#1330).
#[test]
fn a_destination_past_the_end_fails_and_writes_nothing() {
diff --git a/apps/pdfcraft-cli/tests/owner_password.rs b/apps/pdfcraft-cli/tests/owner_password.rs
new file mode 100644
index 000000000..679698098
--- /dev/null
+++ b/apps/pdfcraft-cli/tests/owner_password.rs
@@ -0,0 +1,154 @@
+//! `info`, `text` and `render` take the owner password of revision 2–4 files, as `run doc_open`
+//! does (#2670, item 8). The renderer authenticates only user passwords of those revisions, so
+//! the CLI must hand it the user password that owner authentication recovers.
+
+use std::process::Command;
+use std::sync::Arc;
+
+use pdfcraft_cos::{Algorithm, NewEncryption};
+
+const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
+
+/// A one-page 200×300 pt PDF reading "Page 1", drawn by `content`.
+fn fixture(content: &str) -> Vec {
+ let stream = format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len());
+ let objs: [&[u8]; 5] = [
+ b"<< /Type /Catalog /Pages 2 0 R >>",
+ b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>",
+ b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
+ b"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>",
+ stream.as_bytes(),
+ ];
+ let mut out = b"%PDF-1.7\n".to_vec();
+ let mut offsets = Vec::new();
+ for (i, o) in objs.iter().enumerate() {
+ offsets.push(out.len());
+ out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
+ out.extend_from_slice(o);
+ out.extend_from_slice(b"\nendobj\n");
+ }
+ let xref = out.len();
+ out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
+ for o in offsets {
+ out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
+ }
+ out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
+ out
+}
+
+const PAGE: &str = "BT /F1 24 Tf 20 150 Td (Page 1) Tj ET";
+
+/// `fixture` protected with user password "user" and owner password "owner", allowing nothing,
+/// so copying its text needs the owner password.
+fn protected(algorithm: Algorithm) -> Vec {
+ protected_with(algorithm, PAGE)
+}
+
+fn protected_with(algorithm: Algorithm, content: &str) -> Vec {
+ let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(content))).unwrap();
+ doc.set_encryption(&NewEncryption {
+ algorithm,
+ user_password: "user",
+ owner_password: "owner",
+ permissions: 0,
+ encrypt_metadata: true,
+ seed: [3; 32],
+ })
+ .unwrap();
+ pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()
+}
+
+fn tmp(name: &str) -> std::path::PathBuf {
+ let d = std::env::temp_dir().join(format!("pdfcraft-cli-owner-{}", std::process::id()));
+ std::fs::create_dir_all(&d).unwrap();
+ d.join(name)
+}
+
+const ALGORITHMS: [Algorithm; 4] = [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256];
+
+#[test]
+fn info_accepts_the_owner_password() {
+ for alg in ALGORITHMS {
+ let path = tmp(&format!("info-{alg:?}.pdf"));
+ std::fs::write(&path, protected(alg)).unwrap();
+ for pw in ["owner", "user"] {
+ let out = Command::new(BIN).args(["info", path.to_str().unwrap(), "--password", pw]).output().unwrap();
+ let stderr = String::from_utf8_lossy(&out.stderr);
+ assert!(out.status.success(), "{alg:?} with {pw:?}: {stderr}");
+ let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
+ assert_eq!(json["pages"], 1, "{alg:?} with {pw:?}");
+ assert_eq!(json["encrypted"], true, "{alg:?} with {pw:?}");
+ }
+ let out = Command::new(BIN).args(["info", path.to_str().unwrap(), "--password", "wrong"]).output().unwrap();
+ assert!(!out.status.success(), "{alg:?}: a wrong password still fails");
+ assert!(String::from_utf8_lossy(&out.stderr).contains("incorrect"), "{alg:?}");
+ }
+}
+
+#[test]
+fn text_accepts_the_owner_password() {
+ for alg in ALGORITHMS {
+ let path = tmp(&format!("text-{alg:?}.pdf"));
+ std::fs::write(&path, protected(alg)).unwrap();
+ let out = Command::new(BIN).args(["text", path.to_str().unwrap(), "--password", "owner"]).output().unwrap();
+ let stderr = String::from_utf8_lossy(&out.stderr);
+ assert!(out.status.success(), "{alg:?}: {stderr}");
+ assert!(String::from_utf8_lossy(&out.stdout).contains("Page 1"), "{alg:?}");
+ // The user password opens it, but the permissions still forbid copying.
+ let out = Command::new(BIN).args(["text", path.to_str().unwrap(), "--password", "user"]).output().unwrap();
+ assert!(!out.status.success(), "{alg:?}: copying needs the owner password");
+ assert!(String::from_utf8_lossy(&out.stderr).contains("don't allow copying"), "{alg:?}");
+ }
+}
+
+/// Asserts that `data` is a PNG of the fixture's 200×300 pt page at `dpi` with the text drawn:
+/// a blank page of the right size fails too.
+fn assert_rendered_page(data: &[u8], dpi: u32, what: &str) {
+ let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap_or_else(|e| panic!("{what}: {e}"));
+ let mut buf = vec![0; reader.output_buffer_size().unwrap()];
+ let frame = reader.next_frame(&mut buf).unwrap();
+ assert_eq!((frame.width, frame.height), (200 * dpi / 72, 300 * dpi / 72), "{what}");
+ // A rendered page is opaque, so `render` writes it as 8-bit RGB (`pdfcraft_engine::export::encode_png`).
+ assert_eq!((frame.color_type, frame.bit_depth), (png::ColorType::Rgb, png::BitDepth::Eight), "{what}");
+ // Pixels with every colour channel below half intensity: ink, not white paper.
+ let inked = buf[..frame.buffer_size()].as_chunks::<3>().0.iter().filter(|px| px.iter().all(|c| *c < 128)).count();
+ assert!(inked > 100, "{what}: only {inked} inked pixels, the page is blank");
+}
+
+#[test]
+fn render_accepts_the_owner_password() {
+ const DPI: u32 = 144;
+ for alg in ALGORITHMS {
+ let path = tmp(&format!("render-{alg:?}.pdf"));
+ std::fs::write(&path, protected(alg)).unwrap();
+ for pw in ["owner", "user"] {
+ let png = tmp(&format!("render-{alg:?}-{pw}.png"));
+ let _ = std::fs::remove_file(&png);
+ let out = Command::new(BIN)
+ .args(["render", path.to_str().unwrap(), "--page", "1", "--dpi", &DPI.to_string()])
+ .args(["--password", pw, "--out", png.to_str().unwrap()])
+ .output()
+ .unwrap();
+ let stderr = String::from_utf8_lossy(&out.stderr);
+ assert!(out.status.success(), "{alg:?} with {pw:?}: {stderr}");
+ assert_rendered_page(&std::fs::read(&png).unwrap(), DPI, &format!("{alg:?} with {pw:?}"));
+ }
+ }
+}
+
+#[test]
+fn audit_places_findings_with_the_owner_password() {
+ // A black box over the text: a faux redaction, so `audit` reads the page geometry too.
+ let covered = format!("{PAGE} 0 g 10 140 120 40 re f");
+ for alg in ALGORITHMS {
+ let path = tmp(&format!("audit-{alg:?}.pdf"));
+ std::fs::write(&path, protected_with(alg, &covered)).unwrap();
+ let out = Command::new(BIN).args(["audit", path.to_str().unwrap(), "--password", "owner"]).output().unwrap();
+ let stderr = String::from_utf8_lossy(&out.stderr);
+ assert!(!stderr.contains("incorrect"), "{alg:?}: {stderr}");
+ let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| panic!("{alg:?}: {e}: {stderr}"));
+ assert_eq!(report["findings"][0]["covered_text"], "Page 1", "{alg:?}");
+ // Top-left points on the 300 pt high page: the box spans y 140–180 from the bottom.
+ assert_eq!(report["findings"][0]["rect"], serde_json::json!([10.0, 120.0, 130.0, 160.0]), "{alg:?}");
+ }
+}
diff --git a/apps/pdfcraft-cli/tests/render.rs b/apps/pdfcraft-cli/tests/render.rs
index 695431b65..561f9fa78 100644
--- a/apps/pdfcraft-cli/tests/render.rs
+++ b/apps/pdfcraft-cli/tests/render.rs
@@ -88,3 +88,27 @@ fn an_unknown_extension_fails_and_writes_nothing() {
assert!(stderr.contains(".png, .jpg, .tif or .pam"), "{stderr}");
assert!(!out.exists());
}
+
+/// `--page` and `--dpi` are checked before the file is opened. A page that is not a positive
+/// whole number names the same expectation `text` names; a dpi that parses as an `f32` but
+/// cannot be rendered (`0`, a negative value, `NaN`) names the option and the value, instead
+/// of reaching the renderer as a scale it refuses with its own internal message.
+#[test]
+fn a_page_or_dpi_the_renderer_cannot_use_is_refused_before_reading_the_file() {
+ for (option, value, diagnostic) in [
+ ("--page", "abc", "bad --page: expected a positive page number"),
+ ("--page", "0", "bad --page: expected a positive page number"),
+ ("--dpi", "abc", "bad --dpi: expected a positive number of dots per inch, got \"abc\""),
+ ("--dpi", "0", "bad --dpi: expected a positive number of dots per inch, got \"0\""),
+ ("--dpi", "-3", "bad --dpi: expected a positive number of dots per inch, got \"-3\""),
+ ("--dpi", "NaN", "bad --dpi: expected a positive number of dots per inch, got \"NaN\""),
+ ("--dpi", "inf", "bad --dpi: expected a positive number of dots per inch, got \"inf\""),
+ ] {
+ let out = tmp("refuse", "out.png");
+ // `missing.pdf` is never read: the option's own message is the error.
+ let r = Command::new(BIN).args(["render", "missing.pdf", "--out", out.to_str().unwrap(), option, value]).output().unwrap();
+ assert_eq!(r.status.code(), Some(1), "{option} {value}");
+ assert_eq!(r.stderr, format!("pdfcraft-cli: {diagnostic}\n").as_bytes(), "{option} {value}");
+ assert!(!out.exists(), "{option} {value}: no output may be written");
+ }
+}
diff --git a/apps/pdfcraft-cli/tests/split.rs b/apps/pdfcraft-cli/tests/split.rs
index 7a4a254f8..71523191d 100644
--- a/apps/pdfcraft-cli/tests/split.rs
+++ b/apps/pdfcraft-cli/tests/split.rs
@@ -1,4 +1,5 @@
//! `split --out-dir DIR` creates DIR when it does not exist yet (#249), and says so when it can't.
+//! `--every` must be a positive whole number of pages, checked before the file is opened.
use std::path::PathBuf;
use std::process::{Command, Output};
@@ -74,3 +75,22 @@ fn an_out_dir_that_cannot_be_created_is_reported_as_such() {
assert_eq!(std::fs::read(¬_a_dir).unwrap(), b"in the way");
let _ = std::fs::remove_dir_all(&dir);
}
+
+/// `--every` must be a positive whole number of pages. `0` parses, but the engine used to
+/// clamp it to 1 silently; refused here, before the file is opened, it cannot surprise anyone.
+#[test]
+fn a_non_positive_or_unparseable_every_is_refused_before_the_file_is_opened() {
+ let dir = workdir("every");
+ for value in ["0", "abc"] {
+ let out_dir = dir.join("parts");
+ let out = Command::new(BIN).args(["split", "missing.pdf", "--every", value, "--out-dir", out_dir.to_str().unwrap()]).output().unwrap();
+ assert_eq!(out.status.code(), Some(1), "--every {value}");
+ assert_eq!(
+ out.stderr,
+ format!("pdfcraft-cli: bad --every: expected a positive whole number of pages, got {value:?}\n").as_bytes(),
+ "--every {value}"
+ );
+ assert!(!out_dir.exists(), "--every {value}: nothing may be written");
+ }
+ let _ = std::fs::remove_dir_all(&dir);
+}
diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml
index 3ce1c96ef..6943ad6e4 100644
--- a/apps/pdfcraft/Cargo.toml
+++ b/apps/pdfcraft/Cargo.toml
@@ -23,6 +23,8 @@ egui = { workspace = true }
serde_json = { workspace = true }
# Records from every crate go to standard error and the log file (src/logging.rs).
log = { workspace = true }
+# The log timestamp goes through chrono (no hand-rolled calendar code).
+chrono = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
# Finder double-click / Open With / Dock drops arrive as Apple events (#73); this small audited
diff --git a/apps/pdfcraft/src/logging.rs b/apps/pdfcraft/src/logging.rs
index 71ed1d2d2..bef27de16 100644
--- a/apps/pdfcraft/src/logging.rs
+++ b/apps/pdfcraft/src/logging.rs
@@ -90,7 +90,10 @@ fn matches(name: &str, target: &str) -> bool {
}
}
-/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
+/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch (the
+/// `duration_since` clamp); past the last date chrono can represent (year 262143) the proleptic
+/// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch, so the
+/// output never changes for a value the old formatter accepted and logging never panics.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
@@ -99,20 +102,25 @@ pub fn timestamp(t: SystemTime) -> String {
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
}
-/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
-/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). `days` is at most
-/// `u64::MAX / 86_400`, so none of the arithmetic below can overflow.
-fn civil_from_days(days: u64) -> (u64, u64, u64) {
- let z = days + 719_468;
- let era = z / 146_097;
- let doe = z % 146_097;
- let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
- let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
- let mp = (5 * doy + 2) / 153;
- let d = doy - (153 * mp + 2) / 5 + 1;
- let m = if mp < 10 { mp + 3 } else { mp - 9 };
- let y = yoe + era * 400 + u64::from(m <= 2);
- (y, m, d)
+/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
+///
+/// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run
+/// past `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years
+/// (146_097 days), so chrono converts the day within one cycle and whole cycles go back into the
+/// year (an `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and
+/// reproducing the old `civil_from_days` result exactly without hand-rolled day math. 1970-01-01
+/// is `num_days_from_ce` 719_163.
+fn civil_from_days(days: u64) -> (i64, u32, u32) {
+ use chrono::Datelike;
+ let (cycles, rest) = (days / 146_097, days % 146_097);
+ // `rest + 719_163` is 719_163..865_259, always a valid CE day.
+ match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
+ Some(d) => {
+ let y = i128::from(d.year()) + i128::from(cycles) * 400;
+ (i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day())
+ }
+ None => (1970, 1, 1), // unreachable
+ }
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -337,6 +345,18 @@ mod tests {
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_767_225_599)), "2025-12-31T23:59:59.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
+ // Past chrono's range (a clock set absurdly far ahead) keeps the proleptic Gregorian date
+ // the old formatter gave, rather than falling back to the epoch.
+ // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
+ if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
+ assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
+ }
+ // Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists.
+ if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
+ assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
+ }
+ // Years past 9999 keep the plain zero-padded year (no `+`).
+ assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
}
#[test]
diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs
index 721b7da1b..83923fbe5 100644
--- a/apps/pdfcraft/src/main.rs
+++ b/apps/pdfcraft/src/main.rs
@@ -7,7 +7,8 @@
//! `--page N --zoom 150 --layout single|continuous|two-up|two-page --continuous on|off --panel comments|bookmarks|pages|fields|layers|attachments|none
//! --theme light|dark|system --language auto| --mode all|read|edit|convert|sign --tool --left open|closed
//! --organize on --fields on --dialog properties|shortcuts|about --palette --home on
-//! --cover on|off --default-layout single|continuous|two-up|two-page --default-continuous on|off --default-zoom fit-width|fit-page|
+//! --cover on|off --default-layout single|continuous|two-up|two-page --default-continuous on|off --default-zoom fit-width|fit-page| --always-default-view on|off
+//! --page-gaps on|off --default-page-gaps on|off
//! --combine-view grid|list --combine-zoom 60..200`
//!
//! `--new-window` opens a window of its own. Without it, on Windows, a launch that only names files
@@ -557,6 +558,11 @@ fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()>
/// `WGPU_BACKEND` asks for them. Creating a Vulkan instance loads every installed Vulkan driver
/// into the process, and a faulty one (an Intel driver in issue #37) crashed PdfCraft before
/// its window appeared. D3D12 is the native, best-supported backend there.
+/// - On Windows, Direct3D 12 presents through a DirectComposition visual rather than a swap chain
+/// on the window itself, unless `WGPU_DX12_PRESENTATION_SYSTEM` says otherwise. While the window
+/// is being resized, Windows stretched the window's swap chain, still at the old size, to the
+/// new one, so the text shimmered and the left panel's width bounced for as long as the drag
+/// lasted (issue #2738). A visual's last frame is shown at its own size instead.
fn configure_gpu(native: &mut eframe::NativeOptions) {
let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut native.wgpu_options.wgpu_setup else { return };
// Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only costs a
@@ -604,6 +610,15 @@ fn configure_gpu(native: &mut eframe::NativeOptions) {
if cfg!(target_os = "windows") && std::env::var_os("WGPU_BACKEND").is_none() {
setup.instance_descriptor.backends = eframe::wgpu::Backends::DX12 | eframe::wgpu::Backends::GL;
}
+ if cfg!(target_os = "windows") {
+ setup.instance_descriptor.backend_options.dx12.presentation_system = dx12_presentation(eframe::wgpu::Dx12SwapchainKind::from_env());
+ }
+}
+
+/// How Direct3D 12 presents on Windows: through a DirectComposition visual (issue #2738), unless
+/// `WGPU_DX12_PRESENTATION_SYSTEM` asked for something else (`asked`).
+fn dx12_presentation(asked: Option) -> eframe::wgpu::Dx12SwapchainKind {
+ asked.unwrap_or(eframe::wgpu::Dx12SwapchainKind::DxgiFromVisual)
}
/// Surface textures must fit the device's enabled limits, not just the physical GPU's limits.
@@ -878,7 +893,7 @@ mod tests {
}
#[test]
- fn gpu_backends_avoid_vulkan_on_windows_and_prefer_low_power() {
+ 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 {
@@ -896,6 +911,18 @@ mod tests {
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]
diff --git a/assets/icons/move-horizontal.svg b/assets/icons/move-horizontal.svg
new file mode 100644
index 000000000..7be9e3af6
--- /dev/null
+++ b/assets/icons/move-horizontal.svg
@@ -0,0 +1,17 @@
+
+
diff --git a/assets/icons/pilcrow.svg b/assets/icons/pilcrow.svg
new file mode 100644
index 000000000..1a1b38b15
--- /dev/null
+++ b/assets/icons/pilcrow.svg
@@ -0,0 +1,17 @@
+
+
diff --git a/assets/icons/subscript.svg b/assets/icons/subscript.svg
new file mode 100644
index 000000000..a37e9c42d
--- /dev/null
+++ b/assets/icons/subscript.svg
@@ -0,0 +1,17 @@
+
+
diff --git a/assets/icons/superscript.svg b/assets/icons/superscript.svg
new file mode 100644
index 000000000..c7978fb09
--- /dev/null
+++ b/assets/icons/superscript.svg
@@ -0,0 +1,17 @@
+
+
diff --git a/contributors/people.toml b/contributors/people.toml
index f7cb39292..72796216e 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -20,6 +20,11 @@
[people.YouseifUS]
sync_github_name = true
+[people.brian-moon]
+real_name = "Il Min Brian Moon"
+display_name = "Il Min Brian Moon"
+sync_github_name = false
+
[people.echelon]
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
@@ -30,6 +35,9 @@ real_name = "Eduardo Soares"
display_name = "Eduardo Soares"
sync_github_name = false
+[people.guillehr2]
+real_name = "Guillem Hermida"
+
[people.jcl-bit]
real_name = "James Locke"
display_name = "James Locke"
@@ -153,3 +161,15 @@ sync_github_name = true
real_name = "Eric Kaden"
display_name = "Eric Kaden"
sync_github_name = false
+
+[people.MatrixMagician]
+sync_github_name = true
+
+[people.Lcoding15]
+real_name = "Livio Oswald"
+
+[people.Bode512]
+sync_github_name = true
+
+[people.amoisttowe1]
+real_name = "Nick Nyssen"
diff --git a/crates/a11y/README.md b/crates/a11y/README.md
index 0a653f131..96cdad13c 100644
--- a/crates/a11y/README.md
+++ b/crates/a11y/README.md
@@ -16,6 +16,10 @@ let html = report_html(&report, "file.pdf", "2026-10-02", "en", &in_english); /
- Each result is Passed, Failed (with findings and pages), Needs manual check, or Skipped.
- **Content checks** walk the page content streams (and form XObjects) tracking marked
content: painting outside MCID-marked or `/Artifact` content is untagged.
+- **Character encoding** accepts a predefined non-Identity CJK CMap or an Adobe CJK
+ character collection (`GB1`, `CNS1`, `Japan1`, `Korea1`, `KR`), including Identity-H/V.
+ Unknown CMaps and foreign registries do not establish that route. This is a font-level
+ declaration check, not validation of every ToUnicode entry or used glyph.
- **Structure checks** read the structure tree through the role map; table regularity counts
`ColSpan`/`RowSpan` attributes.
- The report is a self-contained HTML page. Fixes that edit the document (language, title, tab
diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs
index 92a5ae8c2..e4755ab38 100644
--- a/crates/a11y/src/alt.rs
+++ b/crates/a11y/src/alt.rs
@@ -165,7 +165,7 @@ pub(crate) fn page_content(doc: &Document, page: &Dict) -> Vec {
.flat_map(|x| match &*doc.resolve(x) {
Object::Stream(s) => {
let mut v = s.decoded().unwrap_or_default();
- v.push(b'\n');
+ v.push(b' ');
v
}
_ => Vec::new(),
@@ -321,25 +321,33 @@ fn marked(doc: &Document, props: &Dict, op: &pdfcraft_content::Op, ids: &HashSet
fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids: &HashSet) -> Result {
let Some(entries) = content_entries(doc, &page.dict) else { return Ok(false) };
let props = page_properties(doc, &page.dict);
+ let mut streams: Vec