UI: preserve shared document workspace on current main

This commit is contained in:
skylar taylor-barrick committed 2026-10-10 19:10:14 -06:00
commit ecd9d158ea
178 files changed
+20094 -1156

No files matched your search

+5 -1
View File
@@ -4,7 +4,7 @@
Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
## In this repository (260)
## In this repository (264)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
@@ -17,6 +17,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `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 |
@@ -134,6 +135,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `assets/icons/messages-square.svg` | Lucide icon "messages-square" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/messages-square.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/mic.svg` | Lucide icon "mic" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mic.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/minimize.svg` | Lucide icon "minimize" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/minimize-2.svg` | Lucide icon "minimize-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/minus.svg` | Lucide icon "minus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/moon.svg` | Lucide icon "moon" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/moon.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/mouse-pointer-2.svg` | Lucide icon "mouse-pointer-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mouse-pointer-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
@@ -230,6 +232,8 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `assets/app-icon/pdfcraft-1024.png` | PdfCraft app icon, 1024 px on Apple's icon grid | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Runtime window/Dock icon on macOS (apps/pdfcraft/src/main.rs) |
| `assets/app-icon/pdfcraft.icns` | PdfCraft app icon (macOS .icns) | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | macOS app bundle icon |
| `assets/app-icon/pdfcraft.ico` | PdfCraft app icon (Windows .ico, 16-256 px) | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Embedded in pdfcraft.exe by apps/pdfcraft/build.rs (Explorer, taskbar, Start menu, Alt-Tab) |
| `assets/app-icon/pdfcraft-document.svg` | PdfCraft PDF document icon (master vector) | Lucide Icons and Contributors (document outline); PdfCraft contributors (PDF badge, lettering and composition) | ISC AND (MIT OR Apache-2.0) | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-text.svg); contributor-original composition (assets/app-icon/README.md) | Master artwork for the Windows PDF file type icon; rendered by packaging/icons.sh, without fonts or external images |
| `assets/app-icon/pdfcraft-document.ico` | PdfCraft PDF document icon (Windows .ico, 16-256 px) | Lucide Icons and Contributors (document outline); PdfCraft contributors (PDF badge, lettering and composition) | ISC AND (MIT OR Apache-2.0) | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-text.svg); contributor-original composition (assets/app-icon/README.md) | Embedded in pdfcraft.exe as resource 2; the Windows installer uses it for PdfCraft.Document and Applications/pdfcraft.exe PDF file associations |
| `assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 16 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 24 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 32 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
+59 -15
View File
@@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "5aedd14b5c652b54db8bfb00d32657d690b4601618de1d574919ff8fec161694"
sha256 = "500ba36d4a37162d935a1b5e2b16eff5b2011c6aa9f9285a87d46807acdb97e6"
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
@@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "1cf01b46973e74f2df8d3269e6569cf1264ebcca5351a569bd494e9cfcf86b91"
sha256 = "57c35909b2f19f16ff0497d110abbf1c79a5627d2a98f95706e726c59348d09e"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.tsv"
@@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "b243b1b0152d1236b99fc375fb42f5810d55786292d3b8d5d2393593a5789a5c"
sha256 = "abd8d0c409be846fcf48cbc1338def7f77f2d301bd84d863e4ad66434e5f821f"
[[asset]]
path = "crates/ui-egui/src/i18n/pt-br.tsv"
@@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "e0b6b7d1441501b929f9df6409edda88283d00a4343a0627acdaa126e549286a"
sha256 = "130059ef7014f1dfb59ec8dc2435657d5158abca2d1825959c6366288d28ea92"
[[asset]]
path = "crates/ui-egui/src/i18n/es.tsv"
@@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "3528e2b18c73c038b0ffc4b364155827ddede69a8cecd5aa0b20bbe409598738"
sha256 = "c67b49f2caa527fe5a241b1046f582b83f324ac86945319fd489971c038acd9b"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
@@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "36b08deba87a7cb9c52a678d37f4f289bb291a0a097615bc1a9a77f2d8b44b2a"
sha256 = "4e8e7f71dd896c58ad2bcad8d8c57ae8c9933611cff412985eb32845821f8899"
[[asset]]
path = "crates/ui-egui/src/i18n/de.tsv"
@@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "832a8f15f8402d0bc09a40937fd47feffe7afb002d41c288878c3920996fbd4f"
sha256 = "09db52c316a7e9312a30d80ce4e385f2c957096563c509b7b6ed2bcc95bf39d8"
[[asset]]
path = "crates/ui-egui/src/i18n/te.tsv"
@@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "c22c0153196e3e8f6a846e5a2f4210ffe393cc6997a32a4a8214bea222f98d56"
sha256 = "21767432503afac87196a87557637692dcec76c0eba02a1dcf6bd1294926107a"
[[asset]]
path = "crates/ui-egui/src/i18n/it.tsv"
@@ -107,7 +107,18 @@ 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 = "2edbaa97d218efa890043f780268f74ee6d4b4b56512523bbd050f447ced37df"
sha256 = "58f9514a06c74a7cdf90c6f7fb86ebb092af39857633f0779b44f0867a1c96e0"
[[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"
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 = "7052f5ee37d26e0173bc5fe48411c94e1b48e31e0fc8a9188fca2bec54f29e32"
[[asset]]
path = "crates/ui-egui/src/i18n/ru.tsv"
@@ -118,7 +129,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "64b352359b9b3919e5f04fa0fafa44f9cf1d3759f1829522a754bf391a20bd60"
sha256 = "e780ffd93f9606c1a762862a961c3a82bde948a8a5f116b46e6f78ba84ac6ff4"
[[asset]]
path = "crates/ui-egui/src/i18n/hu.tsv"
@@ -129,7 +140,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "3afb05c1bfedaca890e3d79ff27640be200b2174d550ce0474103eb4c33b31fa"
sha256 = "ebbd6cd7bc586ca253047f7aa3b5c34cd9ecb29645afc3d9d2097c35277ffddc"
[[asset]]
path = "crates/ui-egui/src/i18n/bg.tsv"
@@ -140,7 +151,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "a91e7153bc3208aafc7705601848666499ae2ac95dc4bd3b9a78ce749693c092"
sha256 = "b760879b720c1d6de0dc9286ab044bebe4360962dece93060d7041b8955be7b2"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"
@@ -151,7 +162,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "65927f653a4b33944f981ecef78c2bd81a7840924804063581bd09af94beee22"
sha256 = "b07366d224bd5a394f705c17fa5d26e4f45d8903927e5dc18ed12e7713751d0a"
[[asset]]
path = "crates/ui-egui/src/i18n/uk.tsv"
@@ -162,7 +173,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "d96bb503b25afc2f584f00253b371e8bd0266fa277a0b631d537377742c272cb"
sha256 = "97c79be066c982347b48556a19b71e0db67a7f46932d1ce342b2ba5d29bf4582"
[[asset]]
path = "crates/ui-egui/src/i18n/ar.tsv"
@@ -173,7 +184,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "f33971aa4e215933494fe181b83ee102f6e30178a83bfc944d35f7e7824d7692"
sha256 = "f5e369934bf2ceca49224bf57f72289f0c82fc4c81b7c6363676d39362750d17"
[[asset]]
path = "assets/icons/accessibility.svg"
@@ -1396,6 +1407,17 @@ kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
sha256 = "fe917d6144a9aed9a5297cb28addb9e582ac591a8f1c498a55aecea9b5c1783e"
[[asset]]
path = "assets/icons/minimize-2.svg"
title = "Lucide icon \"minimize-2\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize-2.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 = "1150437d5a870321c59b7ebca2e1bc3f6227f0fa03dd10484ab384dfe5110739"
[[asset]]
path = "assets/icons/minus.svg"
title = "Lucide icon \"minus\""
@@ -2786,6 +2808,28 @@ kind = "icon"
usage = "Embedded in pdfcraft.exe by apps/pdfcraft/build.rs (Explorer, taskbar, Start menu, Alt-Tab)"
sha256 = "56daaaae9ae5389794909570885809adf5e1d9183bef2d9669ed2f9bba3ed6f5"
[[asset]]
path = "assets/app-icon/pdfcraft-document.svg"
title = "PdfCraft PDF document icon (master vector)"
author = "Lucide Icons and Contributors (document outline); PdfCraft contributors (PDF badge, lettering and composition)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-text.svg); contributor-original composition (assets/app-icon/README.md)"
licence = "ISC AND (MIT OR Apache-2.0)"
licence_file = "assets/app-icon/LICENSE-document.txt"
kind = "icon"
usage = "Master artwork for the Windows PDF file type icon; rendered by packaging/icons.sh, without fonts or external images"
sha256 = "8da8278c2b89362be0a960879d453ff879d7544d6d58e47d5389574e8dfff77b"
[[asset]]
path = "assets/app-icon/pdfcraft-document.ico"
title = "PdfCraft PDF document icon (Windows .ico, 16-256 px)"
author = "Lucide Icons and Contributors (document outline); PdfCraft contributors (PDF badge, lettering and composition)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-text.svg); contributor-original composition (assets/app-icon/README.md)"
licence = "ISC AND (MIT OR Apache-2.0)"
licence_file = "assets/app-icon/LICENSE-document.txt"
kind = "icon"
usage = "Embedded in pdfcraft.exe as resource 2; the Windows installer uses it for PdfCraft.Document and Applications/pdfcraft.exe PDF file associations"
sha256 = "ba3f9128b368fc6d17d376cf62dbdbc22455451c5c8c4779210d7a2a7409b9e6"
[[asset]]
path = "assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png"
title = "PdfCraft app icon, 16 px"
+2 -2
View File
@@ -3,8 +3,8 @@
PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Adobe Acrobat Pro parity. It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. It is the sibling of `../photocraft` (a Photoshop-class editor) and follows the same conventions.
## Start every session here
1. Read `plan/STATUS.md`: the current milestone, the next unchecked task and any blockers. `ROADMAP.md` holds the milestone estimates and progress; update its table and log at the end of every session.
- Read `ROADMAP.md` §Honest assessment and §Where we're lacking and where we're going before choosing work. They rank the gaps (own renderer, hardening, fidelity against Acrobat, editing existing content, Pro workflows, 1.0 polish); prefer them over new P2/P3 features, and keep both sections true when things change.
1. Read `plan/STATUS.md`: the current milestone, the next unchecked task and any blockers. `ROADMAP.md` is the one-page summary (stage, numbers, progress log) and `docs/roadmap.md` the milestone table; at the end of every session update the milestone row and add a line to the ROADMAP.md progress log.
- Read `docs/gaps.md` (the ranked gaps: Acrobat interop, the open-issue backlog, the renderer and a fidelity harness, editing existing content, Pro workflows, 1.0 polish) and `docs/target-app-parity.md` (the numbers and how they are measured) before choosing work. Prefer the gaps over new P2/P3 features, and keep both documents true when things change (`docs/` progress docs follow craftrules `standards/progress-docs.md`: status line, revision history).
- "Shipped" in `parity/` means "exists and tested", not "as good as Acrobat". Don't mark a feature shipped on a generic test, and say in its notes what is still missing.
2. Read that task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README of the crate you're touching.
3. Follow the **autonomous operation protocol** in `plan/execution-plan.md` §7 (orient → plan → implement + test → verify → record → commit). Don't stop to ask unless §7 lists the decision as the user's.
Generated
+4
View File
@@ -3648,6 +3648,7 @@ dependencies = [
name = "pdfcraft-create"
version = "0.5.0"
dependencies = [
"encoding_rs",
"hayro-syntax",
"image",
"pdfcraft-cos",
@@ -3823,6 +3824,9 @@ dependencies = [
[[package]]
name = "pdfcraft-platform"
version = "0.5.0"
dependencies = [
"zbus",
]
[[package]]
name = "pdfcraft-preflight"
+4
View File
@@ -13,6 +13,10 @@ This product bundles third-party material under its own licenses, listed below.
list (author, source, license) is in ATTRIBUTION.md.
- Lucide icons (assets/icons/*.svg) — ISC License. See assets/icons/LICENSE-lucide.txt.
The Windows PDF file icon (assets/app-icon/pdfcraft-document.svg and .ico) adapts Lucide's
file-text outline and combines it with contributor-original artwork. See
assets/app-icon/LICENSE-document.txt (ISC AND (MIT OR Apache-2.0)), packaged as
LICENSE-document.txt beside the Windows executables.
- Inter typeface (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
- JetBrains Mono typeface (assets/fonts/JetBrainsMono-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt.
- Dancing Script typeface (assets/fonts/DancingScript.ttf), Copyright 2016 The Dancing Script Project Authors — SIL Open Font License 1.1 (Reserved Font Name "Dancing Script"). See assets/fonts/OFL-DancingScript.txt.
+4 -4
View File
@@ -412,16 +412,16 @@ Logs, environment variables and other development notes are in [docs/development
PdfCraft is young and moving fast. The aim is a workbench where you can view, organize, annotate, fill, sign and edit PDFs, at parity with Acrobat Pro.
**Where it stands (October 2026), honestly:** about half of Acrobat Pro's offline features are in (88% of the must-haves), but that is roughly a third of the work, because the hardest parts are still ahead.
**Where it stands (October 2026), honestly:** PdfCraft is **alpha**. Most of Acrobat Pro's offline features exist in some form (57% of all tracked features, 94% of the must-haves). For everyday work (viewing, commenting, filling and signing forms, organizing pages) it is about 55% of the way to replacing Acrobat; against all of Acrobat Pro, about 49%; for someone who only needs the essentials, about 66%. Every everyday workflow can be completed, but converting and sharing is weak: password-protected files Acrobat can't open, no Office-to-PDF, and Word export that loses layout.
- **Good today:** viewing and search; organizing, combining and splitting; most kinds of comment; filling and authoring forms (with sandboxed JavaScript); passwords, redaction and sanitizing; basic digital signatures; printing; the Accessibility Checker; agent control through the CLI and MCP.
- **Still borrowed:** pages are drawn by the `hayro` crate while our own renderer is built.
- **Thin or missing:** reliable editing of existing text (especially CJK), OCR beyond Latin script, Office import/export, signature timestamps and long-term validation, PDF/A/X/UA preflight, XFA forms and localization.
- **Thin or missing:** reliable editing of existing text (especially CJK and right-to-left), OCR beyond Latin script, Office import/export, signature tokens and online long-term validation, Preflight and PDF/X/UA, printing on Windows, and full XFA forms.
- **Hardening:** fuzzing still turns up crashes and hangs on hostile files; each one is fixed with a regression test. Quality has not yet been compared with Acrobat side by side.
**Next, in order:** our own renderer, hardening and a fidelity harness against Acrobat, editing existing content, then the Pro workflows (signatures, OCR, Office, preflight, XFA) and 1.0 polish.
**Next, in order:** encrypted files that Acrobat opens, the reported bugs and Windows printing, a fidelity harness against Acrobat and the renderer, editing existing content, then the Pro workflows (OCR, Office, Preflight, signatures, accessibility tagging) and 1.0 polish.
The honest assessment by area, what's lacking and where we're going are in **[ROADMAP.md](ROADMAP.md#honest-assessment-2026-10-05)**, with the full plan, progress and estimates.
The summary is in **[ROADMAP.md](ROADMAP.md)**; the full assessment by dimension and area is in [docs/target-app-parity.md](docs/target-app-parity.md), the ranked gaps in [docs/gaps.md](docs/gaps.md), and the milestones in [docs/roadmap.md](docs/roadmap.md).
---
+161 -182
View File
@@ -1,160 +1,175 @@
# PdfCraft roadmap
The milestones, current progress and time estimates to Acrobat Pro feature parity. Keep this up to date:
- **Every session:** update the progress column and add a line to the log.
- **Every milestone:** re-estimate.
**Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away
Detailed task lists and acceptance tests are in `plan/execution-plan.md` (local-only). This file is the public summary.
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (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)
**What "parity" means here:** the offline feature set of Acrobat Pro, milestones M0–M14. It excludes Adobe's cloud services (Document Cloud storage, Adobe Sign, the Adobe AI Assistant). Those have no clean-room equivalent; PdfCraft's alternatives are local-first, plus opt-in providers (M13).
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
Assistant) are out of scope; PdfCraft's alternatives are local-first plus opt-in providers.
## Estimate summary
| Document | Holds |
|---|---|
| [docs/target-app-parity.md](docs/target-app-parity.md) | The authoritative assessment: method, weights, every dimension and area, stage, calibration |
| [docs/gaps.md](docs/gaps.md) | Every known shortfall, ranked: the work list |
| [docs/roadmap.md](docs/roadmap.md) | Milestones M0–M14, current focus, upcoming milestones, risks |
| [docs/pdf-spec-parity.md](docs/pdf-spec-parity.md) | ISO 32000-2 coverage, annotations, actions, signatures, PDF/A/X/UA |
| [docs/file-format-parity.md](docs/file-format-parity.md) | Every format Acrobat reads or writes, create/export conversions, OCR |
| [docs/ui-parity.md](docs/ui-parity.md) | Tools, handles, shortcuts, panels, preferences |
| [docs/hardware-parity.md](docs/hardware-parity.md) | GPU, printers, scanners, smart cards, pen, platforms |
| [docs/localization-parity.md](docs/localization-parity.md) | Per-language status |
| [docs/architecture.md](docs/architecture.md) | How PdfCraft is built |
| `parity/acrobat-features.toml` | The machine-checked checklist (`cargo xtask parity`) |
The unit is **wall-clock hours of agent work** (Claude Opus 5.5 coding continuously; human review time not included).
**Keep it current:** every session, update the milestone row in [docs/roadmap.md](docs/roadmap.md)
and add a line to the [progress log](#progress-log); when a gap closes, remove it from
[docs/gaps.md](docs/gaps.md); re-measure fully when a major arc lands, at least monthly.
**Where we are (2026-10-07, measured by `cargo xtask parity` over 806 tracked Acrobat Pro features; 23 more are Adobe-cloud-only and out of scope):**
## Headline numbers
| Tier | Features | Shipped | Partial | Shipped % | Weighted % (partial = ½) |
|---|---|---|---|---|---|
| P0 (must-have for 1.0) | 251 | 222 | 25 | 88.4% | 93.4% |
| P1 | 326 | 174 | 38 | 53.4% | 59.2% |
| P2 | 186 | 14 | 7 | 7.5% | 9.4% |
| P3 | 43 | 1 | 0 | 2.3% | 2.3% |
| **All** | **806** | **411** | **70** | **51.0%** | **55.3%** |
**Effort-weighted parity: ≈ 30–35%.** Feature counts overstate progress: the remaining features include the hardest ones (our own renderer and font engine, editing existing text and reflow, OCR beyond Latin, XFA, PDF/A/X/UA preflight, Office export, long-term signature validation). Weighting each milestone by its estimated size gives about a third of the total work done. See **[Honest assessment](#honest-assessment-2026-10-05)** for what the numbers don't show.
**Observed throughput:** about 72k lines of kept, tested Rust (plus 97 agent tools and ≈ 600 tests) in roughly 65–75 agent-hours since 2026-09-30, about 1.0k lines per agent-hour, with corpus, oracle and visual checks. That is faster than the original plan assumed, so the hour estimates below are revised down from the first plan (2,000–4,000 h).
| Scenario | Remaining hours to full parity (M0–M14) | Calendar time |
| | Value | Kind |
|---|---|---|
| One Opus 5.5 agent, continuous | **≈ 600–1,100 h** | ≈ 4–7 weeks |
| 3–5 agents in parallel on separate crates | **≈ 250–450 h wall clock** | ≈ 2–3 weeks |
| With human review, integration and pauses | — | ≈ 2–4 months |
| **Feature breadth** | **≈ 67%** tier-weighted (56.9% unweighted: 423 shipped + 74 partial of 808; P0 94.2%) | measured, `parity/acrobat-features.toml` |
| Feature breadth, mainstream practitioner | ≈ 70% tier-weighted (63.2% unweighted; P0 95.3%) | measured ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) |
| **Ready for real work**, full Acrobat Pro | **≈ 49%** (45–53%) | estimated, weighted sum over [dimensions](#by-dimension) |
| **Ready for real work**, mainstream practitioner | **≈ 55%** (50–60%) | estimated ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) |
| **Ready for real work**, essentials user | **≈ 66%** (60–70%) | estimated ([method](docs/target-app-parity.md#essentials-user)) |
| Remaining to **beta** | ≈ 400–700 Opus 5.5 agent-hours | estimated |
| Remaining to **full parity** | ≈ 780–1,400 Opus 5.5 agent-hours (≈ 300–550 h wall clock with 3–5 agents) | estimated, calibrated on this repo's history |
**How the estimate is built:** each milestone's remaining fraction (table below) times its size, re-based on the measured rate. The long poles are M2 (own renderer, ≈ 150–250 h), M7 (editing existing text and images, ≈ 150–250 h), M10 OCR and Office export (≈ 100–150 h), M6 JavaScript engine (≈ 60–100 h), M11 PDF/A/X/UA preflight (≈ 80–140 h) and M12 XFA/compare (≈ 80–140 h). The last 5–10% (odd real-world files, pixel-level polish against Acrobat) costs about as much as a mid-sized milestone.
**Why alpha:** all six of Acrobat Pro's everyday workflows can be completed end to end on macOS
(the [alpha gate](docs/roadmap.md#alpha-gate)), and users praise them (7.6k stars, ≈ 217k downloads
of 0.4–0.5, switching reports such as #179). Two are only partial: editing (no CJK/RTL) and convert
and share (password-protected files we write don't open in Acrobat or Reader, #774; no in-app
Office→PDF; Word export loses layout, #773). Those, unmeasured quality against Acrobat, the borrowed
`hayro` renderer, no printing on Windows (#756) and 245 open issues keep it far from beta.
## Honest assessment (2026-10-05)
### Readiness by audience
Read this before choosing work. The feature table above counts what exists; this section says how solid it is.
| Audience | Ready | Opus 5.5 agent-hours to ≈ 95% | Work that dominates |
|---|---|---|---|
| Full Acrobat Pro (ready for real work) | ≈ 49% | ≈ 780–1,400 h (≈ 300–550 h wall clock with 3–5 agents; 60–70% parallelizes) | Own renderer and fidelity harness, editing existing content, OCR and Office conversion, Preflight and PDF/A/X/UA, accessibility tagging, signatures for institutions |
| Mainstream practitioner | ≈ 55% | ≈ 500–900 h (≈ 200–400 h wall clock; ≈ 60% parallelizes) | Rendering fidelity, editing existing text (CJK, RTL, subsets), conversion (Word layout, Excel, PowerPoint, Office→PDF), OCR languages, Windows printing, the bug backlog |
| Essentials user | ≈ 66% | ≈ 180–330 h (≈ 90–180 h wall clock; ≈ 50% parallelizes) | Rendering fidelity on everyday files, Windows printing, launch and GPU stability, encrypted output Acrobat opens (#774), simple text fixes, Word export layout, Chinese UI glyphs |
| Dimension | State | In one line |
Hours are calibrated as in [Effort and calibration](docs/target-app-parity.md#effort-and-calibration) (≈ 1k kept lines per agent-hour for new features, 3–10× slower for hardening and fidelity). The audiences are nested subsets, so essentials ≤ mainstream ≤ full.
## By dimension
| Dimension | Ready | Weight | Remaining (h) | Doc |
|---|---|---|---|---|
| Features (14 areas) | ≈ 49% depth · 67% breadth | 40% | 610–1,100 | [target-app-parity.md](docs/target-app-parity.md#by-feature-area) |
| UI/UX fidelity | ≈ 55% | 10% | 30–60 | [ui-parity.md](docs/ui-parity.md) |
| File formats and conversion | ≈ 40% | 12% | 120–220 (within features) | [file-format-parity.md](docs/file-format-parity.md) |
| PDF specification and standards | ≈ 60% | 8% | 150–260 (within features) | [pdf-spec-parity.md](docs/pdf-spec-parity.md) |
| Hardware | ≈ 35% | 4% | 25–50 | [hardware-parity.md](docs/hardware-parity.md) |
| Localization | ≈ 59% | 5% | 35–60 | [localization-parity.md](docs/localization-parity.md) |
| Performance | ≈ 40% | 6% | 25–45 | [gaps.md](docs/gaps.md) gap 13 |
| Stability | ≈ 50% | 8% | 45–80 | [gaps.md](docs/gaps.md) gap 6 |
| Platforms | ≈ 70% | 5% | 15–30 | [hardware-parity.md](docs/hardware-parity.md#platforms) |
| Ecosystem and plug-ins | ≈ 30% | 1% | 10–20 (within features) | [gaps.md](docs/gaps.md) |
| AI features | ≈ 0% (in scope: local providers) | 1% | 20–40 (within features) | [gaps.md](docs/gaps.md) gap 23 |
| **Total (weighted sum)** | **≈ 49%** | 100% | **≈ 780–1,400** | |
## Features
| Area | Breadth (measured) | Ready (estimated) | Remaining (h) |
|---|---|---|---|
| A Core model and fidelity | 76.8% | 70% | 25–45 |
| B View and navigation | 62.5% | 55% | 110–190 |
| C Content editing | 53.3% | 30% | 100–180 |
| D Organize pages | 61.2% | 70% | 12–25 |
| E Comments and review | 77.7% | 70% | 12–25 |
| F Forms and JavaScript | 78.5% | 65% | 40–75 |
| G Protect, redact, sanitize | 58.5% | 55% | 15–30 |
| H Digital signatures | 62.2% | 45% | 40–70 |
| I Scan and OCR | 23.1% | 15% | 40–75 |
| J Create and export | 39.7% | 25% | 55–100 |
| K Optimize and standards | 34.5% | 20% | 65–115 |
| L Print | 39.7% | 35% | 30–55 |
| M Accessibility | 63.1% | 30% | 40–70 |
| N Automation, preferences, misc | 30.0% | 35% | 25–45 |
What's strong and missing in each: [target-app-parity.md](docs/target-app-parity.md#by-feature-area).
## Languages
Measured from `crates/ui-egui/src/i18n/*.tsv` (2,143 keys). 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 |
| Hindi | `hi` | 0% | none |
| Arabic | `ar` | 100% | partial (layout not mirrored) |
| French | `fr` | 99.3% | partial |
| Portuguese (Brazil) | `pt-br` | 99.4% | partial |
| Indonesian | `id` | 0% | none |
| Japanese | `ja` | 99.1% | partial |
| German | `de` | 99.3% | partial |
| Korean | `ko` | 0% | none |
| Vietnamese | `vi` | 0% | none |
Also shipped: Traditional Chinese, Russian, Bulgarian, Telugu, Hungarian, Ukrainian, Italian
(94–100%) and Czech (menus only): **8 more languages**.
## Upcoming
Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md).
| Rank | Milestone | Estimate (h) |
|---|---|---|
| Feature count | 51.0% shipped (P0 88%, P1 53%, P2 8%, P3 2%) | A typical viewer, annotator, form-filler or page organizer is mostly covered |
| Effort | ≈ 30–35% | The remaining work is the hardest: M2 15%, M7 17%, M11 18%, M12 23%, M14 0% |
| Foundations | Weakest | Rendering is still the bootstrap `hayro`; the local #307 candidate reads inspector data lazily through COS with `lopdf` compatibility logic; vendored patches are still carried |
| Robustness | Early beta | Each 15-minute fuzz run found new out-of-memory crashes or hangs until 2026-10-05 |
| Acrobat fidelity | Unmeasured | No systematic side-by-side comparison with Acrobat, visual or behavioural |
| Product readiness | 0.2.1 | No code signing, performance budgets or keyboard-only operation yet; localization has its catalog system (English, partial Japanese) but few strings go through it |
| 0 | Convert and share: encrypted output opens in Acrobat (#774), Create PDF from Office, Word export that keeps layout (#773) | 40–70 |
| 1 | Backlog and platform: crash and wrong-result reports, Windows printing (#756), Chinese UI face (#826) | 65–120 |
| 2 | Fidelity harness against Acrobat and the renderer decision (M2; owner decision on #841) | 110–190 |
| 3 | Editing existing content (M7) | 100–180 |
| 4 | OCR languages and Office export/import (M10) | 95–175 |
| 5 | Preflight and PDF/A/X/UA (M11) | 65–115 |
| 6 | Signatures for institutions: PKCS #11, timestamp servers, online LTV (M9) | 40–70 |
| 7 | Accessibility tagging (M12) | 40–70 |
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
**By area** (shipped share of each area's features):
## Progress log
| Area | Shipped | What's strong / what's missing |
|---|---|---|
| A Core | 75% | Parser, repair, encryption, incremental saves. Missing: lazy loading (`ByteSource`), own image codecs, PDF 2.0 extras, Arlington validation |
| F Forms | 71% | Filling, authoring, AF scripts, sandboxed JavaScript, data exchange. Missing: XFA, wider JavaScript object model |
| E Comments | 71% | Markup types with appearances, XFDF/FDF, summaries, calibrated 2D measuring (single-unit decimal scales). Missing: replace-text proposals, summary layouts, compound/fractional measurement formats |
| M Accessibility | 62% | Checker (all 32 rules). Missing: autotag, Tags/Order/Content panels, Reading Order tool, keyboard-only operation |
| B View | 55% | Shell, find, panels, tiles, web build. **Rendering is borrowed (`hayro`)**, so the 7 rendering P0s are only partial |
| D Organize | 55% | Pages, combine, split, bookmarks, labels. Missing: replace pages, transitions |
| G Protect | 54% | Passwords, permissions, redaction (including word lists), sanitize. Redaction codes (built-in FOIA and Privacy Act sets). Missing: certificate security, custom redaction code sets |
| H Sign | 48% | PAdES B-B signing and validation; macOS Keychain and Windows CNG certificate store signing. Missing: timestamps (B-T), LTV (DSS/OCSP/CRL), FieldMDP, smart cards, PKCS #11 |
| C Edit | 46% | Added text and images stay editable; header/footer/watermark. Missing: robust editing of existing text and images (fonts, subsets, reflow) |
| L Print | 36% | Acrobat-style sizing, n-up, booklet, CUPS. Missing: Windows and web printing, production options |
| J Create | 32% | From images, text, clipboard; Word/HTML/RTF export. Missing: Office import, Excel/PowerPoint export |
| N Misc | 27% | CLI, MCP, UI control channel, Action Wizard. Missing: AI providers, performance budgets |
| K Optimize | 26% | Reduce File Size, Optimizer. Missing: preflight, PDF/X/UA, transparency/fonts panels |
| I OCR | 19% | Searchable image for Latin script. Missing: other scripts and accents, editable-text output, deskew |
Newest first: the date, what moved, and the new overall estimate.
**What "shipped" means, and doesn't.** A feature is shipped when it exists and at least one specific test covers it. 159 of 398 shipped features rest on exactly one test, and only about 5 cite an external oracle (`pdftotext`, `pdfsig`, OpenSSL, a corpus). Shipped does not mean "as good as Acrobat".
- **2026-10-10 (M14, interface size):** Preferences ▸ Interface size scales the whole interface (50–250 %, setting `ui_scale`, control option `ui-scale`); 50 % brings an interface the system draws at 200 % back to 100 % (#456). Auto, the default, follows the desktop's text scaling: on Linux and the BSDs the desktop app reads GNOME's `text-scaling-factor` once at launch, so with Large Text (1.25) on a 1× display the interface no longer draws 13 px labels that show their pixels next to the system's text. The size is egui's zoom factor, so `pixels_per_point` grows with it and page rasters stay one texel per screen pixel; egui's own ⌘+/⌘− interface zoom is turned off so those keys stay page zoom. Still missing: KDE's font DPI and Windows' "Make text bigger" aren't read (both scale only text there), and egui's text is still greyscale-antialiased without the system's gamma, so small text looks rougher than GTK's at 100 %.
- **2026-10-10 (M14, #496):** Shortcut displays share a UI formatter across toolbar search, registry menus, command palette, Help and tooltips. Modifier and key names use a dedicated catalog context in every bundled translation; missing entries keep the English key name rather than a command translation. German shows Strg/Umschalt/Entf, macOS retains its symbols, and runtime language changes refresh the display. Regression coverage checks localized layout widths, English/German switching, platform conventions and unchanged command execution. Milestone and effort estimates unchanged.
**Robustness, measured.** `main`'s CI was red for 30+ runs until 2026-10-04 (a clippy lint, Windows line endings, flaky perf tests). Once the nightly fuzz job could finish, three 15-minute runs found 7 out-of-memory crashes (one aborted on every platform) and 11 hangs, 7 of them in our own `cos` parser rather than `hayro`. All are fixed or in review, but expect more: keep the nightly fuzz job green and turn every finding into a synthetic test.
- **2026-10-10 (M5, shape fill colour, #686):** Rectangles, ovals, polygons and clouds can be filled. The comment quick bar has a Fill colour control for those tools ("No fill" or a swatch; it persists with the other per-tool styles), Comment Properties ▸ Appearance has a Fill colour row (one undo step with the other changes, carried over by Make Current Properties Default), and `comment_edit` takes `fill` (`"none"` removes it). `pdfcraft-annot::set_fill` writes or removes `/IC` and redraws the appearance; lines, ink and locked comments refuse it. Still missing: a custom colour picker (only the comment palette), and a separate fill for line endings.
- **2026-10-10 (M10, #645):** Export all images and Save image as keep transparency. A JPEG with a soft mask (the usual cut-out photo) was written as the bare JPEG, and soft masks of another size than the image, JPEG-compressed soft masks, stencil `/Mask` images and colour-key `/Mask` arrays were dropped, so cut-outs came out on solid white or black. These images are now PNG with the mask as alpha (soft masks scaled to the image, `/Matte` un-blended); JPEGs without transparency are still written byte for byte. A negative image size no longer overflows in Save image as. JPX/JBIG2/CCITT masks are still left out. Overall estimate unchanged (about 30–35%).
- **2026-10-10 (M12, XFA rows and areas):** Dynamic XFA forms lay out a repeating subform inside a top-to-bottom container in positioned content (a table in an area) with all its instances, drawn and measured, instead of one; and areas no longer appear in data paths (`pdfcraft_xfa::data_path`), since they are not data scopes. Before, a form whose fields sit in an area read none of the values Adobe's viewers saved, wrote what was filled here under a new element named after the area, which Adobe's viewers would most likely not read, and a FormCalc total looping over the table's rows failed on the rows that were missing. Layout, the script tree, reading and writing the data and the engine's value and row operations share the one path rule. Synthetic tests cover the layout, the path rule and the whole round trip (rows, Adobe-written values, a FormCalc total over all rows, the written data, reopening). Still open for such forms: XFA script objects (`<variables><script>`), which aren't supported. Overall estimate unchanged (about 30–35%).
## Where we're lacking and where we're going
The gaps that matter most, in priority order. Agents: prefer these over adding P2/P3 features, and check `parity/acrobat-features.toml` notes for the specifics of each.
1. **Our own renderer (M2).** Replace `hayro` with the `model` crate, our font engine and the DisplayList devices (ADR-0004). It turns the 7 rendering P0 partials into shipped features and removes most vendored patches. The largest single lever.
2. **Hardening.** Keep the nightly fuzz job green; fix every crash and hang with a synthetic regression test; finish lazy loading so large files aren't read whole; set performance budgets (`misc.performance-budgets`).
3. **Measure fidelity against Acrobat.** Build a side-by-side harness (render, text, form behaviour) over synthetic fixtures (`plan/acrobat/`, clean-room rules apply). Until then, parity numbers count features, not quality.
4. **Editing existing content (M7).** Fonts, subsets, reflow and CJK. The most visible gap for Pro users.
5. **Pro workflows.** Signatures with timestamps and LTV (M9), OCR beyond Latin (M10), Office import/export (M10), preflight and PDF/A/X/UA (M11), XFA (M12).
6. **1.0 polish (M14).** Code-signed installers, localization (the catalog system is in place and French, German, Spanish, Japanese, Russian and both Chinese catalogs cover the UI; Czech and Brazilian Portuguese still cover menus only), keyboard-only operation, a full screen-reader audit.
6. **1.0 polish (M14).** Code-signed installers, localization (the catalog system is in place and French, Spanish, Japanese, Russian, Ukrainian and both Chinese catalogs cover the UI; Czech and Brazilian Portuguese still cover menus only), keyboard-only operation, a full screen-reader audit.
6. **1.0 polish (M14).** Code-signed installers, localization (the catalog system is in place and French, Spanish, Italian, Japanese, Russian and both Chinese catalogs cover the UI; Czech and Brazilian Portuguese still cover menus only), keyboard-only operation, a full screen-reader audit.
Decided against for now (owner, 2026-10-05): self-installing updates and an update check at start. Help ▸ Check for updates is manual only.
## Milestones
Hours are for a single agent (low–high). "Done" is the estimated fraction of that milestone's *acceptance criteria* that are met. It is not a count of lines of code.
| M | Milestone | Est. hours | Done | Remaining (h) | Notes |
|---|---|---|---|---|---|
| M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 92% | 2–4 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates |
| M2 | Model, render, text | 200–350 | 15% | 170–300 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Missing: model crate, fonts, DisplayList, renderer independent of hayro |
| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 78% | 25–45 | Done:<br>- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;<br>- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.<br>Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.<br>Full saves now pack objects into compressed object streams. Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs |
| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 78% | 25–45 | Done:<br>- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;<br>- cos: parse and repair (including invalid xref object-number ranges), decrypt on load, re-encrypt on save, incremental and full writing.<br>Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.<br>Full saves now pack objects into compressed object streams. Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs |
| M2 | Model, render, text | 200–350 | 15% | 170–300 | hayro bootstrap renderer (vendored patches). Text extraction reaches word-F1 0.98 against pdftotext. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
| M3 | Viewer app (native + web) | 80–150 | 88% | 10–19 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel. |
| M3 | Viewer app (native + web) | 80–150 | 88% | 10–19 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; reusable icon catalog/buttons with keyboard focus indication; late browser startup downloads preserve the current document and Save target (#171). |
| M4 | Engine, history, save, organize | 100–180 | 95% | 9–17 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web |
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 15–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export; flatten; Fill & Sign; agent tools. Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page |
| M6 | Forms + JavaScript | 160–320 | 45% | 95–185 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done. Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 17% | 210–420 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
| M8 | Security + redaction | 100–180 | 66% | 35–65 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize. Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding |
| M9 | Signatures (PAdES, validation) | 160–280 | 55% | 70–125 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL. Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
| M10 | OCR, create, export, print | 200–350 | 33% | 130–235 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, CUPS spooler, print-ready PDF). Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; Create PDF from multiple files (PDFs, images and text, edited as pages in the grid). Missing: OCR languages beyond Latin, editable-text OCR output, Office export/import, Windows/web printing |
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 165–290 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 23% | 155–295 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export. Missing: autotag, Tags/Order/Content panels, Reading Order tool, alt-text workflow, compare, geospatial/3D measurement, search index, XFA |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 33–66 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 5% | 115–240 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`; Japanese, Traditional Chinese, Simplified Chinese, Russian, Bulgarian, German, Spanish, French, Hungarian and Ukrainian catalogs have ≈1,800–2,100 entries each; Simplified Chinese, French, German, Hungarian and Ukrainian coverage is checked against the UI source; Czech and Brazilian Portuguese for menus. French locales (`fr`, `fr_FR`, `fr_CA`) follow the French catalog; Ukrainian locales (`uk`, `uk_UA`) follow the Ukrainian one; its certificate and accessibility wording has a dictionary-checked follow-up. #307 (bounded large-PDF memory): share resource indexes and native parser metadata, inspect structure lazily, bound scan/full-save caches and raster storage, and reuse signature discovery safely. Three Linux trials retain full-save workflow RSS at 1.06 GiB (previously 3.69 GiB); rebased engine navigation uses 821 MiB (previously 997 MiB). With the RSS guard removed, the unmodified baseline was kernel OOM-killed in a 15 GiB container; rebased optimized inspection completed at 497.7 MiB in 1.78 s. Strict Clippy, 1,336 workspace tests and 30 WASM crate checks pass. The larger-host follow-up is closed. File-backed input, global memory budgets and Windows validation remain pending. Missing: more catalogs filled in, installers, performance budgets |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 5% | 115–240 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`; Japanese, Traditional Chinese, Simplified Chinese, Russian, Spanish, French and Italian catalogs have ≈1,800 entries each; Simplified Chinese and French coverage is checked against the UI source; Czech and Brazilian Portuguese for menus. French locales (`fr`, `fr_FR`, `fr_CA`) follow the French catalog; Italian locales (`it`, `it_IT`) the Italian one. Arabic (`ar`, every `ar-*` locale) has a full catalog whose labels are reordered for display; the layout itself is still left to right. Missing: a right-to-left layout for Arabic, more catalogs filled in, installers, performance budgets |
| | **Total (original plan sizing)** | **2,085–3,840** | **≈ 35%** | **≈ 1,350–2,500 at the planned rate; ≈ 600–1,100 at the measured rate** | |
**Overall progress: about 30–35% of the effort (51.0% of features shipped).** The viewer and the core are far ahead of the editing features, because the viewer was built first so progress could be seen; rendering itself is still borrowed from `hayro`. See [Honest assessment](#honest-assessment-2026-10-05).
## Critical path
M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run in parallel across crates. The long poles are M7 (content editing), M9 (signatures) and M12 (XFA).
## Risks most likely to push estimates up
- Fidelity of text editing: fonts, subsets, reflow.
- XFA dynamic layout.
- Real-world signature chains and revocation checks.
- The rendering long tail: Type3 fonts, broken fonts, shadings.
- Correctness of PDF/A and PDF/UA conversion.
- CPU rendering performance on the web.
- Hostile input: every fuzz run so far has found new crashes or hangs.
- Unmeasured fidelity: without an Acrobat comparison harness, quality gaps surface as user reports.
## Log
- **2026-10-10 (M9, #179 follow-up):** A Windows store certificate that can't sign is no longer left out of the Sign dialog silently: `windows::list` names it with the reason and Windows error code (no private key, a P-521/Brainpool/Ed25519 key, a key CNG can't open), the picker shows it greyed and unselectable, and `sign_windows_ids` returns it under `unusable`. A key that needs a permission prompt to open is listed instead of skipped. Reported by a user whose CA-issued identity showed in Acrobat but not here; a plain .p12 import into the CNG and legacy Microsoft providers was verified to list, so the next report will carry the reason. Store test adds a key-less certificate; overall estimate unchanged (about 30–35%).
- **2026-10-10 (M10.2, JPEG scanning follow-up to #729):** Skip marker fill bytes and keep reading JFIF, APP14 and ICC metadata after the frame header, stopping before scan data or at EOI. Invalid segment lengths return errors; damaged or mismatched ICC profiles retain #729's device-space fallback. Synthetic regression cases added but not run at the contributor's request. Estimates unchanged.
- **2026-10-10 (M13, desktop action outputs):** Desktop Action Wizard keeps every result when input basenames repeat, or when the destination already has a result from an earlier run. It preserves the original filename and extension for a free name and adds numbered suffixes for occupied names; the bounded search returns an error rather than reusing an occupied path. A real-shell regression drives Start twice and reopens the three-page and one-page results, checking that the original inputs and earlier output remain intact. This covers the desktop writer separately from the CLI/MCP action_run repair in #877. Milestone and effort estimates unchanged.
- **2026-10-10 (M3, #740):** Column select, as in Acrobat: holding Alt (Option on macOS) when a drag starts selects only the text inside the rectangle, with the Select tool, the text markup tools and Redact; Edit ▸ Column select does it with a plain drag, for Linux desktops whose window manager takes Alt-drag (Xfce, Cinnamon, MATE, KDE Plasma 5 by default). Copy gives rows top to bottom with tab-separated cells (right to left in right-to-left rows, reading order for vertical text); `text_extract` takes the same rectangle as `rect`. The new label is in all 15 catalogs. Also repaired `parity/acrobat-features.toml`, which #650 had left unparseable (duplicate keys on `form.field-list`). Still missing: column selection across pages and ⇧-click extension. Estimates unchanged.
- **2026-10-10 (progress docs, hours per audience):** Each readiness number gets its own hours to ≈ 95%: full ≈ 49% / 780–1,400 h, mainstream ≈ 55% / 500–900 h, essentials ≈ 66% / 180–330 h. The full number is now the standard's additive weighted sum over the dimensions (written weights), 47% → 49%: method aligned with the standard, no new evidence. Beta ≈ 26 points, ≈ 400–700 h.
- **2026-10-10 (progress docs, gate consistency check):** Re-judged the convert-and-share row with the rule applied across all Crafting Apps (a workflow blocks only when it can't be completed at all; lossy exchange and missing sub-cases are beta items, as for FilmCraft's .prproj or VectorCraft's .ai). Unencrypted sharing works, encryption interop (#774) and Office→PDF are sub-cases, Word export is lossy rather than absent: partial, not blocking. **Stage pre-alpha → alpha**; numbers unchanged (47%, mainstream 55%; beta ≈ 400–700 h). Added an essentials-user score: ≈ 66% for someone who only opens, reads, comments, fills, signs, prints and rearranges pages.
- **2026-10-10 (progress docs, second look and alpha gate):** At the owner's request, after practitioners praised PdfCraft, re-examined the numbers. User sentiment: 7,595 stars, 177k + 39k downloads of 0.4.0/0.5.0, 23 user issues with explicit praise and switching reports (#179, #232, #273), issue themes concentrated in core paths (launch/GPU, rendering, editing, CJK, signing, printing). The checklist is not niche-dominated (497 mainstream vs 311 specialist rows; P0 95% / 91%). The first pass's 9-point cross-cutting deduction double-counted Windows printing, interop and glyphs: **ready for real work 40% → 47%**, and **≈ 55% for the mainstream practitioner** (new). The new core-workflow gate (craftrules progress-docs) fails on convert and share (#774, no Office import, #773): **stage alpha → pre-alpha**, ≈ 40–70 h from alpha; beta still ≈ 400–700 h.
- **2026-10-10 (progress docs, full re-measure):** Re-measured against Acrobat Pro 26.002.21931 (installed bundle inspected: document types, localizations, plug-in and framework names). Checklist: 423 shipped + 74 partial of 808 (56.9%; P0 94.2%), ≈ 67% tier-weighted. Ready for real work ≈ 40%; stage **alpha**; ≈ 400–700 h to beta, ≈ 780–1,400 h to full parity (up from 600–1,100 h: the first public releases surfaced Acrobat interop of encrypted output, Windows printing, Chinese UI glyphs, Word-export layout and a 245-issue backlog). This file restructured to the craftrules progress-docs standard; the honest assessment moved to `docs/target-app-parity.md`, the gap list to `docs/gaps.md`, the milestones (duplicated rows merged) to `docs/roadmap.md`; new `docs/architecture.md`, `docs/localization-parity.md`, `docs/file-format-parity.md`, `docs/pdf-spec-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`.
- **2026-10-10 (M8, redact word lists):** Find text and redact gains "Multiple words or phrases": a list with one word or phrase per line, typed or imported from a `.txt` file, whose every match is marked in one undo step. Lists are trimmed, deduplicated and bounded (1,000 entries of 256 characters, files up to 1 MB). `redact_mark` takes `words` and reports how many marks each made, so an agent sees which found nothing. Tests cover the list parsing, the tool end to end (mark, undo, apply, invalid lists) and the dialog with an imported list; the new labels are translated in all 13 UI catalogs. `protect.redact-word-lists` is shipped; whole-word and case-sensitive matching and exporting the list are still missing. Estimates unchanged (about 30–35%).
- **2026-10-10 (M4, bookmarks from structure):** Bookmark options ▸ New bookmarks from structure (command `bookmark.from_structure`, tool `bookmark_from_structure`) turns a tagged document's headings into bookmarks: H1–H6 through the role map, a plain H by its section nesting, nested by level under a new first "Untitled" bookmark as in Acrobat, each linked to its structure element (/SE). Titles come from /ActualText, the marked text on the page (decoded through the font's ToUnicode or encoding), /Alt or /T. The walk is bounded (depth, visits, heading and title caps, cycle-safe), an untagged document gets a clear error, and the edit is one undo step. Tests cover role maps, nesting, ToUnicode text, a self-referencing heading, the tool, undo and the panel. Still missing: Acrobat's element-type choice and destinations at the heading's position. The M4 row no longer lists Replace pages as missing (it shipped earlier). Overall estimate unchanged (about 30–35%).
- **2026-10-10 (M9, signing encrypted documents):** Digital signatures, certification and document timestamps work on password-protected PDFs instead of failing with "not supported: signing encrypted documents". `pdfcraft-cos` no longer encrypts or decrypts a signature dictionary's `/Contents` (§7.6.2; by `/Type /Sig|/DocTimeStamp` or `/Contents` + `/ByteRange`), so the placeholder is patched after hashing as in a plain file and existing signatures in encrypted files read their real CMS (which also fixes their DSS `/VRI` keys and keeps them intact in a full rewrite). Opened with the user password, the signer applies the document's permissions (fill-forms for existing fields and timestamps, annotate + modify for new fields, modify for certifying) and names the permissions password when it refuses. Synthetic tests cover all four algorithms, counter-signing, tampering, permissions and the automation tools. Overall estimate unchanged (about 30–35%).
- **2026-10-10 (M9, signer certificate usage):** A trusted signer's certificate must also allow signing documents (RFC 5280 §4.2.1.3, §4.2.1.12, §4.2). A key usage without digitalSignature or nonRepudiation, an extended key usage with no document-signing purpose (any, emailProtection, codeSigning, RFC 9336 documentSigning, Adobe Authentic Documents, Microsoft document signing), or a critical extension PdfCraft doesn't process makes the verdict Unknown instead of Valid, and the details say which. Certificate policies are accepted as any policy. In-process certificates test each refusal and the accepted cases; a signed PDF from a trusted TLS-server certificate was Valid before and is Unknown now. Still missing: name constraints, and unknown critical extensions on issuer certificates. Overall estimate unchanged (about 30–35%).
- **2026-10-10 (M14, side panel width):** The side panel keeps the width it is dragged to. The Bookmarks search row was 2 px wider than the panel, and a resizable egui panel keeps the width its content used, so every repaint (any pointer move) widened it up to 520 px. The Comments reply row did the same in languages with a longer "Post" (Russian, Spanish, …); Post now sits under a full-width reply field. Tests hold both panels' widths steady across 30 repaints. Overall estimate unchanged (about 30–35%).
- **2026-10-10 (M8):** Redaction codes. Redaction properties offers "Custom text" or "Redaction code" under "Use overlay text": pick the U.S. FOIA (5 U.S.C. 552(b)) or U.S. Privacy Act (552a) set and tick codes, and new marks carry them as overlay text in the set's order ("(b)(6), (b)(7)(C)"). `redact_mark` takes `code_set` and `codes`, rejecting unknown sets and codes with the allowed list. Tests cover the code sets, the tool end to end (mark, apply, text) and the dialog; the new label is translated in all 13 UI catalogs. `protect.redaction-codes` is partial: Edit Codes, custom sets and their import/export are still missing. Estimates unchanged.
- **2026-10-10 (M14, platform shortcut labels):** View ▸ Zoom and Page navigation, the rail, find-bar, toolbar and bookmark tooltips, and Help ▸ Keyboard shortcuts write shortcuts for the platform (`Ctrl+1`, `Ctrl+Shift++` off macOS) instead of hard-coded ⌘. Labels follow egui's detected OS, so the web build in a Linux or Windows browser shows Ctrl too. Fit visible is listed once in View. Translated tooltips take a `{key}` placeholder. Overall estimate unchanged (about 30–35%).
- **2026-10-10 (M14):** Arabic interface language: a 2,137-entry catalog (`ar.tsv`, six CLDR plural forms, Arabic month and weekday names for dates), selected by `ar` and every `ar-*` locale. egui joins Arabic letters but places words left to right, so the catalog is put into display order when it loads (`bidi::display_rtl`: placeholders, keyboard shortcuts, edge spaces and caption colons are kept in place), and the command search matches a multi-word Arabic query word by word. Arabic glyphs come from the craft-fonts `Arab` face (kept in the web build); the CI and release pin of craft-fonts moves to the revision that has it, and `crates/fonts/build.rs` now refuses to embed the Noto Sans CJK SC that revision also carries (AGENTS.md §1.1). Not done: the layout is not mirrored, a wrapped Arabic paragraph breaks at the wrong end, and text typed into fields is not reordered; these need bidi support in the text layout itself.
- **2026-10-10 (M6, #95):** Prepare a form edits shared General, Appearance and applicable Options properties in one atomic undo step. Explicit opt-in controls keep mixed values unchanged; partial appearance patches preserve custom font resources, each widget's style, indirect dictionary contents and unknown keys. Ctrl/Command+A selects the current page; rectangle selection and the Fields panel support multi-selection, with panel double-click opening properties. CLI/MCP `form_set_props` accepts a bounded, unique `fields` list. Regression tests cover save/reopen, undo/redo, locked-field rollback, stale dialogs, invalid arguments and selection. Names, positions, choices/defaults and script tabs remain individual GUI edits. Integrated upstream 0.5.0 and completed Portuguese/Italian catalogs without losing translator credit; shared-property labels now cover all 14 bundled translations. Kept the new Italian interface and Fill & Sign date-language registries aligned, including accented date output; the About-dialog regression waits for the larger contributor list to settle before clicking. Milestone and overall effort estimates unchanged (about 30–35%).
- **2026-10-09 (M5, line-ending labels):** Comment properties names each line ending in words ("Reverse open arrow", "Butt", …) and those labels are translated. The PDF `/LE` token is unchanged. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M3, #171):** A browser startup `?file=` download opens in a background tab once the user has started working, so it cannot switch the active document or the next Save. Deliberate opens still activate their tab; Home, Combine files, workspace choices and another file's password prompt are preserved. Initial-view geometry is applied to the background document, and its script notices wait until its tab is selected. Synthetic shell tests cover the race, saving the local edit, tab switching, encrypted arrivals, errors and converted text. Milestone and overall effort estimates unchanged (about 30–35%).
- **2026-10-09 (never-crash, #378):** On Windows the window is drawn on the GPU that drives the primary display, as on Linux: `EnumDisplayDevices` (through the safe `winsafe` crate) says which adapter shows each desktop display, and `pick_adapter` matches it to the wgpu adapters by PCI ids. Before, the low-power preference chose a Ryzen integrated GPU with no monitor on a desktop whose monitors hang off an NVIDIA card, and presenting across adapters took the AMD display driver down for two minutes. The log now names the GPU chosen. Synthetic tests for the Windows device-id parser and the primary-display rule; a live test on the running machine. Overall unchanged.
- **2026-10-09 (page preview resolution):** The page view draws whole pages at twice the screen density when that bitmap still fits, then filters it down, so type and fine lines keep more detail. Print preview, page thumbnails, alternate-text figures and the page behind a moving signature are rendered at the size they are shown instead of being stretched up from a small image. Milestone and effort estimates unchanged.
- **2026-10-09 (M5, line endings):** Lines, connected lines and callouts draw all ten standard line-ending styles. `comment_add` accepts `endings`, and Comment properties can change them in one undo step. An unknown name is left undrawn, and a non-finite direction is rejected. Cloudy borders remain polygon-only, so `comment.line-endings` stays partial. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M6, field rotation):** Regenerated form appearances honour `/MK /R` (0, 90, 180, 270). Filling or restyling a rotated field keeps the text, check mark and button caption turned, and Field Properties ▸ Position and `form_set_props` `rotation` set it, swapping width and height around the field's center when the axis changes. `form.appearance-regeneration` stays partial (Helvetica metrics are still approximate). Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M2, JPX sample normalization):** JPEG 2000 rescaling uses bounded integer arithmetic for supported 1–32-bit samples, including the 32-bit boundary that previously shifted past the integer width. Zero-sized rows are refused before scaling. Small contributor-original codestreams exercise decoding and rendering; endpoint and standard-depth regressions cover the same helper. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M14, #577):** The desktop GPU setup enables the selected adapter's supported 2D texture extent instead of egui-wgpu's fixed 8192-pixel extent. This removes the artificial surface ceiling behind a mixed-DPI Windows startup crash when opening Outlook PDF attachments. Synthetic regressions cover the observed 8600 × 3423 surface, downlevel adapters and preservation of other device limits. Hardware resolution limits still apply; milestone and overall effort estimates are unchanged.
- **2026-10-09 (M14, #577 follow-up):** A window larger than the GPU's texture limit no longer closes PdfCraft. Vendored egui-wgpu 0.36.2 fits the window's surface within `max_texture_dimension_2d` and draws egui at the matching scale, on every path that sizes a surface (start-up restore, resize, maximise, DPI change, a recreated surface); upstream is emilk/egui#8361. Root cause of the Outlook-only trigger: winit creates the window on the launching app's monitor and converts the restored size at that monitor's scale. Unit tests cover the 8600 × 3423 case and edge sizes; a Windows Sandbox (WARP) run of a window one pixel over the limit panicked before and kept drawing after. Estimates unchanged.
- **2026-10-09 (M14, #519):** A GPU that can't configure the window's surface at launch no longer panics, so PdfCraft's OpenGL retry runs. Vendored egui-wgpu configures a new window's surface inside wgpu error scopes (`winit::catch_errors`) and returns a failure as an error, so eframe returns `Error::Wgpu` and the existing OpenGL retry (#530) runs (a device lost during that configure still panics, on the first frame). Before, wgpu's default handler panicked: on a 2015 Intel GPU with no D3D12 driver wgpu picked the GL adapter over WARP and timed out in `Surface::configure` (`GpuWaitTimeout`). A real-device regression test (a wgpu validation error inside `catch_errors`) panicked with the scopes removed; in Windows Sandbox a forced configure failure panicked unpatched and retried with OpenGL patched (Sandbox has no OpenGL 2.0, so the OpenGL window itself was not shown there). Defaulting Windows to DX12 only (WARP instead of wgpu's GL backend) is left to the maintainers. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M5, Fill & Sign flatten on save):** Preferences and `doc_save` can bake Fill & Sign text, marks and signatures into the page when saving. The option defaults off. Other comments and form fields stay, a second flatten does not reuse an XObject name already on the page, and a document that cannot be changed is left unwritten. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M3.9, native control lifecycle):** The opt-in UI control channel can request bounded window sizes, report observed dimensions, focus accessible widgets and inspect actual keyboard focus. Graceful quit uses the existing unsaved-work prompt, including uncommitted typing; force/discard parameters are rejected. Tests cover resize validation, Tab/Shift-Tab traversal, keyboard activation, invalid focus targets and quit cancellation with retained edits. Focused buttons preserve document undo/redo shortcuts, while text editors keep their own undo history. No milestone percentage change.
@@ -164,103 +179,61 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
- **2026-10-09 (M5.7, #434 partial):** Natural-size image stamps placed at a click or through `stamp_custom` remain upright on rotated pages, with a displayed-space-sized rectangle and a counterrotated standard appearance matrix. Move, resize, restyle and undo/redo preserve that orientation. Native and external Poppler save/reopen rendering cover all four quarter-turns; UI tests verify the displayed bounds. Explicit rectangle placement, image signatures, PDF-page stamps and image-file orientation metadata remain outside this repair. #434 stays open; milestone table and effort estimates remain unchanged.
- **2026-10-09 (M1, raw-stream decoding limits):** COS checks unfiltered decoded data against the caller and per-stream limits before copying it, including empty filter arrays and streams whose Crypt filter was applied on load. Strict decoding uses the same raw-data guard. Tiny synthetic regressions cover exceeded, exact and zero-byte limits while retaining the filtered decoder behavior. Milestone and overall effort estimates unchanged (about 30–35%).
- **2026-10-09 (M2, bounded page labels):** Inspector number trees have shared work, depth, and cycle limits; prefixes are bounded before decoding and complete custom labels have per-label and aggregate UTF-8 byte caps. A refused tree leaves physical page numbers intact and records one warning, with custom labels applied only after every range succeeds. Pure COS Roman and repeated-letter formatters are shared with Organize; label queries and edits return errors for unsupported output before changing the stored tree, while ordinary labels and prefix-only ranges remain supported. Synthetic tests cover safe just-over-limit cases, large first numbers, aliases, cycles, wide/deep trees, UTF-16/UTF-8 boundaries, aggregate output, and unchanged refused edits. Milestone and overall effort estimates unchanged (about 30–35%).
- **2026-10-09 (M4, page grid zoom):** The Organize page grid zooms from 50% to 300%: toolbar buttons with a percentage that resets, trackpad pinch or Ctrl/⌘ + wheel about the page under the pointer, and ⌘+ / ⌘− / ⌘0. Pages drawn larger than their thumbnails are rendered at the size drawn while in view, so text stays sharp in documents of any length. Not yet: a slider, remembering the size, a size control for the Pages panel. Overall estimate unchanged (about 30–35%).
- **2026-10-08 (M14, #307, upstream integration):** Rebased the local candidate onto main `a6bc63a`, preserving render-request deduplication, pixel-aligned rasters, signature editing and organizer insertion controls. Regressions cover inactive signature-preview cleanup, insertion gaps under virtualization, and canceled/reintroduced render requests. All 1,336 workspace tests pass (6 ignored), 193 parser tests pass (2 missing-fixture tests filtered), strict workspace/all-target Clippy passes without allowances, and 30 WASM crate checks pass. All 1,010 navigation frames, 12 workflow artifacts and eight 100-page external PDF checks match; 1×/2× desktop protocols pass again. Inspection is 497.7 MiB / 1.78 s, full-save 1.06 GiB / 29.64 s, and fresh desktop navigation/close/reopen 877 MiB. The desktop result is higher than the pre-rebase 832 MiB, while remaining 21.9% below the earlier bounded-writer stage. Windows, cold-start latency and global memory budgets remain open; no milestone percentage increase.
- **2026-10-09 (M7, grouped artwork):** Edit text/images selects page-drawn Form XObjects as whole figures, including nested raster/vector/text content. Their BBox and optional Matrix determine the selection; move/resize/rotate/flip/delete rewrite only the selected page placement, preserving shared Forms and other placements. Engine, automation and UI core regressions cover geometry, rendered movement, save/reopen, undo and selection/drag/Delete. Bitmap replacement/extraction remain raster-only; inline images and individual elements inside a Form are not editable through this tool. Overall estimate unchanged (about 30–35%).
- **2026-10-08 (M5.7, signature resize proportions):** Image signature corner handles preserve the embedded image's original aspect ratio and anchor the opposite corner; edge midpoint handles stretch width or height independently. The live preview and committed edit share the constrained geometry, including page/view rotation and reopened PDFs whose rectangles were stretched previously. Synthetic UI tests cover all corners, edge resizing, undo and redo. Milestone and effort estimates unchanged.
- **2026-10-08 (M5.7, live signature adjustment):** Embedded image signatures resize and move with every pointer frame. Movement hides the frame and handles, which return on release; Escape cancels without committing. A cached background preserves the page beneath the image, and only the completed gesture enters undo history. The read-only engine preview and `comment_image_preview` tool share bounded image decoding and render layers. Synthetic pixel tests cover live geometry, alpha, coloured page content, rotation, cancellation and encrypted save/reopen. Milestone and effort estimates unchanged.
- **2026-10-08 (M10/M4, create from multiple files):** Create a PDF ▸ Multiple files is live. The picked PDFs, images and `.txt` files open as one unsaved document in the Organize page grid, where pages are removed and reordered and Save pages writes what is shown. The grid gained a "+" at every gap that inserts files right there, and on desktop files dropped on the grid go to the gap under the pointer; Insert from file now takes several files in order, converts images and text, and selects what it inserted. File-type detection moved from the shell into `pdfcraft-create` (`source_kind`), with one engine entry point (`Session::convert_to_pdf`) shared by Open, Insert, Create and the new `doc_create_multiple` tool (which can also write one PDF per file). Not yet: Office/HTML/Markdown inputs, PDF Portfolios, progress for big batches, dropping onto the grid in the web build. Overall estimate unchanged (about 30–35%).
- **2026-10-08 (M5.7, signature placement):** Image signatures and initials preview at the pointer at their actual page size, including zoom and document/view rotation. A successful placement switches to Select with the new annotation selected; stamp resize handles use the existing engine edit, automation tool and undo history while preserving the appearance. Synthetic rendered UI and automation tests verify positioning, one placement per selection, resizing, undo/redo and save/reopen. Milestone and overall effort estimates unchanged.
- **2026-10-08 (M5.7, image signatures):** Fill & Sign imports local PNG/JPEG signatures and initials through the Image tab, with previews, local persistence and undoable placement. The shared engine validates bounded images and preserves transparency and orientation; `fill_sign_add` accepts an image path. Synthetic UI and automation tests cover cancellation, invalid input, settings, undo/redo and save/reopen rendering. Overall effort estimate unchanged (about 30–35%).
- **2026-10-08 (M14, #307, confirmed baseline OOM):** Removed the benchmark RSS guard and ran the frozen original and optimized CLI binaries in separate containers with a verified 15 GiB kernel memory limit, no swap and four CPUs. The unmodified baseline was kernel OOM-killed after 20.06 s with 14.97 GiB observed RSS; Docker recorded `OOMKilled=true`, exit 137 and an `oom` event. The optimized build completed the same 9,156-page inspection at 497.5 MiB in 1.75 s with unchanged metadata and no warnings. The original RSS remains a lower bound and elapsed time is time to failure. The kernel limit still constrains this experiment; no remote run was needed. No product changes or milestone percentage increase.
- **2026-10-08 (M14, #307, original-baseline cutoff):** Repeated original unmodified CLI inspection with a 14 GiB RSS guard, up from 10 GiB, on the unchanged 9,156-page PDF. It reached 14,682,080 KiB and was stopped after 48.21 s before completion; no cgroup OOM kill occurred. The optimized build completed the same inspection at 495.7 MiB in 1.70 s. The original measurement is a lower bound, not a completed peak or timing. This guarded run failed before completion without an OS OOM kill. The user closed the larger-host follow-up; the prepared benchmark is retained as evidence. The later local guard-free experiment is recorded above. No product changes or milestone percentage increase.
- **2026-10-08 (M14, #307, shared parsing and raster budgets):** Native render workers share a lazily initialized parser after cold-load/repair concurrency and watchdog recovery fixes, while retaining private render caches. Pages, organizer and Print request current visible thumbnails; retained active-view textures are limited to 24/128/128 MiB for thumbnails/pages/tiles and completed raster storage to 32 MiB/32 results, allowing one oversized result alone. Signature discovery uses a bounded source/edit-aware cache; the writer borrows unchanged objects and reuses xref rows. Engine navigation drops from 997 to 807 MiB with all 1,010 frame hashes unchanged. Three-trial full/incremental-save workflow times improve to 29.92/16.35 s at unchanged 1.06/1.08 GiB peaks; full serialization remains slower than before bounded saving, and the single mixed workflow takes 67.99 s versus 65.52 s. All 1,158 workspace tests pass (6 ignored in the regular run), 193 vendor tests pass (2 missing-fixture tests filtered), eight outputs pass 100-page external checks, and 29 WASM crate checks pass. Existing strict workspace Clippy warnings remain; UI lint validation explicitly allows the existing collapsible-match category. Fresh desktop navigation/close/reopen falls from 1,123 to 832 MiB; 1x/2x GUI runs pass visible-demand and texture-budget checks on original 10,000-page fixtures and the real file. Output-capacity and LRU experiments did not justify production changes. These are local Linux results and specific storage budgets, not global RAM limits or Windows validation. No parity or milestone percentage increase.
- **2026-10-08 (M14, #307, bounded saving):** Full rewriting now uses private temporary caches and packs objects in batches limited by count and serialized bytes, preserving unknown objects, reference identity and encryption behavior without populating undo snapshots. Native render workers initialize only on a live request, avoiding unused parser copies during refresh/rebase. Three same-file Linux trials reduce rotate/full-save/close/reopen peak RSS from 3.69 to 1.06 GiB (35.53 to 32.51 s); incremental workflow RSS falls from 1.56 to 1.08 GiB. All 1,114 workspace tests pass (4 ignored); eight saved outputs pass external text/raster checks on 100 sampled pages each, and 1,010 navigation frames match. First use of each worker now pays parser initialization; steady-state viewing remains about 1 GiB. Single oversized objects, whole source/output buffers, and Windows behavior remain outside a global budget claim. No parity or milestone percentage increase.
- **2026-10-08 (M14, #307, research/comparison):** Added a local external-reference suite outside the repository, using the same 9,156-page PDF with MuPDF, PDFium, Poppler and qpdf. Three Linux trials put the candidate's single-renderer 102-frame peak at 392 MiB, versus 348/347 MiB for MuPDF/PDFium with whole-file bytes (164/163 MiB with file input). The rotate/full-save/close/reopen workflow peaks at 3.69 GiB during serialization; incremental saving peaks at 1.56 GiB during rebasing. Different UI features, input/cache policies and writer compression settings prevent treating these as app rankings. Next targets are bounded object-stream packing, parsed-object lifetimes, snapshot overlap and file-backed input. No further runtime optimization or milestone percentage increase in this research step.
- **2026-10-08 (M14, #307, local candidate):** Validated the 192.9 MB, 9,156-page VMware document in Linux. The original CLI exceeded a 10 GiB measurement cutoff before finishing inspection; the candidate inspects in about 1.8 s at 496 MiB and a 1,000-page navigation/close/reopen probe peaks at 990 MiB. It shares validated large resource indexes within each parser, reads panel structure on demand through COS with the previous inspector as a compatibility fallback, and scans standalone timestamps with temporary caches instead of retaining every object in the editor. Inspection output and 104 common sampled page renders match the handoff candidate. This is a different PDF revision from the original Windows issue; Windows GUI private memory and responsiveness remain unverified. No parity or milestone percentage increase.
- **2026-10-08 (M9, #179):** Windows Current User Personal certificate store identities join file-based digital IDs, signing through CNG without exporting keys. RSA-2048 and ECDSA P-256 round trips use temporary certificates with cleanup guards; enumeration, missing identities, automation errors and the password-free UI have regression coverage. Smart cards and PKCS #11 remain planned; overall effort estimate unchanged.
Newest first. One line per session: the date, what moved, and the new overall percentage.
- **2026-10-09 (M9, review of #144):** Chain building checks what an issuer may do. `x509::build_chain` linked certificates by name and signature only, so an ordinary subscriber certificate embedded in a PDF, or one in the trusted list, could "issue" a certificate for any identity and chain to a trusted root. An issuer now needs basicConstraints CA:TRUE (an old v1 self-signed root without constraints still anchors), keyCertSign when it has a key usage, a pathLenConstraint that allows the CAs below it, and validity at the signing time (or the trusted timestamp/token time) when known; the signature's details say why an issuer was refused. Negative tests issue certificates in-process and cover each refusal, plus a signed PDF whose signer chains through an end-entity certificate (Unknown, not Valid). Still missing: key usage and extended key usage of the signer, name constraints and unknown critical extensions. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M9):** A document timestamp stored as a signature field (Documenso writes `Timestamp_1` that way, its `/V` a `/Type /DocTimeStamp` dictionary) was read as a CMS signature and reported "altered or corrupted since the signature was applied" on an untouched file. A field value with `/SubFilter /ETSI.RFC3161` is validated as a timestamp now, and standalone timestamps are found by `/DocTimeStamp` (ISO 32000-2 §12.8.5) as well as the `/Type /Sig` older writers used; `timestamp_document` writes `/DocTimeStamp`. Regression tests build the same structure with a test TSA. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M9, signatures as DocuSign and Documenso write them, #144):** A real DocuSign + Telekom qualified-signature PDF showed "DER signature wrong" although Acrobat validates it: both CMS blobs use BER indefinite lengths. The CMS is read with the BER reader that #453 added (`Tlv::parse_ber`: indefinite lengths, segmented OCTET STRINGs, depth-bounded) while certificates and signed attributes are still verified over their own exact bytes, and the details say the signature was BER; tests build the nesting bombs a crafted /Contents could hold. Behind that, later revisions that add DSS data (indirect `/Certs`, `/VRI`) or rewrite the catalog's XMP were classified as disallowed changes; they are permitted now, narrowly (review by echelon on #144): only objects reached through `/DSS` `/Certs|CRLs|OCSPs|VRI` with the shape of their role, and new or only grown since signing, count as the store, and only the catalog's `/Metadata` is the document's XMP. A `/DSS` entry naming a page's contents, or a `/Type /DSS` or `/Type /Metadata` label on an existing dictionary or stream, makes nothing permitted. Six of the new tests fail on the previous code, among them a Form XObject labelled `/Type /Metadata` before signing that could be rewritten under a Valid signature. Negative tests at every DocMDP level. Cross-checked with pdfsig (both signatures valid). Still missing: AATL/EUTL trust lists (a separate change: they need CA-constraint checks in chain building first). Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M9, signature algorithms, #386):** Validation reads what real signers write. Digests SHA-224, SHA-512/224, SHA-512/256, SHA3-224/256/384/512 and RIPEMD-160 (beside SHA-1/2); RSA PKCS#1 with or without the DigestInfo's NULL (checked by building the one expected encoding and comparing it byte for byte, in `rsa_pad`, for every digest; a bare digest without any DigestInfo is refused, it doesn't say which hash it uses); RSA-PSS with the hash, MGF1 hash and salt length the signature declares; ECDSA as DER, non-minimal DER or raw r‖s on P-256, P-384, P-521 and brainpoolP256r1/P384r1/P512r1 (the last hand-written on `crypto-bigint`, no RustCrypto crate exists); Ed25519 (RFC 8419); the legacy `adbe.x509.rsa_sha1` format. Algorithms PdfCraft can't check give Unknown instead of Invalid. Review of #386 (echelon): an RSA signature value must be below the modulus (RFC 8017 §5.2.2; `s` and `s + n` both verified before), and SHA-1 and RIPEMD-160 signatures still validate but the signature's details now say the algorithm is weak (wording pinned by tests). Found on the way, each with a regression test that failed first: a brainpoolP512r1 key with a coordinate not reduced modulo p panicked inside the bigint arithmetic (the certificate comes from the PDF), now refused, and the point at infinity is an invalid signature rather than an error. Tolerated encodings are listed in the signature's details. Vectors made with OpenSSL; cross-checked with pdfsig. Still missing: Ed448/DSA, signing with the new digests. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M14, German interface follow-up):** Follow the merged German catalog with shorter search/action labels, corrected callout/accessibility wording and a distinct certificate Subject context. Localized tool names retain their capitalization and comment composer titles go through the catalog. Search hints reserve shortcut space and Home cards bound text with full hover text; layout, context and German Preferences/search control regression tests cover these changes. Overall effort estimate remains about 30–35%.
- **2026-10-09 (M2, PDF-function hardening):** Vendored function construction has a shared 64-level/10,000-node limit and path cycle detection; calculator parsing and evaluation have shared depth/work budgets. Calculator integer division, logical shifts, rotations and indices avoid input-driven panics, and fixed-size stack errors propagate. Synthetic tests exercise exact/over limits, shared children and cyclic references, with invalid shading calculations preserving later page content. Limits return the existing optional failure and log their reason when logging is enabled. Milestone/overall effort estimates remain unchanged (about 30–35%).
- **2026-10-09 (community, destinations):** Bookmarks and links go to their destination's position, not just its page (ISO 32000-2 §12.3.2.2): `/XYZ` puts (left, top) at the window's top-left and applies a non-zero zoom; `/Fit`, `/FitH` and `/FitV` switch to fit page, width and height with `top` or `left` at the window's edge; `/FitR` zooms to fit the rectangle, centred. Named destinations resolve the same way; crop-box origin, `/Rotate` and view rotation are accounted for; malformed or non-finite operands fall back to the top of the page. Not yet: the `/FitB*` content box (read as the page). Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M2, image resampling bounds):** Image resampling checks source buffers, finite transforms, target RGBA sizes, intermediate scratch dimensions and estimated filter work before planning or allocating; wide valid sources may shrink before backend side limits. Oversized or failed resampling retains the original pixels and placement when the source fits the backend; invalid sources, unrepresentable fallbacks and failed staging/output reserves are skipped with a logged warning. Small synthetic metadata regressions and an anisotropically transformed image cover the guards and fallback. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (community, M14, Hungarian):** Magyar (`hu`) is a complete interface catalog (2,036 entries) covering every command, All tools entry, dialog, panel and notice, selected automatically for `hu` and `hu_HU`, and from Preferences. Informal "te"; commands are nouns (Mentés, Megnyitás). After a numeral the noun stays singular, so both plural forms match. Coverage tests check commands, All tools labels and every `tl!` literal; history labels keep captured names; the bundled Latin faces cover Hungarian diacritics including ő and ű. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M14, Windows Auto):** Neutral launcher locales (`C`, `C.UTF-8`, `POSIX`) no longer select English ahead of the Windows preferred display-language list. Explicit supported locale overrides retain their priority. Regression tests use existing catalogs and cover neutral/mixed overrides, unsupported first display tags and multiple preferred Windows languages. Catalogs and other platforms are unchanged; overall effort estimate remains about 30–35%.
- **2026-10-09 (community, M14, German):** Deutsch (`de`) is a complete interface catalog (2,035 entries) covering every command, All tools entry, dialog, panel and notice, selected automatically for `de`, `de_DE`, `de_AT`, `de_CH` and other German locales, and from Preferences. Informal "du", infinitive for commands. Coverage tests check commands, All tools labels and every `tl!` literal; history labels keep captured names; the bundled Latin faces cover umlauts, ß and German quotes. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (community, M7, Arabic added text):** Add text writes Arabic into the PDF instead of `?`. The text is shaped with the craft-fonts `Arab` face through harfrust (already in the tree via egui): joining forms, ligatures and mark positions. Each line is laid out in display order by the Unicode bidirectional algorithm, so Arabic mixed with Latin words, European or Arabic-Indic digits and brackets reads correctly; justified right-to-left paragraphs end at the right edge. Each cluster (a letter with its dots or marks) becomes one glyph of a Type 3 font built from the face's outlines, with a ToUnicode entry, so `text_extract`, copy and search get every letter once and no font file is embedded. Wrapping measures shaped words. Each item has its own fonts under names free on the page (a full save renumbers objects but not resource names); updating or deleting the item removes them. Without the face, adding Arabic text returns a clear error and changes nothing, while an existing item holding Arabic still moves and retypes, drawn as `?` as before. Tests cover shaping, display order, ToUnicode, font names and clean-up on one page, and odd input (empty lines, direction controls, lone marks, 18,000 characters). Not yet: bold/italic Arabic, Hebrew, right-to-left typing in the box (misc.rtl-ui), editing existing Arabic text (edit.rtl-cjk-editing). The craft-fonts revision pinned for releases predates its Arabic face, so release builds report the missing font until the pin moves. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M2, predictor hardening):** Empty PNG predictor frames return without row buffers. Packed TIFF Predictor 2 rows reject overflowing dimensions and size per-component scratch to the samples present in the input, aligned with upstream lopdf#589. Synthetic regressions cover malformed dimensions, partial rows, 1/2/4-bit decoding and padding. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (community, document inspection):** Annotation dates preserve non-date Unicode text instead of panicking while opening; failed auxiliary structure inspection keeps renderer page geometry and reports a warning. Synthetic UTF16BE annotation, UTF8-boundary and independent panic regressions cover the fix. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (community, M14, #503):** Ukrainian (Українська, `uk`) adds 2,047 original catalog entries covering commands, tools, dialogs, panels, notices, history and contributor controls. Auto resolves `uk`/`uk_UA`; the manual preference persists. Integer plurals distinguish one/few/many, including 11–14 and large counts; fixed-count templates use count-neutral wording. Calendar dates use genitive month names; Home cards fit wrapped text and the search hint keeps clear of its shortcut. Coverage checks scan UI literals, and font checks use bundled Inter and JetBrains Mono, including ґ/є/і/ї. Control tests check translated Preferences, command search, error framing and language switching with unsaved documents. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (community, M5, #158):** Fill & Sign date format. Preferences ▸ Date format sets the pattern Fill & Sign dates use: 12 presets or any pattern in Acrobat date letters (with `\` escapes), checked and previewed as typed, remembered, and settable through `fill_sign_date_format`, `fill_sign_add` `format`/`language` and `ui set date-format`/`date-language`. Month and weekday names come in the interface languages (Czech in the genitive after a day), following the interface language unless one is picked. Fill & Sign text is still Helvetica/WinAnsi, so a date with other letters (Czech ř, Japanese, Chinese) is refused with a warning rather than saved as "?". This goes past Acrobat, whose Fill & Sign has no format setting. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M14, #503 follow-up):** Reviewed the complete Ukrainian catalog with VESUM 6.8.6 and manually checked the labels added when #509 landed. Certificate Subject now names the certificate holder separately from the document topic; certificate types and fingerprints, equal field sizes, keyboard/assistive-technology guidance and table sections use clearer contextual wording. Reviewed technical compounds and abbreviations remain unchanged. Overall effort estimate remains about 30–35%.
- **2026-10-09 (community, Home pinned folders):** Home gets a Folders section: File ▸ Pin folder to Home… (or "Pin a folder…" on Home) pins up to 8 folders, and each lists its newest PDFs (name, how long ago, size; 8 at first, up to 100 with Show more), one click to open, Unpin to remove. Folders are read on a worker thread and listed again every few seconds while Home shows, so new scans appear without reopening anything; a folder that can't be read (moved, offline synced drive) stays pinned and says so. Each folder is described by where it is ("iCloud Drive", "OneDrive · Contoso", "Documents › Work") rather than its full path, so two folders called "Scans" can be told apart. Cloud drives work through their local synced folder; nothing is read through a provider's API. Settings keep only absolute paths, without duplicates. Command label translated in every catalog; Home strings in French, Spanish, Japanese and both Chinese catalogs. Not yet: subfolders, search inside a pinned folder, the web build. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (community, M14, Telugu):** Telugu (తెలుగు, `te`) as a complete catalog of 1,984 entries covering every command, All tools entry, dialog, panel and notice; `te` and `te_IN` locales follow it. Telugu text is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, storytold/craft-fonts#10), kept in the web build too, after the CJK and Arabic faces; egui shapes its conjuncts. Commands use the plain imperative and sentences the polite form. The craft-fonts revision pinned for releases has no Telugu face yet, so release builds show Telugu as replacement boxes until the pin moves. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M14, Simplified Chinese About):** The About tabs, contributor controls and statistics, and model table now have Simplified Chinese translations, along with multiline link-privacy and cut-and-stack guidance. Coverage checks include direct `i18n::t` calls, multiline literals and dynamic credit labels; UI control checks exercise the translated About tabs and tables. Chinese font delivery remains incomplete; overall ≈ 30–35% (unchanged).
- **2026-10-09 (M14, #305):** Windows MSI rejects per-user installation overrides while allowing removal of legacy per-user installs and keeps its progress message visible. README documents unattended deployment without a desktop shortcut. Compiled x64 MSI checks pass; ARM64 install regressions added for CI. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M13, MCP #451):** common command, batch, inspection and bounded preview tools share the existing confined automation path. MCP tools carry complete hints and reject unknown top-level keys as invalid parameters.
- **2026-10-08 (M14, #307, upstream integration):** Rebased the local candidate onto main `a6bc63a`, preserving render-request deduplication, pixel-aligned rasters, signature editing and organizer insertion controls. Regressions cover inactive signature-preview cleanup, insertion gaps under virtualization, and canceled/reintroduced render requests. All 1,336 workspace tests pass (6 ignored), 193 parser tests pass (2 missing-fixture tests filtered), strict workspace/all-target Clippy passes without allowances, and 30 WASM crate checks pass. All 1,010 navigation frames, 12 workflow artifacts and eight 100-page external PDF checks match; 1×/2× desktop protocols pass again. Inspection is 497.7 MiB / 1.78 s, full-save 1.06 GiB / 29.64 s, and fresh desktop navigation/close/reopen 877 MiB. The desktop result is higher than the pre-rebase 832 MiB, while remaining 21.9% below the earlier bounded-writer stage. Windows, cold-start latency and global memory budgets remain open; no milestone percentage increase.
- **2026-10-08 (M5.7, signature resize proportions):** Image signature corner handles preserve the embedded image's original aspect ratio and anchor the opposite corner; edge midpoint handles stretch width or height independently. The live preview and committed edit share the constrained geometry, including page/view rotation and reopened PDFs whose rectangles were stretched previously. Synthetic UI tests cover all corners, edge resizing, undo and redo. Milestone and effort estimates unchanged.
- **2026-10-08 (M5.7, live signature adjustment):** Embedded image signatures resize and move with every pointer frame. Movement hides the frame and handles, which return on release; Escape cancels without committing. A cached background preserves the page beneath the image, and only the completed gesture enters undo history. The read-only engine preview and `comment_image_preview` tool share bounded image decoding and render layers. Synthetic pixel tests cover live geometry, alpha, coloured page content, rotation, cancellation and encrypted save/reopen. Milestone and effort estimates unchanged.
- **2026-10-08 (M10/M4, create from multiple files):** Create a PDF ▸ Multiple files is live. The picked PDFs, images and `.txt` files open as one unsaved document in the Organize page grid, where pages are removed and reordered and Save pages writes what is shown. The grid gained a "+" at every gap that inserts files right there, and on desktop files dropped on the grid go to the gap under the pointer; Insert from file now takes several files in order, converts images and text, and selects what it inserted. File-type detection moved from the shell into `pdfcraft-create` (`source_kind`), with one engine entry point (`Session::convert_to_pdf`) shared by Open, Insert, Create and the new `doc_create_multiple` tool (which can also write one PDF per file). Not yet: Office/HTML/Markdown inputs, PDF Portfolios, progress for big batches, dropping onto the grid in the web build. Overall estimate unchanged (about 30–35%).
- **2026-10-08 (M5.7, signature placement):** Image signatures and initials preview at the pointer at their actual page size, including zoom and document/view rotation. A successful placement switches to Select with the new annotation selected; stamp resize handles use the existing engine edit, automation tool and undo history while preserving the appearance. Synthetic rendered UI and automation tests verify positioning, one placement per selection, resizing, undo/redo and save/reopen. Milestone and overall effort estimates unchanged.
- **2026-10-08 (M5.7, image signatures):** Fill & Sign imports local PNG/JPEG signatures and initials through the Image tab, with previews, local persistence and undoable placement. The shared engine validates bounded images and preserves transparency and orientation; `fill_sign_add` accepts an image path. Synthetic UI and automation tests cover cancellation, invalid input, settings, undo/redo and save/reopen rendering. Overall effort estimate unchanged (about 30–35%).
- **2026-10-08 (M14, #307, confirmed baseline OOM):** Removed the benchmark RSS guard and ran the frozen original and optimized CLI binaries in separate containers with a verified 15 GiB kernel memory limit, no swap and four CPUs. The unmodified baseline was kernel OOM-killed after 20.06 s with 14.97 GiB observed RSS; Docker recorded `OOMKilled=true`, exit 137 and an `oom` event. The optimized build completed the same 9,156-page inspection at 497.5 MiB in 1.75 s with unchanged metadata and no warnings. The original RSS remains a lower bound and elapsed time is time to failure. The kernel limit still constrains this experiment; no remote run was needed. No product changes or milestone percentage increase.
- **2026-10-08 (M14, #307, original-baseline cutoff):** Repeated original unmodified CLI inspection with a 14 GiB RSS guard, up from 10 GiB, on the unchanged 9,156-page PDF. It reached 14,682,080 KiB and was stopped after 48.21 s before completion; no cgroup OOM kill occurred. The optimized build completed the same inspection at 495.7 MiB in 1.70 s. The original measurement is a lower bound, not a completed peak or timing. This guarded run failed before completion without an OS OOM kill. The user closed the larger-host follow-up; the prepared benchmark is retained as evidence. The later local guard-free experiment is recorded above. No product changes or milestone percentage increase.
- **2026-10-08 (M14, #307, shared parsing and raster budgets):** Native render workers share a lazily initialized parser after cold-load/repair concurrency and watchdog recovery fixes, while retaining private render caches. Pages, organizer and Print request current visible thumbnails; retained active-view textures are limited to 24/128/128 MiB for thumbnails/pages/tiles and completed raster storage to 32 MiB/32 results, allowing one oversized result alone. Signature discovery uses a bounded source/edit-aware cache; the writer borrows unchanged objects and reuses xref rows. Engine navigation drops from 997 to 807 MiB with all 1,010 frame hashes unchanged. Three-trial full/incremental-save workflow times improve to 29.92/16.35 s at unchanged 1.06/1.08 GiB peaks; full serialization remains slower than before bounded saving, and the single mixed workflow takes 67.99 s versus 65.52 s. All 1,158 workspace tests pass (6 ignored in the regular run), 193 vendor tests pass (2 missing-fixture tests filtered), eight outputs pass 100-page external checks, and 29 WASM crate checks pass. Existing strict workspace Clippy warnings remain; UI lint validation explicitly allows the existing collapsible-match category. Fresh desktop navigation/close/reopen falls from 1,123 to 832 MiB; 1x/2x GUI runs pass visible-demand and texture-budget checks on original 10,000-page fixtures and the real file. Output-capacity and LRU experiments did not justify production changes. These are local Linux results and specific storage budgets, not global RAM limits or Windows validation. No parity or milestone percentage increase.
- **2026-10-08 (M14, #307, bounded saving):** Full rewriting now uses private temporary caches and packs objects in batches limited by count and serialized bytes, preserving unknown objects, reference identity and encryption behavior without populating undo snapshots. Native render workers initialize only on a live request, avoiding unused parser copies during refresh/rebase. Three same-file Linux trials reduce rotate/full-save/close/reopen peak RSS from 3.69 to 1.06 GiB (35.53 to 32.51 s); incremental workflow RSS falls from 1.56 to 1.08 GiB. All 1,114 workspace tests pass (4 ignored); eight saved outputs pass external text/raster checks on 100 sampled pages each, and 1,010 navigation frames match. First use of each worker now pays parser initialization; steady-state viewing remains about 1 GiB. Single oversized objects, whole source/output buffers, and Windows behavior remain outside a global budget claim. No parity or milestone percentage increase.
- **2026-10-08 (M14, #307, research/comparison):** Added a local external-reference suite outside the repository, using the same 9,156-page PDF with MuPDF, PDFium, Poppler and qpdf. Three Linux trials put the candidate's single-renderer 102-frame peak at 392 MiB, versus 348/347 MiB for MuPDF/PDFium with whole-file bytes (164/163 MiB with file input). The rotate/full-save/close/reopen workflow peaks at 3.69 GiB during serialization; incremental saving peaks at 1.56 GiB during rebasing. Different UI features, input/cache policies and writer compression settings prevent treating these as app rankings. Next targets are bounded object-stream packing, parsed-object lifetimes, snapshot overlap and file-backed input. No further runtime optimization or milestone percentage increase in this research step.
- **2026-10-08 (M14, #307, local candidate):** Validated the 192.9 MB, 9,156-page VMware document in Linux. The original CLI exceeded a 10 GiB measurement cutoff before finishing inspection; the candidate inspects in about 1.8 s at 496 MiB and a 1,000-page navigation/close/reopen probe peaks at 990 MiB. It shares validated large resource indexes within each parser, reads panel structure on demand through COS with the previous inspector as a compatibility fallback, and scans standalone timestamps with temporary caches instead of retaining every object in the editor. Inspection output and 104 common sampled page renders match the handoff candidate. This is a different PDF revision from the original Windows issue; Windows GUI private memory and responsiveness remain unverified. No parity or milestone percentage increase.
- **2026-10-08 (M9, #179):** Windows Current User Personal certificate store identities join file-based digital IDs, signing through CNG without exporting keys. RSA-2048 and ECDSA P-256 round trips use temporary certificates with cleanup guards; enumeration, missing identities, automation errors and the password-free UI have regression coverage. Smart cards and PKCS #11 remain planned; overall effort estimate unchanged.
- **2026-10-08 (M4, Combine files):** Combine files is now its own tab instead of a dialog: a table of the files (name, pages, size, modified, warnings) whose columns sort, resize and move, with the layout kept in the settings. Files, folders (optionally with subfolders) and open documents can be added, or dropped on the tab; rows select like a file manager (Ctrl/Shift), move as a block by toolbar, Alt+arrows or drag, and every change undoes with ⌘Z. Problems show before Combine: password-protected files, security settings that withhold page assembly, bad page ranges (checked as typed), duplicates; protected files unlock with their open or permissions password, also headlessly (`doc_combine` `passwords`). The result is never encrypted, and the list says so. `organize.combine-sort` shipped; `organize.combine-options` partial (open files). Still missing: protecting the result in the same step, file-size and PDF/A options. Overall estimate unchanged (about 30–35%).
- **2026-10-08 (M14, French):** Français (`fr`) is a complete interface catalog (about 1,880 entries), selected automatically for `fr`, `fr_FR`, `fr_CA` and other French locales, and from Preferences. Coverage tests check commands, All tools labels and every `tl!` literal; history labels keep captured names. Latin UI faces cover the catalog. Localization remains partial for Czech and Brazilian Portuguese; overall ≈ 30–35% (unchanged).
- **2026-10-08 (M14, right-to-left file names):** A tab with an Arabic or Hebrew file name showed replacement boxes. The interface now takes an `Arab` face from craft-fonts when the build input has one, and on desktop falls back to one font installed on the machine for characters no embedded face has (`PDFCRAFT_SYSTEM_FONTS=0` turns that off; README screenshots do). File names and paths in the tab strip, the recent-files list and Properties are put in display order, since egui joins the letters but does not reorder runs. Still missing: multi-line text (bookmarks, comments, search results), text input and a mirrored layout, so `misc.rtl-ui` stays planned; the craft-fonts revision pinned for releases predates its Arabic face, so release builds rely on the installed font until the pin moves. Overall estimate unchanged (about 30–35%).
- **2026-10-08 (community, M3, #214):** In single-page view, when the whole page fits, the wheel turns pages: one per wheel notch, one per trackpad swipe including its momentum. The rail gets a Page display button between rotate and zoom with Acrobat's view choices (Fit to width scrolling, Fit one full page, Actual size, Zoom to page level, Fit visible, Read mode, Full screen) and the page layouts. Preferences gets Default page display and Default zoom, which every new tab uses. In continuous view, Fit page and Fit height fit the largest page, so mixed page sizes no longer jump while scrolling. Overall estimate unchanged (about 30–35%).
- **2026-10-08 (M14, Windows Chinese Auto):** Auto now reads the Windows display language after locale environment overrides, selecting Simplified or Traditional Chinese through the existing locale mapping. Added Chinese region/script and Windows query regressions. Chinese font delivery remains incomplete. Localization remains partial; overall ≈ 30–35% (unchanged).
- **2026-10-08 (M3, themes):** Toolbar and Display theme now share System, Light and Dark preferences, one command path and persistent settings; the duplicate View toggle is removed. Effective colours track the OS only in System mode, with egui popup styles kept in sync. Overall estimate unchanged (about 30–35%).
- **2026-10-08 (M14, Simplified Chinese):** Added 208 translations, bringing the catalog from 1,625 to 1,833 entries. PhotoCraft-style coverage checks protect registered commands, All tools labels and UI `tl!` literals; generated history and diagnostic regressions preserve user values. Chinese font delivery remains incomplete: the release-pinned craft-fonts input has no Hans face and the web build only embeds the Japanese UI face (see #112 and #140). Localization remains partial; overall ≈ 30–35% (unchanged).
- **2026-10-08 (community, #135):** CLI output exits cleanly when a pipe reader closes early. Other stdout errors return a failure instead of panicking, and command/file errors keep their diagnostics. Synthetic subprocess regressions cover closed pipes and a reader stopping after one line. Parity counts and effort estimate unchanged (≈ 30–35%).
- **2026-10-08 (community):** Cut and stack in Print > Multiple: single-sided n-up sheets can be cut into cell piles and restacked in selected page order, with aligned gutter marks and blanks kept in their cells. Available through doc_print; duplex is refused. Grid-size overflow returns an error. No change to the parity count or the overall estimate (about 30–35%).
- **2026-10-07 (community, #208):** Measure: distance, perimeter and area annotations (standard LineDimension/PolyLineDimension/PolygonDimension with rectilinear /Measure), persistent viewport scales with two-point calibration, vector snapping (endpoints, midpoints, intersections, paths) through nested forms, live readings, CSV export, and eight `measure_*` agent tools. Hardened on landing: snap extraction caps segments and snap targets page-wide and skips undecodable streams and over-deep `q` instead of failing; unsupported imported measurements (compound ft-in, fractional, invalid geometry) are listed as unsupported rather than failing the list; non-ASCII units ("m²") are allowed and rendered in WinAnsi; a closing duplicate polygon vertex counts as closed. Distance and scale are partial (single-unit decimal formats only; Acrobat interop untested). 51.0% shipped; ≈ 30–35% effort.
- **2026-10-07 (community, #190):** Create PDF from Images offers embedded resolution, 72 DPI, or custom DPI (1–1200) without resampling. The engine and doc_create share the choice; Windows image context menus open the chooser through --create-images. Overall estimate unchanged (about 30–35%).
- **2026-10-08 (community, #135):** CLI output exits cleanly when a pipe reader closes early. Other stdout errors return a failure instead of panicking, and command/file errors keep their diagnostics. Synthetic subprocess regressions cover closed pipes and a reader stopping after one line. Parity counts and effort estimate unchanged (≈ 30–35%).
- **2026-10-08 (community):** Cut and stack in Print > Multiple: single-sided n-up sheets can be cut into cell piles and restacked in selected page order, with aligned gutter marks and blanks kept in their cells. Available through doc_print; duplex is refused. Grid-size overflow returns an error. No change to the parity count or the overall estimate (about 30–35%).
- **2026-10-07 (community, #161):** The "Save changes?" prompt keeps a long file name inside the modal: the title wraps to the dialog width, so the centered panel no longer stretches past the viewport, clips its message and pushes Save/Cancel off-screen (Windows report). The password prompt's file-name line wraps the same way, and a regression test pins the title and all three buttons inside a 1365×719 screen. Parity counts unchanged; ≈ 30–35%.
- **2026-10-07 (community, #143):** Windows MSI gets a full-UI welcome dialog with a "Create a desktop shortcut" checkbox (on by default; `INSTALLDESKTOPSHORTCUT=0` for unattended installs), progress, files in use, repair/remove, and a "Setup completed successfully" screen with Finish (plus distinct failed/cancelled screens). Start Menu and desktop shortcuts are now plain links to `pdfcraft.exe` instead of advertised MSI shortcuts. Packaging checks the compiled MSI's shortcut, checkbox and dialog tables; the ARM64 smoke test checks both links' targets, their removal on uninstall, and the opt-out. Installer polish only; feature counts unchanged. ≈ 30–35%.
- **2026-10-07 (localization):** The interface translates through the same system as PhotoCraft: one TSV catalog per language (`crates/ui-egui/src/i18n/ja.tsv`), the `tl!` macro, entries keyed by command id or with plural forms, and catalog tests (format, duplicates, placeholders, command ids). Catalogs are validated strictly (unknown escapes, reserved contexts, placeholders, ellipses, plural forms), a bad line is skipped and logged at run time, `fmt` substitutes in one pass, the drawing language is per thread, and each `.tsv` is an attributed asset (`kind = "translation"`, checked by `cargo xtask assets`); validation, attribution and the language-switch control test folded in from #175. The 49 existing Japanese strings moved over unchanged, plus `Auto`. The `language` preference now defaults to `auto` (follow the system language; English when it has no catalog); `en`/`ja` still persist, and unknown values are rejected. Not covered: dialogs and panels, Windows system-language detection. ≈ 30–35% (unchanged).
- **2026-10-07 (session 16):** PrintCraft is renamed PdfCraft: repo `storytold/pdfcraft`, crates and binaries `pdfcraft*`, bundle id `ai.storyteller.pdfcraft`. On first launch the app moves the old PrintCraft settings and crash-recovery folders to the new name, so upgrades keep recent files, preferences and unsaved work. No feature change. ≈ 30–35%.
- **2026-10-07 (community, #163):** Fill & Sign shows saved signature/initials previews with add, change and remove controls in the quick picker and left panel. The editor loads the saved typed text or drawn strokes; Apply replaces future placements and Cancel preserves the saved value. Existing annotations stay unchanged. Long typed names shrink to the placement width and include glyph overhangs in preview/PDF bounds. Regression coverage includes persistence, removal/recreation, long names and the opt-in UI control channel. Parity counts and effort estimate unchanged (50.2% shipped, ≈ 30–35% effort).
- **2026-10-07 (fork, default-workspace-mode):** Preferences ▸ Documents and view remembers the workspace used for newly opened PDFs. All Tools remains the default; explicit CLI/control mode overrides last for the session and PDF Initial View metadata stays independent. Overall estimate unchanged (≈ 30–35%).
- **2026-10-07 (community, M14, #112):** Simplified Chinese (简体中文, `zh-hans`) as a catalog of about 1,600 entries; `zh`, `zh_CN` and `zh-Hans` locales follow it. In Simplified Chinese the craft-fonts `Hans` faces come before the Japanese ones, so Simplified-only characters don't sit on a different baseline.
- **2026-10-07 (community, M14, #140):** Traditional Chinese (繁體中文, `zh-hant`, Taiwan vocabulary) as a catalog of about 1,800 entries; `zh_TW`, `zh_HK`, `zh_MO` and `zh-Hant` locales follow it. Drawn with the craft-fonts CJK faces (missing characters show replacement boxes).
- **2026-10-07 (community, M14, #141):** Every dialog, panel, notice, shortcut list and native-picker title goes through the catalog (`tl!`, `i18n::fmt`), and Japanese grows from 49 to about 1,800 entries (`ja.tsv`); worker threads (export, OCR, actions) write their summaries in the UI language; history labels keep captured file and field names; the command palette searches translated and English labels and ids. Engine and OS error text is no longer matched against English templates: it is shown as is, inside a translated frame. Localization remains partial for other languages.
@@ -269,7 +242,6 @@ Newest first. One line per session: the date, what moved, and the new overall pe
- **2026-10-06 (session 14):** Issues: move and resize existing text boxes in Edit text (#59, closes #40); Windows release builds open no console window and the Start Menu shortcut has its icon (#58, #57; rolled out to every Craft app); native Windows ARM64 installers, installed and run on an ARM64 runner in CI (#66, #71; the intermittent ARM64 test crash was a WARP shader-JIT race between parallel GPU-rendering tests, not our code); XFA forms are detected and explained instead of failing silently (#65, #60 stays open for real XFA). Also: squiggly underline tool, comment opacity, Preferences ▸ Identity, drag-and-drop/hover tests; community table export to Word/HTML/RTF (#62, with linear-time detection and a column cap). #63 (timestamps/LTV with outbound network) held for review fixes and an owner decision on network access. ≈ 30–35% (unchanged).
- **2026-10-06 (community, #68):** Interface language: Czech (Čeština, `ui.set language=cs`) joins English and Japanese. It translates every registered File/Edit/Pages/View/Help command and every label routed through the translation table, with tests that fail when a menu label lacks Czech; the interface fonts are tested to cover Czech diacritics. Dialogs and panels remain English (misc.prefs-language: partial).
- **2026-10-06:** M1: invalid object-number ranges in xref tables and streams take the existing reconstruction path, with the reason recorded, instead of overflowing or aliasing another object. Synthetic open/repair regression added. Overall estimate unchanged (≈ 30–35%).
- **2026-10-05 (session 13, later):** FreeBSD: CI builds and tests the workspace in a FreeBSD 14.3 VM (#45) and the release ships a FreeBSD x86_64 tarball, checked in CI (#42); README lists FreeBSD. Fuzzing: the nightly job's 11 hangs fixed (#41: our parser's exponential retries, inline images, Type 3 fan-out), plus a JBIG2 hang (#43, vendored hayro-jbig2) and a Type 1 font stack overflow (#46, skrifa 0.47); a full run now finds 0 crashes. Landed community PRs #36 (text editing, Japanese replacements), #47 (English/Japanese interface) and #48 (XFDF colour crash). Windows launch crash with Intel's Vulkan driver fixed by using Direct3D 12 (#49, issue #37); one copy of the Japanese font instead of two (#51, −8.3 MB); full screen and About get real tests (#50). P0 88%, P1 52%. ≈ 30–35%.
- **2026-10-05 (community, #47):** Interface language: Preferences ▸ Interface language switches English/Japanese and persists (`ui.set language=ja|en`, reported by `ui.state`). Core menu labels are translated; dialogs, panels and more languages remain open (misc.prefs-language: partial).
- **2026-10-05 (session 13):** Landed community PRs #3–#7 and the never-crash rollout (#11–#25); CI green on every job for the first time in 30+ runs (clippy 1.99, Windows line endings, perf-test noise); nightly fuzz job made to finish (child memory cap) and its findings fixed: 7 out-of-memory crashes (#27, #29, #30) and 11 hangs (in review); issue #8 (integrated GPU, keyboard save prompt); Help ▸ Check for updates (manual only). Added the honest assessment and gap list above. P0 88%, P1 52%; 804 features 49.5% shipped (53.3% weighted). ≈ 30–35%.
@@ -277,7 +249,6 @@ Newest first. One line per session: the date, what moved, and the new overall pe
- **2026-10-03 (session 12):** Edit text and images in place (lines, paragraphs, formatting, existing images), Scan & OCR (new `ocr` crate on ocrs with CC-BY-SA models fetched by `cargo xtask models`: searchable image, page ranges, multiple files), Acrobat JavaScript (new `js` crate on boa: custom field scripts in Acrobat's event order, button scripts, document JavaScripts, console, Enable JavaScript preference, sandbox limits). 110+ tools; P0 88%, P1 48%; 803 features 47.9% shipped (51.1% weighted). ≈ 37%.
- **2026-10-02 (session 11, later):** Sign with macOS Keychain identities, Add alternate text, Advanced Search panel, space audit and link clean-up in the Optimizer, hybrid-reference files, signed documents protected from rewrites, redaction cleans the tags, custom stamps, image fields, Combine files with page choice. 97 tools; P0 82%, P1 45%. ≈ 30%.
- **2026-10-02 (session 11):** Accessibility Checker (new `a11y` crate: all 32 rules, report, fixes, options dialog and results panel), MCP resources for page images and text, revisions (list, open an earlier one), export all images, fit visible, form Preview and locked fields, document title in the window; plus attachment comments, eraser, date picker, duplicate/align/distribute fields. 91 tools; P0 80%, P1 43%. ≈ 29%.
- **2026-10-02 (session 10, last):** Manual tab order, backgrounds/watermarks from files (PDF pages as forms, images), Initial View and reading options (honoured on open), bookmarks expand/collapse, make-default comment properties, required-field borders, cut/copy/paste pages, marquee zoom, snapshot. 85 tools; P0 80%, P1 ≈ 27%.
- **2026-10-02 (session 10, latest):** PDF Optimizer and real Reduce File Size (new `optimize` crate: effective-resolution image downsampling, recompression, discards), Field Properties ▸ Options for every field type, Redaction Properties overlay styling, drag-to-reorder pages, repair notice and log; signature revalidation made incremental (comment edit on a 120 MB signed file 84 → 6 ms). 84 tools; P0 parity ≈ 80%. ≈ 27%.
- **2026-10-02 (session 10, later):** Digital signatures (new `sign` crate: PKCS #12, X.509, CMS; PAdES B-B signing incl. certification; validation with trust store and DocMDP-aware change detection; Signatures panel, message bar, sign and digital ID dialogs; signed documents keep incremental saves), polygon/cloud/connected-lines/callout/caret comments, Create PDF from clipboard, comment filters by colour and checkmark. Verified against pdfsig and OpenSSL. 83 tools; P0 parity 78%, P1 22.5%. ≈ 26%.
@@ -298,14 +269,12 @@ Newest first. One line per session: the date, what moved, and the new overall pe
- Fixed: the Hand tool did not pan.
- 50 agent tools.
- Overall ≈ 16%.
- **2026-10-01 (session 7):**
- Bookmark editing and page numbering (Number pages), with undo, agent tools and UI.
- A render watchdog: pathological pages are skipped after 20 s instead of spinning forever.
- The agent control channel no longer reports success when the window is hidden.
- Community links: Discord button in the title bar, plus Home, About, Help menu, CLI and README.
- Overall ≈ 10%.
- **2026-10-01 (session 6):**
- UI control channel: agents can inspect the widget tree, click, type, press keys, run commands and take screenshots of the running app. Off unless the app is started with `--control`; loopback only, token-authenticated.
- Combine and insert store identical fonts, images and other resources once. 40 copies of the showcase: 130 MB → 3.9 MB, 3× faster, pixel-identical.
@@ -313,14 +282,12 @@ Newest first. One line per session: the date, what moved, and the new overall pe
- Fuzzing (`cargo xtask fuzz`, nightly in CI): about 150,000 mutated files tried. Seven crash and hang bugs found and fixed, each with a regression test: two in our code, five in the temporary renderer.
- Parity checklist: 826 Acrobat Pro features tracked in `parity/acrobat-features.toml`; `cargo xtask parity` checks every claim against code and tests. 11.8% shipped overall, 31.6% of P0.
- Overall ≈ 9.5%.
- **2026-09-30 (session 5, after a machine crash; no work lost):**
- Agent control: new `pdfcraft-automation` crate with 20 JSON-Schema tools, an opt-in MCP server (`pdfcraft-cli mcp`, stdio only, can be compiled out), and `pdfcraft-cli run`/`tools`.
- Text on rotated pages now reads along its lines; the pdf.js oracle is unchanged at median 0.980.
- Finding text in 520 pages: 17.9 s → 3.7 s (parallel), about 20 ms when repeated (cached).
- CI: dependency licence audit (`deny.toml`, `xtask deny`) and a GitHub workflow for macOS, Windows, Linux and wasm.
- Overall ≈ 8%.
- **2026-09-30 (session 4):**
- Combine, extract and split, with links, named destinations, fields, layers, attachments and nested bookmarks all carried over. Combined pages render pixel-identical to their sources.
- Fixed a precision bug in how reals were written.
@@ -343,3 +310,15 @@ Newest first. One line per session: the date, what moved, and the new overall pe
- M13: common command, batch, inspection and bounded preview tools share the existing confined automation path. MCP tools carry complete hints and reject unknown top-level keys as invalid parameters.
- **2026-10-10 (UI docking):** Tools and Inspector can be regrouped, split beside the PDF, or floated inside the app window. Existing toggles reopen panels at their saved locations; UI commands arrange/reset panels, and preferences retain their layout. Read/full-screen modes preserve the focused document view. No milestone percentage change.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | minor | Readiness table with hours per audience; full number restated as the additive weighted sum over dimensions (47% → 49%; method aligned with the standard, no new evidence) |
| 2026-10-10 | minor | Essentials-user score added (≈ 66%) |
| 2026-10-10 | minor | Gate consistency check: convert and share re-judged partial, not blocking; stage pre-alpha → alpha |
| 2026-10-10 | minor | Second look: ready 40% → 47% (cross-cutting double count removed), mainstream-practitioner numbers and user sentiment added; core-workflow alpha gate applied, stage alpha → pre-alpha |
| 2026-10-10 | major | Restructured to the progress-docs standard (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; assessment, gaps and milestones moved to `docs/`; log sorted newest first and a stray merged entry dated |
| 2026-10-07 | minor | 806 tracked features, 51.0% shipped, effort-weighted ≈ 30–35%, 600–1,100 h |
| 2026-10-05 | major | Honest assessment and "Where we're lacking" added |
+1 -1
View File
@@ -16,5 +16,5 @@ egui = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "Location", "Url", "UrlSearchParams", "Response"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "Location", "Url", "UrlSearchParams", "Response", "Event", "MessageEvent", "EventTarget"] }
js-sys = "0.3"
+165
View File
@@ -51,16 +51,55 @@ fn main() {
ctx.request_repaint();
});
}
// eframe never raises a close request on a browser reload or tab close, so `guard_quit` never
// prompts; ask the browser to confirm instead while a document has unsaved work (#812).
warn_before_unload(app.unsaved_flag.clone());
// `?host=parent`: a same-origin page embedding PdfCraft in an iframe (such
// as the Nextcloud app) passes files in and takes saves back (`host` below).
if query_param("host").as_deref() == Some("parent")
&& let Some(bridge) = host::Bridge::new()
{
// `?author=`: the host's name for the user signs their comments,
// unless they chose a name in PdfCraft (the web default is "Guest").
if let Some(author) = query_param("author").map(|a| a.trim().to_string()).filter(|a| !a.is_empty())
&& app.comment_prefs.author == "Guest"
{
app.comment_prefs.author = author.chars().take(200).collect();
}
bridge.listen(app.startup_inbox.clone(), cc.egui_ctx.clone());
app.host_save = Some(bridge.save_fn());
app.host_dirty = Some(bridge.dirty_fn());
bridge.post_ready();
}
Ok(Box::new(app))
}),
)
.await;
if let Err(e) = started {
eframe::web_sys::console::error_1(&e);
if query_param("host").as_deref() == Some("parent")
&& let Some(bridge) = host::Bridge::new()
{
bridge.post_failed(&e.as_string().unwrap_or_else(|| format!("{e:?}")));
}
}
});
}
#[cfg(target_arch = "wasm32")]
fn warn_before_unload(unsaved: std::sync::Arc<std::sync::atomic::AtomicBool>) {
use eframe::wasm_bindgen::{JsCast, closure::Closure};
let Some(window) = web_sys::window() else { return };
let handler = Closure::<dyn FnMut(web_sys::Event)>::new(move |event: web_sys::Event| {
if unsaved.load(std::sync::atomic::Ordering::Relaxed) {
event.prevent_default();
}
});
if window.add_event_listener_with_callback("beforeunload", handler.as_ref().unchecked_ref()).is_ok() {
handler.forget();
}
}
#[cfg(target_arch = "wasm32")]
fn query_param(key: &str) -> Option<String> {
let search = web_sys::window()?.location().search().ok()?;
@@ -80,6 +119,132 @@ async fn fetch_bytes(url: &str) -> Result<Vec<u8>, String> {
Ok(js_sys::Uint8Array::new(&buf).to_vec())
}
/// The `postMessage` bridge to a same-origin parent page. Messages are plain objects with a
/// `type`:
///
/// - `pdfcraft:ready` (to the parent): the app is listening; send files now.
/// - `pdfcraft:open` (from the parent): `{ name, bytes }`, where `bytes` is an `ArrayBuffer` or
/// `Uint8Array` holding a PDF. It opens in a tab, like a `?file=` URL.
/// - `pdfcraft:save` (to the parent): `{ name, saveAs, bytes }` when the user saves; `bytes` is
/// a transferred `ArrayBuffer`. The parent stores the file (and reports its own errors).
/// - `pdfcraft:dirty` (to the parent): `{ dirty }` whenever unsaved work appears or goes away.
/// - `pdfcraft:failed` (to the parent): `{ error }` when the app couldn't start.
///
/// Only the parent window on the app's own origin is heard or answered.
#[cfg(target_arch = "wasm32")]
mod host {
use eframe::wasm_bindgen::{JsCast, JsValue, closure::Closure};
#[derive(Clone)]
pub struct Bridge {
window: web_sys::Window,
parent: web_sys::Window,
origin: String,
}
impl Bridge {
/// `None` when the page isn't framed (there is no parent to talk to).
pub fn new() -> Option<Self> {
let window = web_sys::window()?;
let parent = window.parent().ok().flatten()?;
if js_sys::Object::is(&parent, &window) {
return None;
}
let origin = window.location().origin().ok()?;
Some(Self { window, parent, origin })
}
fn post(&self, message: &js_sys::Object, transfer: Option<&JsValue>) -> Result<(), String> {
let sent = match transfer {
Some(t) => self.parent.post_message_with_transfer(message, &self.origin, &js_sys::Array::of1(t)),
None => self.parent.post_message(message, &self.origin),
};
sent.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
}
pub fn post_ready(&self) {
if let Some(m) = message(&[("type", "pdfcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]) {
let _ = self.post(&m, None);
}
}
/// The app couldn't start (no WebGL, for example): let the parent say so.
pub fn post_failed(&self, error: &str) {
if let Some(m) = message(&[("type", "pdfcraft:failed".into()), ("error", error.into())]) {
let _ = self.post(&m, None);
}
}
/// Hear `pdfcraft:open` from the parent and queue the file like a `?file=` download.
pub fn listen(&self, inbox: pdfcraft_ui_egui::Inbox, ctx: egui::Context) {
let parent = self.parent.clone();
let origin = self.origin.clone();
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
return;
}
let data = event.data();
if string(&data, "type").as_deref() != Some("pdfcraft:open") {
return;
}
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "document.pdf".to_string());
let Ok(bytes) = js_sys::Reflect::get(&data, &JsValue::from_str("bytes")) else { return };
let bytes = if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
array.to_vec()
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
js_sys::Uint8Array::new(&bytes).to_vec()
} else {
return;
};
if let Ok(mut q) = inbox.lock() {
q.push((name, bytes));
}
ctx.request_repaint();
});
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
// The listener lives as long as the page.
on_message.forget();
}
}
pub fn save_fn(&self) -> pdfcraft_ui_egui::HostSaveFn {
let bridge = self.clone();
Box::new(move |name: &str, bytes: &[u8], save_as: bool| {
let buffer = js_sys::Uint8Array::from(bytes).buffer();
let m =
message(&[("type", "pdfcraft:save".into()), ("name", name.into()), ("saveAs", save_as.into()), ("bytes", buffer.clone().into())])
.ok_or_else(|| "couldn't build the message".to_string())?;
bridge.post(&m, Some(&buffer))
})
}
pub fn dirty_fn(&self) -> pdfcraft_ui_egui::HostDirtyFn {
let bridge = self.clone();
let mut last = false;
Box::new(move |dirty: bool| {
if dirty != last {
last = dirty;
if let Some(m) = message(&[("type", "pdfcraft:dirty".into()), ("dirty", dirty.into())]) {
let _ = bridge.post(&m, None);
}
}
})
}
}
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
let m = js_sys::Object::new();
for (key, value) in fields {
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
}
Some(m)
}
fn string(data: &JsValue, key: &str) -> Option<String> {
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
}
}
#[cfg(not(target_arch = "wasm32"))]
fn main() {
eprintln!("pdfcraft-web targets wasm32: run `trunk serve` or `trunk build --release` in apps/pdfcraft-web");
+4 -1
View File
@@ -1,5 +1,6 @@
//! Windows only: embed the app icon and version info (VERSIONINFO) into `pdfcraft.exe`, so it
//! shows in Explorer, the taskbar, the Start menu and Alt-Tab.
//! shows in Explorer, the taskbar, the Start menu and Alt-Tab. A separate PDF document icon
//! uses resource ID 2; the installer references it as `pdfcraft.exe,-2`.
//!
//! On every other target this does nothing. A missing resource compiler is a warning, so a
//! cross-compile from macOS or Linux still links, unless `PDFCRAFT_REQUIRE_WINRES=1` turns it
@@ -11,6 +12,7 @@ mod windows_manifest;
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=../../assets/app-icon/pdfcraft.ico");
println!("cargo:rerun-if-changed=../../assets/app-icon/pdfcraft-document.ico");
println!("cargo:rerun-if-changed=src/windows_manifest.rs");
println!("cargo:rerun-if-env-changed=PDFCRAFT_REQUIRE_WINRES");
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
@@ -18,6 +20,7 @@ fn main() {
}
let mut res = winresource::WindowsResource::new();
res.set_icon("../../assets/app-icon/pdfcraft.ico")
.set_icon_with_id("../../assets/app-icon/pdfcraft-document.ico", "2")
.set_manifest(windows_manifest::WINDOWS_MANIFEST)
.set("ProductName", "PdfCraft")
.set("FileDescription", "PdfCraft PDF workbench")
+4 -1
View File
@@ -7,7 +7,8 @@
//! `--page N --zoom 150 --layout continuous|two-up|single --panel comments|bookmarks|pages|fields|layers|attachments|none
//! --theme light|dark|system --language auto|<code> --mode all|read|edit|convert|sign --tool <catalogue id> --left open|closed
//! --organize on --fields on --dialog properties|shortcuts|about --palette <query> --home on
//! --cover on|off --default-layout continuous|two-up|single --default-zoom fit-width|fit-page|<percent>`
//! --cover on|off --default-layout continuous|two-up|single --default-zoom fit-width|fit-page|<percent>
//! --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
//! hands them to the PdfCraft already running, where they open as tabs (`single_instance`).
@@ -32,6 +33,7 @@ use pdfcraft_ui_egui::PdfCraftApp;
mod apple_events;
mod logging;
mod single_instance;
mod text_scale;
mod updates;
#[cfg(test)]
#[path = "windows_manifest.rs"]
@@ -301,6 +303,7 @@ fn app_creator<'a>(
app.restore(&json);
}
app.integrated_titlebar = integrated;
app.system_text_scale = text_scale::system();
app.update_source = Some(std::sync::Arc::new(updates::latest_release));
app.os_key_store_ids = cfg!(any(target_os = "macos", target_os = "windows"));
#[cfg(target_os = "macos")]
+7 -8
View File
@@ -135,17 +135,16 @@ pub fn claim(files: &[String], may_hand_off: bool) -> Claim {
}
}
/// The socket in `%LOCALAPPDATA%\PdfCraft` (beside the crash-recovery folder); none for a portable
/// copy.
/// The socket in `%LOCALAPPDATA%\PdfCraft` (beside the crash-recovery folder); none when
/// `%LOCALAPPDATA%` is unset.
#[cfg(windows)]
fn socket_path() -> Option<std::path::PathBuf> {
let name = socket_name(std::env::var("SESSIONNAME").ok().as_deref());
// A portable copy may sit in a folder other accounts can write to (`C:\Tools`): a socket
// there would let them receive this user's file paths or send it files to open. Portable
// launches each open their own window instead, and write nothing outside their folder.
if pdfcraft_ui_egui::portable::data_dir().is_some() {
return None;
}
// %LOCALAPPDATA% is private to the user's sign-in, so other accounts can't receive this
// user's file paths or send it files to open. A portable copy uses it too (#889), under its
// own name so it never hands files to an installed copy (which keeps its settings elsewhere).
// Never the portable folder itself: it may sit where other accounts can write (`C:\Tools`).
let name = if pdfcraft_ui_egui::portable::data_dir().is_some() { format!("portable-{name}") } else { name };
std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()).map(|d| std::path::PathBuf::from(d).join("PdfCraft").join(name))
}
+68
View File
@@ -0,0 +1,68 @@
//! The desktop's text scaling, for Preferences ▸ Interface size ▸ Auto.
//!
//! On Linux and the BSDs, GNOME (and desktops sharing its settings schema) keep it in
//! `org.gnome.desktop.interface text-scaling-factor` (Settings ▸ Accessibility ▸ Large Text sets
//! 1.25). It is read once at launch through `gsettings`, which is absent or fails elsewhere, and
//! anything but a sensible number counts as no scaling. Other systems scale the whole display
//! instead, which already reaches egui as `pixels_per_point`.
/// How long `gsettings` may take before launch goes on without it.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))]
const TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500);
/// The text scaling factor, or 1.0.
pub fn system() -> f32 {
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))]
if let Some(output) = gsettings(&["get", "org.gnome.desktop.interface", "text-scaling-factor"]) {
return parse(&output);
}
1.0
}
/// `gsettings` output (`1.25`) as a factor; 1.0 when it isn't a usable one.
#[cfg_attr(not(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd")), allow(dead_code))]
fn parse(output: &str) -> f32 {
output.trim().parse::<f64>().ok().and_then(pdfcraft_ui_egui::ui_scale::valid).unwrap_or(1.0)
}
/// Run `gsettings` and return what it printed, or `None` if it is missing, fails or hangs.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))]
fn gsettings(args: &[&str]) -> Option<String> {
use std::io::Read;
use std::process::{Command, Stdio};
let mut child = Command::new("gsettings").args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().ok()?;
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(status)) if status.success() => break,
Ok(None) if start.elapsed() < TIMEOUT => std::thread::sleep(std::time::Duration::from_millis(5)),
_ => {
let _ = child.kill();
let _ = child.wait();
return None;
}
}
}
let mut out = String::new();
// A number fits in far less; a misbehaving `gsettings` can't make launch read without end.
child.stdout.take()?.take(256).read_to_string(&mut out).ok()?;
Some(out)
}
#[cfg(test)]
mod tests {
#[test]
fn gsettings_output_parses_or_means_no_scaling() {
assert_eq!(super::parse("1.25\n"), 1.25);
assert_eq!(super::parse("1.0"), 1.0);
for bad in ["", "nan", "inf", "-2", "0", "uint32 0", "100"] {
assert_eq!(super::parse(bad), 1.0, "{bad}");
}
}
#[test]
fn reading_the_system_scale_never_fails() {
let f = super::system();
assert!(f.is_finite() && (pdfcraft_ui_egui::ui_scale::MIN..=pdfcraft_ui_egui::ui_scale::MAX).contains(&f), "{f}");
}
}
+31
View File
@@ -0,0 +1,31 @@
PdfCraft PDF document icon (pdfcraft-document.svg and pdfcraft-document.ico).
SPDX-License-Identifier: ISC AND (MIT OR Apache-2.0)
The document outline and fold are adapted from Lucide file-text (lucide-static 1.49.0):
https://github.com/lucide-icons/lucide
The original is kept in assets/icons/file-text.svg.
ISC License
Copyright (c) 2026 Lucide Icons and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
The badge, outlined PDF lettering, colours and composition are original work
by the PdfCraft contributors. Copyright (c) 2026 PdfCraft contributors.
Licensed under either the MIT License or the Apache License, Version 2.0,
at your option. Full texts are LICENSE-MIT and LICENSE-APACHE at the
repository root, and beside this file in Windows distributions.
The icon uses no typeface or external image. Regenerate the ICO with
packaging/icons.sh; every asset and its SHA-256 is recorded in ATTRIBUTION.toml.
+8
View File
@@ -33,6 +33,8 @@ Licence: [LICENSE.txt](LICENSE.txt) (`MIT OR Apache-2.0`, like the repo).
| `pdfcraft-1024.png` | 1024 px on Apple's grid; also the runtime Dock icon on macOS |
| `pdfcraft.icns` | macOS icon (16–1024 px) |
| `pdfcraft.ico` | Windows icon (16–256 px), embedded in `pdfcraft.exe` by `apps/pdfcraft/build.rs` |
| `pdfcraft-document.svg` | Windows PDF file icon master: a Lucide document outline, green badge and outlined PDF lettering; no font dependency |
| `pdfcraft-document.ico` | Windows PDF file icon (16–256 px), embedded as resource 2 in `pdfcraft.exe` |
| `hicolor/<n>x<n>/apps/ai.storyteller.pdfcraft.png` | Linux hicolor theme, 16–512 px; the 256 px one is the runtime icon on Windows and Linux |
| `hicolor/scalable/apps/ai.storyteller.pdfcraft.svg` | Linux scalable icon (copy of the master) |
@@ -40,6 +42,12 @@ Where it shows: `apps/pdfcraft/src/main.rs` sets the window icon (Dock, taskbar,
Wayland app id `ai.storyteller.pdfcraft`; `packaging/linux/ai.storyteller.pdfcraft.desktop` names the
hicolor icon.
Windows PDF files use resource 2 (`"pdfcraft.exe",-2`) when PdfCraft is chosen as the default
reader; the app keeps resource 1. The document outline is adapted from Lucide `file-text`
1.49.0 (ISC); its badge and outlined lettering are original (`MIT OR Apache-2.0`), covered by
[LICENSE-document.txt](LICENSE-document.txt). Both icons are embedded, requiring no runtime
sidecar. This provides a file type icon; first-page thumbnails still need a thumbnail provider.
## Regenerate
```sh
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+16
View File
@@ -0,0 +1,16 @@
<!-- Document outline: Lucide file-text, lucide-static 1.49.0, ISC.
PDF badge and outlined lettering: PdfCraft contributors, MIT OR Apache-2.0.
No fonts or external images. See LICENSE-document.txt and ATTRIBUTION.toml. -->
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<g transform="translate(-2 -2) scale(2.8)" stroke="#0b0b0c" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<path fill="#efe9dc" d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" />
<path fill="#c8ece4" d="M14 2v5a1 1 0 0 0 1 1h5" />
<path fill="none" stroke="#12a58a" d="M8 12h8M8 15h5" />
</g>
<rect x="3" y="34" width="56" height="23" rx="4" fill="#087f6b" />
<g fill="none" stroke="#fff" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M13 50V41h5a3 3 0 0 1 0 6h-5" />
<path d="M27 41v9h3a4.5 4.5 0 0 0 0-9z" />
<path d="M42 50V41h8M42 45h6" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1023 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m14 10 7-7" />
<path d="M20 10h-6V4" />
<path d="m3 21 7-7" />
<path d="M4 14h6v6" />
</svg>

After

Width:  |  Height:  |  Size: 311 B

+3
View File
@@ -71,3 +71,6 @@ sync_github_name = true
[people.Hathor875]
real_name = "Krzysztof Cieślik"
sync_github_name = true
[people.jay-tau]
sync_github_name = true
+108 -2
View File
@@ -1593,8 +1593,9 @@ pub fn move_annotation(doc: &mut Document, page: usize, index: usize, dx: f64, d
Ok(())
}
/// Resize a rectangle, oval, text box or stamp to `rect`. Stamps keep their appearance,
/// which PDF viewers scale from its bounding box into the new rectangle.
/// Resize a rectangle, oval, text box, stamp or drawing to `rect`. Stamps keep their appearance,
/// which PDF viewers scale from its bounding box into the new rectangle; drawings scale their
/// strokes and get a new one.
pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> {
let (_, r) = annot_ref(doc, page, index)?;
unlocked(doc, r)?;
@@ -1611,12 +1612,61 @@ pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], m
})?;
return Ok(());
}
if subtype == "Ink" {
return resize_ink(doc, r, normalize(rect), meta);
}
if !matches!(subtype.as_str(), "Square" | "Circle" | "FreeText") {
return Err(AnnotError::Invalid(format!("{subtype} comments can't be resized")));
}
apply_text_box(doc, r, rect, meta)
}
/// Scale a drawing's strokes into `rect`. The margin between the strokes and the old `/Rect` (half
/// the line width and a little more) stays the same on each side, so the line isn't clipped.
fn resize_ink(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> {
let d = annot_dict(doc, r);
let strokes: Vec<Vec<[f64; 2]>> = d
.get(b"InkList")
.map(|l| doc.resolve(l))
.and_then(|l| l.as_array().cloned())
.unwrap_or_default()
.iter()
.map(|s| {
let v: Vec<f64> = doc.resolve(s).as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()).unwrap_or_default();
v.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect()
})
.collect();
let Some(inner) = bounds(strokes.iter().flatten().copied()) else {
return Err(AnnotError::Invalid("the drawing has no strokes".into()));
};
let old = d.get(b"Rect").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default();
let old: Vec<f64> = old.iter().filter_map(|v| doc.resolve(v).as_f64()).collect();
let old = match old[..] {
[a, b, c, e] => normalize([a, b, c, e]),
_ => inner,
};
let margin = [inner[0] - old[0], inner[1] - old[1], old[2] - inner[2], old[3] - inner[3]].map(|m| m.max(0.0));
let target = [rect[0] + margin[0], rect[1] + margin[1], rect[2] - margin[2], rect[3] - margin[3]];
if !finite(&rect) || target[2] < target[0] || target[3] < target[1] || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
return Err(AnnotError::Invalid("invalid rectangle (too small)".into()));
}
// An axis with no extent (a straight stroke) is centred rather than scaled.
let axis = |v: f64, a0: f64, a1: f64, b0: f64, b1: f64| if a1 > a0 { b0 + (v - a0) / (a1 - a0) * (b1 - b0) } else { (b0 + b1) / 2.0 };
let scaled: Vec<Vec<[f64; 2]>> = strokes
.iter()
.map(|s| {
s.iter().map(|p| [axis(p[0], inner[0], inner[2], target[0], target[2]), axis(p[1], inner[1], inner[3], target[1], target[3])]).collect()
})
.collect();
doc.update_dict(r, |d| {
d.set(b"InkList".to_vec(), Object::Array(scaled.iter().map(|s| num_array(&s.concat())).collect()));
d.set(b"Rect".to_vec(), num_array(&rect));
touch(d, meta);
})?;
set_appearance(doc, r)?;
Ok(())
}
/// Store `rect` as a FreeText annotation's text box (or a square's/oval's rectangle): a callout's
/// leader line re-attaches and `/Rect` grows to hold it, and the appearance is redrawn.
fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> {
@@ -1728,6 +1778,41 @@ pub fn set_style(
set_appearance(doc, r)
}
/// Whether a comment of this subtype has an interior that can be filled (`/IC`: rectangles,
/// ovals, polygons and clouds, §12.5.6.8–9).
pub fn fillable(subtype: &str) -> bool {
matches!(subtype, "Square" | "Circle" | "Polygon")
}
/// Fill a rectangle, oval or polygon comment with `fill`, or remove its fill (`None`), and
/// redraw its appearance (Acrobat: Properties ▸ Fill Color).
pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option<Rgb>, meta: &Meta) -> Result<(), AnnotError> {
let (_, r) = annot_ref(doc, page, index)?;
unlocked(doc, r)?;
let d = annot_dict(doc, r);
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
if !fillable(&subtype) {
return Err(AnnotError::Invalid("only rectangles, ovals and polygons can be filled".into()));
}
// Check before changing anything: a stale appearance would contradict the new fill.
if appearance::build(&d).is_none() {
return Err(AnnotError::Unsupported(subtype));
}
if fill.is_some_and(|c| !finite(&c)) {
return Err(AnnotError::Invalid("invalid fill colour".into()));
}
doc.update_dict(r, |d| {
match fill {
Some(c) => d.set(b"IC".to_vec(), rgb(c)),
None => {
d.remove(b"IC");
}
}
touch(d, meta);
})?;
set_appearance(doc, r)
}
/// Grow a line or polyline's rectangle so a non-`None` ending is not clipped. Idempotent.
fn ensure_ending_room(d: &mut Dict) {
let subtype = d.name(b"Subtype").unwrap_or_default();
@@ -1784,6 +1869,8 @@ pub struct Summary {
pub quads: Vec<[f32; 8]>,
pub locked: bool,
pub intent: Option<String>,
/// A Fill & Sign mark ([`is_fill_sign`]).
pub fill_sign: bool,
}
fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option<String> {
@@ -1854,6 +1941,7 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
quads,
locked: annotation_flags(doc, d) & FLAG_LOCKED != 0,
intent: d.get(b"IT").and_then(|o| doc.resolve(o).as_name().map(|n| String::from_utf8_lossy(n).into_owned())),
fill_sign: is_fill_sign(doc, d),
});
}
}
@@ -1916,6 +2004,10 @@ pub struct Props {
pub locked: bool,
/// `/LE`: two names for a line or polyline (`None` when unset), one for a callout.
pub endings: Option<Vec<LineEnding>>,
/// Rectangles, ovals and polygons: their interior can be filled (see [`fillable`]).
pub fillable: bool,
/// `/IC`, the interior colour (`None`: no fill).
pub fill: Option<Rgb>,
}
/// The current properties of the comment at `(page, index)`.
@@ -1939,11 +2031,25 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
modified: text_value(doc, d, b"M"),
restylable: appearance::build(d).is_some(),
locked: annotation_flags(doc, d) & FLAG_LOCKED != 0,
fillable: fillable(&subtype),
fill: fill_of(d),
subtype,
endings: line_endings_of(d),
})
}
/// `/IC` as an RGB colour. An empty array means no fill; grey and CMYK fills are converted.
fn fill_of(d: &Dict) -> Option<Rgb> {
let c: Vec<f64> = d.get(b"IC")?.as_array()?.iter().filter_map(|x| x.as_f64()).collect();
let rgb = match c.as_slice() {
[g] => [*g; 3],
[r, g, b] => [*r, *g, *b],
[c, m, y, k] => [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)],
_ => return None,
};
finite(&rgb).then(|| rgb.map(|x| x.clamp(0.0, 1.0)))
}
/// `/LE` for a line, polyline or callout. Unknown names yield `None` so the control stays hidden.
fn line_endings_of(d: &Dict) -> Option<Vec<LineEnding>> {
let parse = |name: &[u8]| std::str::from_utf8(name).ok().and_then(LineEnding::parse);
+14 -12
View File
@@ -299,27 +299,29 @@ pub fn remove_all(doc: &mut Document, pages: Option<&[usize]>) -> Result<usize,
/// Find web addresses in text: `http(s)://…`, `www.…` and bare e-mail addresses become
/// `mailto:`. Returns character ranges and the URI for each.
pub fn find_urls(chars: &[char]) -> Vec<(std::ops::Range<usize>, String)> {
let text: String = chars.iter().collect();
let lower = text.to_lowercase();
let lc: Vec<char> = lower.chars().collect();
let mut out = Vec::new();
let url_char = |c: char| !c.is_whitespace() && !"<>\"'()[]{}".contains(c);
// Case-insensitive prefix test that never steps past the end. Comparing against the original
// characters keeps every offset in `chars`'s own index space: case-folding a copy can change
// its length (U+0130 becomes "i" plus a combining dot), so a folded copy must never supply
// offsets into the source (that mismatch panicked; #816).
let starts_with =
|start: usize, pat: &str| pat.chars().enumerate().all(|(k, p)| chars.get(start + k).is_some_and(|c| c.eq_ignore_ascii_case(&p)));
let mut out = Vec::new();
let mut i = 0;
while i < lc.len() {
let at = |s: &str| lc[i..].iter().take(s.chars().count()).copied().eq(s.chars());
let boundary = i == 0 || !lc[i - 1].is_alphanumeric();
if boundary && (at("http://") || at("https://") || at("www.")) {
while i < chars.len() {
let boundary = i == 0 || chars.get(i - 1).is_some_and(|c| !c.is_alphanumeric());
if boundary && (starts_with(i, "http://") || starts_with(i, "https://") || starts_with(i, "www.")) {
let mut j = i;
while j < chars.len() && url_char(chars[j]) {
while chars.get(j).is_some_and(|c| url_char(*c)) {
j += 1;
}
// Trailing punctuation belongs to the sentence.
while j > i && ".,;:!?".contains(chars[j - 1]) {
while j > i && chars.get(j - 1).is_some_and(|c| ".,;:!?".contains(*c)) {
j -= 1;
}
let s: String = chars[i..j].iter().collect();
let s: String = chars.get(i..j).map(|c| c.iter().collect()).unwrap_or_default();
if s.len() > 6 && s.contains('.') {
let uri = if s.to_lowercase().starts_with("www.") { format!("http://{s}") } else { s };
let uri = if s.get(..4).is_some_and(|p| p.eq_ignore_ascii_case("www.")) { format!("http://{s}") } else { s };
out.push((i..j, uri));
i = j;
continue;
+107
View File
@@ -283,6 +283,50 @@ fn restyle_and_resize_redraw_the_appearance() {
assert!(matches!(set_rect(&mut doc, 0, l, [0.0, 0.0, 5.0, 5.0], &meta("")), Err(AnnotError::Invalid(_))));
}
#[test]
fn shapes_can_be_filled_and_unfilled() {
let mut doc = fixture();
let r = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 110.0, 60.0] }), &meta("r")).unwrap();
let p = props(&doc, 0, r).unwrap();
assert!(p.fillable);
assert_eq!(p.fill, None);
set_fill(&mut doc, 0, r, Some([0.0, 0.5, 1.0]), &meta("")).unwrap();
let doc2 = reopen(&doc);
let d = &list(&doc2, 0)[r];
let ap = ap_content(&doc2, d);
assert!(ap.contains("0 0.5 1 rg") && ap.contains("\nB\n"), "filled and stroked: {ap}");
assert_eq!(props(&doc2, 0, r).unwrap().fill, Some([0.0, 0.5, 1.0]));
// No fill again: /IC goes and the appearance only strokes.
set_fill(&mut doc, 0, r, None, &meta("")).unwrap();
let doc2 = reopen(&doc);
let d = &list(&doc2, 0)[r];
assert!(d.get(b"IC").is_none());
let ap = ap_content(&doc2, d);
assert!(!ap.contains(" rg") && ap.contains("\nS\n"), "stroked only: {ap}");
assert_eq!(props(&doc2, 0, r).unwrap().fill, None);
// Ovals and polygons fill too.
let o = add_annotation(&mut doc, &new(0, Shape::Oval { rect: [10.0, 10.0, 60.0, 60.0] }), &meta("o")).unwrap();
set_fill(&mut doc, 0, o, Some([1.0, 1.0, 0.0]), &meta("")).unwrap();
assert!(ap_content(&doc, &list(&doc, 0)[o]).contains("1 1 0 rg"));
let vertices = vec![[0.0, 0.0], [50.0, 0.0], [25.0, 40.0]];
let g = add_annotation(&mut doc, &new(0, Shape::Polygon { vertices, cloud: false }), &meta("g")).unwrap();
set_fill(&mut doc, 0, g, Some([1.0, 0.0, 0.0]), &meta("")).unwrap();
assert!(ap_content(&doc, &list(&doc, 0)[g]).contains("1 0 0 rg"));
// Lines have no interior, and bad colours change nothing.
let l = add_annotation(
&mut doc,
&new(0, Shape::Line { from: [0.0, 0.0], to: [10.0, 10.0], start: LineEnding::None, end: LineEnding::None }),
&meta("l"),
)
.unwrap();
assert!(!props(&doc, 0, l).unwrap().fillable);
assert!(matches!(set_fill(&mut doc, 0, l, Some([1.0; 3]), &meta("")), Err(AnnotError::Invalid(_))));
let before = list(&doc, 0)[r].clone();
assert!(matches!(set_fill(&mut doc, 0, r, Some([f64::NAN, 0.0, 0.0]), &meta("")), Err(AnnotError::Invalid(_))));
assert_eq!(list(&doc, 0)[r], before);
assert!(set_fill(&mut doc, 0, 99, None, &meta("")).is_err());
}
#[test]
fn text_box_text_and_colour_changes_are_drawn() {
let mut doc = fixture();
@@ -537,6 +581,31 @@ fn urls_are_found_in_text() {
assert_eq!(found, ["https://example.org/a?b=1", "http://www.rust-lang.org"]);
}
#[test]
fn urls_are_found_after_length_changing_lowercase() {
// U+0130 (İ) lowercases to "i" plus a combining dot above, so a case-folded copy is longer
// than the source. The old matcher took the address offset from that copy and sliced the
// source with it, panicking at `chars[i..j]` (#816).
let mut text = "\u{130}".repeat(20);
text.push_str(" http://example.com");
let chars: Vec<char> = text.chars().collect();
let found = crate::links::find_urls(&chars);
assert_eq!(found.len(), 1, "{found:?}");
let (range, uri) = found.into_iter().next().unwrap();
assert_eq!(uri, "http://example.com");
assert_eq!(range, 21..39, "range is in source-character indices");
}
#[test]
fn url_scheme_and_www_match_case_insensitively() {
let t: Vec<char> = "HTTP://Example.COM".chars().collect();
let found: Vec<String> = crate::links::find_urls(&t).into_iter().map(|(_, u)| u).collect();
assert_eq!(found, ["HTTP://Example.COM"]);
let t: Vec<char> = "\u{130} www.Example.org".chars().collect();
let found: Vec<String> = crate::links::find_urls(&t).into_iter().map(|(_, u)| u).collect();
assert_eq!(found, ["http://www.Example.org"]);
}
#[test]
fn polygons_clouds_connected_lines_callouts_and_carets_are_drawn() {
let mut doc = fixture();
@@ -989,3 +1058,41 @@ fn non_markup_annotations_are_not_comments() {
assert!(is_comment_subtype(s), "{s}");
}
}
/// A drawing (a drawn Fill & Sign signature is one) resizes by scaling its strokes into the new
/// rectangle, keeping each side's stroke margin, and gets a fresh appearance.
#[test]
fn drawings_resize_by_scaling_their_strokes() {
let mut doc = fixture();
let strokes = vec![vec![[100.0, 400.0], [150.0, 460.0], [200.0, 410.0]], vec![[120.0, 420.0], [180.0, 430.0]]];
let i = add_annotation(&mut doc, &new(0, Shape::Ink { strokes }), &meta("i")).unwrap();
let nums = |o: &Object| -> Vec<f64> { o.as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect() };
let points = |doc: &Document| -> Vec<[f64; 2]> {
let d = &list(doc, 0)[i];
d.get(b"InkList").unwrap().as_array().unwrap().iter().flat_map(|s| nums(s).as_chunks::<2>().0.to_vec()).collect()
};
let before = points(&doc);
let r0 = nums(list(&doc, 0)[i].get(b"Rect").unwrap());
let lo = |k: usize| before.iter().map(|p| p[k]).fold(f64::MAX, f64::min);
let hi = |k: usize| before.iter().map(|p| p[k]).fold(f64::MIN, f64::max);
let margin = [lo(0) - r0[0], lo(1) - r0[1], r0[2] - hi(0), r0[3] - hi(1)];
assert!(margin.iter().all(|m| *m > 0.0), "{margin:?}");
// Twice the size, bottom-left corner fixed.
let rect = [r0[0], r0[1], r0[0] + 2.0 * (r0[2] - r0[0]), r0[1] + 2.0 * (r0[3] - r0[1])];
set_rect(&mut doc, 0, i, rect, &meta("r")).unwrap();
let d = &list(&doc, 0)[i];
let r1 = nums(d.get(b"Rect").unwrap());
assert!(r1.iter().zip(rect).all(|(a, b)| (a - b).abs() < 1e-6), "{r1:?} != {rect:?}");
// Each point keeps its place relative to the drawing's bounds, inside the same margin.
let inner = |r: &[f64]| [r[0] + margin[0], r[1] + margin[1], r[2] - margin[2], r[3] - margin[3]];
let (a, b) = (inner(&r0), inner(&r1));
for (p, q) in before.iter().zip(points(&doc)) {
let expected = [b[0] + (p[0] - a[0]) / (a[2] - a[0]) * (b[2] - b[0]), b[1] + (p[1] - a[1]) / (a[3] - a[1]) * (b[3] - b[1])];
assert!((q[0] - expected[0]).abs() < 1e-6 && (q[1] - expected[1]).abs() < 1e-6, "{q:?} != {expected:?}");
}
let ap = ap_content(&doc, d);
let first = points(&doc)[0];
assert!(ap.contains(&format!("{} {} m", n(first[0]), n(first[1]))), "appearance redrawn at the new size: {ap}");
// Too small to hold the stroke margin.
assert!(matches!(set_rect(&mut doc, 0, i, [100.0, 400.0, 101.0, 401.0], &meta("")), Err(AnnotError::Invalid(_))));
}
+8
View File
@@ -60,6 +60,14 @@ Implemented: `initialize` (protocol 2025-06-18, 2025-03-26, 2024-11-05), `ping`,
4. Add an end-to-end test in `tests/automation.rs`. `tool_table_is_well_formed` checks names, schemas and command links.
## Moving several page objects
`object_list {doc, page}` inventories recognised paragraphs, Image/Form artwork and added items
with generation-scoped kind/index references. `object_move {doc, page, objects, offset, generation?}`
moves 1–1000 references atomically in displayed points (right/down), preserving source glyph bytes
and fonts. A refusal changes nothing; success is one undo step. See
[the selection and movement guide](../../docs/editing-multiple-objects.md) for limits and examples.
## Common command tools
Following FilmCraft's MCP conventions, `command_list {doc?, filter?, enabled_only?}` keeps its
+18 -2
View File
@@ -3,7 +3,7 @@
use pdfcraft_engine::a11y::{Category, Options, Report, Rule, Status};
use serde_json::{Value, json};
use crate::{Args, Automation, Result, ToolError, child, failed, write_atomic};
use crate::{Args, Automation, Result, ToolError, child, failed, unused, write_atomic};
fn status_id(s: Status) -> &'static str {
match s {
@@ -156,10 +156,16 @@ impl Automation {
pub(crate) fn ocr_recognize_files(&mut self, a: &Args) -> Result<Value> {
let settings = self.ocr_settings(a)?;
let folder = self.resolve(a.str("folder")?, true)?;
// A path that leaves the root refuses the whole call before any work starts, like the
// other tools (the write check passes missing files, which stay per-file errors below).
let paths = a.strs("paths")?;
for p in &paths {
self.resolve(p, true)?;
}
std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?;
let ocr = pdfcraft_engine::ocr::engine().map_err(failed)?;
let mut out = Vec::new();
for p in a.strs("paths")? {
for p in paths {
let name = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into());
let result =
self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| std::fs::read(&src).map_err(|e| e.to_string())).and_then(|bytes| {
@@ -341,6 +347,16 @@ impl Automation {
out.push(match result {
Ok(r) => {
let target = child(&folder, &name);
let target = if target.exists() {
let stem =
std::path::Path::new(&name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into());
unused(&folder, &stem)
} else {
target
};
if target.exists() {
return Err(failed(format!("{}: no unused output name available", target.display())));
}
write_atomic(&target, &r.bytes)?;
json!({ "path": p, "output": target.to_string_lossy(), "log": r.log })
}
+19 -2
View File
@@ -191,8 +191,21 @@ impl Automation {
.filter(|c| c.in_reply_to.is_none() && only.is_none_or(|p| c.page == p))
.map(|c| {
let mut v = view(c);
let thread: Vec<&Annotation> = match &c.name {
Some(nm) => all.iter().filter(|r| r.in_reply_to.as_deref() == Some(nm.as_str())).collect(),
// Every reply under this comment, at any depth, in document order. A reply to a
// reply keeps its `/IRT` on the direct parent, so following one level would drop it.
let descends_from = |r: &Annotation, root: &str| -> bool {
let mut parent = r.in_reply_to.as_deref();
for _ in 0..64 {
let Some(nm) = parent else { return false };
if nm == root {
return true;
}
parent = all.iter().find(|x| x.name.as_deref() == Some(nm)).and_then(|x| x.in_reply_to.as_deref());
}
false
};
let thread: Vec<&Annotation> = match c.name.as_deref() {
Some(nm) => all.iter().filter(|r| r.in_reply_to.is_some() && descends_from(r, nm)).collect(),
None => Vec::new(),
};
let status = thread.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone());
@@ -565,6 +578,10 @@ impl Automation {
if color.is_some() || opacity.is_some() || width.is_some() {
edits.push(Edit::StyleAnnotation { page, index, color, opacity, width, endings: None });
}
if let Some(f) = a.opt_str("fill")? {
let fill = if f.eq_ignore_ascii_case("none") { None } else { Some(parse_color(f)?) };
edits.push(Edit::FillAnnotation { page, index, fill });
}
if let Some(r) = a.nums::<4>("rect")? {
edits.push(Edit::ResizeAnnotation { page, index, rect: rect_to_user(&info, r) });
}
+68
View File
@@ -200,3 +200,71 @@ impl Automation {
self.apply(a, Edit::DeleteContent { page, index })
}
}
impl Automation {
/// One mixed inventory, with exact source references and the current document generation.
pub(crate) fn object_list(&self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let doc = self.doc(a)?;
let info = doc.info.pages.get(page).ok_or_else(|| bad("the page no longer exists"))?;
let objects = doc.editable_objects(page).map_err(failed)?;
let items = objects
.iter()
.map(|o| {
let rect = crate::comments::rect_to_view(info, o.rect.map(|v| v as f32));
json!({"kind":o.target.kind.as_str(),"index":o.target.index.saturating_add(1),"type":o.content_type,
"rect":rect.map(|v|(f64::from(v)*100.0).round()/100.0),"text":o.text})
})
.collect::<Vec<_>>();
Ok(json!({"page":page+1,"generation":doc.edit_generation(),"count":items.len(),"objects":items}))
}
pub(crate) fn object_move(&mut self, a: &Args) -> Result<Value> {
use pdfcraft_engine::{MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget};
let page = self.page(a)?;
let rows = a
.get("objects")
.and_then(Value::as_array)
.filter(|r| !r.is_empty() && r.len() <= MAX_MOVE_OBJECTS)
.ok_or_else(|| bad("objects must contain 1–1000 references from object_list"))?;
let mut targets = Vec::with_capacity(rows.len());
for row in rows {
let row = row.as_object().ok_or_else(|| bad("each object must contain kind and index"))?;
if row.keys().any(|k| k != "kind" && k != "index") {
return Err(bad("an object reference accepts only kind and index"));
}
let kind = match row.get("kind").and_then(Value::as_str) {
Some("added") => ObjectKind::Added,
Some("text") => ObjectKind::Text,
Some("image") => ObjectKind::Image,
_ => return Err(bad("object kind must be added, text or image (see object_list)")),
};
let index = row
.get("index")
.and_then(Value::as_u64)
.and_then(|i| i.checked_sub(1))
.and_then(|i| usize::try_from(i).ok())
.ok_or_else(|| bad("object index must be a positive integer from object_list"))?;
targets.push(ObjectTarget { kind, index });
}
let delta = a
.get("offset")
.and_then(Value::as_array)
.filter(|v| v.len() == 2)
.and_then(|v| Some([v.first()?.as_f64()?, v.get(1)?.as_f64()?]))
.filter(|v| v.iter().all(|n| n.is_finite() && n.abs() <= 1e9))
.ok_or_else(|| bad("offset must contain two finite numbers [dx, dy] in displayed points"))?;
let doc = self.doc(a)?;
if let Some(g) = a.get("generation") {
let g = g.as_u64().ok_or_else(|| bad("generation must be a non-negative integer from object_list"))?;
if g != doc.edit_generation() {
return Err(failed("the document changed; call object_list again before moving"));
}
}
let offset = doc.object_move_offset(page, delta).map_err(failed)?;
let count = targets.len();
let mut result = self.apply(a, Edit::MoveObjects { page, objects: targets, offset })?;
result["moved"] = json!(count);
Ok(result)
}
}
+148 -31
View File
@@ -29,7 +29,7 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use pdfcraft_engine::{DocId, Document, Edit, Session, commands};
use pdfcraft_engine::{CombineSource, DocId, Document, Edit, Session, commands};
use pdfcraft_platform::staging::{StagingName, create_staging, staging_suffixes};
use pdfcraft_render::{PageRenderer, PageText, RenderConfig, RenderRequest, RequestKind};
use serde_json::{Value, json};
@@ -77,6 +77,10 @@ fn failed(e: impl std::fmt::Display) -> ToolError {
const DEFAULT_DPI: f64 = 96.0;
const MAX_DPI: f64 = 600.0;
/// Bookmarks in one page of the bookmark list, by default and at most.
const BOOKMARK_PAGE: usize = 100;
const MAX_BOOKMARK_PAGE: usize = 1000;
/// Page texts of one document version: (the working bytes, one slot per page).
type TextCache = (Arc<Vec<u8>>, Vec<Option<Arc<PageText>>>);
@@ -242,7 +246,7 @@ impl Automation {
out["labels"] = json!(self.doc(&a)?.info.pages.iter().map(|p| p.label.clone()).collect::<Vec<_>>());
out
}
"bookmark_list" => json!({ "bookmarks": bookmark_tree(&self.doc(&a)?.info.outline, &[]) }),
"bookmark_list" => self.bookmark_listing(&a)?,
"bookmark_add" => {
let page = self.page(&a)?;
let parent = a.opt_path("parent")?.unwrap_or_default();
@@ -270,7 +274,9 @@ impl Automation {
}
"bookmark_from_structure" => {
let mut out = self.apply(&a, Edit::BookmarksFromStructure)?;
out["bookmarks"] = json!(bookmark_tree(&self.doc(&a)?.info.outline, &[]));
let listing = self.bookmark_listing(&a)?;
out["bookmarks"] = listing["bookmarks"].clone();
out["next"] = listing["next"].clone();
out
}
"doc_protect" => self.doc_protect(&a)?,
@@ -303,8 +309,12 @@ impl Automation {
let before = self.doc(&a)?.bytes.len();
let path = self.resolve(a.str("path")?, true)?;
let (bytes, merged) = self.session.reduced_bytes(id).map_err(failed)?;
write_atomic(&path, &bytes)?;
json!({ "path": path.to_string_lossy(), "bytes_before": before, "bytes_after": bytes.len(), "merged_objects": merged })
// As in the app, a copy that isn't smaller is not written (#490).
let written = bytes.len() < before;
if written {
write_atomic(&path, &bytes)?;
}
json!({ "path": path.to_string_lossy(), "written": written, "bytes_before": before, "bytes_after": bytes.len(), "merged_objects": merged })
}
"doc_optimize" => self.doc_optimize(&a)?,
"doc_initial_view" => self.doc_initial_view(&a)?,
@@ -533,6 +543,8 @@ impl Automation {
"link_delete" => self.link_delete(&a)?,
"links_from_urls" => self.links_from_urls(&a)?,
"links_remove" => self.links_remove(&a)?,
"object_list" => self.object_list(&a)?,
"object_move" => self.object_move(&a)?,
"content_list" => self.content_list(&a)?,
"page_add_text" => self.page_add_text(&a)?,
"page_add_image" => self.page_add_image(&a)?,
@@ -596,14 +608,23 @@ impl Automation {
}
"sign_windows_ids" => {
#[cfg(target_os = "windows")]
let ids: Vec<Value> = pdfcraft_engine::sign::windows::identities()
.map_err(failed)?
.iter()
.map(|id| json!({ "id": pdfcraft_engine::sign::windows::reference(&id.certificate), "certificate": signing::cert_json(&id.certificate) }))
.collect();
let (ids, unusable): (Vec<Value>, Vec<Value>) = {
let listing = pdfcraft_engine::sign::windows::list().map_err(failed)?;
let ids = listing
.ids
.iter()
.map(|id| json!({ "id": pdfcraft_engine::sign::windows::reference(&id.certificate), "certificate": signing::cert_json(&id.certificate) }))
.collect();
let unusable = listing
.unusable
.iter()
.map(|u| json!({ "subject": u.subject, "sha256": u.fingerprint, "reason": u.reason, "no_private_key": u.no_private_key }))
.collect();
(ids, unusable)
};
#[cfg(not(target_os = "windows"))]
let ids: Vec<Value> = Vec::new();
json!({ "count": ids.len(), "ids": ids })
let (ids, unusable): (Vec<Value>, Vec<Value>) = (Vec::new(), Vec::new());
json!({ "count": ids.len(), "ids": ids, "unusable": unusable })
}
"sign_trust" => self.sign_trust(&a)?,
"comment_mark" => self.comment_mark(&a)?,
@@ -619,6 +640,24 @@ impl Automation {
// ---- documents ---------------------------------------------------------------------------
/// One page of a document's bookmarks: `offset` (from 0) and `limit` (1 to `MAX_BOOKMARK_PAGE`).
fn bookmark_listing(&self, a: &Args) -> Result<Value> {
let id = a.int("doc")?;
let id = DocId(u64::try_from(id).map_err(|_| ToolError::InvalidArgs("doc must be positive".into()))?);
let offset = a.opt_int("offset")?.unwrap_or(0);
let offset = usize::try_from(offset).map_err(|_| ToolError::InvalidArgs("offset must be 0 or more".into()))?;
let limit = a.opt_int("limit")?.unwrap_or(BOOKMARK_PAGE as i64);
let limit = usize::try_from(limit)
.ok()
.filter(|l| (1..=MAX_BOOKMARK_PAGE).contains(l))
.ok_or_else(|| ToolError::InvalidArgs(format!("limit must be from 1 to {MAX_BOOKMARK_PAGE}")))?;
let page = self
.session
.bookmark_page(id, offset, limit)
.ok_or_else(|| ToolError::Failed(format!("no open document with id {} (see doc_list)", id.0)))?;
Ok(json!({ "bookmarks": nest_rows(&page.rows), "next": page.next, "truncated": page.truncated }))
}
fn doc(&self, a: &Args) -> Result<&Document> {
let id = a.int("doc")?;
let id = u64::try_from(id).map_err(|_| ToolError::InvalidArgs("doc must be positive".into()))?;
@@ -851,6 +890,7 @@ impl Automation {
"invalid_links": o.invalid_links,
"invalid_bookmarks": o.invalid_bookmarks,
"unreferenced_dests": o.unreferenced_dests,
"unused_xobjects": o.unused_xobjects,
"merged_objects": r.merged,
"discarded": r.discarded.iter().map(|(h, n)| json!({ "category": h.id(), "count": n })).collect::<Vec<_>>(),
}))
@@ -1032,7 +1072,7 @@ impl Automation {
(None, Some(p)) => {
let path = self.resolve(p, false)?;
let t = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
(path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), String::from_utf8_lossy(&t).into_owned())
(path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), pdfcraft_engine::decode_text(&t))
}
(None, None) => return Err(ToolError::InvalidArgs("text needs `text` or `path`".into())),
};
@@ -1280,15 +1320,58 @@ impl Automation {
}
Some(_) => return Err(ToolError::InvalidArgs("passwords must list a password (or null) for each path".into())),
};
let mut sources = Vec::new();
for (p, range) in paths.into_iter().zip(ranges) {
// Entries sharing a group number are one file split into parts placed apart.
let groups: Option<Vec<Option<u64>>> = match a.get("groups") {
None | Some(Value::Null) => None,
Some(Value::Array(v)) if v.len() == paths.len() && v.iter().all(|x| x.is_u64() || x.is_null()) => {
Some(v.iter().map(Value::as_u64).collect())
}
Some(_) => return Err(ToolError::InvalidArgs("groups must list a number (or null) for each path".into())),
};
let mut sources: Vec<CombineSource> = Vec::new();
let mut firsts: Vec<(u64, PathBuf, usize)> = Vec::new();
for (i, (p, range)) in paths.into_iter().zip(ranges).enumerate() {
let path = self.resolve(p, false)?;
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
let group = groups.as_ref().and_then(|g| g.get(i).copied().flatten());
let first = group.and_then(|g| firsts.iter().find(|f| f.0 == g));
let bytes = match first {
Some((g, first_path, at)) => {
if *first_path != path {
return Err(ToolError::InvalidArgs(format!("groups[{i}]: group {g} is {}, not {}", first_path.display(), path.display())));
}
sources.get(*at).map(|s| s.1.clone()).ok_or_else(|| failed("group source missing"))?
}
None => {
if let Some(g) = group {
firsts.push((g, path.clone(), sources.len()));
}
Arc::new(std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?)
}
};
let name = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
sources.push((name, Arc::new(bytes), range));
sources.push((name, bytes, range));
}
let passwords: Vec<Option<&str>> = passwords.iter().map(Option::as_deref).collect();
let bytes = self.session.combine_unlocked(&sources, &passwords).map_err(failed)?;
let bytes = match groups {
None => self.session.combine_unlocked(&sources, &passwords),
Some(groups) => {
// A path without a group is a file of its own: give it a key no group uses.
let mut next = groups.iter().flatten().max().map_or(Some(0), |m| m.checked_add(1));
let mut keys = Vec::with_capacity(groups.len());
for g in &groups {
keys.push(match g {
Some(g) => *g,
None => {
let own = next.ok_or_else(|| ToolError::InvalidArgs("group numbers are too large".into()))?;
next = own.checked_add(1);
own
}
});
}
self.session.combine_grouped(&sources, &keys, &passwords)
}
}
.map_err(failed)?;
self.deliver(a, "Combined", bytes)
}
@@ -1328,13 +1411,21 @@ impl Automation {
let dir = self.resolve(a.str("out_dir")?, true)?;
std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?;
let mut files = Vec::new();
let mut used = std::collections::HashSet::new();
for (i, (first, last, bytes)) in parts.iter().enumerate() {
let safe = |t: &str| t.chars().map(|c| if c.is_alphanumeric() || " -_.,()".contains(c) { c } else { '_' }).collect::<String>();
let file = match titles.iter().find(|t| t.0 + 1 == *first) {
Some((_, t)) => format!("{stem}-{}.pdf", safe(t)),
None => format!("{stem}-part{}.pdf", i + 1),
};
let path = child(&dir, &file);
// Equal titles (or titles that sanitize alike) get -2, -3, ... so no part overwrites another; compared case-insensitively.
let mut unique = file.clone();
let mut n = 1;
while !used.insert(unique.to_lowercase()) {
n += 1;
unique = format!("{}-{n}.pdf", file.trim_end_matches(".pdf"));
}
let path = child(&dir, &unique);
write_atomic(&path, bytes)?;
files.push(json!({ "path": path.to_string_lossy(), "first_page": first, "last_page": last }));
}
@@ -1431,8 +1522,14 @@ impl Automation {
Some(_) => self.pages(a, "pages")?,
None => (0..doc.info.pages.len()).collect(),
};
// Column select (#740): only the text inside the rectangle, row by row.
let rect = a.nums::<4>("rect")?.map(|r| r.map(|v| v as f32));
let texts = self.page_texts(id, &pages)?;
let out: Vec<Value> = pages.iter().zip(texts).map(|(p, t)| json!({ "page": p + 1, "text": t.plain_text() })).collect();
let text = |t: &PageText| match rect {
Some(r) => t.column_text(&t.glyphs_in(r)),
None => t.plain_text(),
};
let out: Vec<Value> = pages.iter().zip(texts).map(|(p, t)| json!({ "page": p + 1, "text": text(&t) })).collect();
Ok(json!({ "pages": out }))
}
@@ -1633,17 +1730,36 @@ impl Args<'_> {
}
}
/// The bookmark tree as JSON, with 1-based paths and pages.
fn bookmark_tree(items: &[pdfcraft_render::OutlineItem], parent: &[usize]) -> Vec<Value> {
items
.iter()
.enumerate()
.map(|(i, o)| {
let mut path = parent.to_vec();
path.push(i + 1);
json!({ "path": path, "title": o.title, "page": o.page.map(|p| p + 1), "open": o.open, "children": bookmark_tree(&o.children, &path) })
})
.collect()
/// Bookmark rows in listing order as JSON: a row holds the rows that follow it on the page at the
/// next level down in `children`, and paths and pages are 1-based.
fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow]) -> Vec<Value> {
// The open rows, outermost first, with their depth and the children gathered so far.
let mut open: Vec<(usize, Value, Vec<Value>)> = Vec::new();
let mut roots = Vec::new();
for row in rows {
let depth = row.path.len().saturating_sub(1);
while open.last().is_some_and(|(d, _, _)| *d >= depth) {
close_row(&mut open, &mut roots);
}
let path: Vec<usize> = row.path.iter().map(|p| p.saturating_add(1)).collect();
let fields = json!({ "path": path, "title": row.title, "page": row.page.map(|p| p.saturating_add(1)), "open": row.open });
open.push((depth, fields, Vec::new()));
}
while !open.is_empty() {
close_row(&mut open, &mut roots);
}
roots
}
/// Closes the innermost open row: it joins the row above it as a child, or the top level.
fn close_row(open: &mut Vec<(usize, Value, Vec<Value>)>, roots: &mut Vec<Value>) {
if let Some((_, mut fields, children)) = open.pop() {
fields["children"] = Value::Array(children);
match open.last_mut() {
Some((_, _, siblings)) => siblings.push(fields),
None => roots.push(fields),
}
}
}
fn one_based(pages: &[i64]) -> Result<Vec<usize>> {
@@ -1713,6 +1829,7 @@ fn info(d: &Document) -> Value {
"page": n + 1, "label": p.label, "width": p.width, "height": p.height, "rotation": p.rotation,
})).collect::<Vec<_>>(),
"outline": outline(&i.outline),
"outline_more": i.outline_more,
// Rectangles use the tools' convention (top-left of the displayed page, like
// comment_list and link_list), not raw PDF user space, so they can be fed back to
// geometry-taking tools (#129).
+40 -12
View File
@@ -150,9 +150,24 @@ pub fn tools() -> Vec<ToolDef> {
t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600).")
.ro()
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "dpi": { "type": "number", "minimum": 1, "maximum": 600 } }), &["doc", "page"])),
t("text_extract", "Extract text", "Extract the text of some or all pages, in reading order.")
t("object_list", "List editable objects", "List existing paragraphs, Image/Form artwork and added content once, with source kind/index references, displayed rectangles and document generation. Indexes change when content changes; use generation in object_move to reject stale references.")
.ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])),
t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Clipping and vertical text are refused. A bad/stale reference refuses the entire move.")
.with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},
"objects":{"type":"array","minItems":1,"maxItems":1000,"uniqueItems":true,"items":{
"type":"object","properties":{"kind":{"type":"string","enum":["added","text","image"]},"index":{"type":"integer","minimum":1}},"required":["kind","index"],"additionalProperties":false}},
"offset":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":2},
"generation":{"type":"integer","minimum":0,"description":"Use generation returned by object_list to reject a stale selection."}}), &["doc","page","objects","offset"])),
t("text_extract", "Extract text", "Extract the text of some or all pages, in reading order. With rect, only the text inside that rectangle on each page, as Column select takes it: one row per visual line, side-by-side pieces (table cells) separated by a tab.")
.ro()
.with(schema(json!({ "doc": doc(), "pages": pages("to extract (default: all)") }), &["doc"])),
.cmd("edit.column_select")
.with(schema(
json!({
"doc": doc(), "pages": pages("to extract (default: all)"),
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "Column select: [x0, y0, x1, y1] in points from the top-left of the displayed page; a glyph counts when its centre is inside." }
}),
&["doc"],
)),
t("text_find", "Find text", "Find a phrase (case-insensitive, whitespace-normalised) and return each match with its page and line rectangles in points (origin top-left).")
.ro()
.cmd("edit.find")
@@ -190,13 +205,14 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "pages": pages("to extract"), "out": save_out.clone(), "open": open.clone(), "separate": { "type": "boolean" }, "out_dir": { "type": "string" }, "delete": { "type": "boolean" } }),
&["doc", "pages"],
)),
t("doc_combine", "Combine files", "Combine PDFs, in order, into one (bookmarks are kept under one entry per file). pages optionally chooses each file's pages, in step with paths: a range such as \"1-3, 6\" or null for all pages. passwords, also in step with paths, opens encrypted files (the open password, or the permissions password where a file's security doesn't allow copying pages; null for none). The result is not encrypted. Passwords are never echoed back.")
t("doc_combine", "Combine files", "Combine PDFs, in order, into one (bookmarks are kept under one entry per file). pages optionally chooses each file's pages, in step with paths: a range such as \"1-3, 6\" or null for all pages. passwords, also in step with paths, opens encrypted files (the open password, or the permissions password where a file's security doesn't allow copying pages; null for none). The result is not encrypted. Passwords are never echoed back. groups, also in step with paths, splits a file around others: entries with the same number (they must name the same path) are one file, copied once, so it keeps one bookmark at its first page, its links and its form fields; null for a file of its own. A group uses its first entry's password.")
.cmd("page.combine")
.with(schema(
json!({
"paths": { "type": "array", "items": { "type": "string" }, "minItems": 2 },
"pages": { "type": "array", "items": { "type": ["string", "null"] } },
"passwords": { "type": "array", "items": { "type": ["string", "null"] } },
"groups": { "type": "array", "items": { "type": ["integer", "null"], "minimum": 0 } },
"out": save_out,
"open": open,
}),
@@ -215,15 +231,26 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["paths"],
)),
t("doc_split", "Split a document", "Split into several files written to out_dir: every N pages, before given pages, at top-level bookmarks (bookmarks: true; files named after them), or by file size (max_mb). Files are <name>-partK.pdf.")
t("doc_split", "Split a document", "Split into several files written to out_dir: every N pages, before given pages, at top-level bookmarks (bookmarks: true; files named after them, repeated titles get -2, -3), or by file size (max_mb). Files are <name>-partK.pdf.")
.cmd("page.split")
.with(schema(
json!({ "doc": doc(), "every": { "type": "integer", "minimum": 1 }, "before": pages("that start a new part"), "bookmarks": { "type": "boolean" }, "max_mb": { "type": "number", "exclusiveMinimum": 0 }, "out_dir": { "type": "string" } }),
&["doc", "out_dir"],
)),
t("bookmark_list", "List bookmarks", "The bookmark tree with each bookmark's path, title, target page and open state.")
.ro()
.with(schema(json!({ "doc": doc() }), &["doc"])),
t(
"bookmark_list",
"List bookmarks",
"The bookmarks in outline order, one page at a time: each with its path (1-based, as the other bookmark tools take it), title, target page and open state, and its children on the same page. next is the offset of the following page, or null at the end. Bookmarks nested deeper than 33 levels are not listed.",
)
.ro()
.with(schema(
json!({
"doc": doc(),
"offset": { "type": "integer", "minimum": 0, "description": "Where the page starts, counted from 0 (the previous page's next)" },
"limit": { "type": "integer", "minimum": 1, "maximum": 1000, "description": "Bookmarks in the page (default 100)" }
}),
&["doc"],
)),
t("bookmark_add", "Add a bookmark", "Add a bookmark that goes to a page, under a parent bookmark (or at the top level), at a position. Undoable.").with(schema(
json!({ "doc": doc(), "title": { "type": "string", "minLength": 1 }, "page": { "type": "integer", "minimum": 1 }, "parent": path("Parent bookmark (omit for the top level)"), "position": { "type": "integer", "minimum": 1, "description": "1-based position among the parent's children (default: last)." } }),
&["doc", "title", "page"],
@@ -242,7 +269,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"bookmark_from_structure",
"New bookmarks from structure",
"Make bookmarks from the document's tagged headings (H, H1-H6), nested by level under a new first bookmark titled \"Untitled\". Fails if the document has no tagged headings. Returns the bookmark tree. Undoable.",
"Make bookmarks from the document's tagged headings (H, H1-H6), nested by level under a new first bookmark titled \"Untitled\". Fails if the document has no tagged headings. Returns the first page of the bookmark tree (see bookmark_list). Undoable.",
)
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("page_number", "Number pages", "Label a range of pages (e.g. i, ii, iii for front matter, or A-1, A-2 for an appendix). Later pages keep their labels. Undoable.").with(schema(
@@ -609,7 +636,7 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["name", "password", "path"],
)),
t("sign_windows_ids", "List Windows store digital IDs", "Windows: signing identities in the Current User Personal certificate store (certificate details and the windows: reference sign_document takes). Private keys remain in CNG; Windows may ask permission to use them.")
t("sign_windows_ids", "List Windows store digital IDs", "Windows: signing identities in the Current User Personal certificate store (certificate details and the windows: reference sign_document takes). Private keys remain in CNG; Windows may ask permission to use them. `unusable` lists the store's other certificates with why they can't sign (no private key, an unsupported key type, a key CNG can't open).")
.ro()
.cmd("sign.digital")
.with(schema(json!({}), &[])),
@@ -656,12 +683,13 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "sort": { "type": "string", "enum": ["page", "author", "date", "type"] }, "out": save_out, "open": open }),
&["doc"],
)),
t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, rectangle (rectangle/oval/text box/stamp) or position (`move` [dx, dy] in points). Stamps keep their original appearance when resized. One undo step.").with(schema(
t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, fill (rectangle/oval/polygon; \"none\" removes it), rectangle (rectangle/oval/text box/stamp) or position (`move` [dx, dy] in points). Stamps keep their original appearance when resized. One undo step.").with(schema(
comment_ref(json!({
"contents": { "type": "string" },
"color": color(),
"opacity": { "type": "number", "minimum": 0, "maximum": 1 },
"width": { "type": "number", "minimum": 0 },
"fill": { "type": "string", "description": "Interior colour of a rectangle, oval or polygon: #RRGGBB, a colour name, or \"none\" for no fill." },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
"move": point(),
})),
@@ -971,7 +999,7 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["from"],
)),
t("doc_reduce", "Reduce file size", "Write a smaller copy of the document to `path` with Acrobat's Reduce File Size choices: images above 225 ppi downsampled to 150 ppi and JPEG-compressed (medium quality), thumbnails dropped, identical fonts and images merged, unused objects dropped, compressed object streams. The open document is unchanged.")
t("doc_reduce", "Reduce file size", "Write a smaller copy of the document to `path` with Acrobat's Reduce File Size choices: images above 225 ppi downsampled to 150 ppi and JPEG-compressed (medium quality), thumbnails dropped, identical fonts and images merged, unused objects dropped, compressed object streams. If the copy wouldn't be smaller, nothing is written and `written` is false. The open document is unchanged.")
.cmd("optimize.reduce")
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])),
t("doc_initial_view", "Initial view", "Read or change how the document opens (Document Properties ▸ Initial View) and its reading options: navigation (page, bookmarks, pages, attachments, layers), layout (default, single, continuous, two_up, two_up_continuous, two_up_cover, two_up_continuous_cover), magnification (default, actual, fit_page, fit_width, fit_height, fit_visible, or a percentage), page, window options (fit_window, center_window, full_screen, display_title), interface options (hide_menubar, hide_toolbar, hide_window_ui), language and binding (left, right). Only the given ones change; returns the result. Undoable.").with(schema(
@@ -1052,7 +1080,7 @@ pub fn tools() -> Vec<ToolDef> {
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.")
.with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])),
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); remove_invalid_links and remove_unreferenced_dests (true). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.")
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.")
.cmd("optimize.advanced")
.with(schema(
json!({
+326 -2
View File
@@ -181,6 +181,34 @@ fn combine_extract_and_split() {
assert!(matches!(a.call("doc_combine", &json!({ "paths": ["a.pdf", "b.pdf"], "pages": ["9", null] })), Err(ToolError::Failed(_))));
assert!(matches!(a.call("doc_combine", &json!({ "paths": ["a.pdf", "b.pdf"], "pages": ["1"] })), Err(ToolError::InvalidArgs(_))));
// A file split around another, as the Combine files grid makes it: copied once, one bookmark.
let split = ok(
&mut a,
"doc_combine",
json!({ "paths": ["a.pdf", "b.pdf", "a.pdf"], "pages": ["1", null, "3, 2"], "groups": [4, null, 4], "open": true }),
);
let split_doc = split["document"]["doc"].as_u64().unwrap();
assert_eq!(page_text(&mut a, split_doc), ["Page 1", "Page 1", "Page 2", "Page 3", "Page 2"]);
let marks = ok(&mut a, "bookmark_list", json!({ "doc": split_doc }))["bookmarks"].clone();
let marks: Vec<_> = marks.as_array().unwrap().iter().map(|b| (b["title"].as_str().unwrap().to_string(), b["page"].as_u64().unwrap())).collect();
assert_eq!(marks, [("a".to_string(), 1), ("b".to_string(), 2)]);
// Without groups the same paths are separate files, each with its own bookmark.
let apart = ok(&mut a, "doc_combine", json!({ "paths": ["a.pdf", "b.pdf", "a.pdf"], "pages": ["1", null, "3, 2"], "open": true }));
let apart = ok(&mut a, "bookmark_list", json!({ "doc": apart["document"]["doc"].as_u64().unwrap() }))["bookmarks"].clone();
assert_eq!(apart.as_array().unwrap().len(), 3);
// A group names one file; groups must be in step with paths and whole numbers.
let not_same = a.call("doc_combine", &json!({ "paths": ["a.pdf", "b.pdf"], "groups": [1, 1] }));
assert!(matches!(not_same, Err(ToolError::InvalidArgs(e)) if e.contains("group 1")));
for groups in [json!([1]), json!([1, -1]), json!([1, 1.5]), json!(["1", null]), json!(7)] {
let bad = a.call("doc_combine", &json!({ "paths": ["a.pdf", "b.pdf"], "groups": groups }));
assert!(matches!(bad, Err(ToolError::InvalidArgs(_))), "{groups}");
}
// The largest group number still leaves a key for a file of its own, or says why not.
let max = ok(&mut a, "doc_combine", json!({ "paths": ["a.pdf", "b.pdf"], "groups": [u64::MAX - 1, null], "open": true }));
assert_eq!(max["document"]["pages"], 5);
let full = a.call("doc_combine", &json!({ "paths": ["a.pdf", "b.pdf"], "groups": [u64::MAX, null] }));
assert!(matches!(full, Err(ToolError::InvalidArgs(_))));
let ex = ok(&mut a, "page_extract", json!({ "doc": doc, "pages": [2, 4] }));
let ex_doc = ex["document"]["doc"].as_u64().unwrap();
assert_eq!(page_text(&mut a, ex_doc), ["Page 2", "Page 1"]);
@@ -898,6 +926,51 @@ fn bookmarks_through_tools() {
assert_eq!((list[0]["title"].as_str(), list[0]["children"][0]["title"].as_str()), (Some("Body"), Some("Details")));
}
/// A flat outline of `n` top-level bookmarks, `Item 0` to `Item n-1`, on one page.
fn flat_outline_pdf(n: usize) -> Vec<u8> {
let mut objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /Outlines 3 0 R >>".into(),
"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(),
format!("<< /Type /Outlines /First 5 0 R /Last {} 0 R /Count {n} >>", 4 + n),
"<< /Type /Page /Parent 2 0 R >>".into(),
];
for k in 0..n {
let prev = if k == 0 { String::new() } else { format!(" /Prev {} 0 R", 4 + k) };
let next = if k + 1 == n { String::new() } else { format!(" /Next {} 0 R", 6 + k) };
objs.push(format!("<< /Title (Item {k}) /Parent 3 0 R{prev}{next} /Dest [4 0 R /Fit] >>"));
}
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, body) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
#[test]
fn bookmark_list_pages_reach_bookmarks_past_the_hundred_thousandth() {
let dir = workdir("bookmarks-large");
std::fs::write(dir.join("large.pdf"), flat_outline_pdf(100_005)).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "large.pdf" }))["doc"].as_u64().unwrap();
// The default call is the first page of 100, with the offset of the next one.
let first = ok(&mut a, "bookmark_list", json!({ "doc": doc }));
assert_eq!((first["bookmarks"].as_array().unwrap().len(), first["next"].clone()), (100, json!(100)));
assert_eq!((first["bookmarks"][0]["title"].clone(), first["bookmarks"][0]["path"].clone()), (json!("Item 0"), json!([1])));
let late = ok(&mut a, "bookmark_list", json!({ "doc": doc, "offset": 100_000, "limit": 100 }));
let titles: Vec<&str> = late["bookmarks"].as_array().unwrap().iter().filter_map(|b| b["title"].as_str()).collect();
assert_eq!((titles.first().copied(), titles.last().copied(), late["next"].clone()), (Some("Item 100000"), Some("Item 100004"), json!(null)));
assert!(matches!(a.call("bookmark_list", &json!({ "doc": doc, "limit": 1001 })), Err(ToolError::InvalidArgs(_))));
assert!(matches!(a.call("bookmark_list", &json!({ "doc": doc, "offset": -1 })), Err(ToolError::InvalidArgs(_))));
}
#[test]
fn bookmarks_from_structure_through_tools() {
let dir = workdir("bookmarks-structure");
@@ -989,6 +1062,15 @@ fn comments_through_tools() {
let undo = ok(&mut a, "edit_undo", json!({ "doc": doc }));
assert_eq!(undo["undone"], "Edit comment");
ok(&mut a, "edit_redo", json!({ "doc": doc }));
// Rectangles, ovals and polygons take a fill (#686); "none" removes it. Lines have none.
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "yellow" }));
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "none" }));
assert!(matches!(a.call("comment_edit", &json!({ "doc": doc, "page": page, "index": index, "fill": "mauve" })), Err(ToolError::InvalidArgs(_))));
let arrow = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 3 }))["comments"][0].clone();
assert!(matches!(
a.call("comment_edit", &json!({ "doc": doc, "page": arrow["page"], "index": arrow["index"], "fill": "red" })),
Err(ToolError::Failed(_))
));
// Editing a text box re-fits its rectangle to the new text: the wrap width and top edge
// stay, the height follows the wrapped lines.
@@ -1023,6 +1105,29 @@ fn comments_through_tools() {
assert_eq!(ok(&mut b, "comment_list", json!({ "doc": re }))["count"], 5);
}
/// #806: a reply to another reply stays in the listing, at any depth.
#[test]
fn nested_replies_stay_in_the_listing() {
let dir = workdir("nested-replies");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
let root = ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [40, 40], "contents": "ROOT_NOTE", "author": "A" }));
let root_id = root["comment"]["id"].as_str().unwrap().to_string();
ok(&mut a, "comment_reply", json!({ "doc": doc, "id": root_id, "text": "DIRECT_REPLY", "author": "B" }));
let listed = ok(&mut a, "comment_list", json!({ "doc": doc }));
let root_comment = listed["comments"].as_array().unwrap().iter().find(|c| c["contents"] == "ROOT_NOTE").unwrap().clone();
let direct_id =
root_comment["replies"].as_array().unwrap().iter().find(|r| r["contents"] == "DIRECT_REPLY").unwrap()["id"].as_str().unwrap().to_string();
ok(&mut a, "comment_reply", json!({ "doc": doc, "id": direct_id, "text": "NESTED_REPLY", "author": "C" }));
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [120, 120], "contents": "CONTROL_NOTE", "author": "D" }));
let list = ok(&mut a, "comment_list", json!({ "doc": doc }));
assert_eq!(list["count"], 2, "the two notes stay roots: {list}");
let root = list["comments"].as_array().unwrap().iter().find(|c| c["contents"] == "ROOT_NOTE").unwrap().clone();
let replies: Vec<&str> = root["replies"].as_array().unwrap().iter().filter_map(|r| r["contents"].as_str()).collect();
assert!(replies.contains(&"DIRECT_REPLY") && replies.contains(&"NESTED_REPLY"), "the nested reply is listed: {replies:?}");
}
#[test]
fn protecting_through_tools() {
let dir = workdir("protect");
@@ -1768,8 +1873,11 @@ fn creating_and_reducing_through_tools() {
let t = ok(&mut a, "doc_create", json!({ "from": "text", "path": "notes.txt" }))["doc"].as_u64().unwrap();
assert_eq!(page_text(&mut a, t), ["Meeting notes\nAction items"]);
ok(&mut a, "doc_save", json!({ "doc": t, "path": "notes.pdf" }));
// A new text PDF is already compact: Reduce writes nothing rather than a copy no smaller.
let r = ok(&mut a, "doc_reduce", json!({ "doc": t, "path": "notes-small.pdf" }));
assert!(r["bytes_after"].as_u64().unwrap() > 0 && dir.join("notes-small.pdf").exists());
assert!(r["bytes_after"].as_u64().unwrap() >= r["bytes_before"].as_u64().unwrap(), "{r}");
assert_eq!(r["written"], false);
assert!(!dir.join("notes-small.pdf").exists());
assert!(matches!(a.call("doc_create", &json!({ "from": "images", "paths": ["notes.txt"] })), Err(ToolError::Failed(_))));
}
@@ -2311,6 +2419,27 @@ fn stamps_through_tools() {
assert!(matches!(a.call("stamp_custom", &json!({ "doc": doc, "page": 1, "path": "nope.png", "at": [1, 1] })), Err(ToolError::Failed(_))));
}
#[test]
fn bookmark_split_with_equal_titles_keeps_every_part() {
let dir = workdir("organize_dup_titles");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "Same", "page": 1 }));
ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "Same", "page": 2 }));
ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "same", "page": 3 }));
let s = ok(&mut a, "doc_split", json!({ "doc": doc, "bookmarks": true, "out_dir": "parts" }));
let files: Vec<String> = s["files"]
.as_array()
.unwrap()
.iter()
.map(|f| std::path::Path::new(f["path"].as_str().unwrap()).file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert_eq!(files, ["a-Same.pdf", "a-Same-2.pdf", "a-same-3.pdf"]);
for f in &files {
assert!(dir.join("parts").join(f).exists(), "{f} missing");
}
}
#[test]
fn organizing_with_filters_bookmark_splits_and_extract_options() {
let dir = workdir("organize2");
@@ -2708,8 +2837,11 @@ fn digital_ids_signing_and_validation_through_tools() {
let ids = store["ids"].as_array().unwrap();
assert_eq!(store["count"].as_u64().unwrap(), ids.len() as u64);
assert!(ids.iter().all(|id| id["id"].as_str().unwrap().starts_with("windows:")));
// Store certificates that can't sign are explained, not dropped (issue #179).
let unusable = store["unusable"].as_array().unwrap();
assert!(unusable.iter().all(|u| u["subject"].is_string() && !u["reason"].as_str().unwrap().is_empty() && u["no_private_key"].is_boolean()));
#[cfg(not(windows))]
assert!(ids.is_empty());
assert!(ids.is_empty() && unusable.is_empty());
assert!(matches!(
a.call("sign_document", &json!({ "doc": doc, "id": "ada.p12", "password": "wrong!", "out": "signed.pdf" })),
Err(ToolError::InvalidArgs(_))
@@ -2780,6 +2912,8 @@ fn optimizing_through_tools() {
assert_eq!(small["pages"], 1);
let reduced = ok(&mut a, "doc_reduce", json!({ "doc": doc, "path": "reduced.pdf" }));
assert!(reduced["bytes_after"].as_u64().unwrap() < reduced["bytes_before"].as_u64().unwrap());
assert_eq!(reduced["written"], true);
assert_eq!(std::fs::metadata(dir.join("reduced.pdf")).unwrap().len(), reduced["bytes_after"].as_u64().unwrap());
assert!(matches!(
a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "color": { "compression": "gif" } })),
Err(ToolError::InvalidArgs(_))
@@ -2829,6 +2963,18 @@ fn ocr_tools_make_a_scan_searchable() {
assert!(matches!(a.call("ocr_recognize", &json!({ "doc": scan, "language": "xx" })), Err(ToolError::InvalidArgs(_))));
}
#[test]
fn ocr_recognize_files_refuses_paths_outside_the_root() {
let dir = workdir("ocr-files-root");
let mut a = auto(&dir);
// Refused before anything else, with or without the OCR models installed.
for path in ["../outside.pdf", "sub/../../outside.pdf", "/outside.pdf"] {
let r = a.call("ocr_recognize_files", &json!({ "paths": ["a.pdf", path], "folder": "out" }));
assert!(matches!(&r, Err(e) if e.to_string().contains("outside the allowed directory")), "{path}: {r:?}");
}
assert!(!dir.join("out").exists(), "nothing was started");
}
#[test]
fn ocr_recognize_files_writes_searchable_copies() {
let dir = workdir("ocr-files");
@@ -2991,6 +3137,36 @@ fn actions_through_tools() {
assert!(a.call("action_run", &json!({ "steps": [{ "step": "fly" }], "paths": ["a.pdf"], "folder": "x" })).is_err());
}
#[test]
fn action_run_keeps_outputs_with_duplicate_input_basenames() {
let dir = workdir("action-duplicate-basenames");
std::fs::create_dir_all(dir.join("left")).unwrap();
std::fs::create_dir_all(dir.join("right")).unwrap();
std::fs::write(dir.join("left/report.pdf"), fixture(1)).unwrap();
std::fs::write(dir.join("right/report.pdf"), fixture(2)).unwrap();
let mut a = auto(&dir);
let args = json!({
"steps": [{ "step": "set_title", "arg": "Processed" }],
"paths": ["left/report.pdf", "right/report.pdf"],
"folder": "results"
});
let result = ok(&mut a, "action_run", args.clone());
let files = result["files"].as_array().unwrap();
assert_eq!(files.len(), 2);
let first_path = files[0]["output"].as_str().unwrap();
let second_path = files[1]["output"].as_str().unwrap();
assert!(first_path.ends_with("report.pdf"), "{first_path}");
assert!(second_path.ends_with("report (2).pdf"), "{second_path}");
let first_bytes = std::fs::read(first_path).unwrap();
assert_eq!(ok(&mut a, "doc_open", json!({ "path": first_path }))["pages"], 1);
assert_eq!(ok(&mut a, "doc_open", json!({ "path": second_path }))["pages"], 2);
let repeated = ok(&mut a, "action_run", args);
assert!(repeated["files"][0]["output"].as_str().unwrap().ends_with("report (3).pdf"));
assert!(repeated["files"][1]["output"].as_str().unwrap().ends_with("report (4).pdf"));
assert_eq!(std::fs::read(first_path).unwrap(), first_bytes, "an earlier output must not be replaced");
}
#[test]
fn pdfa_through_tools() {
let dir = workdir("pdfa");
@@ -3593,3 +3769,151 @@ fn command_batch_refuses_more_than_a_thousand_steps() {
let err = a.call("command_batch", &json!({"steps": steps})).unwrap_err();
assert!(err.to_string().contains("at most 1000 steps"), "{err}");
}
#[test]
fn mixed_objects_move_through_tools_with_generation_and_one_undo() {
let dir = workdir("mixed-object-move");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap();
ok(&mut a, "page_add_text", json!({"doc":doc,"page":1,"text":"Added label","rect":[20,20,130,45]}));
ok(&mut a, "doc_save", json!({"doc":doc,"path":"before.pdf"}));
let inventory = ok(&mut a, "object_list", json!({"doc":doc,"page":1}));
assert_eq!(inventory["count"], 2);
let objects: Vec<_> = inventory["objects"].as_array().unwrap().iter().map(|o| json!({"kind":o["kind"],"index":o["index"]})).collect();
let moved = ok(&mut a, "object_move", json!({"doc":doc,"page":1,"generation":inventory["generation"],"objects":objects,"offset":[18,27]}));
assert_eq!(moved["moved"], 2);
let after = ok(&mut a, "object_list", json!({"doc":doc,"page":1}));
for (old, new) in inventory["objects"].as_array().unwrap().iter().zip(after["objects"].as_array().unwrap()) {
for i in 0..4 {
assert!((new["rect"][i].as_f64().unwrap() - old["rect"][i].as_f64().unwrap() - if i % 2 == 0 { 18.0 } else { 27.0 }).abs() < 0.02);
}
assert_eq!(old["text"], new["text"]);
}
assert!(a.call("object_move", &json!({"doc":doc,"page":1,"generation":inventory["generation"],"objects":objects,"offset":[1,1]})).is_err());
let stable = ok(&mut a, "object_list", json!({"doc":doc,"page":1}));
assert_eq!(stable, after, "a stale generation cannot edit");
ok(&mut a, "edit_undo", json!({"doc":doc}));
assert_eq!(ok(&mut a, "object_list", json!({"doc":doc,"page":1}))["objects"], inventory["objects"]);
ok(&mut a, "edit_redo", json!({"doc":doc}));
ok(&mut a, "doc_save", json!({"doc":doc,"path":"moved.pdf"}));
let reopened = ok(&mut a, "doc_open", json!({"path":"moved.pdf"}))["doc"].as_u64().unwrap();
assert_eq!(ok(&mut a, "object_list", json!({"doc":reopened,"page":1}))["objects"], after["objects"]);
assert!(page_text(&mut a, reopened)[0].contains("Added label"));
}
#[test]
fn object_move_rejects_invalid_nested_references_without_any_change() {
let dir = workdir("object-move-refusals");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap();
let before = ok(&mut a, "object_list", json!({"doc":doc,"page":1}));
for objects in [
json!([]),
json!([{"kind":"text","index":0}]),
json!([{"kind":"path","index":1}]),
json!([{"kind":"text","index":1,"extra":true}]),
json!([{"kind":"text","index":1.5}]),
json!([{"kind":"text","index":1},{"kind":"text","index":1}]),
json!([{"kind":"text","index":1},{"kind":"image","index":99}]),
json!(vec![json!({"kind":"text","index":1}); 1001]),
] {
assert!(a.call("object_move", &json!({"doc":doc,"page":1,"objects":objects,"offset":[10,20]})).is_err());
assert_eq!(ok(&mut a, "object_list", json!({"doc":doc,"page":1})), before);
}
for offset in [json!([0, 0]), json!([1]), json!([1, 2, 3]), json!(["1", 2]), json!([1e30, 1])] {
assert!(a.call("object_move", &json!({"doc":doc,"page":1,"objects":[{"kind":"text","index":1}],"offset":offset})).is_err());
assert_eq!(ok(&mut a, "object_list", json!({"doc":doc,"page":1})), before);
}
}
#[test]
fn a_deleted_image_is_not_kept_by_reduce_or_optimize() {
let dir = workdir("deleted-image");
let (w, h) = (600u32, 400u32);
let px: Vec<u8> = (0..w * h).flat_map(|i| [(i % w * 255 / w) as u8 ^ (i & 7) as u8, (i / w * 255 / h) as u8, 128]).collect();
let mut photo = Vec::new();
{
let mut enc = png::Encoder::new(&mut photo, w, h);
enc.set_color(png::ColorType::Rgb);
enc.set_depth(png::BitDepth::Eight);
enc.write_header().unwrap().write_image_data(&px).unwrap();
}
std::fs::write(dir.join("photo.png"), photo).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["photo.png"] }))["doc"].as_u64().unwrap();
ok(&mut a, "image_edit", json!({ "doc": doc, "page": 1, "image": 1, "action": "delete" }));
assert_eq!(ok(&mut a, "page_images", json!({ "doc": doc, "page": 1 }))["count"], 0);
// The page is blank, so the copies are tiny: the picture's data is not carried along.
let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "optimized.pdf" }));
assert_eq!(r["unused_xobjects"], 1, "{r}");
assert!(r["bytes_after"].as_u64().unwrap() < 4_000, "{r}");
let r = ok(&mut a, "doc_reduce", json!({ "doc": doc, "path": "reduced.pdf" }));
assert!(r["bytes_after"].as_u64().unwrap() < 4_000, "{r}");
let reopened = ok(&mut a, "doc_open", json!({ "path": "reduced.pdf" }));
assert_eq!(reopened["pages"], 1);
}
/// A two-page PDF with the same three-row table ("Name Qty / Apple 12 / Pear 7"); the second
/// page is turned with /Rotate 90.
fn table_pdf() -> Vec<u8> {
let body = "BT /F1 12 Tf 20 250 Td (Name) Tj 130 0 Td (Qty) Tj -130 -20 Td (Apple) Tj 130 0 Td (12) Tj -130 -20 Td (Pear) Tj 130 0 Td (7) Tj ET";
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [4 0 R 5 0 R] /Count 2 /MediaBox [0 0 300 300] >>".to_string(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_string(),
"<< /Type /Page /Parent 2 0 R /Rotate 90 /Contents 6 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_string(),
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
/// Issue #740: `text_extract` with `rect` takes what Column select takes, in the displayed-page
/// coordinates `text_find` reports, on upright and turned pages alike.
#[test]
fn text_extract_with_a_rect_takes_one_column() {
let dir = workdir("column");
std::fs::write(dir.join("table.pdf"), table_pdf()).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "table.pdf" }))["doc"].as_u64().unwrap();
let reading = ok(&mut a, "text_extract", json!({ "doc": doc, "pages": [1] }));
assert_eq!(reading["pages"][0]["text"], "Name\nApple\nPear\nQty\n12\n7", "reading order is column by column");
for page in [1u64, 2] {
// The box around "Qty" and "7", as text_find reports them on this page.
let rect_of = |a: &mut Automation, q: &str| -> [f64; 4] {
let found = ok(a, "text_find", json!({ "doc": doc, "query": q }));
let m = found["matches"].as_array().unwrap().iter().find(|m| m["page"] == page).unwrap().clone();
let r: Vec<f64> = m["rects"][0].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
[r[0], r[1], r[2], r[3]]
};
let (qty, seven) = (rect_of(&mut a, "Qty"), rect_of(&mut a, "7"));
let rect = [qty[0].min(seven[0]) - 2.0, qty[1].min(seven[1]) - 2.0, qty[2].max(seven[2]) + 2.0, qty[3].max(seven[3]) + 2.0];
let column = ok(&mut a, "text_extract", json!({ "doc": doc, "pages": [page], "rect": rect }));
assert_eq!(column["pages"][0]["text"], "Qty\n12\n7", "page {page}, rect {rect:?}");
}
// The whole table, row by row with tab-separated cells (upright page).
let all = ok(&mut a, "text_extract", json!({ "doc": doc, "pages": [1], "rect": [0, 0, 300, 300] }));
assert_eq!(all["pages"][0]["text"], "Name\tQty\nApple\t12\nPear\t7");
// Nothing inside: empty text, not an error.
let none = ok(&mut a, "text_extract", json!({ "doc": doc, "pages": [1], "rect": [0, 0, 5, 5] }));
assert_eq!(none["pages"][0]["text"], "");
// A rect that is not four numbers is refused.
for bad in [json!([1, 2, 3]), json!("0 0 1 1"), json!([0, 0, "a", 1])] {
let e = a.call("text_extract", &json!({ "doc": doc, "rect": bad })).unwrap_err();
assert!(matches!(e, ToolError::InvalidArgs(_)), "{bad}: {e}");
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ pub use bytes::Bytes;
pub use document::{Document, Revision, SourceIdentity, XrefEntry};
pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream};
pub use parser::{Lexer, parse_indirect};
pub use pdfcraft_crypt::{Algorithm, Auth, Method as CryptMethod, NewEncryption, Permissions, SecurityHandler};
pub use pdfcraft_crypt::{Algorithm, Auth, Method as CryptMethod, NewEncryption, Permissions, SecurityHandler, pdfdoc_byte, pdfdoc_char};
pub use writer::{SaveOptions, pdf_date, serialize, write_full, write_incremental};
#[derive(Debug, thiserror::Error, Clone, PartialEq)]
+1 -15
View File
@@ -66,27 +66,13 @@ impl PdfString {
b => match std::str::from_utf8(b) {
// Pure ASCII is identical either way; only non-ASCII UTF-8 needs the switch.
Ok(utf8) if !b.is_ascii() => utf8.to_owned(),
_ => b.iter().map(|&c| pdfdoc_char(c)).collect(),
_ => b.iter().map(|&c| pdfcraft_crypt::pdfdoc_char(c)).collect(),
},
};
if s.starts_with('\u{FEFF}') { s.trim_start_matches('\u{FEFF}').to_owned() } else { s }
}
}
/// PDFDocEncoding → Unicode (ISO 32000-2 Annex D). Bytes 0x18–0x1F and 0x80–0xA0 differ from Latin-1.
fn pdfdoc_char(c: u8) -> char {
const HIGH: [char; 33] = [
'•', '†', '‡', '…', '—', '–', 'ƒ', '⁄', '‹', '›', '−', '‰', '„', '“', '”', '‘', '’', '‚', '™', 'fi', 'fl', 'Ł', 'Œ', 'Š', 'Ÿ', 'Ž', 'ı', 'ł',
'œ', 'š', 'ž', '\u{FFFD}', '€',
];
const LOW: [char; 8] = ['˘', 'ˇ', 'ˆ', '˙', '˝', '˛', '˚', '˜'];
match c {
0x18..=0x1F => LOW[(c - 0x18) as usize],
0x80..=0xA0 => HIGH[(c - 0x80) as usize],
_ => c as char,
}
}
/// A name object (without the leading `/`, with `#xx` escapes already decoded).
pub type Name = Vec<u8>;
+6 -1
View File
@@ -284,13 +284,18 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>, CosErro
collect_refs(&reader.get(r), &mut |x| pending.push(x));
}
}
// Acrobat refuses an encrypted file whose catalog sits in an object stream ("the file is
// damaged and could not be repaired", before it even asks for the password), while other
// readers accept it. qpdf keeps the catalog out of object streams too. Verified with
// Acrobat DC on macOS (#774).
let root_outside = if reader.document.output_security().0.is_some() { trailer_in.reference(b"Root") } else { None };
let mut rows: BTreeMap<u32, Row> = BTreeMap::new();
// Keep only references, not renamed copies of the entire object graph. Standalone
// objects still precede object streams, preserving the established output order.
let mut packed: Vec<(u32, ObjRef)> = Vec::new();
for (i, (r, is_stream)) in order.iter().enumerate() {
let num = output_number(i)?;
let in_stream = opts.object_streams && !is_stream && !encryption_objects.contains(r);
let in_stream = opts.object_streams && !is_stream && !encryption_objects.contains(r) && root_outside != Some(*r);
if in_stream {
packed.push((num, *r));
} else {
+29
View File
@@ -152,6 +152,35 @@ fn qpdf_accepts_object_stream_output() {
assert!(check.status.success() && report.contains("No syntax or stream encoding errors"), "{report}");
}
#[test]
fn the_catalog_of_an_encrypted_document_stays_outside_object_streams() {
// Acrobat reports an encrypted file as damaged when its catalog is in an object stream,
// before asking for the password (#774). Unencrypted output still packs the catalog.
fn root_is_packed(bytes: &[u8], password: Option<&str>) -> bool {
let doc = Document::open_with_password(Arc::new(bytes.to_vec()), password).unwrap();
let root = doc.trailer().reference(b"Root").unwrap();
let direct = format!("\n{} 0 obj", root.num);
!bytes.windows(direct.len()).any(|w| w == direct.as_bytes())
}
let plain = Document::open(Arc::new(fixture())).unwrap();
assert!(root_is_packed(&write_full(&plain, &SaveOptions::default()).unwrap(), None), "unencrypted: catalog in an object stream");
let mut enc = plain.clone();
enc.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Aes256,
user_password: "user",
owner_password: "owner",
permissions: -4,
encrypt_metadata: true,
seed: [7; 32],
})
.unwrap();
let packed = write_full(&enc, &SaveOptions::default()).unwrap();
assert!(!root_is_packed(&packed, Some("user")), "encrypted: catalog must be a standalone object");
let back = Document::open_with_password(Arc::new(packed.clone()), Some("user")).expect("reopens");
assert!(back.repair_log().is_empty(), "{:?}", back.repair_log());
assert_eq!(title(&back), b"Caf\xe9 (draft)");
}
#[test]
fn objects_referenced_by_the_encrypt_dictionary_stay_outside_object_streams() {
// Encrypt a document, then move its crypt filters into indirect objects, as some producers
+4 -2
View File
@@ -14,9 +14,11 @@ pdfcraft-model = { workspace = true }
pdfcraft-fonts = { workspace = true }
png = "0.18"
tiff = "0.11"
image = { version = "0.25", default-features = false, features = ["bmp", "gif"] }
image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg"] }
thiserror = { workspace = true }
# Already in the graph through lopdf; used only to read plain-text files (decode_text).
encoding_rs = "0.8"
[dev-dependencies]
image = { version = "0.25", default-features = false, features = ["bmp", "gif"] }
image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg"] }
hayro-syntax = { workspace = true }
+4 -1
View File
@@ -8,7 +8,10 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2:
(`/DCTDecode`, grey, RGB or Adobe-inverted CMYK); PNG is decoded and stored with Flate,
with transparency as a soft mask. An ICC profile in the file (JPEG `APP2 ICC_PROFILE`, PNG
`iCCP`) that matches the image's colour space is kept as an `/ICCBased` colour space with
the device space as `/Alternate`; images with the same profile share one profile object;
the device space as `/Alternate`; images with the same profile share one profile object.
JPEG metadata scanning skips marker fill bytes and reads application segments after the
frame header, stopping at the first scan or end marker. Broken or mismatched ICC profiles
still fall back to the device colour space;
- `from_images_with_resolution(images, ImageResolution::Dpi(dpi))`: override the page
resolution (1–1200 dpi), without resampling or recompressing image pixels. Use 72 dpi
for one point per pixel, or `ImageResolution::Embedded` for the same behavior as
+185 -30
View File
@@ -1,7 +1,8 @@
//! Export a PDF ▸ Image ▸ Export all images, and Edit ▸ Save image as: the images that pages
//! use, as files. JPEG images are written unchanged; other images are decoded and written as
//! PNG (with their soft mask as alpha). Images PdfCraft can't decode yet (JPEG 2000, JBIG2,
//! CCITT, separations) are reported, never silently left out.
//! use, as files. JPEG images are written unchanged; other images, and JPEG images with
//! transparency, are decoded and written as PNG with their soft mask, stencil mask or colour
//! key as alpha. Images PdfCraft can't decode yet (JPEG 2000, JBIG2, CCITT, separations) are
//! reported, never silently left out.
use std::collections::HashSet;
@@ -99,42 +100,75 @@ fn filters(s: &Stream) -> Vec<Vec<u8>> {
}
}
/// The file for one image: its JPEG data as is, or a PNG.
/// The file for one image: its JPEG data as is, or a PNG. An image with transparency (a soft
/// mask, a stencil `/Mask` or a colour-key `/Mask`) is always a PNG with that transparency as
/// alpha, JPEG images included, so cut-out images keep their transparent background.
fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String> {
let f = filters(s);
let jpeg = matches!(f.last().map(Vec::as_slice), Some(b"DCTDecode" | b"DCT")) && f.len() == 1;
match f.last().map(Vec::as_slice) {
Some(b"DCTDecode" | b"DCT") if f.len() == 1 => return Ok(("jpg", s.raw.to_vec())),
Some(b"JPXDecode") => return Err("JPEG 2000 images can't be exported yet".into()),
Some(b"JBIG2Decode") => return Err("JBIG2 images can't be exported yet".into()),
Some(b"CCITTFaxDecode" | b"CCF") => return Err("CCITT fax images can't be exported yet".into()),
Some(b"DCTDecode" | b"DCT") => return Err("JPEG images inside other filters can't be exported yet".into()),
Some(b"DCTDecode" | b"DCT") if !jpeg => return Err("JPEG images inside other filters can't be exported yet".into()),
_ => {}
}
let (w, h) = (s.dict.int(b"Width").unwrap_or(0) as usize, s.dict.int(b"Height").unwrap_or(0) as usize);
if (w as u64) * (h as u64) > MAX_PIXELS {
return Err(format!("{w} × {h} is too large to export"));
}
let data = s.decoded().map_err(|e| e.to_string())?;
let as_is = || Ok(("jpg", s.raw.to_vec()));
let (w, h) = (s.dict.int(b"Width").unwrap_or(0), s.dict.int(b"Height").unwrap_or(0));
let Some((w, h)) = size(w, h) else {
return match () {
_ if jpeg => as_is(),
_ if w <= 0 || h <= 0 => Err("the image has no size".into()),
_ => Err(format!("{w} × {h} is too large to export")),
};
};
let mask = s.dict.get(b"ImageMask").is_some_and(|m| matches!(&*doc.resolve(m), Object::Bool(true)));
let bpc = if mask { 1 } else { s.dict.int(b"BitsPerComponent").unwrap_or(8) as usize };
if !matches!(bpc, 1 | 2 | 4 | 8 | 16) {
return Err(format!("{bpc} bits per component isn't supported"));
// A stencil mask (`ImageMask true`) has no /SMask or /Mask of its own (8.9.6.2).
let mut alpha = if mask { None } else { soft_mask(doc, s, w, h).or_else(|| stencil_mask(doc, s, w, h)) };
let key = if mask { None } else { colour_key(doc, s) };
if jpeg && alpha.is_none() && key.is_none() {
return as_is();
}
let space = if mask { Space::Gray } else { space(doc, s.dict.get(b"ColorSpace"))? };
let mut space = if mask { Space::Gray } else { space(doc, s.dict.get(b"ColorSpace"))? };
let (samples, bpc) = if jpeg {
// A JPEG decoder gives grey or RGB (CMYK and YCCK are converted); a JPEG that doesn't
// decode, or an indexed one (not valid), is written as is, without its transparency.
let Some((samples, decoded)) = jpeg_samples(&s.raw, &space, w, h) else { return as_is() };
space = decoded;
(samples, 8)
} else {
let data = s.decoded().map_err(|e| e.to_string())?;
let bpc = if mask { 1 } else { s.dict.int(b"BitsPerComponent").unwrap_or(8) };
let bpc = match bpc {
1 => 1,
2 => 2,
4 => 4,
8 => 8,
16 => 16,
other => return Err(format!("{other} bits per component isn't supported")),
};
(unpack(&data, w, h, space.components(), bpc).ok_or("the image data is shorter than its size says")?, bpc)
};
let n = space.components();
let samples = unpack(&data, w, h, n, bpc).ok_or("the image data is shorter than its size says")?;
// Decode arrays: only inversion ([1 0] per component) is honoured, the common case.
if alpha.is_none() {
alpha = key.and_then(|k| keyed_alpha(&samples, n, bpc, &k));
}
if jpeg && alpha.is_none() {
return as_is();
}
// Decode arrays: only inversion ([1 0] per component) is honoured, the common case. A JPEG
// decoder has already applied an Adobe JPEG's own inversion.
let invert = s
.dict
.get(b"Decode")
.map(|d| doc.resolve(d))
.and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64)));
let invert = invert.unwrap_or(false) ^ mask; // a stencil mask paints its 0 samples
let invert = (invert.unwrap_or(false) && !jpeg) ^ mask; // a stencil mask paints its 0 samples
let max = (1u32 << bpc.min(8)) - 1;
let mut rgb = Vec::with_capacity(w * h * 3);
for px in samples.chunks_exact(n) {
let v = |i: usize| {
let x = (px[i] as u32 * 255 / max) as u8;
let x = (px.get(i).copied().unwrap_or(0) as u32 * 255 / max) as u8;
if invert { 255 - x } else { x }
};
match &space {
@@ -142,7 +176,7 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>
Space::Rgb => rgb.extend_from_slice(&[v(0), v(1), v(2)]),
Space::Cmyk => rgb.extend_from_slice(&cmyk(v(0), v(1), v(2), v(3))),
Space::Indexed(base, table) => {
let i = px[0] as usize;
let i = px.first().copied().unwrap_or(0) as usize;
let k = base.components();
let e = table.get(i * k..i * k + k).unwrap_or(&[0, 0, 0, 0][..k]);
rgb.extend_from_slice(&match **base {
@@ -153,10 +187,89 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>
}
}
}
let alpha = soft_mask(doc, s, w, h);
if let (Some(a), Some(matte)) = (alpha.as_deref(), matte(doc, s, &space)) {
unpremultiply(&mut rgb, a, matte);
}
png(w as u32, h as u32, &rgb, alpha.as_deref()).map(|p| ("png", p))
}
/// Width and height as sizes, when both are positive and the image is at most `MAX_PIXELS`.
fn size(w: i64, h: i64) -> Option<(usize, usize)> {
let (w, h) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?);
let pixels = (w as u64).checked_mul(h as u64)?;
(w > 0 && h > 0 && pixels <= MAX_PIXELS).then_some((w, h))
}
/// A JPEG's samples (8 bits): grey for a grey image, else RGB, with the space they are in.
fn jpeg_samples(data: &[u8], space: &Space, w: usize, h: usize) -> Option<(Vec<u8>, Space)> {
if matches!(space, Space::Indexed(..)) {
return None;
}
let img = image::load_from_memory_with_format(data, image::ImageFormat::Jpeg).ok()?;
if img.width() as usize != w || img.height() as usize != h {
return None;
}
Some(match space {
Space::Gray => (img.into_luma8().into_raw(), Space::Gray),
_ => (img.into_rgb8().into_raw(), Space::Rgb),
})
}
/// The colour-key `/Mask` (8.9.6.4): a [min max] pair per colour component, in sample values.
fn colour_key(doc: &Document, s: &Stream) -> Option<Vec<(u32, u32)>> {
let m = doc.resolve(s.dict.get(b"Mask")?);
let a = m.as_array()?;
let v: Vec<u32> = a.iter().map(|o| doc.resolve(o).as_f64().map(|f| f.clamp(0.0, 65_535.0) as u32)).collect::<Option<_>>()?;
(!v.is_empty() && v.len().is_multiple_of(2)).then(|| v.as_chunks::<2>().0.iter().map(|&[lo, hi]| (lo, hi)).collect())
}
/// Alpha from a colour key: pixels whose every component falls in its range are transparent.
fn keyed_alpha(samples: &[u8], n: usize, bpc: usize, key: &[(u32, u32)]) -> Option<Vec<u8>> {
if key.len() != n {
return None;
}
// 16-bit samples keep their high byte (see `unpack`), so compare the ranges' high bytes.
let shift = if bpc == 16 { 8 } else { 0 };
Some(
samples
.chunks_exact(n)
.map(|px| {
let hidden = px.iter().zip(key).all(|(&c, &(lo, hi))| (lo >> shift..=hi >> shift).contains(&(c as u32)));
if hidden { 0 } else { 255 }
})
.collect(),
)
}
/// The `/Matte` colour of the image's soft mask (11.6.5.3), for grey and RGB images: the colour
/// the image samples were pre-blended with.
fn matte(doc: &Document, s: &Stream, space: &Space) -> Option<Vec<u8>> {
let r = s.dict.get(b"SMask")?.as_ref()?;
let obj = doc.get(r);
let Object::Stream(m) = &*obj else { return None };
let a = doc.resolve(m.dict.get(b"Matte")?);
let v: Vec<u8> =
a.as_array()?.iter().map(|o| doc.resolve(o).as_f64().map(|f| (f.clamp(0.0, 1.0) * 255.0).round() as u8)).collect::<Option<_>>()?;
match (space, v.len()) {
(Space::Gray, 1) => Some(vec![v[0]; 3]),
(Space::Rgb, 3) => Some(v),
_ => None,
}
}
/// Undo a pre-blend with `matte`: c = m + (c′ − m) / α.
fn unpremultiply(rgb: &mut [u8], alpha: &[u8], matte: Vec<u8>) {
for (px, &a) in rgb.as_chunks_mut::<3>().0.iter_mut().zip(alpha) {
if a == 0 {
continue;
}
for (c, &m) in px.iter_mut().zip(&matte) {
let v = m as i32 + (*c as i32 - m as i32) * 255 / a as i32;
*c = v.clamp(0, 255) as u8;
}
}
}
#[derive(Clone, Debug)]
enum Space {
Gray,
@@ -249,21 +362,63 @@ fn cmyk(c: u8, m: u8, y: u8, k: u8) -> [u8; 3] {
[f(c), f(m), f(y)]
}
/// The soft mask as 8-bit alpha, when it is an 8-bit gray image of the same size.
/// The soft mask (`/SMask`) as 8-bit alpha, scaled to the image's size when it has another.
fn soft_mask(doc: &Document, s: &Stream, w: usize, h: usize) -> Option<Vec<u8>> {
let r = s.dict.get(b"SMask")?.as_ref()?;
let obj = doc.get(r);
let Object::Stream(m) = &*obj else { return None };
if m.dict.int(b"Width")? as usize != w || m.dict.int(b"Height")? as usize != h {
return None;
gray_plane(doc, m, w, h)
}
/// A stencil `/Mask` (an image mask stream, 8.9.6.3) as 8-bit alpha: its 1 samples (0 with
/// `/Decode [1 0]`) hide the image.
fn stencil_mask(doc: &Document, s: &Stream, w: usize, h: usize) -> Option<Vec<u8>> {
let r = s.dict.get(b"Mask")?.as_ref()?;
let obj = doc.get(r);
let Object::Stream(m) = &*obj else { return None };
Some(gray_plane(doc, m, w, h)?.into_iter().map(|v| 255 - v).collect())
}
/// A one-component mask image as 8-bit values (its `/Decode` inversion applied), scaled to
/// `w` × `h`. `None` when it can't be decoded (JPEG 2000, JBIG2 and CCITT masks).
fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option<Vec<u8>> {
let (mw, mh) = size(m.dict.int(b"Width")?, m.dict.int(b"Height")?)?;
let stencil = m.dict.get(b"ImageMask").is_some_and(|v| matches!(&*doc.resolve(v), Object::Bool(true)));
let f = filters(m);
let mut px = match f.last().map(Vec::as_slice) {
Some(b"DCTDecode" | b"DCT") if f.len() == 1 => {
let img = image::load_from_memory_with_format(&m.raw, image::ImageFormat::Jpeg).ok()?.into_luma8();
(img.width() as usize == mw && img.height() as usize == mh).then(|| img.into_raw())?
}
Some(b"DCTDecode" | b"DCT" | b"JPXDecode" | b"JBIG2Decode" | b"CCITTFaxDecode" | b"CCF") => return None,
_ => {
let bpc = if stencil { 1 } else { m.dict.int(b"BitsPerComponent").unwrap_or(8) };
let bpc = match bpc {
1 => 1,
2 => 2,
4 => 4,
8 => 8,
16 => 16,
_ => return None,
};
let max = (1u32 << bpc.min(8)) - 1;
unpack(&m.decoded().ok()?, mw, mh, 1, bpc)?.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect()
}
};
let invert = m
.dict
.get(b"Decode")
.map(|d| doc.resolve(d))
.and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64)))
.unwrap_or(false);
if invert {
px.iter_mut().for_each(|v| *v = 255 - *v);
}
let bpc = m.dict.int(b"BitsPerComponent").unwrap_or(8) as usize;
if !filters(m).iter().all(|f| !matches!(f.as_slice(), b"DCTDecode" | b"JPXDecode" | b"JBIG2Decode" | b"CCITTFaxDecode")) {
return None;
if (mw, mh) == (w, h) {
return Some(px);
}
let px = unpack(&m.decoded().ok()?, w, h, 1, bpc)?;
let max = (1u32 << bpc.min(8)) - 1;
Some(px.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect())
let img = image::GrayImage::from_raw(mw as u32, mh as u32, px)?;
Some(image::imageops::resize(&img, w as u32, h as u32, image::imageops::FilterType::Triangle).into_raw())
}
fn png(w: u32, h: u32, rgb: &[u8], alpha: Option<&[u8]>) -> Result<Vec<u8>, String> {
+43 -10
View File
@@ -228,18 +228,31 @@ fn jpeg(name: &str, bytes: &[u8]) -> Result<Embedded, CreateError> {
}
let (mut i, mut size, mut comps, mut dpi, mut adobe) = (2usize, None, 0u8, (72.0, 72.0), false);
let mut icc_chunks = Vec::new();
while i + 4 <= bytes.len() {
if bytes[i] != 0xFF {
while let Some(&byte) = bytes.get(i) {
if byte != 0xFF {
i += 1;
continue;
}
let marker = bytes[i + 1];
i += 1;
// Markers may be preceded by any number of 0xFF fill bytes.
while bytes.get(i) == Some(&0xFF) {
i += 1;
}
let marker = *bytes.get(i).ok_or_else(|| bad("truncated JPEG marker"))?;
i += 1;
// Metadata ends at the first scan; entropy-coded bytes are not segments.
if marker == 0xDA || marker == 0xD9 {
break;
}
if marker == 0xD8 || marker == 0x01 || (0xD0..=0xD7).contains(&marker) {
i += 2;
continue;
}
let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize;
let seg = bytes.get(i + 4..i + 2 + len).ok_or_else(|| bad("truncated"))?;
let len = usize::from(be16(bytes, i).ok_or_else(|| bad("truncated JPEG segment length"))?);
if len < 2 {
return Err(bad("invalid JPEG segment length"));
}
let end = i.checked_add(len).ok_or_else(|| bad("JPEG segment length overflow"))?;
let seg = bytes.get(i + 2..end).ok_or_else(|| bad("truncated JPEG segment"))?;
match marker {
// APP0 JFIF density.
0xE0 if seg.starts_with(b"JFIF\0") && seg.len() >= 12 => {
@@ -259,18 +272,16 @@ fn jpeg(name: &str, bytes: &[u8]) -> Result<Embedded, CreateError> {
icc_chunks.push((*seq, *count, data));
}
}
0xC0..=0xCF if marker != 0xC4 && marker != 0xC8 && marker != 0xCC => {
0xC0..=0xCF if marker != 0xC4 && marker != 0xC8 && marker != 0xCC && size.is_none() => {
if seg.len() < 6 {
return Err(bad("bad frame header"));
}
size = Some((u16::from_be_bytes([seg[3], seg[4]]) as u32, u16::from_be_bytes([seg[1], seg[2]]) as u32));
comps = seg[5];
break;
}
0xDA => break,
_ => {}
}
i += 2 + len;
i = end;
}
let (w, h) = size.filter(|(w, h)| *w > 0 && *h > 0).ok_or_else(|| bad("no image size"))?;
let mut d = Dict::new();
@@ -572,6 +583,28 @@ pub fn from_images_with_resolution(images: &[(String, Vec<u8>)], resolution: Ima
// ── text ────────────────────────────────────────────────────────────────────────────────────
/// The text of a plain-text file. A byte order mark picks UTF-8 or UTF-16 (little or big
/// endian) and is dropped; without one the bytes are UTF-8 when they are valid UTF-8, and
/// otherwise the Windows code page they most likely are: 1254 (Turkish) or 1252 (Western).
pub fn decode_text(bytes: &[u8]) -> String {
if let Some((encoding, bom)) = encoding_rs::Encoding::for_bom(bytes) {
return encoding.decode_without_bom_handling(bytes.get(bom..).unwrap_or_default()).0.into_owned();
}
if let Ok(text) = std::str::from_utf8(bytes) {
return text.to_string();
}
let encoding = if turkish_code_page(bytes) { encoding_rs::WINDOWS_1254 } else { encoding_rs::WINDOWS_1252 };
encoding.decode_without_bom_handling(bytes).0.into_owned()
}
/// Windows-1254 and 1252 differ only in six bytes: Ğ İ Ş ğ ı ş in 1254 are Ð Ý Þ ð ý þ in 1252.
/// Of the Western languages only Icelandic and Faroese use those, and both also write á í ó ú,
/// which Turkish never does.
fn turkish_code_page(bytes: &[u8]) -> bool {
bytes.iter().any(|b| matches!(b, 0xD0 | 0xDD | 0xDE | 0xF0 | 0xFD | 0xFE))
&& !bytes.iter().any(|b| matches!(b, 0xC1 | 0xCD | 0xD3 | 0xDA | 0xE1 | 0xED | 0xF3 | 0xFA))
}
/// Plain text set in Helvetica on pages of `page` size with 1-inch margins.
pub fn from_text(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result<Document, CreateError> {
let size = if font_size.is_finite() && font_size > 0.0 { font_size.clamp(4.0, 72.0) } else { 11.0 };
+234
View File
@@ -289,6 +289,28 @@ fn jpeg_2000_images_are_embedded_as_is() {
assert_eq!(media(&pages(&doc)[0])[2], 64.0);
}
#[test]
fn text_files_are_decoded_by_byte_order_mark_then_utf8_then_code_page() {
let utf16 = |text: &str, big: bool| -> Vec<u8> {
let mut v = if big { vec![0xFE, 0xFF] } else { vec![0xFF, 0xFE] };
for u in text.encode_utf16() {
v.extend_from_slice(&if big { u.to_be_bytes() } else { u.to_le_bytes() });
}
v
};
assert_eq!(decode_text("Kış İstanbul".as_bytes()), "Kış İstanbul");
assert_eq!(decode_text(b"\xEF\xBB\xBFMerhaba"), "Merhaba", "a UTF-8 byte order mark is dropped");
assert_eq!(decode_text(&utf16("Kış İstanbul\r\nŞubat", false)), "Kış İstanbul\r\nŞubat");
assert_eq!(decode_text(&utf16("Grüße 日本", true)), "Grüße 日本");
// Not UTF-8: Windows-1254 for Turkish, 1252 for other Western text (Icelandic included).
assert_eq!(decode_text(b"K\xFD\xFE \xDDstanbul \xF0\xFC\xE7"), "Kış İstanbul ğüç");
assert_eq!(decode_text(b"\xDE\xF3r\xF0ur \xE1 \xEDslandi"), "Þórður á íslandi");
assert_eq!(decode_text(b"caf\xE9 \x80 5"), "café € 5");
// Odd and broken input never fails.
assert_eq!(decode_text(&[0xFF, 0xFE, b'A']), "\u{FFFD}");
assert_eq!(decode_text(b""), "");
}
#[test]
fn source_kind_tells_pdfs_images_and_text_apart() {
assert_eq!(source_kind("a.bin", b"%PDF-1.7\n"), Some(SourceKind::Pdf));
@@ -346,6 +368,64 @@ fn icc_of(doc: &Document, image: &Dict) -> Option<(i64, Vec<u8>, Vec<u8>, Object
Some((s.dict.int(b"N")?, s.dict.name(b"Alternate")?.to_vec(), s.decoded().unwrap(), r))
}
/// Move the synthetic fixture's frame before its application segments.
fn jpeg_frame_first(mut bytes: Vec<u8>) -> Vec<u8> {
let at = bytes.windows(2).position(|w| w == [0xFF, 0xC0]).unwrap();
let len = usize::from(u16::from_be_bytes([bytes[at + 2], bytes[at + 3]]));
let frame: Vec<_> = bytes.drain(at..at + 2 + len).collect();
bytes.splice(2..2, frame);
bytes
}
#[test]
fn jpeg_fill_bytes_and_metadata_after_frame_are_preserved() {
let profile = icc_profile(b"CMYK");
let mut bytes = jpeg_frame_first(cmyk_jpeg_bytes(&profile));
// Fill before SOF and APP14, including multiple consecutive fill bytes.
bytes.splice(24..24, [0xFF, 0xFF]);
bytes.insert(2, 0xFF);
let doc = reopen(&from_images(&[("filled.jpg".into(), bytes.clone())]).unwrap());
let image = image_of(&doc, 0);
let (n, alt, data, _) = icc_of(&doc, &image).unwrap();
assert_eq!((n, alt.as_slice(), data), (4, &b"DeviceCMYK"[..], profile));
assert!(image.contains(b"Decode"), "APP14 after SOF still inverts CMYK");
assert_eq!(extract_images(&doc, &[0], 0).images[0].data, bytes);
let embedded = jpeg("density.jpg", &jpeg_frame_first(jpeg_bytes())).unwrap();
assert_eq!(embedded.px, (3, 2));
assert_eq!(embedded.dpi, (300.0, 300.0), "JFIF after SOF is read");
// A mismatched profile found after SOF still falls back leniently, as in #729.
let bytes = jpeg_frame_first(cmyk_jpeg_bytes(&icc_profile(b"RGB ")));
let doc = reopen(&from_images(&[("mismatch.jpg".into(), bytes)]).unwrap());
assert_eq!(image_of(&doc, 0).name(b"ColorSpace"), Some(&b"DeviceCMYK"[..]));
}
#[test]
fn jpeg_segment_scan_stops_at_sos_or_eoi() {
let profile = icc_profile(b"CMYK");
for marker in [0xDA, 0xD9] {
let mut bytes = jpeg_frame_first(cmyk_jpeg_bytes(&profile));
bytes.truncate(bytes.len() - 2);
// Neither marker needs a segment length to stop metadata scanning. The trailing
// invalid APP2 would fail if entropy data or bytes after EOI were walked.
bytes.extend_from_slice(&[0xFF, 0xFF, marker, 0xFF, 0xE2, 0, 1]);
let embedded = jpeg("boundary.jpg", &bytes).unwrap();
assert_eq!(embedded.icc, Some(profile.clone()));
assert_eq!(embedded.px, (3, 2));
}
}
#[test]
fn jpeg_segment_scan_rejects_truncated_or_invalid_lengths() {
for tail in [&[0xFF][..], &[0xFF, 0xFF], &[0xFF, 0xE2], &[0xFF, 0xE2, 0], &[0xFF, 0xE2, 0, 0], &[0xFF, 0xE2, 0, 1], &[0xFF, 0xE2, 0, 3]] {
let mut bytes = jpeg_bytes();
bytes.truncate(bytes.len() - 2);
bytes.extend_from_slice(tail);
assert!(jpeg("truncated.jpg", &bytes).is_err(), "tail: {tail:?}");
}
}
#[test]
fn embedded_icc_profiles_tag_the_image_colour_space() {
// A CMYK JPEG keeps its profile as /ICCBased (N 4), its data byte for byte and its Decode.
@@ -403,3 +483,157 @@ fn embedded_icc_profiles_tag_the_image_colour_space() {
let Object::Stream(s) = &*doc.get(r) else { panic!() };
assert_eq!(icc_of(&doc, &s.dict).map(|(n, ..)| n), Some(4));
}
/// Replaces page 0's image with the stream `image` builds (it can add objects, e.g. a mask).
fn with_image(doc: &mut Document, image: impl FnOnce(&mut Document) -> Stream) {
let page = pdfcraft_model::pages(doc)[0].clone();
let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap();
let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap();
let r = xo.iter().next().map(|(_, v)| v.as_ref().unwrap()).unwrap();
let s = image(doc);
doc.set(r, Object::Stream(s));
}
fn image_dict(w: i64, h: i64, space: &str, bpc: i64) -> Dict {
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("XObject"));
d.set(b"Subtype".to_vec(), Object::name("Image"));
d.set(b"Width".to_vec(), Object::Int(w));
d.set(b"Height".to_vec(), Object::Int(h));
d.set(b"ColorSpace".to_vec(), Object::name(space));
d.set(b"BitsPerComponent".to_vec(), Object::Int(bpc));
d
}
/// The exported file's extension and its pixels as RGBA.
fn exported_rgba(doc: &Document) -> (&'static str, Vec<u8>) {
let out = extract_images(doc, &[0], 0);
assert!(out.skipped.is_empty(), "{:?}", out.skipped);
let img = &out.images[0];
if img.extension == "jpg" {
return ("jpg", image::load_from_memory(&img.data).unwrap().into_rgba8().into_raw());
}
let dec = png::Decoder::new(std::io::Cursor::new(img.data.clone()));
let mut r = dec.read_info().unwrap();
let mut buf = vec![0; r.output_buffer_size().unwrap()];
let info = r.next_frame(&mut buf).unwrap();
buf.truncate(info.buffer_size());
let rgba = match info.color_type {
png::ColorType::Rgba => buf,
png::ColorType::Rgb => buf.as_chunks::<3>().0.iter().flat_map(|c| [c[0], c[1], c[2], 255]).collect(),
other => panic!("unexpected {other:?}"),
};
("png", rgba)
}
fn alphas(rgba: &[u8]) -> Vec<u8> {
rgba.as_chunks::<4>().0.iter().map(|p| p[3]).collect()
}
/// A 4×2 grey JPEG (mid grey).
fn real_jpeg() -> Vec<u8> {
let mut out = Vec::new();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 95).encode(&[128u8; 8], 4, 2, image::ExtendedColorType::L8).unwrap();
out
}
#[test]
fn jpeg_images_with_a_soft_mask_export_as_png_with_alpha() {
// Issue #645: a cut-out photo (JPEG with an /SMask) lost its transparent background.
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
with_image(&mut doc, |doc| {
let mask = Stream::from_raw(image_dict(4, 2, "DeviceGray", 8), vec![0, 255, 0, 255, 255, 0, 255, 0]);
let m = doc.add(Object::Stream(mask));
let mut d = image_dict(4, 2, "DeviceGray", 8);
d.set(b"Filter".to_vec(), Object::name("DCTDecode"));
d.set(b"SMask".to_vec(), Object::Ref(m));
Stream::from_raw(d, real_jpeg())
});
let (ext, rgba) = exported_rgba(&doc);
assert_eq!(ext, "png", "a JPEG with transparency is written as a PNG");
assert_eq!(alphas(&rgba), [0, 255, 0, 255, 255, 0, 255, 0]);
assert!(rgba.as_chunks::<4>().0.iter().all(|p| p[0].abs_diff(128) <= 4 && p[0] == p[1] && p[1] == p[2]), "{rgba:?}");
// Without a mask the JPEG is still written as is.
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
with_image(&mut doc, |_| {
let mut d = image_dict(4, 2, "DeviceGray", 8);
d.set(b"Filter".to_vec(), Object::name("DCTDecode"));
Stream::from_raw(d, real_jpeg())
});
let out = extract_images(&doc, &[0], 0);
assert_eq!((out.images[0].extension, &out.images[0].data), ("jpg", &real_jpeg()));
}
#[test]
fn soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha() {
// A 2×1 soft mask on a 4×2 image is scaled to the image's size.
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
with_image(&mut doc, |doc| {
let m = doc.add(Object::Stream(Stream::from_raw(image_dict(2, 1, "DeviceGray", 8), vec![0, 255])));
let mut d = image_dict(4, 2, "DeviceRGB", 8);
d.set(b"SMask".to_vec(), Object::Ref(m));
Stream::from_raw(d, vec![10; 24])
});
let a = alphas(&exported_rgba(&doc).1);
assert!(a[0] < 64 && a[3] > 192 && a[4] < 64 && a[7] > 192, "{a:?}");
// A stencil /Mask: its 1 samples hide the image.
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
with_image(&mut doc, |doc| {
let mut md = Dict::new();
md.set(b"Type".to_vec(), Object::name("XObject"));
md.set(b"Subtype".to_vec(), Object::name("Image"));
md.set(b"Width".to_vec(), Object::Int(4));
md.set(b"Height".to_vec(), Object::Int(2));
md.set(b"ImageMask".to_vec(), Object::Bool(true));
let m = doc.add(Object::Stream(Stream::from_raw(md, vec![0b1100_0000, 0b0011_0000])));
let mut d = image_dict(4, 2, "DeviceRGB", 8);
d.set(b"Mask".to_vec(), Object::Ref(m));
Stream::from_raw(d, vec![10; 24])
});
assert_eq!(alphas(&exported_rgba(&doc).1), [0, 0, 255, 255, 255, 255, 0, 0]);
// A colour-key /Mask: pure white pixels are transparent.
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
with_image(&mut doc, |_| {
let mut d = image_dict(2, 1, "DeviceRGB", 8);
d.set(b"Mask".to_vec(), Object::Array([250, 255, 250, 255, 250, 255].map(Object::Int).to_vec()));
Stream::from_raw(d, vec![255, 255, 255, 255, 0, 0])
});
assert_eq!(exported_rgba(&doc).1, [255, 255, 255, 0, 255, 0, 0, 255]);
// A soft mask with /Matte: the colours are un-blended from the matte colour.
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
with_image(&mut doc, |doc| {
let mut md = image_dict(2, 1, "DeviceGray", 8);
md.set(b"Matte".to_vec(), Object::Array(vec![Object::Int(1)]));
let m = doc.add(Object::Stream(Stream::from_raw(md, vec![255, 128])));
let mut d = image_dict(2, 1, "DeviceGray", 8);
d.set(b"SMask".to_vec(), Object::Ref(m));
// Black at half alpha, pre-blended with white: 255 + (0 − 255) × 128 / 255 ≈ 127.
Stream::from_raw(d, vec![0, 127])
});
let rgba = exported_rgba(&doc).1;
assert_eq!(rgba[..4], [0, 0, 0, 255]);
assert!(rgba[4] <= 2 && rgba[7] == 128, "{rgba:?}");
}
#[test]
fn damaged_masks_and_sizes_never_panic() {
for (w, h, mw, mh, data) in [(4, 2, -3, 1, vec![]), (4, 2, i64::MAX, 2, vec![0; 8]), (-4, 2, 4, 2, vec![0; 8]), (4, 2, 4, 2, vec![])] {
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
with_image(&mut doc, |doc| {
let m = doc.add(Object::Stream(Stream::from_raw(image_dict(mw, mh, "DeviceGray", 8), data.clone())));
let mut d = image_dict(w, h, "DeviceRGB", 8);
d.set(b"SMask".to_vec(), Object::Ref(m));
d.set(b"Mask".to_vec(), Object::Array(vec![Object::Real(f64::NAN), Object::Int(-5)]));
Stream::from_raw(d, vec![10; 24])
});
let page = pdfcraft_model::pages(&doc)[0].clone();
let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap();
let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap();
let r = xo.iter().next().map(|(_, v)| v.as_ref().unwrap()).unwrap();
let _ = extract_images(&doc, &[0], 0);
let _ = image_file(&doc, r);
}
}
+53 -10
View File
@@ -151,15 +151,19 @@ impl SecurityHandler {
let mut recovered_user = None;
let (key, auth) = match dict.r {
2..=4 => {
let legacy = legacy_password(pw);
if let Some((k, user)) = owner_key_r4(&dict, id0, &legacy) {
recovered_user = Some(user);
(k, Auth::Owner)
} else if let Some(k) = user_key_r4(&dict, id0, &legacy) {
(k, Auth::User)
} else {
return Err(CryptError::WrongPassword);
let mut found = None;
for legacy in legacy_candidates(pw) {
if let Some((k, user)) = owner_key_r4(&dict, id0, &legacy) {
recovered_user = Some(user);
found = Some((k, Auth::Owner));
} else if let Some(k) = user_key_r4(&dict, id0, &legacy) {
found = Some((k, Auth::User));
}
if found.is_some() {
break;
}
}
found.ok_or(CryptError::WrongPassword)?
}
5 | 6 => {
let pw = sasl_password(pw);
@@ -300,9 +304,48 @@ impl SecurityHandler {
// ── Passwords ──────────────────────────────────────────────────────────────────────────────────
/// Revisions 2–4 use PDFDocEncoding passwords; characters outside Latin-1 are dropped.
/// PDFDocEncoding (ISO 32000-2 Annex D) where it differs from Latin-1: bytes 0x18–0x1F and
/// 0x80–0xA0 (0x9F is undefined). Here rather than in pdfcraft-cos, which decodes text strings
/// with it, because R2–R4 passwords need it too and cos depends on this crate.
const PDFDOC_LOW: [char; 8] = ['˘', 'ˇ', 'ˆ', '˙', '˝', '˛', '˚', '˜'];
const PDFDOC_HIGH: [char; 33] = [
'•', '†', '‡', '…', '—', '–', 'ƒ', '⁄', '‹', '›', '−', '‰', '„', '“', '”', '‘', '’', '‚', '™', 'fi', 'fl', 'Ł', 'Œ', 'Š', 'Ÿ', 'Ž', 'ı', 'ł', 'œ',
'š', 'ž', '\u{FFFD}', '€',
];
/// PDFDocEncoding → Unicode.
pub fn pdfdoc_char(c: u8) -> char {
match c {
0x18..=0x1F => PDFDOC_LOW[usize::from(c - 0x18)],
0x80..=0xA0 => PDFDOC_HIGH[usize::from(c - 0x80)],
_ => char::from(c),
}
}
/// Unicode → PDFDocEncoding, `None` for a character it lacks (ğ, ş, İ, Cyrillic, CJK, …).
pub fn pdfdoc_byte(c: char) -> Option<u8> {
let at = |table: &[char], base: u8| table.iter().position(|&t| t == c && t != '\u{FFFD}').and_then(|i| u8::try_from(i).ok()).map(|i| base + i);
at(&PDFDOC_LOW, 0x18).or_else(|| at(&PDFDOC_HIGH, 0x80)).or_else(|| match u32::from(c) {
0x18..=0x1F | 0x80..=0xA0 => None,
n => u8::try_from(n).ok(),
})
}
/// Revisions 2–4: the password in PDFDocEncoding (§7.6.4.3.2, Algorithm 2), at most 32 bytes.
/// Characters PDFDocEncoding lacks are dropped.
fn legacy_password(pw: &str) -> Vec<u8> {
pw.chars().filter_map(|c| u8::try_from(u32::from(c)).ok()).take(32).collect()
pw.chars().filter_map(pdfdoc_byte).take(32).collect()
}
/// The bytes an R2–R4 password may have been written as: PDFDocEncoding, as the specification
/// asks; and for a password with characters PDFDocEncoding lacks (ğ, ş, İ, …), which it gives
/// no bytes, also its UTF-8 bytes, which qpdf and other tools write for such passwords.
fn legacy_candidates(pw: &str) -> Vec<Vec<u8>> {
let mut out = vec![legacy_password(pw)];
if pw.chars().any(|c| pdfdoc_byte(c).is_none()) {
out.push(pw.bytes().take(32).collect());
}
out
}
/// Revisions 5–6: SASLprep (RFC 4013), UTF-8, at most 127 bytes.
+72
View File
@@ -163,3 +163,75 @@ fn owner_authentication_recovers_the_user_password() {
let d = new(Algorithm::Rc4_128, "", "owner", -1).dict().clone();
assert_eq!(SecurityHandler::open(d, ID, Some("owner")).unwrap().recovered_user_password().as_deref(), Some(&b""[..]));
}
/// /O, /U and the first /ID of files qpdf 12.4.2 wrote (`--static-id`), with the file keys
/// `qpdf --show-encryption-key` reports.
fn qpdf_file(r: i64, o: &str, u: &str) -> EncryptDict {
let mut d = EncryptDict {
filter: b"Standard".to_vec(),
v: if r == 4 { 4 } else { 2 },
r,
length_bits: 128,
o: hex(o),
u: hex(u),
p: -4,
encrypt_metadata: true,
..Default::default()
};
if r == 4 {
d.crypt_filters = vec![(b"StdCF".to_vec(), Method::Aes128)];
d.stm_f = b"StdCF".to_vec();
d.str_f = b"StdCF".to_vec();
}
d
}
const QPDF_ID: &str = "56b57383aafa8314f3f98222995f5604";
#[test]
fn legacy_passwords_are_pdfdoc_encoded_with_a_utf8_fallback() {
let id = hex(QPDF_ID);
// R3 RC4: user "ılık€" in PDFDocEncoding (ı = 0x9A, € = 0xA0); owner "Öğretmen", which
// PDFDocEncoding can't hold, so qpdf wrote its UTF-8 bytes.
let r3 = qpdf_file(
3,
"d519aa8120ce8c54812b45e5391916fed167b1c84961d90c4ee4c182ad83534b",
"4985c03ddfa3eeb8a90c343512913ec40021446990b9e4114071a4d9104984c1",
);
let user = SecurityHandler::open(r3.clone(), &id, Some("ılık€")).unwrap();
assert_eq!((user.auth(), user.file_key().to_vec()), (Auth::User, hex("3564ffcea186122595ad55ee157da71f")));
let owner = SecurityHandler::open(r3.clone(), &id, Some("Öğretmen")).unwrap();
assert_eq!((owner.auth(), owner.file_key().to_vec()), (Auth::Owner, hex("3564ffcea186122595ad55ee157da71f")));
assert_eq!(owner.recovered_user_password(), Some(vec![0x9A, b'l', 0x9A, b'k', 0xA0]));
// Dropping ı and € (the old Latin-1 conversion) or a near miss doesn't open it.
for wrong in ["lk", "ilik€", "ılık", "Ogretmen"] {
assert_eq!(SecurityHandler::open(r3.clone(), &id, Some(wrong)).unwrap_err(), CryptError::WrongPassword, "{wrong}");
}
// R4 AES-128: user "şifre" (UTF-8 bytes), owner "owner".
let r4 = qpdf_file(
4,
"bb4f8f4bd2236569e765906caf64e4429a4c20d6e996fdef963e9b5080f9e083",
"4a965fb91882772cd09ce8bbff2c33270021446990b9e4114071a4d9104984c1",
);
let user = SecurityHandler::open(r4.clone(), &id, Some("şifre")).unwrap();
assert_eq!((user.auth(), user.file_key().to_vec()), (Auth::User, hex("9f19d465cfd8e525d32aa0bdf10acd06")));
assert_eq!(SecurityHandler::open(r4.clone(), &id, Some("owner")).unwrap().auth(), Auth::Owner);
assert_eq!(SecurityHandler::open(r4, &id, Some("ifre")).unwrap_err(), CryptError::WrongPassword);
}
#[test]
fn pdfdoc_encoding_round_trips() {
for b in 0..=255u8 {
if b != 0x9F {
assert_eq!(pdfdoc_byte(pdfdoc_char(b)), Some(b), "{b:#04x}");
}
}
assert_eq!(pdfdoc_byte('ı'), Some(0x9A));
assert_eq!(pdfdoc_byte('€'), Some(0xA0));
assert_eq!(pdfdoc_byte('ç'), Some(0xE7));
for c in ['ğ', 'ş', 'İ', '\u{A0}', '\u{85}', 'Ж', '日'] {
assert_eq!(pdfdoc_byte(c), None, "{c}");
}
assert_eq!(legacy_candidates("Çok"), [vec![0xC7, b'o', b'k']]);
assert_eq!(legacy_candidates("şifre"), [b"ifre".to_vec(), "şifre".as_bytes().to_vec()]);
}
+17
View File
@@ -118,6 +118,23 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<us
.unwrap_or_default();
let mut gone: HashSet<ObjRef> = HashSet::new();
let mut gone_inline: HashSet<usize> = HashSet::new();
// A viewer draws many comments from their dictionary even when the file stores no
// appearance (notes, FreeText, ink, stamps…). Build the appearance it would show, so
// flattening keeps a comment that still has ink on screen instead of deleting it.
for entry in &list {
let Some(r) = entry.as_ref() else { continue };
let d = doc.resolve(entry).as_dict().cloned().unwrap_or_default();
let subtype = d.name(b"Subtype").unwrap_or_default();
if subtype == b"Widget" || !selected(doc, &d, which) {
continue;
}
let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
if flags & (HIDDEN | NO_VIEW) != 0 || appearance(doc, &d).is_some() {
continue;
}
// A subtype this build cannot draw only fails here; it is still removed below.
let _ = pdfcraft_annot::set_appearance(doc, r);
}
for (k, entry) in list.iter().enumerate() {
let obj = doc.resolve(entry);
let Some(d) = obj.as_dict() else { continue };
+70
View File
@@ -304,3 +304,73 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
})?;
Ok(())
}
/// Inventory indexes exclude the streams that belong to added content.
pub(crate) fn selectable_images(
doc: &Document,
page: usize,
excluded: &std::collections::BTreeSet<ObjRef>,
) -> Result<Vec<(usize, PageImage)>, EditError> {
let p = page_of(doc, page)?;
let all = streams(doc, &p.dict);
Ok(page_images(doc, page)?
.into_iter()
.enumerate()
.filter(|(_, image)| !all.get(image.stream).and_then(|(o, _)| o.as_ref()).is_some_and(|r| excluded.contains(&r)))
.collect())
}
/// Plan all transforms before changing a stream; indexes refer to the original inventory.
/// Splice only the chosen Do operators and preserve all other bytes and stream dictionaries.
pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize], offset: [f64; 2]) -> Result<(), EditError> {
if indexes.is_empty() {
return Ok(());
}
let images = page_images(doc, page)?;
let p = page_of(doc, page)?;
let all = streams(doc, &p.dict);
let mut plans = std::collections::BTreeMap::new();
for index in indexes {
let image = images.get(*index).ok_or_else(|| EditError::Invalid("a selected image no longer exists".into()))?;
let placement = Matrix(image.draw_matrix);
let inverse = placement.invert().ok_or_else(|| EditError::Invalid("a selected image has no area".into()))?;
let [a, b, c, d, _, _] = inverse.0;
let movement = Matrix([1.0, 0.0, 0.0, 1.0, a * offset[0] + c * offset[1], b * offset[0] + d * offset[1]]);
if !movement.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
return Err(EditError::Invalid("the image move is too large".into()));
}
plans.insert((image.stream, image.op), movement);
}
let mut data = Vec::with_capacity(all.len());
let mut inside_text = false;
for (stream, (_, bytes)) in all.iter().enumerate() {
let pieces = pdfcraft_content::Pieces::join(&[bytes.as_slice()]);
let ops = pieces.parse().ops;
for (index, op) in ops.iter().enumerate() {
if op.is("BT") {
inside_text = true;
} else if op.is("ET") {
inside_text = false;
} else if inside_text && plans.contains_key(&(stream, index)) {
return Err(EditError::Invalid("an image inside a text object cannot be moved in a group".into()));
}
}
let changed = crate::text::splice(&pieces, &ops, |index| {
let Some(matrix) = plans.remove(&(stream, index)) else { return (Vec::new(), true) };
let original = ops.get(index).cloned().into_iter();
// Local translations can be tiny under a large CTM. Preserve their precision
// rather than rounding them to zero or amplifying an error on the page.
let mut out = vec![Op::new("q", vec![]), Op::new("cm", matrix.0.map(Object::Real).to_vec())];
out.extend(original);
out.push(Op::new("Q", vec![]));
(out, false)
});
data.push(changed.into_iter().next().unwrap_or_default());
}
if !plans.is_empty() {
return Err(EditError::Invalid("a selected image operation is missing".into()));
}
let contents = crate::text::rewritten_contents(doc, &all, data);
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
Ok(())
}
+19 -1
View File
@@ -414,6 +414,20 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter,
if !(hf.font_size.is_finite() && hf.font_size > 0.0 && hf.font_size <= 200.0 && hf.margins.iter().all(|m| m.is_finite() && *m >= 0.0)) {
return Err(EditError::Invalid("invalid font size or margins".into()));
}
// Page numbers count up from start_number, so the last selected page needs the most room.
// Refuse the whole run before drawing anything: headers applied to some pages and then a
// failed page number would leave the document half changed.
let last_number = u64::from(hf.start_number)
.checked_add(pages.len().saturating_sub(1) as u64)
.ok_or_else(|| EditError::Invalid("the header page numbers are too large".into()))?;
if last_number > u64::from(u32::MAX) {
return Err(EditError::Invalid(format!(
"the header start number {} leaves no room for {} pages (page numbers stop at {})",
hf.start_number,
pages.len(),
u32::MAX
)));
}
if replace {
remove_marks(doc, pages, MarkKind::HeaderFooter)?;
}
@@ -423,7 +437,8 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter,
for (k, &i) in pages.iter().enumerate() {
let page = &all[i];
let (w, h) = page.display_size(doc);
let number = hf.start_number + k as u32;
let number =
u32::try_from(u64::from(hf.start_number) + k as u64).map_err(|_| EditError::Invalid("the header page numbers are too large".into()))?;
let bates = bates0 + k as u64;
let size = hf.font_size;
let [top, bottom, left, right] = hf.margins;
@@ -611,6 +626,9 @@ pub fn marks_present(doc: &Document) -> Vec<MarkKind> {
found
}
pub mod objects;
pub use objects::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, editable_objects, move_objects};
mod flatten;
pub use flatten::{flatten, flatten_fill_sign};
pub mod images;
+338
View File
@@ -0,0 +1,338 @@
//! Mixed-object inventory and atomic translation in Edit a PDF. Source indexes are resolved
//! together before mutation. Added content appears once, not again as a paragraph/image.
use std::collections::{BTreeMap, BTreeSet};
use pdfcraft_content::{Matrix, Op, Pieces};
use pdfcraft_cos::{Dict, Document, Object};
use crate::{Content, EditError};
pub const MAX_MOVE_OBJECTS: usize = 1_000;
const MAX_PAGE_OBJECTS: usize = 10_000;
const MAX_COORDINATE: f64 = 1e9;
const MAX_CONTENT_BYTES: usize = 128 * 1024 * 1024;
/// The source inventory of an editable item. Indexes are zero-based within that inventory.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ObjectKind {
Added,
Text,
Image,
}
impl ObjectKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Added => "added",
Self::Text => "text",
Self::Image => "image",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ObjectTarget {
pub kind: ObjectKind,
pub index: usize,
}
/// An item recognised by Edit a PDF. Geometry is in the page's user space; a Form is one
/// grouped artwork item. Its nested content is kept intact, not selected again separately.
#[derive(Clone, Debug, PartialEq)]
pub struct EditableObject {
pub target: ObjectTarget,
pub rect: [f64; 4],
pub content_type: &'static str,
pub text: Option<String>,
}
fn valid_rect(rect: [f64; 4]) -> bool {
rect.iter().all(|v| v.is_finite() && v.abs() <= MAX_COORDINATE) && rect[2] > rect[0] && rect[3] > rect[1]
}
pub fn editable_objects(doc: &Document, page: usize) -> Result<Vec<EditableObject>, EditError> {
checked_streams(doc, page)?;
let added: Vec<_> = crate::list_added(doc).into_iter().filter(|a| a.page == page).collect();
if added.len() > MAX_PAGE_OBJECTS {
return Err(EditError::Invalid("this page has too many objects to select together".into()));
}
let geometry = added_geometry(doc, page)?.1;
let excluded = added.iter().map(|a| a.obj).collect();
let mut objects = Vec::new();
for (index, a) in added.into_iter().enumerate() {
let (content_type, text) = match &a.content {
Content::Text(t) => ("text", Some(t.text.clone())),
Content::Image(_) => ("image", None),
};
objects.push(EditableObject {
target: ObjectTarget { kind: ObjectKind::Added, index },
rect: geometry.get(index).ok_or_else(|| EditError::Invalid("an added item has no placement".into()))?.rect,
content_type,
text,
});
}
for (index, b) in crate::text::selectable_blocks(doc, page, &excluded)? {
objects.push(EditableObject {
target: ObjectTarget { kind: ObjectKind::Text, index },
rect: b.rect,
content_type: "text",
text: Some(b.text),
});
}
for (index, image) in crate::images::selectable_images(doc, page, &excluded)? {
objects.push(EditableObject {
target: ObjectTarget { kind: ObjectKind::Image, index },
rect: image.rect,
content_type: if image.is_form { "form" } else { "image" },
text: None,
});
}
if objects.len() > MAX_PAGE_OBJECTS {
return Err(EditError::Invalid("this page has too many objects to select together".into()));
}
if objects.iter().any(|o| !valid_rect(o.rect)) {
return Err(EditError::Invalid("an object has invalid geometry".into()));
}
Ok(objects)
}
/// Move selected mixed objects by a user-space vector as one atomic operation. All targets
/// refer to the original inventories. A refusal leaves the graph, including overlay, untouched.
pub fn move_objects(doc: &mut Document, page: usize, targets: &[ObjectTarget], offset: [f64; 2]) -> Result<(), EditError> {
if targets.is_empty() || targets.len() > MAX_MOVE_OBJECTS {
return Err(EditError::Invalid("select between 1 and 1000 objects to move".into()));
}
if !offset.iter().all(|v| v.is_finite() && v.abs() <= MAX_COORDINATE) || offset == [0.0, 0.0] {
return Err(EditError::Invalid("the move must be finite and non-zero".into()));
}
let inventory: BTreeMap<_, _> = editable_objects(doc, page)?.into_iter().map(|o| (o.target, o)).collect();
let mut unique = BTreeSet::new();
for target in targets {
if !unique.insert(*target) {
return Err(EditError::Invalid("an object was selected more than once".into()));
}
let object =
inventory.get(target).ok_or_else(|| EditError::Invalid("a selected object no longer exists; select the objects again".into()))?;
let r = object.rect;
if !valid_rect([r[0] + offset[0], r[1] + offset[1], r[2] + offset[0], r[3] + offset[1]]) {
return Err(EditError::Invalid("the object move is too large".into()));
}
}
let indexes = |kind| targets.iter().filter(|t| t.kind == kind).map(|t| t.index).collect::<Vec<_>>();
let mut work = doc.clone();
crate::text::translate_blocks(&mut work, page, &indexes(ObjectKind::Text), offset)?;
crate::images::translate_images(&mut work, page, &indexes(ObjectKind::Image), offset)?;
translate_added(&mut work, page, &indexes(ObjectKind::Added), offset)?;
// Placement operators add to size/depth/work budgets, and moving paragraphs can change
// their grouping. Keep the resulting inventory usable; refuse before swapping otherwise.
editable_objects(&work, page)?;
*doc = work;
Ok(())
}
struct AddedGeometry {
rect: [f64; 4],
local_rect: [f64; 4],
origin: Matrix,
outside: Matrix,
current: Matrix,
stream: usize,
op: usize,
params: Dict,
}
/// An added item's metadata is in the coordinate space it was written in, which can differ
/// from today's page rotation/crop/UserUnit. Read its original placement, including graphics
/// state from preceding streams. MoveOrigin retains that basis after a byte-preserving move.
fn added_geometry(doc: &Document, page: usize) -> Result<(Pieces, Vec<AddedGeometry>), EditError> {
let added: Vec<_> = crate::list_added(doc).into_iter().filter(|a| a.page == page).collect();
if added.is_empty() {
return Ok((Pieces::default(), Vec::new()));
}
let streams = checked_streams(doc, page)?;
let pieces = Pieces::join(&streams.iter().map(|(_, bytes)| bytes.as_slice()).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let owned: BTreeSet<_> = added.iter().map(|a| a.obj).collect();
let mut geometry = BTreeMap::new();
let mut first = BTreeMap::new();
let mut matrix = Matrix::IDENTITY;
let mut stack = Vec::new();
for (index, op) in ops.iter().enumerate() {
let (stream, end) = pieces.pieces_of(op);
let object = streams.get(stream).and_then(|(r, _)| r.as_ref());
if object.is_some_and(|r| owned.contains(&r)) {
if let std::collections::btree_map::Entry::Vacant(entry) = first.entry(stream) {
if stream != end || !op.is("q") {
return Err(EditError::Invalid("an added item has an unsupported placement".into()));
}
entry.insert((index, matrix));
} else if first.get(&stream).is_some_and(|(i, _)| i.checked_add(1) == Some(index)) {
if stream != end || !op.is("cm") {
return Err(EditError::Invalid("an added item has an unsupported placement".into()));
}
let current = op.nums::<6>().map(Matrix).ok_or_else(|| EditError::Invalid("an added item has invalid geometry".into()))?;
let outside = first.get(&stream).map_or(Matrix::IDENTITY, |(_, m)| *m);
let params = object
.and_then(|r| doc.get(r).as_dict().and_then(|d| d.get(b"PCAdded")).map(|p| doc.resolve(p)))
.and_then(|p| p.as_dict().cloned())
.ok_or_else(|| EditError::Invalid("an added item has no parameters".into()))?;
let origin = match params.get(b"MoveOrigin") {
None => current,
Some(o) => doc
.resolve(o)
.as_array()
.and_then(|a| Matrix::from_operands(a))
.ok_or_else(|| EditError::Invalid("an added item has invalid geometry".into()))?,
};
let raw = params
.get(b"Rect")
.map(|r| doc.resolve(r))
.and_then(|r| r.as_array().cloned())
.ok_or_else(|| EditError::Invalid("an added item has no rectangle".into()))?;
let local_rect = raw
.iter()
.map(Object::as_f64)
.collect::<Option<Vec<_>>>()
.and_then(|r| <[f64; 4]>::try_from(r).ok())
.ok_or_else(|| EditError::Invalid("an added item has invalid geometry".into()))?;
if !current.0.iter().chain(origin.0.iter()).chain(outside.0.iter()).all(|v| v.is_finite() && v.abs() <= 1e12)
|| !valid_rect(local_rect)
{
return Err(EditError::Invalid("an added item has invalid geometry".into()));
}
geometry.insert(
stream,
AddedGeometry { rect: origin.then(&outside).bbox(local_rect), local_rect, origin, outside, current, stream, op: index, params },
);
}
}
match op.op.as_slice() {
b"q" => {
if stack.len() >= 1024 {
return Err(EditError::Invalid("the page graphics state is nested too deeply".into()));
}
stack.push(matrix);
}
b"Q" => matrix = stack.pop().unwrap_or(matrix),
b"cm" => {
if let Some(m) = op.nums::<6>() {
matrix = Matrix(m).then(&matrix);
}
}
_ => {}
}
}
let geometry: Vec<_> = geometry.into_values().collect();
if geometry.len() != added.len() {
return Err(EditError::Invalid("an added item has no placement".into()));
}
Ok((pieces, geometry))
}
fn translate_added(doc: &mut Document, page: usize, indexes: &[usize], offset: [f64; 2]) -> Result<(), EditError> {
if indexes.is_empty() {
return Ok(());
}
let p = pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))?;
let streams = checked_streams(doc, page)?;
let (pieces, geometry) = added_geometry(doc, page)?;
let ops = pieces.parse().ops;
let mut replacements = BTreeMap::new();
let mut parameters = Vec::new();
let linear = |inverse: Matrix| {
let [a, b, c, d, _, _] = inverse.0;
[a * offset[0] + c * offset[1], b * offset[0] + d * offset[1]]
};
for index in indexes {
let g = geometry.get(*index).ok_or_else(|| EditError::Invalid("a selected added item no longer exists".into()))?;
let delta = linear(g.origin.then(&g.outside).invert().ok_or_else(|| EditError::Invalid("an added item has no area".into()))?);
let outer_delta = linear(g.outside.invert().ok_or_else(|| EditError::Invalid("an added item has no area".into()))?);
let current = g.current.then(&Matrix([1.0, 0.0, 0.0, 1.0, outer_delta[0], outer_delta[1]]));
let r = g.local_rect;
let rect = [r[0] + delta[0], r[1] + delta[1], r[2] + delta[0], r[3] + delta[1]];
if !valid_rect(rect) || !current.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
return Err(EditError::Invalid("the added item move is too large".into()));
}
let mut params = g.params.clone();
params.set(b"MoveOrigin".to_vec(), Object::Array(g.origin.0.map(Object::Real).to_vec()));
params.set(b"Rect".to_vec(), Object::Array(rect.map(Object::Real).to_vec()));
parameters.push((g.stream, params));
replacements.insert(g.op, Op::new("cm", current.0.map(Object::Real).to_vec()));
}
// A placement replaces exactly one operator. Keep its existing following separator,
// rather than adding a blank line every time the item is moved.
let edits = replacements
.into_iter()
.map(|(i, op)| {
let original = ops.get(i).ok_or_else(|| EditError::Invalid("an added item placement is missing".into()))?;
let mut bytes = pdfcraft_content::serialize_ops(&[op]);
if bytes.last() == Some(&b'\n') {
bytes.pop();
}
Ok((original.span.clone(), bytes))
})
.collect::<Result<Vec<_>, EditError>>()?;
let data = pieces.splice(edits);
let contents = crate::text::rewritten_contents(doc, &streams, data);
let refs = match &contents {
Object::Array(a) => a.clone(),
one => vec![one.clone()],
};
for (stream, params) in parameters {
let r = refs.get(stream).and_then(Object::as_ref).ok_or_else(|| EditError::Invalid("the added item stream is missing".into()))?;
doc.update_dict(r, |d| d.set(b"PCAdded".to_vec(), Object::Dict(params)))?;
}
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
Ok(())
}
/// A missing or undecodable preceding stream can hide graphics state that affects every
/// later item. Refuse group editing rather than inventing an inventory from partial data.
fn checked_streams(doc: &Document, page: usize) -> Result<Vec<(Object, Vec<u8>)>, EditError> {
let p = pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))?;
let contents = match p.dict.get(b"Contents") {
None => Vec::new(),
Some(c) => match &*doc.resolve(c) {
Object::Array(a) => {
if a.len() > MAX_PAGE_OBJECTS {
return Err(EditError::Invalid("this page has too many content streams to select objects together".into()));
}
a.clone()
}
_ => vec![c.clone()],
},
};
let mut remaining = MAX_CONTENT_BYTES;
let mut depth = 0usize;
let mut operations = 0usize;
let mut streams = Vec::with_capacity(contents.len());
for object in contents {
let resolved = doc.resolve(&object);
let Object::Stream(stream) = &*resolved else {
return Err(EditError::Invalid("a page content stream is missing".into()));
};
let bytes = stream
.decoded_within(remaining.min(64 * 1024 * 1024))
.map_err(|_| EditError::Invalid("the page content cannot be decoded within its size limit".into()))?;
remaining =
remaining.checked_sub(bytes.len()).ok_or_else(|| EditError::Invalid("the page content is too large for group selection".into()))?;
let parsed = pdfcraft_content::parse(&bytes);
operations = operations.saturating_add(parsed.ops.len());
if operations > 100_000 {
return Err(EditError::Invalid("this page has too many content operations for group selection".into()));
}
for op in parsed.ops {
if op.is("q") {
depth = depth.saturating_add(1);
} else if op.is("Q") {
depth = depth.saturating_sub(1);
}
if depth > 1024 {
return Err(EditError::Invalid("the page graphics state is nested too deeply".into()));
}
}
streams.push((object, bytes));
}
Ok(streams)
}
+421
View File
@@ -96,6 +96,25 @@ fn header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content()
assert_eq!(marks_present(&doc), [MarkKind::HeaderFooter]);
}
#[test]
fn header_start_number_that_would_wrap_is_refused_without_drawing() {
// u32::MAX is a valid start_number on its own, but there is no room for a second page
// number after it. The old code wrapped the second page to zero; now the whole run is
// refused and the document keeps no header at all.
let mut doc = fixture();
let hf = HeaderFooter {
text: [String::new(), String::new(), "<<1>>".into(), String::new(), String::new(), String::new()],
start_number: u32::MAX,
..HeaderFooter::default()
};
let err = add_header_footer(&mut doc, &[0, 1], &hf, false, &cx()).unwrap_err();
assert!(err.to_string().contains("leaves no room"), "{err}");
assert!(marks_present(&doc).is_empty(), "nothing is drawn when the run is refused");
// A single page still fits the start number itself.
add_header_footer(&mut doc, &[0], &hf, false, &cx()).unwrap();
assert_eq!(marks_present(&doc).len(), 1);
}
#[test]
fn replace_and_remove_restore_the_original_content() {
let mut doc = fixture();
@@ -175,6 +194,26 @@ fn flattening_draws_appearances_into_the_page_and_removes_the_comments() {
assert!(marks_present(&doc).is_empty(), "flattened content is not a removable mark");
}
/// #805: a supported note with no stored appearance is still drawn by viewers. Flattening must
/// generate that appearance and bake it into the page, not delete a comment that shows an icon.
#[test]
fn flattening_keeps_a_note_whose_appearance_is_missing() {
let page = stream("", " ");
let mut doc = build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R] >>",
&page,
"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (Synthetic note) /Name /Comment /Open false /F 0 >>",
]);
assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1, "the note icon is drawn");
let doc = reopen(&doc);
let p = &pdfcraft_model::pages(&doc)[0];
assert!(!p.dict.contains(b"Annots"), "the note is gone once its icon is baked in");
let flat = streams(&doc, 0).pop().unwrap();
assert!(flat.contains("q 1 0 0 1 10 10 cm /PCFl0 Do Q"), "{flat}");
}
/// Display space is in points, so on a `/UserUnit 2` page an item's box maps to half as many
/// user-space units. Items record the `/UserUnit` they were written for; one without it was
/// written when display space was in user-space units, and reads back scaled to points.
@@ -1607,3 +1646,385 @@ fn hostile_form_xobjects_are_read_once() {
assert_eq!(texts, ["once", "from b", "from a", "inherited", "bad matrix"]);
assert!(images::reading_images(&doc, 0).unwrap().is_empty());
}
#[test]
fn group_moves_preserve_glyph_bytes_and_unselected_text_state() {
for content in [
"BT /F1 12 Tf 1.3 Tc 2 Tw 80 Tz 14 TL 1 0.2 -0.1 1 50 600 Tm (A\\101) Tj 1 0 0 rg (Neighbour) Tj T* (Next) Tj ET",
"BT /F1 12 Tf 14 TL 50 600 Td (Before) Tj 0 0 1 rg (Quoted) ' 1 0 0 rg (After) Tj ET",
"BT /F1 12 Tf 14 TL 50 600 Td (Before) Tj 0 0 1 rg 2 1 (Quoted) \" 1 0 0 rg [(After) -120 (wards)] TJ T* (Last) Tj ET",
"q 1.2 0.3 -0.1 0.9 20 30 cm BT /F1 12 Tf 50 600 Td [(A) -120 (B)] TJ 1 0 0 rg (After) Tj ET Q %keep-this-comment\n",
"BT /F1 12.3456789 Tf 81.23456789 Tz 0.123456789 Ts 14 TL 50 600 Td (Before) Tj 0 0 1 rg 2.123456789 1.987654321 (Quoted) \" 1 0 0 rg (After) Tj ET",
] {
let mut doc = text_page(content);
let before = text::text_lines(&doc, 0).unwrap();
let blocks = text::text_blocks(&doc, 0).unwrap();
let index = blocks.iter().position(|b| b.text.contains("Quoted")).unwrap_or(0);
let selected: Vec<_> = blocks[index].lines.iter().map(|i| before[*i].text.clone()).collect();
let glyph_bytes = |doc: &Document| {
pdfcraft_content::parse(&page_content_bytes(doc, 0))
.ops
.iter()
.flat_map(|op| {
op.operands
.iter()
.flat_map(|o| match o {
Object::String(s) => vec![s.bytes.clone()],
Object::Array(a) => a.iter().filter_map(|v| v.as_string().map(|s| s.bytes.clone())).collect(),
_ => Vec::new(),
})
.collect::<Vec<_>>()
})
.collect::<Vec<_>>()
};
let original_bytes = glyph_bytes(&doc);
let target = ObjectTarget { kind: ObjectKind::Text, index };
move_objects(&mut doc, 0, &[target], [35.0, -27.0]).unwrap();
let doc = reopen(&doc);
assert_eq!(glyph_bytes(&doc), original_bytes, "original codes and kerning strings survive");
let after = text::text_lines(&doc, 0).unwrap();
assert_eq!(after.len(), before.len(), "{content}");
for old in before {
let new = after.iter().find(|l| l.text == old.text).unwrap();
let expected = if selected.contains(&old.text) {
[old.rect[0] + 35.0, old.rect[1] - 27.0, old.rect[2] + 35.0, old.rect[3] - 27.0]
} else {
old.rect
};
assert!(new.rect.iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-6), "{}: {:?} != {:?}", old.text, new.rect, expected);
assert_eq!((new.font.clone(), new.color), (old.font, old.color));
}
if content.contains("%keep-this-comment") {
assert!(String::from_utf8_lossy(&page_content_bytes(&doc, 0)).contains("%keep-this-comment"));
}
}
}
#[test]
fn group_moves_preserve_small_offsets_under_large_transforms() {
let mut doc = image_page();
let page = pdfcraft_model::pages(&doc)[0].obj;
let content = doc.add(Object::Stream(Stream::flate(
Dict::new(),
b"q 100000 0 0 100000 100 100 cm /Im0 Do Q q 10000 0 0 10000 0 0 cm BT /F1 0.00123456789 Tf 0.02 0.06 Td (Small) Tj 1 0 0 rg (Neighbour) Tj ET Q",
)));
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(content))).unwrap();
let before = editable_objects(&doc, 0).unwrap();
let targets = before.iter().filter(|o| o.text.as_deref() != Some("Neighbour")).map(|o| o.target).collect::<Vec<_>>();
assert_eq!(targets.len(), 2);
let offset = [0.00123456789, -0.00987654321];
move_objects(&mut doc, 0, &targets, offset).unwrap();
let after = editable_objects(&reopen(&doc), 0).unwrap();
for old in before {
let new = after.iter().find(|o| o.target == old.target).unwrap();
let [dx, dy] = if targets.contains(&old.target) { offset } else { [0.0, 0.0] };
assert!(close(new.rect, [old.rect[0] + dx, old.rect[1] + dy, old.rect[2] + dx, old.rect[3] + dy]), "{old:?}: {new:?}");
}
}
#[test]
fn mixed_object_inventory_does_not_select_added_content_twice() {
let mut doc = image_page();
add_content(&mut doc, 0, &Content::Text(AddedText { text: "Added label".into(), rect: [50.0, 350.0, 200.0, 380.0], ..AddedText::default() }))
.unwrap();
let before = editable_objects(&doc, 0).unwrap();
assert_eq!(before.len(), 3, "one native paragraph, one native image, one added text");
let targets = before.iter().map(|o| o.target).collect::<Vec<_>>();
let image = images::page_images(&doc, 0).unwrap()[0].object.unwrap();
let original_image = doc.get(image).clone();
move_objects(&mut doc, 0, &targets, [20.0, -15.0]).unwrap();
let doc = reopen(&doc);
let after = editable_objects(&doc, 0).unwrap();
assert_eq!(after.len(), before.len());
for old in before {
let new = after.iter().find(|o| o.target == old.target).unwrap();
assert!(close(new.rect, [old.rect[0] + 20.0, old.rect[1] - 15.0, old.rect[2] + 20.0, old.rect[3] - 15.0]));
assert_eq!(new.text, old.text);
}
assert_eq!(doc.get(image).clone(), original_image, "pixel data is shared, not recompressed");
}
#[test]
fn group_move_refusals_leave_the_object_graph_untouched() {
let mut doc = image_page();
let target = editable_objects(&doc, 0).unwrap()[0].target;
let original = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap();
for (targets, offset) in [
(vec![], [1.0, 2.0]),
(vec![target, target], [1.0, 2.0]),
(vec![target, ObjectTarget { kind: ObjectKind::Image, index: usize::MAX }], [1.0, 2.0]),
(vec![target], [f64::NAN, 2.0]),
(vec![target], [f64::INFINITY, 2.0]),
(vec![target], [0.0, 0.0]),
(vec![target], [1e300, 2.0]),
(vec![target; MAX_MOVE_OBJECTS + 1], [1.0, 2.0]),
] {
assert!(move_objects(&mut doc, 0, &targets, offset).is_err());
assert_eq!(pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(), original);
}
let mut clipped = text_page("BT /F1 12 Tf 50 600 Td (Ordinary) Tj ET BT /F1 12 Tf 4 Tr 50 400 Td (Clipping) Tj ET");
let targets = editable_objects(&clipped, 0).unwrap().iter().map(|o| o.target).collect::<Vec<_>>();
let original = pdfcraft_cos::write_full(&clipped, &SaveOptions::default()).unwrap();
assert!(move_objects(&mut clipped, 0, &targets, [10.0, 5.0]).unwrap_err().to_string().contains("clipping"));
assert_eq!(pdfcraft_cos::write_full(&clipped, &SaveOptions::default()).unwrap(), original);
}
#[test]
fn added_and_native_objects_move_together_on_cropped_rotated_user_unit_pages() {
for rotation in [0, 90, 180, 270] {
let mut doc = image_page();
let page = pdfcraft_model::pages(&doc)[0].obj;
doc.update_dict(page, |d| {
d.set(b"CropBox".to_vec(), Object::Array([10, 20, 500, 750].map(Object::Int).to_vec()));
d.set(b"Rotate".to_vec(), Object::Int(rotation));
d.set(b"UserUnit".to_vec(), Object::Int(2));
})
.unwrap();
add_content(&mut doc, 0, &Content::Text(AddedText { text: "Added".into(), rect: [60.0, 100.0, 180.0, 125.0], ..AddedText::default() }))
.unwrap();
let before = editable_objects(&doc, 0).unwrap();
move_objects(&mut doc, 0, &before.iter().map(|o| o.target).collect::<Vec<_>>(), [12.0, -8.0]).unwrap();
let after = editable_objects(&reopen(&doc), 0).unwrap();
for (a, b) in before.iter().zip(after) {
assert!(
close(b.rect, [a.rect[0] + 12.0, a.rect[1] - 8.0, a.rect[2] + 12.0, a.rect[3] - 8.0]),
"rotation {rotation}: {:?} -> {:?}",
a.rect,
b.rect
);
}
}
}
#[test]
fn group_moves_keep_split_stream_tokens_and_shared_form_resources() {
let mut doc = split_streams_page();
let before = text::text_lines(&doc, 0).unwrap();
let objects: Vec<_> = editable_objects(&doc, 0).unwrap().iter().map(|o| o.target).collect();
move_objects(&mut doc, 0, &objects, [30.0, -20.0]).unwrap();
let doc = reopen(&doc);
let after = text::text_lines(&doc, 0).unwrap();
assert_eq!(before.len(), after.len());
for (a, b) in before.iter().zip(&after) {
assert_eq!(a.text, b.text);
assert!(close(b.rect, [a.rect[0] + 30.0, a.rect[1] - 20.0, a.rect[2] + 30.0, a.rect[3] - 20.0]));
}
let joined = page_content_bytes(&doc, 0);
let ops = pdfcraft_content::parse(&joined).ops;
assert_eq!(ops.iter().find(|o| o.is("BDC")).unwrap().operands.len(), 2);
assert!(ops.iter().any(|o| o.is("EMC")));
// Two pages share a content stream and a Form; only one placement on one page moves.
let mut doc = fixture();
let mut form = Dict::new();
form.set(b"Subtype".to_vec(), Object::name("Form"));
form.set(b"BBox".to_vec(), Object::Array([0, 0, 60, 30].map(Object::Int).to_vec()));
form.set(b"PrivateData".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"retain this".to_vec())));
let artwork = doc.add(Object::Stream(Stream::flate(form, b"1 0 0 rg 0 0 60 30 re f")));
doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| {
let mut xo = Dict::new();
xo.set(b"Figure".to_vec(), Object::Ref(artwork));
d.set(b"XObject".to_vec(), Object::Dict(xo));
})
.unwrap();
doc.set(
pdfcraft_cos::ObjRef::new(7, 0),
Object::Stream(Stream::flate(
Dict::new(),
b"q 1 0 0 1 40 80 cm /Figure Do Q q 1 0 0 1 300 400 cm /Figure Do Q BT /F1 9 Tf 40 160 Td (Caption) Tj ET",
)),
);
let original_form = doc.get(artwork);
let source_page = page_content_bytes(&doc, 1);
let images = images::page_images(&doc, 0).unwrap();
move_objects(
&mut doc,
0,
&[ObjectTarget { kind: ObjectKind::Image, index: 0 }, ObjectTarget { kind: ObjectKind::Text, index: 0 }],
[15.0, -10.0],
)
.unwrap();
let after = images::page_images(&doc, 0).unwrap();
assert!(close(after[0].rect, [55.0, 70.0, 115.0, 100.0]));
assert_eq!(after[1].rect, images[1].rect);
assert_eq!(*doc.get(artwork), *original_form);
assert_eq!(page_content_bytes(&doc, 1), source_page);
}
#[test]
fn group_moves_preserve_encoded_glyphs_and_refuse_vertical_cmaps() {
let mut doc = text_page("BT /F1 12 Tf 40 400 Td <004100420043> Tj ET");
let mut font = Dict::from_iter([
(b"Type".to_vec(), Object::name("Font")),
(b"Subtype".to_vec(), Object::name("Type0")),
(b"BaseFont".to_vec(), Object::name("SyntheticCID")),
(b"Encoding".to_vec(), Object::name("Identity-H")),
]);
let unicode = doc.add(Object::Stream(Stream::flate(
Dict::new(),
b"1 begincodespacerange <0000> <ffff> endcodespacerange 3 beginbfchar <0041> <0041> <0042> <0042> <0043> <0043> endbfchar",
)));
font.set(b"ToUnicode".to_vec(), Object::Ref(unicode));
font.set(
b"DescendantFonts".to_vec(),
Object::Array(vec![Object::Dict(Dict::from_iter([(b"Subtype".to_vec(), Object::name("CIDFontType2")), (b"DW".to_vec(), Object::Int(600))]))]),
);
// No external font asset: this synthetic font is enough to exercise raw CID bytes.
let p = pdfcraft_model::pages(&doc)[0].obj;
let mut resources = Dict::new();
let mut fonts = Dict::new();
fonts.set(b"F1".to_vec(), Object::Dict(font.clone()));
resources.set(b"Font".to_vec(), Object::Dict(fonts));
doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(resources.clone()))).unwrap();
let original = text::text_lines(&doc, 0).unwrap();
assert!(!original.is_empty());
move_objects(&mut doc, 0, &[ObjectTarget { kind: ObjectKind::Text, index: 0 }], [25.0, 15.0]).unwrap();
let encoded =
|doc: &Document| pdfcraft_content::parse(&page_content_bytes(doc, 0)).ops.into_iter().find(|o| o.is("Tj")).unwrap().operands[0].clone();
assert_eq!(encoded(&doc).as_string().unwrap().bytes, [0, 65, 0, 66, 0, 67]);
let after = text::text_lines(&doc, 0).unwrap();
assert_eq!(original[0].text, after[0].text);
for encoding in [
Object::name("Identity-V"),
Object::Stream(Stream::flate(Dict::new(), b"/WMode 1 def")),
Object::Stream(Stream::flate(Dict::from_iter([(b"WMode".to_vec(), Object::Int(1))]), b"")),
] {
let mut f = font.clone();
f.set(b"Encoding".to_vec(), encoding);
let mut fonts = Dict::new();
fonts.set(b"F1".to_vec(), Object::Dict(f));
resources.set(b"Font".to_vec(), Object::Dict(fonts));
doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(resources.clone()))).unwrap();
let before = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap();
assert!(move_objects(&mut doc, 0, &[ObjectTarget { kind: ObjectKind::Text, index: 0 }], [5.0, 5.0]).is_err());
assert_eq!(pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(), before);
}
}
#[test]
fn moving_added_content_after_page_rotation_preserves_streams_and_resources() {
let mut doc = text_page("BT /F1 12 Tf 40 400 Td (Native) Tj ET");
added::add_content(
&mut doc,
0,
&Content::Text(added::AddedText { text: "Added label".into(), rect: [50.0, 300.0, 200.0, 330.0], ..Default::default() }),
)
.unwrap();
let p = pdfcraft_model::pages(&doc)[0].obj;
let resources = pdfcraft_model::pages(&doc)[0].dict.get(b"Resources").cloned();
let item = added::list_added(&doc)[0].clone();
let source = stream_bytes(&doc, &Object::Ref(item.obj)).unwrap();
let content_after_cm = |bytes: &[u8]| {
let ops = pdfcraft_content::parse(bytes).ops;
bytes[ops[1].span.end..].to_vec()
};
for rotation in [90, 180, 270, 0] {
doc.update_dict(p, |d| d.set(b"Rotate".to_vec(), Object::Int(rotation))).unwrap();
let before_lines = text::text_lines(&doc, 0).unwrap();
let before_objects = editable_objects(&doc, 0).unwrap();
let a = before_objects.iter().find(|o| o.target.kind == ObjectKind::Added).unwrap();
// The logical added box must contain the actual source glyph geometry after rotation.
let label = before_lines.iter().find(|l| l.text == "Added label").unwrap();
assert!(
label.rect[0] >= a.rect[0] - 0.01
&& label.rect[2] <= a.rect[2] + 0.01
&& label.rect[1] >= a.rect[1] - 0.01
&& label.rect[3] <= a.rect[3] + 0.01
);
move_objects(&mut doc, 0, &[a.target], [13.0, -17.0]).unwrap();
let after_lines = text::text_lines(&doc, 0).unwrap();
for (old, new) in before_lines.iter().zip(&after_lines) {
let delta = if old.text == "Added label" { [13.0, -17.0] } else { [0.0, 0.0] };
assert_eq!(old.text, new.text);
assert!(close(new.rect, [old.rect[0] + delta[0], old.rect[1] + delta[1], old.rect[2] + delta[0], old.rect[3] + delta[1]]));
}
let current = added::list_added(&doc)[0].clone();
let bytes = stream_bytes(&doc, &Object::Ref(current.obj)).unwrap();
assert_eq!(content_after_cm(&bytes), content_after_cm(&source), "all drawing bytes after the placement, including glyphs, remain identical");
assert_eq!(pdfcraft_model::pages(&doc)[0].dict.get(b"Resources").cloned(), resources);
doc = reopen(&doc);
}
}
#[test]
fn group_moves_of_shared_added_streams_change_only_the_selected_page() {
let mut doc = fixture();
added::add_content(
&mut doc,
0,
&Content::Text(added::AddedText { text: "Shared added label".into(), rect: [40.0, 400.0, 240.0, 420.0], ..Default::default() }),
)
.unwrap();
let pages = pdfcraft_model::pages(&doc);
let contents = pages[0].dict.get(b"Contents").cloned().unwrap();
doc.update_dict(pages[1].obj, |d| d.set(b"Contents".to_vec(), contents)).unwrap();
let doc = reopen(&doc);
let mut doc = doc;
let other = page_content_bytes(&doc, 1);
let before = editable_objects(&doc, 0).unwrap();
let target = before.iter().find(|o| o.target.kind == ObjectKind::Added).unwrap().target;
move_objects(&mut doc, 0, &[target], [15.0, -20.0]).unwrap();
assert_eq!(page_content_bytes(&doc, 1), other, "a shared stream is copied on write");
assert_ne!(added::list_added(&doc).iter().find(|a| a.page == 0).unwrap().obj, added::list_added(&doc).iter().find(|a| a.page == 1).unwrap().obj);
}
#[test]
fn group_moves_handle_added_content_under_inherited_graphics_state() {
let mut doc = text_page("2 0 0 2 10 20 cm BT /F1 12 Tf 40 250 Td (Native) Tj ET");
added::add_content(
&mut doc,
0,
&Content::Text(added::AddedText { text: "Added label".into(), rect: [50.0, 100.0, 200.0, 130.0], ..Default::default() }),
)
.unwrap();
let before = text::text_lines(&doc, 0).unwrap();
let target = editable_objects(&doc, 0).unwrap().iter().find(|o| o.target.kind == ObjectKind::Added).unwrap().target;
move_objects(&mut doc, 0, &[target], [30.0, -20.0]).unwrap();
let after = text::text_lines(&doc, 0).unwrap();
for (old, new) in before.iter().zip(&after) {
let delta = if old.text == "Added label" { [30.0, -20.0] } else { [0.0, 0.0] };
assert!(close(new.rect, [old.rect[0] + delta[0], old.rect[1] + delta[1], old.rect[2] + delta[0], old.rect[3] + delta[1]]));
}
}
#[test]
fn group_selection_refuses_incomplete_content_and_resource_budget_overruns() {
for data in [
format!("{} BT /F1 12 Tf 40 400 Td (Target) Tj ET", "q ".repeat(1025)),
format!("{} BT /F1 12 Tf 40 400 Td (Target) Tj ET", "n ".repeat(100001)),
] {
let mut doc = text_page(&data);
let before = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap();
assert!(editable_objects(&doc, 0).is_err());
assert!(move_objects(&mut doc, 0, &[ObjectTarget { kind: ObjectKind::Text, index: 0 }], [10.0, 10.0]).is_err());
assert_eq!(pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(), before);
}
let mut doc = text_page("BT /F1 12 Tf 40 400 Td (Target) Tj ET");
let page = pdfcraft_model::pages(&doc)[0].obj;
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(pdfcraft_cos::ObjRef::new(999999, 0)))).unwrap();
assert!(editable_objects(&doc, 0).is_err(), "a missing stream cannot produce a partial inventory");
let mut filter = Dict::new();
filter.set(b"Filter".to_vec(), Object::name("NotAFilter"));
let broken = doc.add(Object::Stream(Stream::from_raw(filter, vec![1, 2, 3])));
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(broken))).unwrap();
assert!(editable_objects(&doc, 0).is_err(), "an undecodable stream is refused");
}
#[test]
fn group_move_budget_refusals_keep_the_original_page_selectable() {
let text = text_page(&format!("{} BT /F1 12 Tf 40 400 Td (Target) Tj ET", "n ".repeat(99_995)));
assert_eq!(pdfcraft_content::parse(&page_content_bytes(&text, 0)).ops.len(), 100_000);
let mut image = image_page();
let page = pdfcraft_model::pages(&image)[0].obj;
let bytes = format!("{} /Im0 Do {}", "q ".repeat(1024), "Q ".repeat(1024));
let content = image.add(Object::Stream(Stream::flate(Dict::new(), bytes.as_bytes())));
image.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(content))).unwrap();
for mut doc in [text, image] {
let objects = editable_objects(&doc, 0).unwrap();
assert_eq!(objects.len(), 1);
let before = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap();
let error = move_objects(&mut doc, 0, &[objects[0].target], [10.0, 5.0]).unwrap_err();
assert!(error.to_string().contains("too many content operations") || error.to_string().contains("nested too deeply"), "{error}");
assert_eq!(pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(), before);
assert_eq!(editable_objects(&doc, 0).unwrap(), objects, "refusal retains a usable inventory");
}
}
+178 -3
View File
@@ -148,6 +148,7 @@ struct Ts {
scale: f64,
leading: f64,
rise: f64,
render_mode: i64,
}
fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec<u8>)> {
@@ -175,7 +176,7 @@ fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec<u8>)> {
/// between any two tokens (ISO 32000-2 §7.8.2), so an operator's operands can end one piece and
/// its keyword start the next; removing or replacing it rewrites every piece it spans. Returns
/// each piece's new bytes.
fn splice(pieces: &Pieces, ops: &[Op], mut edit: impl FnMut(usize) -> (Vec<Op>, bool)) -> Vec<Vec<u8>> {
pub(crate) fn splice(pieces: &Pieces, ops: &[Op], mut edit: impl FnMut(usize) -> (Vec<Op>, bool)) -> Vec<Vec<u8>> {
let data = pieces.data();
let mut edits = Vec::new();
for (i, op) in ops.iter().enumerate() {
@@ -198,7 +199,7 @@ fn splice(pieces: &Pieces, ops: &[Op], mut edit: impl FnMut(usize) -> (Vec<Op>,
/// The page's `/Contents` after a [`splice`]: pieces whose bytes changed become new streams
/// (keeping their dictionaries), the others stay as they were.
fn rewritten_contents(doc: &mut Document, streams: &[(Object, Vec<u8>)], new: Vec<Vec<u8>>) -> Object {
pub(crate) fn rewritten_contents(doc: &mut Document, streams: &[(Object, Vec<u8>)], new: Vec<Vec<u8>>) -> Object {
let mut contents: Vec<Object> = Vec::with_capacity(streams.len());
for ((obj, old), data) in streams.iter().zip(new) {
if *old == data {
@@ -290,6 +291,7 @@ impl Carry {
scale: 1.0,
leading: 0.0,
rise: 0.0,
render_mode: 0,
};
Carry { ts, stack: Vec::new() }
}
@@ -356,6 +358,7 @@ fn interpret(
b"Tz" => ts.scale = op.num(0).unwrap_or(100.0) / 100.0,
b"TL" => ts.leading = op.num(0).unwrap_or(0.0),
b"Ts" => ts.rise = op.num(0).unwrap_or(0.0),
b"Tr" => ts.render_mode = op.operands.first().and_then(Object::as_int).unwrap_or(-1),
b"Do" => {
if let (Some(list), Some(name)) = (invoked.as_deref_mut(), op.name(0)) {
list.push((i, name.to_vec(), ts.clone()));
@@ -405,7 +408,7 @@ fn interpret(
Object::String(s) => {
for (code, len) in m.codes(&s.bytes) {
match m.text_of(code) {
Some(t) => text.push_str(t),
Some(t) => text.push_str(&t),
None => decodable = false,
}
let w = m.width(code) * ts.size + ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 };
@@ -437,6 +440,11 @@ fn interpret(
.iter()
.fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]);
let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size;
// A numeric-only TJ adjusts the current text matrix but paints no glyphs.
// It must not create a phantom editable line or enlarge its neighbours' boxes.
if !pieces.iter().any(|p| matches!(p, Object::String(s) if !s.bytes.is_empty())) {
continue;
}
out.push(Shown {
op: i,
tm: Matrix([tm.0[0], tm.0[1], tm.0[2], tm.0[3], tm.0[4] - x_text * tm.0[0], tm.0[5] - x_text * tm.0[1]]),
@@ -1366,6 +1374,173 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
Ok(LineEdit { substituted })
}
/// Existing paragraph indexes, excluding streams owned by an added-content item.
pub(crate) fn selectable_blocks(
doc: &Document,
page: usize,
excluded: &std::collections::BTreeSet<pdfcraft_cos::ObjRef>,
) -> Result<Vec<(usize, TextBlock)>, EditError> {
let p = page_dict(doc, page)?;
let streams = content_streams(doc, &p.dict);
let lines = text_lines(doc, page)?;
Ok(group_blocks(&lines)
.into_iter()
.enumerate()
.filter(|(_, b)| {
!b.lines
.iter()
.filter_map(|i| lines.get(*i))
.any(|l| streams.get(l.stream).and_then(|(o, _)| o.as_ref()).is_some_and(|r| excluded.contains(&r)))
})
.collect())
}
/// Detect vertical CMaps before moving horizontal text. A broken/cyclic embedded CMap is
/// refused rather than guessing which direction TJ advances its text matrix.
fn vertical_encoding(doc: &Document, font: &Dict) -> Result<bool, EditError> {
if font.name(b"Subtype") != Some(b"Type0") {
return Ok(false);
}
let mut next = font.get(b"Encoding").cloned();
let mut seen = std::collections::BTreeSet::new();
for _ in 0..8 {
let Some(o) = next.take() else { return Ok(false) };
if let Some(r) = o.as_ref()
&& !seen.insert(r)
{
return Err(EditError::Invalid("the text encoding is cyclic".into()));
}
match &*doc.resolve(&o) {
Object::Name(n) => return Ok(n.ends_with(b"-V")),
Object::Stream(stream) => {
if let Some(mode) = stream.dict.get(b"WMode") {
match mode.as_int() {
Some(1) => return Ok(true),
Some(0) => {}
_ => return Err(EditError::Invalid("the text writing mode is invalid".into())),
}
}
let bytes = stream
.decoded_within(8 * 1024 * 1024)
.map_err(|_| EditError::Invalid("the text encoding cannot be decoded within its size limit".into()))?;
let ops = parse(&bytes).ops;
if let Some(mode) = ops.iter().rev().find(|op| op.is("def") && op.name(0) == Some(b"WMode")) {
return match mode.num(1) {
Some(0.0) => Ok(false),
Some(1.0) => Ok(true),
_ => Err(EditError::Invalid("the text writing mode is invalid".into())),
};
}
next = stream
.dict
.get(b"UseCMap")
.cloned()
.or_else(|| ops.iter().rev().find(|op| op.is("usecmap")).and_then(|op| op.operands.first().cloned()));
}
_ => return Err(EditError::Invalid("the text encoding is invalid".into())),
}
}
Err(EditError::Invalid("the text encoding is nested too deeply".into()))
}
/// Translate paragraphs together without re-encoding glyphs, changing fonts or reflowing.
/// Numeric TJ offsets and text rise temporarily move each selected show operation, then
/// restore both advances and rise. The PDF reader advances the original glyph bytes itself:
/// no approximate font metrics are used to position following, unselected text (§9.4.4).
pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize], offset: [f64; 2]) -> Result<(), EditError> {
if indexes.is_empty() {
return Ok(());
}
let p = page_dict(doc, page)?;
let streams = content_streams(doc, &p.dict);
let lines = text_lines(doc, page)?;
let blocks = group_blocks(&lines);
let mut selected = std::collections::BTreeSet::new();
for index in indexes {
let b = blocks.get(*index).ok_or_else(|| EditError::Invalid("a selected paragraph no longer exists".into()))?;
for line in &b.lines {
let l = lines.get(*line).ok_or_else(|| EditError::Invalid("a selected text line no longer exists".into()))?;
selected.extend(l.ops.iter().copied());
}
}
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let fonts = res.get(b"Font").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None);
let mut patches = HashMap::new();
// Keep full precision: a tiny rounded text-space change can be magnified by a CTM,
// and restoring rise/spacing must not alter following unselected text.
let n = Object::Real;
for s in shown.into_iter().filter(|s| selected.contains(&s.op)) {
if !(0..=3).contains(&s.state.render_mode) {
return Err(EditError::Invalid("text used as a clipping path cannot be moved in a group".into()));
}
let font = fonts
.get(&s.font)
.map(|f| doc.resolve(f))
.and_then(|f| f.as_dict().cloned())
.ok_or_else(|| EditError::Invalid("a selected paragraph has no font".into()))?;
if vertical_encoding(doc, &font)? {
return Err(EditError::Invalid("vertical text cannot be moved in a group".into()));
}
let inverse = s.tm.then(&s.state.ctm).invert().ok_or_else(|| EditError::Invalid("a selected paragraph has no area".into()))?;
let [a, b, c, d, _, _] = inverse.0;
let (dx, dy) = (a * offset[0] + c * offset[1], b * offset[0] + d * offset[1]);
let denominator = s.state.size * s.state.scale;
let shift = if dx == 0.0 {
0.0
} else if denominator.abs() > 1e-12 {
-1000.0 * dx / denominator
} else {
return Err(EditError::Invalid("a selected paragraph has no horizontal advance".into()));
};
let rise = s.state.rise + dy;
if ![shift, rise, dx, dy].iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
return Err(EditError::Invalid("the paragraph move is too large".into()));
}
let original = ops.get(s.op).ok_or_else(|| EditError::Invalid("a selected text operation is missing".into()))?;
let mut replacement = Vec::new();
let show = if original.is("'") || original.is("\"") {
if original.is("\"") {
let spacing = original
.operands
.get(..2)
.filter(|a| a.iter().all(|o| o.as_f64().is_some()))
.ok_or_else(|| EditError::Invalid("a text operation has invalid spacing".into()))?;
for (operator, operand) in ["Tw", "Tc"].into_iter().zip(spacing) {
replacement.push(Op::new(operator, vec![operand.clone()]));
}
}
replacement.push(Op::new("T*", vec![]));
Op::new("Tj", vec![original.operands.last().cloned().ok_or_else(|| EditError::Invalid("a text operation has no string".into()))?])
} else {
original.clone()
};
if dy != 0.0 {
replacement.push(Op::new("Ts", vec![n(rise)]));
}
if shift != 0.0 {
replacement.push(Op::new("TJ", vec![Object::Array(vec![n(shift)])]));
}
replacement.push(show);
if shift != 0.0 {
replacement.push(Op::new("TJ", vec![Object::Array(vec![n(-shift)])]));
}
if dy != 0.0 {
replacement.push(Op::new("Ts", vec![n(s.state.rise)]));
}
patches.insert(s.op, replacement);
}
if patches.len() != selected.len() {
return Err(EditError::Invalid("a selected text operation cannot be moved".into()));
}
let data = splice(&pieces, &ops, |i| patches.remove(&i).map_or((Vec::new(), true), |v| (v, false)));
let contents = rewritten_contents(doc, &streams, data);
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
+4
View File
@@ -201,6 +201,10 @@ pub const COMMANDS: &[CommandSpec] = &[
// View menu (where it would show twice). The palette and ⌘3 still run it.
c("view.fit_visible", "Fit visible", None, Some(Shortcut::cmd("3")), Document, "scan"),
c("view.marquee_zoom", "Marquee zoom", VIEW, None, Document, "zoom-in"),
// Acrobat's column select: drag a rectangle and select only the text inside it. Alt/Option-
// drag with the Select tool does the same; this is the way in where the desktop takes
// Alt-drag for moving windows (several Linux window managers).
c("edit.column_select", "Column select", EDIT, None, Document, "text-select"),
c("edit.snapshot", "Take a snapshot", EDIT, None, Document, "camera"),
c("view.full_screen", "Full screen mode", VIEW, Some(Shortcut::cmd("L")), Document, "maximize"),
c("view.read_mode", "Read mode", VIEW, Some(Shortcut { command: true, shift: false, mac_ctrl: true, key: "H" }), Document, "book-open"),
+23 -1
View File
@@ -56,7 +56,7 @@ const AR_DAYS: [&str; 7] = ["الأحد", "الاثنين", "الثلاثاء",
/// The languages month and weekday names can be written in (Preferences ▸ Date format ▸ Language):
/// the interface languages, in the same order and with the same names.
pub const DATE_LANGUAGES: [DateLanguage; 16] = [
pub const DATE_LANGUAGES: [DateLanguage; 17] = [
DateLanguage {
code: "en",
name: "English",
@@ -241,6 +241,28 @@ pub const DATE_LANGUAGES: [DateLanguage; 16] = [
days: AR_DAYS,
days_short: AR_DAYS,
},
DateLanguage {
code: "pl",
name: "Polski",
months: ["styczeń", "luty", "marzec", "kwiecień", "maj", "czerwiec", "lipiec", "sierpień", "wrzesień", "październik", "listopad", "grudzień"],
months_with_day: Some([
"stycznia",
"lutego",
"marca",
"kwietnia",
"maja",
"czerwca",
"lipca",
"sierpnia",
"września",
"października",
"listopada",
"grudnia",
]),
months_short: ["sty", "lut", "mar", "kwi", "maj", "cze", "lip", "sie", "wrz", "paź", "lis", "gru"],
days: ["niedziela", "poniedziałek", "wtorek", "środa", "czwartek", "piątek", "sobota"],
days_short: ["niedz.", "pon.", "wt.", "śr.", "czw.", "pt.", "sob."],
},
];
/// The date language for `code` (any case), or `None` when there's none.
+205 -18
View File
@@ -35,7 +35,7 @@ pub use pdfcraft_organize::LabelStyle;
pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation};
pub use pdfcraft_cos::Algorithm;
pub use pdfcraft_create::{CONVERTIBLE, ImageResolution, SourceKind, source_kind};
pub use pdfcraft_create::{CONVERTIBLE, ImageResolution, SourceKind, decode_text, source_kind};
pub use pdfcraft_edit::{
Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark,
};
@@ -50,6 +50,8 @@ pub use pdfcraft_edit::{BlockStyle, PageImage, TextBlock, TextLine, first_undraw
pub use pdfcraft_measure as measure;
pub use pdfcraft_xfa::Report as XfaLayout;
pub use pdfcraft_edit::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget};
/// A change to an existing page image.
#[derive(Clone, Debug, PartialEq)]
pub enum ImageEdit {
@@ -184,6 +186,7 @@ fn scope_of(edit: &Edit) -> Scope {
| Edit::MoveAnnotation { .. }
| Edit::ResizeAnnotation { .. }
| Edit::StyleAnnotation { .. }
| Edit::FillAnnotation { .. }
| Edit::SetAnnotationInfo { .. } => Scope::Comments,
Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => Scope::Form,
Edit::Batch { edits, .. } => {
@@ -272,6 +275,32 @@ impl Document {
self.editor.as_ref().and_then(|e| pdfcraft_edit::page_images(&e.cos, page).ok()).unwrap_or_default()
}
/// Mixed editable objects on one page. Source inventories are zero-based and added items
/// appear once. Rectangles are user-space points, independent of the viewer's zoom.
pub fn editable_objects(&self, page: usize) -> Result<Vec<EditableObject>, String> {
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
pdfcraft_edit::editable_objects(&editor.cos, page).map_err(|e| e.to_string())
}
/// Displayed top-left-origin motion to a user-space vector. Use the linear map only,
/// so even a large crop-box origin cannot erase a small drag by cancellation.
pub fn object_move_offset(&self, page: usize, delta: [f64; 2]) -> Result<[f64; 2], String> {
let p = self.info.pages.get(page).ok_or("the page no longer exists")?;
let [x0, y0, x1, y1] = p.crop.map(f64::from);
let (a, b) = (delta[0] / f64::from(p.width.max(1e-3)), delta[1] / f64::from(p.height.max(1e-3)));
let (u, v) = match p.rotation {
90 => (b, -a),
180 => (-a, -b),
270 => (-b, a),
_ => (a, b),
};
let offset = [u * (x1 - x0), -v * (y1 - y0)];
if !offset.iter().all(|v| v.is_finite()) {
return Err("the move must be finite and non-zero".into());
}
Ok(offset)
}
/// Save image as: image `index` on `page` as a file (extension, bytes).
pub fn page_image_file(&self, page: usize, index: usize) -> Result<(&'static str, Vec<u8>), String> {
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
@@ -555,6 +584,51 @@ fn apply_layer_state(layers: &mut [Layer], groups: &[Vec<(u32, u16)>], changes:
/// A file to combine: its name (the bookmark title), bytes, and page range (`None`: all).
pub type CombineSource = (String, Arc<Vec<u8>>, Option<String>);
/// A bookmark as the bookmark list shows it.
#[derive(Clone, Debug, PartialEq)]
pub struct BookmarkRow {
/// Child indices from the top level, 0-based, as the edit functions take them.
pub path: Vec<usize>,
pub title: String,
/// Shown expanded (a positive `/Count`).
pub open: bool,
/// The 0-based page it goes to.
pub page: Option<usize>,
}
/// One page of the bookmark list, in outline order.
#[derive(Clone, Debug, PartialEq)]
pub struct BookmarkPage {
pub rows: Vec<BookmarkRow>,
/// Where the next page starts, or `None` at the end.
pub next: Option<usize>,
/// Set only when the document's structure could not be read for editing: its listing then
/// comes from the viewer's copy of the outline, which is shorter than the file's.
pub truncated: bool,
}
/// The rows of a viewer outline, in outline order, with their paths.
fn outline_rows(items: &[pdfcraft_render::OutlineItem]) -> Vec<BookmarkRow> {
let mut rows = Vec::new();
// One entry per open level: its items, and how many of them have been listed.
let mut levels: Vec<(&[pdfcraft_render::OutlineItem], usize)> = vec![(items, 0)];
while let Some(&(list, next)) = levels.last() {
let Some(item) = list.get(next) else {
levels.pop();
continue;
};
if let Some(top) = levels.last_mut() {
top.1 += 1;
}
let path: Vec<usize> = levels.iter().map(|(_, listed)| listed.saturating_sub(1)).collect();
rows.push(BookmarkRow { path, title: item.title.clone(), open: item.open, page: item.page });
if !item.children.is_empty() {
levels.push((&item.children, 0));
}
}
rows
}
/// A document's working file captured for crash recovery.
#[derive(Clone, Debug)]
pub struct RecoverySnapshot {
@@ -879,6 +953,12 @@ pub enum Edit {
/// Line or polyline: two endings. Callout: one. `None` leaves `/LE` unchanged.
endings: Option<Vec<pdfcraft_annot::LineEnding>>,
},
/// Fill a rectangle, oval or polygon comment, or remove its fill (`None`).
FillAnnotation {
page: usize,
index: usize,
fill: Option<Rgb>,
},
/// Comment properties ▸ General / note icon.
SetAnnotationInfo {
page: usize,
@@ -987,6 +1067,13 @@ pub enum Edit {
index: usize,
change: ImageEdit,
},
/// Move mixed existing and added objects together. References resolve before any change;
/// offset is a user-space vector. No retyping, reflow or font substitution is involved.
MoveObjects {
page: usize,
objects: Vec<ObjectTarget>,
offset: [f64; 2],
},
/// Edit text in a paragraph box: replace paragraph `block` (from `Document::text_blocks`),
/// rewrapped to the box's width.
EditTextBlock {
@@ -1156,7 +1243,7 @@ impl Edit {
Edit::LockAnnotation { .. } => "Unlock comment".into(),
Edit::MoveAnnotation { .. } => "Move comment".into(),
Edit::ResizeAnnotation { .. } => "Resize comment".into(),
Edit::StyleAnnotation { .. } | Edit::SetAnnotationInfo { .. } => "Change comment properties".into(),
Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::SetAnnotationInfo { .. } => "Change comment properties".into(),
Edit::SetFieldValue { name, .. } => format!("Fill in {name}"),
Edit::SetFieldImage { name, .. } => format!("Set the image of {name}"),
Edit::ResetForm { .. } => "Clear form".into(),
@@ -1176,6 +1263,7 @@ impl Edit {
Edit::SetDocumentScript { script: None, .. } => "Delete document JavaScript".into(),
Edit::SetDocumentScript { .. } => "Edit document JavaScript".into(),
Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } => "Edit text".into(),
Edit::MoveObjects { objects, .. } => plural("Move object", objects.len()),
Edit::EditPageImage { change, .. } => match change {
ImageEdit::Move(_) => "Move image".into(),
ImageEdit::Rotate(_) => "Rotate image".into(),
@@ -1295,6 +1383,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed
| Edit::MoveAnnotation { .. }
| Edit::ResizeAnnotation { .. }
| Edit::StyleAnnotation { .. }
| Edit::FillAnnotation { .. }
| Edit::SetAnnotationInfo { .. }
| Edit::SetMeasurementScale { .. } => {
if p.annotate() {
@@ -1327,6 +1416,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed
| Edit::AddText { .. }
| Edit::AddImage { .. }
| Edit::UpdateContent { .. }
| Edit::MoveObjects { .. }
| Edit::DeleteContent { .. }
| Edit::ReplaceImage { .. }
| Edit::ClearRedactions
@@ -1537,6 +1627,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
Edit::StyleAnnotation { page, index, color, opacity, width, endings } => {
pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, endings.as_deref(), &cx.meta())?;
}
Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?,
Edit::SetAnnotationInfo { page, index, author, subject, icon } => {
pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?;
}
@@ -1626,6 +1717,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
};
pdfcraft_edit::change_image(doc, *page, *index, &c)?;
}
Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?,
Edit::EditTextBlock { page, block, text, style } => {
pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?;
}
@@ -1772,6 +1864,7 @@ fn comment_list(doc: &pdfcraft_cos::Document) -> Vec<pdfcraft_render::Annotation
quads: s.quads,
locked: s.locked,
intent: s.intent,
fill_sign: s.fill_sign,
})
.collect()
}
@@ -1802,6 +1895,17 @@ fn keys_after(edit: &Edit) -> Option<Keys> {
}
}
/// The recovered user password of an R2–R4 file as text for the renderer, which tries a password
/// as its UTF-8 bytes and then in PDFDocEncoding: UTF-8 bytes (as some writers store passwords
/// PDFDocEncoding can't hold, such as "şifre") stay UTF-8, and other bytes are read as
/// PDFDocEncoding, so either way the renderer gets back exactly these bytes.
fn renderer_password(bytes: &[u8]) -> String {
match std::str::from_utf8(bytes) {
Ok(text) => text.to_owned(),
Err(_) => bytes.iter().map(|b| pdfcraft_cos::pdfdoc_char(*b)).collect(),
}
}
/// The last-resort guard (AGENTS.md §4): run `f`, turning a panic that escapes it into an error
/// message, so one bad file or edit can't take the app and its other documents down. It is a
/// safety net for bugs, not a substitute for returning errors.
@@ -1974,9 +2078,14 @@ fn xfa_field_data(doc: &pdfcraft_cos::Document) -> Vec<pdfcraft_xfa::FieldDatum>
/// be written.
/// `datasets`: the stream this edit already wrote, replaced in place rather than added again
/// (and set to the one written).
fn xfa_sync_datasets(doc: &mut pdfcraft_cos::Document, datasets: &mut Option<pdfcraft_cos::ObjRef>) -> Result<Vec<String>, String> {
/// `tpl`: the form's parsed template when the document has one cached (else it is parsed).
fn xfa_sync_datasets(
doc: &mut pdfcraft_cos::Document,
tpl: Option<&pdfcraft_xfa::model::Template>,
datasets: &mut Option<pdfcraft_cos::ObjRef>,
) -> Result<Vec<String>, String> {
let data = xfa_field_data(doc);
let r = pdfcraft_xfa::write_datasets_reusing(doc, &data, *datasets).map_err(|e| e.to_string())?;
let r = pdfcraft_xfa::write_datasets_with(doc, tpl, &data, *datasets).map_err(|e| e.to_string())?;
if r.stream.is_some() {
*datasets = r.stream;
}
@@ -2114,7 +2223,7 @@ impl Session {
Ok(Ok(d)) => d.security().and_then(|s| s.recovered_user_password()),
_ => None,
};
let user: String = user.ok_or(OpenError::WrongPassword)?.iter().map(|b| char::from(*b)).collect();
let user = renderer_password(&user.ok_or(OpenError::WrongPassword)?);
(inspect(bytes.clone(), Some(&user))?, Some(user))
}
Err(e) => return Err(e),
@@ -2366,7 +2475,7 @@ impl Session {
if let Some(tpl) = cx.xfa.clone() {
if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full) {
let datasets = &mut cx.xfa_datasets;
let notes = guard(|| xfa_sync_datasets(&mut next, datasets))
let notes = guard(|| xfa_sync_datasets(&mut next, Some(&tpl), datasets))
.unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})")))
.map_err(EditError::Write)?;
cx.xfa_out.errors.extend(notes);
@@ -2398,8 +2507,8 @@ impl Session {
// XFA forms keep their values in the datasets packet too, for Adobe's viewers.
let mut xfa_notes = Vec::new();
if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full) {
let datasets = &mut cx.xfa_datasets;
xfa_notes = guard(|| xfa_sync_datasets(&mut next, datasets))
let (tpl, datasets) = (cx.xfa.as_deref(), &mut cx.xfa_datasets);
xfa_notes = guard(|| xfa_sync_datasets(&mut next, tpl, datasets))
.unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})")))
.map_err(EditError::Write)?;
}
@@ -2688,7 +2797,7 @@ impl Session {
let created = guard(|| match kind {
SourceKind::Pdf => open_source(name, bytes).map(|_| bytes.clone()),
SourceKind::Image => self.create_from_images(&[(name.to_string(), bytes.to_vec())]),
SourceKind::Text => self.create_from_text(title, &String::from_utf8_lossy(bytes)),
SourceKind::Text => self.create_from_text(title, &decode_text(bytes)),
})
.map_err(|_| EditError::Source(format!("{name}: the file could not be read")))?;
Ok((kind, created?))
@@ -2784,6 +2893,50 @@ impl Session {
self.write_new(&out)
}
/// [`Self::combine_unlocked`] where sources with the same `groups` key (by position) are one
/// file split into several runs placed apart: each file is opened and copied once, so it keeps
/// one bookmark, its links between its own pages, its fields and its attachments whole (see
/// `pdfcraft_organize::combine_grouped`). A group's later sources use its first source's bytes
/// and password; a source without a key is a file of its own.
pub fn combine_grouped(&self, sources: &[CombineSource], groups: &[u64], passwords: &[Option<&str>]) -> Result<Arc<Vec<u8>>, EditError> {
// Each file opened once: `file[i]` is source i's place in `docs`.
let (mut keys, mut docs, mut file): (Vec<Option<u64>>, Vec<pdfcraft_cos::Document>, Vec<usize>) = (Vec::new(), Vec::new(), Vec::new());
for (i, (name, bytes, _)) in sources.iter().enumerate() {
let key = groups.get(i).copied();
let known = key.and_then(|k| keys.iter().position(|g| *g == Some(k)));
let at = match known {
Some(at) => at,
None => {
docs.push(open_source_with(name, bytes, passwords.get(i).copied().flatten())?);
keys.push(key);
docs.len() - 1
}
};
file.push(at);
}
let mut pages = Vec::with_capacity(sources.len());
for ((name, _, range), at) in sources.iter().zip(&file) {
let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty());
let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) };
pages.push(match range {
Some(r) => {
let n = pdfcraft_organize::page_count(d)?;
let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false)
.map_err(|e| EditError::Print(format!("{name}: {e}")))?;
Some(p)
}
None => None,
});
}
let mut runs: Vec<pdfcraft_organize::Run<'_>> = Vec::with_capacity(sources.len());
for (((name, _, _), at), p) in sources.iter().zip(&file).zip(&pages) {
let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) };
runs.push((*at, name.as_str(), d, p.as_deref()));
}
let out = pdfcraft_organize::combine_grouped(&runs)?;
self.write_new(&out)
}
/// New PDF bytes containing copies of `pages` of the document (Extract Pages).
pub fn extract(&self, id: DocId, pages: &[usize]) -> Result<Arc<Vec<u8>>, EditError> {
let src = self.cos(id)?;
@@ -2876,10 +3029,29 @@ impl Session {
out
}
/// A page of the bookmark list in outline order: up to `limit` bookmarks from position `offset`.
/// The document's own structure is read for it, so every bookmark can be reached by paging.
pub fn bookmark_page(&self, id: DocId, offset: usize, limit: usize) -> Option<BookmarkPage> {
let doc = self.get(id)?;
if let Some(editor) = doc.editor.as_ref() {
let page = pdfcraft_organize::bookmark_page(&editor.cos, offset, limit);
let rows = page.bookmarks.into_iter().map(|b| BookmarkRow { path: b.path, title: b.title, open: b.open, page: b.page }).collect();
return Some(BookmarkPage { rows, next: page.next, truncated: false });
}
let all = outline_rows(&doc.info.outline);
let end = offset.saturating_add(limit);
let rows = all.iter().skip(offset).take(limit).cloned().collect();
Some(BookmarkPage { rows, next: (end < all.len()).then_some(end), truncated: doc.info.outline_more })
}
/// Top-level bookmarks as split points: (first page of each part, its bookmark's title).
pub fn bookmark_splits(&self, id: DocId) -> Vec<(usize, String)> {
let Some(doc) = self.get(id) else { return Vec::new() };
let mut out: Vec<(usize, String)> = doc.info.outline.iter().filter_map(|o| Some((o.page?, o.title.clone()))).collect();
let top: Vec<(Option<usize>, String)> = match doc.editor.as_ref() {
Some(editor) => pdfcraft_organize::top_level_bookmarks(&editor.cos).into_iter().map(|b| (b.page, b.title)).collect(),
None => doc.info.outline.iter().map(|o| (o.page, o.title.clone())).collect(),
};
let mut out: Vec<(usize, String)> = top.into_iter().filter_map(|(page, title)| Some((page?, title))).collect();
out.sort_by_key(|x| x.0);
out.dedup_by_key(|x| x.0);
out
@@ -2890,6 +3062,8 @@ impl Session {
let id = self.open(name, None, bytes, None)?;
if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) {
d.dirty = true;
// Unsaved work with no edit yet still needs its first recovery snapshot.
d.generation += 1;
}
Ok(id)
}
@@ -3188,9 +3362,25 @@ pub fn comment_kind(a: &pdfcraft_render::Annotation) -> &str {
pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: SummarySort) -> String {
use std::fmt::Write;
let top: Vec<&pdfcraft_render::Annotation> = all.iter().filter(|a| a.in_reply_to.is_none() && a.state.is_none()).collect();
let replies_of = |a: &pdfcraft_render::Annotation| -> Vec<&pdfcraft_render::Annotation> {
all.iter().filter(|r| r.state.is_none() && r.in_reply_to.is_some() && r.in_reply_to == a.name && a.name.is_some()).collect()
};
// Write a comment's replies, indenting each level. A reply to a reply keeps its `/IRT` on the
// direct parent, so following one level would leave the deeper replies out of the summary.
fn write_replies(out: &mut String, all: &[pdfcraft_render::Annotation], parent: &str, depth: usize) {
use std::fmt::Write;
if depth > 64 {
return;
}
for r in all.iter().filter(|r| r.state.is_none() && r.in_reply_to.as_deref() == Some(parent)) {
let indent = " ".repeat(depth + 1);
let _ =
writeln!(out, "{indent}Author: {} Subject: Reply Date: {}", r.author.as_deref().unwrap_or(""), r.modified.as_deref().unwrap_or(""));
for line in r.contents.as_deref().unwrap_or("").lines() {
let _ = writeln!(out, "{indent}{line}");
}
if let Some(nm) = r.name.as_deref() {
write_replies(out, all, nm, depth + 1);
}
}
}
let mut numbered: Vec<(usize, &pdfcraft_render::Annotation)> = Vec::new();
let mut last = usize::MAX;
let mut n = 0;
@@ -3227,11 +3417,8 @@ pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: Su
if let Some(c) = a.contents.as_deref().filter(|c| !c.is_empty()) {
let _ = writeln!(out, "{c}");
}
for r in replies_of(a) {
let _ = writeln!(out, " Author: {} Subject: Reply Date: {}", r.author.as_deref().unwrap_or(""), r.modified.as_deref().unwrap_or(""));
for line in r.contents.as_deref().unwrap_or("").lines() {
let _ = writeln!(out, " {line}");
}
if let Some(nm) = a.name.as_deref() {
write_replies(&mut out, all, nm, 0);
}
out.push('\n');
}
+356
View File
@@ -465,6 +465,105 @@ fn mixed_files_convert_and_combine_in_order() {
assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["notes→1", "a→2", "scan→3"]);
}
#[test]
fn utf16_and_code_page_text_files_convert_to_their_text() {
let mut s = Session::new();
// Notepad's "Unicode": UTF-16 little endian with a byte order mark.
let mut utf16 = vec![0xFF, 0xFE];
utf16.extend("Grüße aus Köln".encode_utf16().flat_map(u16::to_le_bytes));
let (_, pdf) = s.convert_to_pdf("notes.txt", &Arc::new(utf16)).unwrap();
let id = s.open_new("notes.pdf", pdf).unwrap();
assert_eq!(page_texts(&s, id), ["Grüße aus Köln"]);
// A UTF-8 byte order mark is not text; Windows-1252 bytes are not replacement characters.
let (_, pdf) = s.convert_to_pdf("menu.txt", &Arc::new(b"\xEF\xBB\xBFCaf\xC3\xA9".to_vec())).unwrap();
let id = s.open_new("menu.pdf", pdf).unwrap();
assert_eq!(page_texts(&s, id), ["Café"]);
let (_, pdf) = s.convert_to_pdf("old.txt", &Arc::new(b"Cr\xE8me br\xFBl\xE9e".to_vec())).unwrap();
let id = s.open_new("old.pdf", pdf).unwrap();
assert_eq!(page_texts(&s, id), ["Crème brûlée"]);
}
#[test]
fn a_file_split_around_another_combines_in_order_with_one_bookmark() {
let mut s = Session::new();
let (a, b) = (Arc::new(fixture(3)), Arc::new(fixture(2)));
// a's pages 1–2, then b, then a's page 3: one file (group 7) split around another.
let sources: Vec<CombineSource> = vec![("a".into(), a.clone(), Some("1-2".into())), ("b".into(), b, None), ("a".into(), a, Some("3".into()))];
let combined = s.combine_grouped(&sources, &[7, 9, 7], &[]).unwrap();
let id = s.open_new("Combined.pdf", combined).unwrap();
assert_eq!(page_texts(&s, id), ["Page 1", "Page 2", "Page 1", "Page 2", "Page 3"]);
assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["a→1", "b→3"], "one bookmark for a, at its first page");
// Without groups each source is a file of its own, as combine_ranges.
let (a, b) = (Arc::new(fixture(3)), Arc::new(fixture(2)));
let sources: Vec<CombineSource> = vec![("a".into(), a.clone(), Some("1".into())), ("b".into(), b, None), ("a".into(), a, Some("3".into()))];
let combined = s.combine_grouped(&sources, &[], &[]).unwrap();
let id = s.open_new("Combined 2.pdf", combined).unwrap();
assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["a→1", "b→2", "a→4"]);
// A bad range is refused with the file's name.
let bad: Vec<CombineSource> = vec![("a".into(), Arc::new(fixture(1)), Some("9".into()))];
assert!(matches!(s.combine_grouped(&bad, &[1], &[]), Err(EditError::Print(e)) if e.starts_with("a:")));
}
#[test]
fn a_page_with_a_form_field_shown_twice_keeps_one_field() {
// Regression (found by review): the repeated page copied the whole field again, so the
// result had two fields named "shared", filled in separately.
use pdfcraft_cos::{Dict, Document, Object, PdfString};
let mut doc = Document::open(Arc::new(fixture(2))).unwrap();
let pages: Vec<_> = pdfcraft_organize::pages(&doc).unwrap().into_iter().map(|p| p.obj).collect();
let field = doc.add(Object::Null);
let widgets: Vec<_> = pages
.iter()
.map(|p| {
let mut w = Dict::new();
w.set(b"Type".to_vec(), Object::name("Annot"));
w.set(b"Subtype".to_vec(), Object::name("Widget"));
w.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(100), Object::Int(20)]));
w.set(b"Parent".to_vec(), Object::Ref(field));
w.set(b"P".to_vec(), Object::Ref(*p));
doc.add(w)
})
.collect();
let mut f = Dict::new();
f.set(b"FT".to_vec(), Object::name("Tx"));
f.set(b"T".to_vec(), Object::String(PdfString::text("shared")));
f.set(b"V".to_vec(), Object::String(PdfString::text("Ada")));
f.set(b"Kids".to_vec(), Object::Array(widgets.iter().copied().map(Object::Ref).collect()));
doc.set(field, Object::Dict(f));
for (p, w) in pages.iter().zip(&widgets) {
doc.update_dict(*p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(*w)]))).unwrap();
}
let mut form = Dict::new();
form.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(field)]));
doc.update_dict(doc.root().unwrap(), |d| d.set(b"AcroForm".to_vec(), Object::Dict(form))).unwrap();
let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap());
// Pages 1, 2, then page 1 again: one file split around nothing but itself.
let s = Session::new();
let sources: Vec<CombineSource> = vec![("form".into(), bytes.clone(), Some("1-2".into())), ("form".into(), bytes, Some("1".into()))];
let out = s.combine_grouped(&sources, &[1, 1], &[]).unwrap();
let mut out = Document::open(out).unwrap();
pdfcraft_forms::set_value(&mut out, "shared", &pdfcraft_forms::FieldValue::Text("Grace".into())).unwrap();
let fields = pdfcraft_forms::fields(&out);
assert!(
!fields.is_empty() && fields.iter().all(|f| f.name == "shared" && f.value == ["Grace"]),
"one field, one value: {:?}",
fields.iter().map(|f| (&f.name, &f.value)).collect::<Vec<_>>()
);
let catalog = out.get(out.root().unwrap()).as_dict().cloned().unwrap();
let form = out.resolve(catalog.get(b"AcroForm").unwrap()).as_dict().cloned().unwrap();
let listed = form.get(b"Fields").and_then(|f| f.as_array()).cloned().unwrap();
assert_eq!(listed.len(), 1, "the field isn't copied again for the repeated page");
// Its widgets: one on each of the three pages, each belonging to its own page.
let out_pages: Vec<_> = pdfcraft_organize::pages(&out).unwrap().into_iter().map(|p| p.obj).collect();
let kids = out.resolve(&listed[0]).as_dict().and_then(|f| f.get(b"Kids").and_then(|k| k.as_array()).cloned()).unwrap();
assert_eq!(kids.len(), 3);
let mut on: Vec<_> = kids.iter().map(|k| out.resolve(k).as_dict().and_then(|w| w.reference(b"P")).unwrap()).collect();
on.sort();
let mut expected = out_pages.clone();
expected.sort();
assert_eq!(on, expected, "a widget per page, on its own page");
}
#[test]
fn files_that_cannot_be_converted_are_refused_clearly() {
let s = Session::new();
@@ -577,6 +676,19 @@ fn autosave_snapshots_only_changed_documents() {
assert!(s.autosave_snapshots().is_empty(), "saved documents need no recovery");
}
#[test]
fn autosave_snapshots_a_new_unsaved_document_once() {
let (mut s, clean) = session_with(1);
let id = s.open_new("Untitled.pdf", Arc::new(fixture(1))).unwrap();
let snaps = s.autosave_snapshots();
assert_eq!(snaps.len(), 1, "the new document is unsaved work");
assert_eq!(snaps[0].doc, id);
assert!(snaps.iter().all(|x| x.doc != clean), "the clean document stays out");
assert!(s.autosave_snapshots().is_empty(), "nothing new since the last snapshot");
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
assert_eq!(s.autosave_snapshots().len(), 1, "a later edit is snapshotted again");
}
#[test]
fn recovered_documents_reopen_unsaved_at_their_original_path() {
let (mut s, id) = session_with(2);
@@ -1540,6 +1652,19 @@ fn comments_lock_take_checkmarks_hide_and_summarize() {
assert_eq!(comment_summary("x", &[], SummarySort::Page), "Summary of Comments on x\n\nThis document has no comments.\n");
}
/// #820: the generated summary includes replies to replies, indented one level deeper.
#[test]
fn comment_summary_includes_replies_to_replies() {
let (mut s, id) = session_with(1);
s.apply(id, rect_comment(0, [40.0, 40.0, 90.0, 90.0])).unwrap();
s.apply(id, Edit::ReplyToAnnotation { page: 0, index: 0, text: "FIRST_REPLY".into(), author: "A".into() }).unwrap();
s.apply(id, Edit::ReplyToAnnotation { page: 0, index: 1, text: "NESTED_REPLY".into(), author: "B".into() }).unwrap();
let text = comment_summary("doc.pdf", &s.get(id).unwrap().info.annotations, SummarySort::Page);
assert!(text.contains("FIRST_REPLY"), "{text}");
assert!(text.contains("NESTED_REPLY"), "replies to replies are summarized: {text}");
assert!(text.contains(" NESTED_REPLY"), "the nested reply is indented under its parent: {text}");
}
#[test]
fn signing_saving_trusting_and_commenting_afterwards() {
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
@@ -1684,6 +1809,24 @@ fn backgrounds_and_watermarks_from_files() {
/// Scan & OCR ▸ Recognize text on a page that is only a picture of text (needs the models:
/// `cargo xtask models`; skipped without them).
#[test]
fn recognize_text_reads_the_euro_sign() {
if !ocr::available() {
eprintln!("skipped: OCR models not installed");
return;
}
let mut s = Session::new().with_clock(|| 1_700_000_000);
let text = s.create_from_text("t", "Total amount due: €250 by Friday").unwrap();
let id = s.open("text.pdf", None, text, None).unwrap();
let png = export::Exporter::new(s.get(id).unwrap()).png(0, 150.0).unwrap();
let scan = s.create_from_images(&[("scan.png".into(), png)]).unwrap();
let id = s.open("scan.pdf", None, scan, None).unwrap();
s.recognize_text(id, &[], ocr::OcrSettings::default()).unwrap();
let text = page_texts(&s, id)[0].clone();
// The model was trained with € where the ocrs crate's alphabet has a second E.
assert!(text.contains("€250"), "{text}");
}
#[test]
fn recognize_text_makes_a_scanned_page_searchable() {
if !ocr::available() {
@@ -2281,6 +2424,46 @@ fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
assert!(!d2.dirty);
}
/// A repeating table inside an area of a positioned page (as in real Designer forms): every
/// row is laid out, the values Adobe wrote (under the area's parent: areas are
/// not data scopes) are read, a FormCalc total over all rows computes, and what is filled here
/// is written back where Adobe's viewers read it, with no element for the area.
#[test]
fn xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them() {
let data = "<form><page><table><row><reason>Regular</reason><days>4</days></row><row><reason/><days/></row><row><reason/><days/></row></table><carried>5</carried></page></form>";
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::leave_template(data)));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("leave.pdf", None, bytes, None).expect("opens");
let value = |s: &Session, n: &str| {
s.get(id).unwrap().form.iter().find(|f| f.name == n).unwrap_or_else(|| panic!("no field {n}")).value.first().cloned().unwrap_or_default()
};
let names = |s: &Session| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::<Vec<_>>();
// Three rows (occur min="3"), not one.
assert_eq!(names(&s).iter().filter(|n| n.starts_with("reason")).count(), 3, "{:?}", names(&s));
// Adobe's data, bound past the area.
assert_eq!((value(&s, "reason"), value(&s, "days"), value(&s, "carried")), ("Regular".into(), "4".into(), "5".into()));
// The total walks rows 0 to 2: 5 + 30 - 4.
assert_eq!(value(&s, "rest"), "31", "{:?}", s.take_js_output(id));
// A second regular row recalculates it.
s.apply(id, Edit::SetFieldValue { name: "reason_2".into(), value: FieldValue::Text("Regular".into()) }).unwrap();
s.apply(id, Edit::SetFieldValue { name: "days_2".into(), value: FieldValue::Text("2".into()) }).unwrap();
assert_eq!(value(&s, "rest"), "29", "{:?}", s.take_js_output(id));
assert!(s.take_js_output(id).errors.is_empty());
// Written where Adobe reads it: no element for the area, the rows under the table.
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
let d = pdfcraft_xfa::data_of(&cos).unwrap();
let path = |som: &str| pdfcraft_xfa::som_to_path(som);
assert_eq!(d.count(&path("form[0].page[0]"), "box"), 0, "an element for the area was written");
assert_eq!(d.count(&path("form[0].page[0].table[0]"), "row"), 3);
assert_eq!(d.text_at(&path("form[0].page[0].table[0].row[1].days[0]")), Some("2"));
assert_eq!(d.text_at(&path("form[0].page[0].rest[0]")), Some("29"));
// Reopened, the values come back from that data.
let mut s2 = Session::new().with_clock(|| 1_700_000_000);
let id2 = s2.open("again.pdf", None, s.get(id).unwrap().bytes.clone(), None).expect("reopens");
let v2 = |n: &str| s2.get(id2).unwrap().form.iter().find(|f| f.name == n).unwrap().value.first().cloned().unwrap_or_default();
assert_eq!((v2("days_2"), v2("rest")), ("2".into(), "29".into()));
}
#[test]
fn dynamic_xfa_forms_run_formcalc_scripts() {
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::formcalc_template()));
@@ -2634,3 +2817,176 @@ fn oversized_exports_clamp_and_report_their_dpi_unless_strict() {
let err = export::Exporter::from_source(strict).png(0, 600.0).unwrap_err();
assert!(err.contains("exceeds renderer limits"), "{err}");
}
#[test]
fn mixed_object_moves_are_one_undo_step_and_survive_save_reopen() {
let (mut s, id) = session_with(1);
let text = AddedText { text: "Added label".into(), rect: [20.0, 240.0, 140.0, 265.0], ..Default::default() };
s.apply(id, Edit::AddText { page: 0, text }).unwrap();
let image = image::RgbImage::from_pixel(12, 8, image::Rgb([240, 20, 20]));
let mut png = std::io::Cursor::new(Vec::new());
image.write_to(&mut png, image::ImageFormat::Png).unwrap();
s.apply(id, Edit::AddImage { page: 0, rect: Some([40.0, 40.0, 80.0, 70.0]), name: "red.png".into(), bytes: Arc::new(png.into_inner()) }).unwrap();
let saved = s.save_bytes(id).unwrap();
s.mark_saved(id, saved.clone(), None).unwrap();
let before = s.get(id).unwrap().editable_objects(0).unwrap();
assert_eq!(before.len(), 3, "one native paragraph, one added label and one added image");
let generation = s.get(id).unwrap().edit_generation();
let targets = before.iter().map(|o| o.target).collect();
s.apply(id, Edit::MoveObjects { page: 0, objects: targets, offset: [24.0, -24.0] }).unwrap();
let doc = s.get(id).unwrap();
assert!(doc.dirty && doc.edit_generation() > generation);
assert_eq!(doc.can_undo(), Some("Move objects"));
let after = doc.editable_objects(0).unwrap();
for (a, b) in before.iter().zip(&after) {
assert_eq!(a.text, b.text);
for (i, (x, y)) in a.rect.iter().zip(b.rect).enumerate() {
assert!((y - x - if i % 2 == 0 { 24.0 } else { -24.0 }).abs() < 0.001);
}
}
let moved = s.save_bytes(id).unwrap();
assert!(moved.starts_with(saved.as_ref()), "an incremental update preserves the original revision");
let mut again = Session::new();
let other = again.open("moved.pdf", None, moved.clone(), None).unwrap();
assert_eq!(again.get(other).unwrap().editable_objects(0).unwrap(), after);
assert_eq!(s.undo(id).unwrap(), "Move objects");
assert_eq!(s.get(id).unwrap().editable_objects(0).unwrap(), before);
assert_eq!(s.get(id).unwrap().can_undo(), Some("Add image"), "one undo restores all three objects");
s.redo(id).unwrap();
assert_eq!(s.get(id).unwrap().editable_objects(0).unwrap(), after);
let mut renderer = pdfcraft_render::PageRenderer::new(moved, Default::default());
let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
let pixel = |x: usize, y: usize| &rendered.rgba[(y * rendered.width as usize + x) * 4..][..3];
assert!(pixel(84, 269)[0] > 200 && pixel(84, 269)[1] < 50, "the added image renders at its new position");
assert!(pixel(45, 240).iter().all(|v| *v > 240), "the old image position is clear");
}
#[test]
fn failed_group_moves_preserve_generation_dirty_state_and_redo_history() {
let (mut s, id) = session_with(1);
let objects: Vec<_> = s.get(id).unwrap().editable_objects(0).unwrap().iter().map(|o| o.target).collect();
s.apply(id, Edit::MoveObjects { page: 0, objects: objects.clone(), offset: [10.0, 0.0] }).unwrap();
s.undo(id).unwrap();
let before = s.save_bytes(id).unwrap();
let generation = s.get(id).unwrap().edit_generation();
let dirty = s.get(id).unwrap().dirty;
let mut bad = objects;
bad.push(ObjectTarget { kind: ObjectKind::Image, index: 999 });
assert!(s.apply(id, Edit::MoveObjects { page: 0, objects: bad, offset: [10.0, 0.0] }).is_err());
let doc = s.get(id).unwrap();
assert_eq!(doc.edit_generation(), generation);
assert_eq!(doc.dirty, dirty);
assert!(doc.can_undo().is_none());
assert_eq!(doc.can_redo(), Some("Move object"));
assert_eq!(s.save_bytes(id).unwrap(), before);
s.redo(id).unwrap();
assert!(s.get(id).unwrap().dirty);
}
#[test]
fn group_move_respects_modify_permission_and_display_coordinate_rotation() {
for rotation in [0, 90, 180, 270] {
let (mut s, id) = session_with(1);
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: rotation }).unwrap();
let doc = s.get(id).unwrap();
let delta = doc.object_move_offset(0, [17.0, 23.0]).unwrap();
let info = &doc.info.pages[0];
let a = info.user_to_view(30.0, 70.0);
let b = info.user_to_view(30.0 + delta[0] as f32, 70.0 + delta[1] as f32);
assert!((b[0] - a[0] - 17.0).abs() < 0.001 && (b[1] - a[1] - 23.0).abs() < 0.001);
}
let (mut s, id) = session_with(1);
let mut p = protection(None, Some("owner"));
p.changes = Changes::None;
s.apply(id, Edit::Protect(p)).unwrap();
let bytes = s.save_bytes(id).unwrap();
let mut reader = Session::new();
let id = reader.open("restricted.pdf", None, bytes, None).unwrap();
let objects = reader.get(id).unwrap().editable_objects(0).unwrap().iter().map(|o| o.target).collect();
let generation = reader.get(id).unwrap().edit_generation();
assert!(reader.apply(id, Edit::MoveObjects { page: 0, objects, offset: [10.0, 0.0] }).is_err());
let doc = reader.get(id).unwrap();
assert!(!doc.dirty && doc.can_undo().is_none() && doc.edit_generation() == generation);
}
#[test]
fn group_moves_preserve_rotated_added_content_raster_geometry() {
let (mut s, id) = session_with(1);
s.apply(id, Edit::AddText { page: 0, text: AddedText { text: "Added label".into(), rect: [20.0, 240.0, 140.0, 265.0], ..Default::default() } })
.unwrap();
let image = image::RgbImage::from_fn(20, 10, |x, _| image::Rgb(if x < 10 { [240, 20, 20] } else { [20, 20, 240] }));
let mut png = std::io::Cursor::new(Vec::new());
image.write_to(&mut png, image::ImageFormat::Png).unwrap();
s.apply(id, Edit::AddImage { page: 0, rect: Some([40.0, 40.0, 80.0, 70.0]), name: "quadrants.png".into(), bytes: Arc::new(png.into_inner()) })
.unwrap();
let AddedContent::Image(mut image) = s.get(id).unwrap().added[1].content.clone() else { panic!() };
image.flip_h = true;
image.crop = [0.1, 0.0, 0.2, 0.0];
s.apply(id, Edit::UpdateContent { page: 0, index: 1, content: AddedContent::Image(image) }).unwrap();
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
let render = |bytes| {
let mut r = pdfcraft_render::PageRenderer::new(bytes, Default::default());
r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() })
};
let before = render(s.get(id).unwrap().bytes.clone());
let doc = s.get(id).unwrap();
let offset = doc.object_move_offset(0, [24.0, 36.0]).unwrap();
let objects = doc.editable_objects(0).unwrap().iter().map(|o| o.target).collect();
s.apply(id, Edit::MoveObjects { page: 0, objects, offset }).unwrap();
let after = render(s.get(id).unwrap().bytes.clone());
assert_eq!((before.width, before.height), (after.width, after.height));
let mut painted = 0;
for y in 0..before.height as usize {
for x in 0..before.width as usize {
let pos = (y * before.width as usize + x) * 4;
let expected = if x >= 24 && y >= 36 {
let old = ((y - 36) * before.width as usize + x - 24) * 4;
&before.rgba[old..old + 4]
} else {
&[255, 255, 255, 255]
};
// Raster coverage can round by one intensity level when equivalent placements
// reach the renderer through different text operators. Geometry and image colours
// must still agree across every pixel of the translated page.
assert!(after.rgba[pos..pos + 4].iter().zip(expected).all(|(a, b)| a.abs_diff(*b) <= 2), "pixel {x},{y}");
painted += usize::from(expected[..3].iter().any(|c| *c < 240));
}
}
assert!(painted > 500, "the comparison contains real rendered text and image pixels");
}
#[test]
fn legacy_passwords_with_turkish_characters_open() {
// RC4-128 (R3): user "ılık€" in PDFDocEncoding (ı = 0x9A, € = 0xA0), owner "Çağlar"
// (PDFDocEncoding has no ğ: written without it, as pdfcraft-crypt writes R2–R4 passwords).
let mut cos = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap();
let params = pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Rc4_128,
user_password: "ılık€",
owner_password: "Çağlar",
permissions: -1,
encrypt_metadata: true,
seed: [7; 32],
};
cos.set_encryption(&params).unwrap();
let bytes = Arc::new(pdfcraft_cos::write_full(&cos, &pdfcraft_cos::SaveOptions::default()).unwrap());
let mut s = Session::new();
// The renderer used to try only the UTF-8 bytes, and the owner fallback handed it the
// recovered user password as Latin-1: both came back as a wrong password.
for pw in ["ılık€", "Çağlar"] {
let id = s.open("x.pdf", None, bytes.clone(), Some(pw)).unwrap_or_else(|e| panic!("{pw}: {e:?}"));
assert_eq!(page_texts(&s, id), ["Page 1"], "{pw}");
assert!(s.get(id).unwrap().info.encrypted, "{pw}");
}
assert!(matches!(s.open("x.pdf", None, bytes, Some("ilik€")), Err(OpenError::WrongPassword)));
}
#[test]
fn recovered_user_passwords_reach_the_renderer_byte_for_byte() {
assert_eq!(crate::renderer_password(b"pw"), "pw");
// UTF-8 bytes (qpdf writes "şifre" so; its \xC5\x9F has no PDFDocEncoding reading).
assert_eq!(crate::renderer_password("şifre".as_bytes()), "şifre");
// PDFDocEncoding bytes: ı = 0x9A, € = 0xA0, Ç = 0xC7.
assert_eq!(crate::renderer_password(&[0x9A, b'l', 0x9A, b'k', 0xA0]), "ılık€");
assert_eq!(crate::renderer_password(&[0xC7, b'o', b'k']), "Çok");
}
+11 -9
View File
@@ -167,10 +167,10 @@ fn count_instances(root: &FormNode, parent_som: &str, name: &str) -> usize {
parent.map_or(0, |p| p.children.iter().filter(|c| c.name == name).count())
}
/// An instance SOM (`…table[0].row[2]`) split into its parent's data path and SOM, its name
/// and its index.
fn split_instance(som: &str) -> Option<(pdfcraft_xfa::DataPath, String, String, usize)> {
let mut path = pdfcraft_xfa::som_to_path(som);
/// An instance SOM (`…table[0].row[2]`) split into its parent's data path (areas left out,
/// see [`pdfcraft_xfa::data_path`]) and SOM, its name and its index.
fn split_instance(tpl: &Template, som: &str) -> Option<(pdfcraft_xfa::DataPath, String, String, usize)> {
let mut path = pdfcraft_xfa::data_path(tpl, som);
let (name, index) = path.pop()?;
let parent_som = som.rsplit_once('.').map(|(p, _)| p).unwrap_or("").to_string();
Some((path, parent_som, name, index))
@@ -356,7 +356,7 @@ impl<'a, 'b> Runner<'a, 'b> {
/// Give the object at `som` the value `value` (field `name` when it has a widget). Returns
/// whether it changed.
fn set_value(&mut self, som: &str, value: &str) -> bool {
let path = pdfcraft_xfa::som_to_path(som);
let path = pdfcraft_xfa::data_path(self.tpl, som);
let name = self.by_som().get(som).cloned();
let Some(name) = name else {
// No widget (a hidden field, a draw, a row not laid out yet): the data alone.
@@ -484,7 +484,7 @@ impl<'a, 'b> Runner<'a, 'b> {
if matches!(info.kind, pdfcraft_forms::FieldKind::PushButton | pdfcraft_forms::FieldKind::Signature) {
continue;
}
let path = pdfcraft_xfa::som_to_path(som);
let path = pdfcraft_xfa::data_path(self.tpl, som);
let text = data_text(info);
if self.data.as_ref().and_then(|d| d.text_at(&path)).unwrap_or("") != text {
self.data_op(pdfcraft_xfa::DataOp::Value { path, text });
@@ -501,10 +501,12 @@ impl<'a, 'b> Runner<'a, 'b> {
// Instances of each repeating subform before the script ran, as the effects leave them.
let mut counts: HashMap<(String, String), usize> = HashMap::new();
{
// A copy of the shared reference: `live()` borrows `self` mutably.
let tpl = self.tpl;
let live = self.live();
for e in effects {
if let XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } = e
&& let Some((_, parent_som, name, _)) = split_instance(som)
&& let Some((_, parent_som, name, _)) = split_instance(tpl, som)
{
let have = count_instances(&live.root, &parent_som, &name);
counts.entry((parent_som, name)).or_insert(have);
@@ -542,7 +544,7 @@ impl<'a, 'b> Runner<'a, 'b> {
}
}
XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } => {
let Some((parent, parent_som, name, index)) = split_instance(som) else { continue };
let Some((parent, parent_som, name, index)) = split_instance(self.tpl, som) else { continue };
let key = (parent_som, name.clone());
let have = counts.get(&key).copied().unwrap_or(0);
if matches!(e, XfaEffect::AddInstance { .. }) {
@@ -750,7 +752,7 @@ mod tests {
assert!(!som_matches("form[0].page1[0].qty[0]", "qt"));
assert!(!som_matches("form[0].page1[0].price[0]", "qty"));
assert_eq!(
split_instance("form[0].table[0].row[2]").map(|(p, ps, n, i)| (p.len(), ps, n, i)),
split_instance(&Template::default(), "form[0].table[0].row[2]").map(|(p, ps, n, i)| (p.len(), ps, n, i)),
Some((2, "form[0].table[0]".into(), "row".into(), 2))
);
}
+561 -47
View File
@@ -5,7 +5,8 @@
//! approximations. Meanings come from `/ToUnicode`, else the encoding (`/Encoding` base and
//! `/Differences` glyph names, ISO 32000-2 Annex D).
use std::collections::HashMap;
use std::borrow::Cow;
use std::collections::{BinaryHeap, HashMap, HashSet};
use pdfcraft_cos::{Dict, Document, Object};
@@ -46,7 +47,7 @@ pub struct Metrics {
pub descent: f64,
pub composite: bool,
/// Code → Unicode text.
unicode: HashMap<u32, String>,
unicode: Unicode,
/// `/BaseFont` (or Type 3 descriptor's `/FontName`), and whether it is a subset (`ABCDEF+Name`: other glyphs are missing).
pub base_font: String,
pub subset: bool,
@@ -73,13 +74,138 @@ fn descriptor_flags(doc: &Document, descriptor: &Dict) -> u32 {
u32::try_from(value).unwrap_or(0)
}
/// Most entries a CMap may add, so a small stream of overlapping `bfrange`s can't take minutes
/// or gigabytes; a full 2-byte code space is 65 536. Entries past it are ignored.
/// Most mappings a CMap may add, where each `bfchar` or array-form entry counts one and each sequential
/// range counts two (it covers at least two codes). So a CMap with at most this many codes is kept whole;
/// mappings past the budget are ignored, so a stream of them can't take minutes or gigabytes.
const MAX_CMAP_ENTRIES: usize = 1 << 20;
/// Most codespace or CID ranges kept from a CMap; Adobe's largest published CMaps have a few
/// thousand.
const MAX_CMAP_RANGES: usize = 1 << 16;
/// Code → Unicode of a font. Explicit mappings and sequential ranges are kept as declared, so a range
/// costs one record however many codes it covers; where two overlap, the one declared later wins.
#[derive(Clone, Debug, Default, PartialEq)]
struct Unicode {
/// Simple fonts: the encoding table and `/Differences`, which ToUnicode overrides.
encoding: HashMap<u32, String>,
/// `bfchar` and array-form entries: the text of each code, with the declaration order of its last one.
explicit: HashMap<u32, (u32, String)>,
/// Sequential `bfrange` records, in declaration order.
ranges: Vec<BfRange>,
/// Which record covers each stretch of codes; see [`cover_index`].
cover: Vec<(u64, Option<usize>)>,
/// The latest-declared range covering each code below 65 536, as an index into `ranges` (`u32::MAX` for
/// none); empty without ranges. See [`owners_below_64k`].
low: Vec<u32>,
/// One bit per code below 65 536 that has an explicit entry; empty without any. A miss then skips the
/// hash lookup. See [`bit_set_below_64k`].
explicit_low: Vec<u64>,
}
/// A sequential `bfrange`: code `lo + k` (up to `hi`) maps to `units` with its last unit moved on by `k`.
#[derive(Clone, Debug, PartialEq)]
struct BfRange {
lo: u32,
hi: u32,
/// Declaration order, shared with the explicit entries.
seq: u32,
units: Box<[u16]>,
}
impl Unicode {
/// Whether `code` may have an explicit entry; false is certain for codes below 65 536 without one.
#[inline]
fn may_be_explicit(&self, code: u32) -> bool {
code >= 0x1_0000 || self.explicit_low.get(code as usize / 64).is_some_and(|w| (*w >> (code % 64)) & 1 == 1)
}
/// The latest-declared range that covers `code`.
#[inline]
fn covering(&self, code: u32) -> Option<&BfRange> {
if code < 0x1_0000 {
return self.ranges.get(*self.low.get(code as usize)? as usize);
}
let step = self.cover.partition_point(|&(start, _)| start <= u64::from(code));
let &(_, record) = self.cover.get(step.checked_sub(1)?)?;
self.ranges.get(record?)
}
/// The text of `code`: its explicit entry, unless a range declared after it covers the code;
/// otherwise the covering range, otherwise the encoding.
#[inline]
fn text(&self, code: u32) -> Option<Cow<'_, str>> {
let range = self.covering(code);
let explicit = if self.may_be_explicit(code) { self.explicit.get(&code) } else { None };
if let Some((seq, text)) = explicit
&& range.is_none_or(|r| r.seq < *seq)
{
return Some(Cow::Borrowed(text.as_str()));
}
match range {
Some(r) => Some(Cow::Owned(r.text_at(code))),
None => self.encoding.get(&code).map(|t| Cow::Borrowed(t.as_str())),
}
}
}
impl BfRange {
/// The text of `code`, which lies in this range.
fn text_at(&self, code: u32) -> String {
// The offset is below 65 536 (the width limit in `parse_to_unicode`), so truncating it is exact.
let offset = code.saturating_sub(self.lo) as u16;
let last = self.units.len().saturating_sub(1);
let units = self.units.iter().enumerate().map(|(i, &u)| if i == last { u.wrapping_add(offset) } else { u });
char::decode_utf16(units).map(|r| r.unwrap_or(char::REPLACEMENT_CHARACTER)).collect()
}
/// The lowest code of this range whose own text is `c` and that `valid` accepts. Only a single
/// unit (a BMP character, or U+FFFD through a surrogate unit) or a surrogate pair can decode to
/// one character `c`; longer destinations always decode to several.
fn first_code(&self, c: char, valid: impl Fn(u32) -> bool) -> Option<u32> {
let width = self.hi.checked_sub(self.lo)?;
match &*self.units {
[base] if c == '\u{FFFD}' => {
// The offsets whose unit is a surrogate or U+FFFD, in order; each decodes to U+FFFD.
let mut cursor = 0u32;
loop {
let unit = (u32::from(*base) + cursor) & 0xFFFF;
let offset = cursor.checked_add(match unit {
0..=0xD7FF => 0xD800 - unit,
0xD800..=0xDFFF | 0xFFFD => 0,
0xE000..=0xFFFC => 0xFFFD - unit,
_ => 0x1_0000 - unit + 0xD800,
})?;
if offset > width {
return None;
}
if let Some(code) = self.lo.checked_add(offset)
&& valid(code)
{
return Some(code);
}
cursor = offset.checked_add(1)?;
}
}
[base] => {
let offset = u32::from(u16::try_from(u32::from(c)).ok()?.wrapping_sub(*base));
let code = self.lo.checked_add(offset).filter(|_| offset <= width)?;
valid(code).then_some(code)
}
[lead, last] => {
let v = u32::from(c).checked_sub(0x1_0000)?;
if u32::from(*lead) != 0xD800 + (v >> 10) {
return None;
}
let low = u16::try_from(0xDC00 + (v & 0x3FF)).ok()?;
let offset = u32::from(low.wrapping_sub(*last));
let code = self.lo.checked_add(offset).filter(|_| offset <= width)?;
valid(code).then_some(code)
}
_ => None,
}
}
}
/// A token of a CMap stream (PostScript syntax, ISO 32000-2 §9.10.3).
#[derive(Debug, PartialEq)]
enum Tok<'a> {
@@ -286,7 +412,7 @@ impl Metrics {
ascent: 0.9,
descent: -0.25,
composite: false,
unicode: (32..127u8).map(|c| (u32::from(c), char::from(c).to_string())).collect(),
unicode: Unicode { encoding: (32..127u8).map(|c| (u32::from(c), char::from(c).to_string())).collect(), ..Unicode::default() },
base_font: "Helvetica".into(),
subset: false,
bold: false,
@@ -481,12 +607,13 @@ impl Metrics {
impl Metrics {
/// The text a string shows (codes without a known meaning are left out).
pub fn decode(&self, s: &[u8]) -> String {
self.codes(s).into_iter().filter_map(|(c, _)| self.unicode.get(&c).cloned()).collect()
self.codes(s).into_iter().filter_map(|(c, _)| self.text_of(c)).collect()
}
/// The Unicode text of one code, if known.
pub fn text_of(&self, code: u32) -> Option<&str> {
self.unicode.get(&code).map(String::as_str)
#[inline]
pub fn text_of(&self, code: u32) -> Option<Cow<'_, str>> {
self.unicode.text(code)
}
/// Whether the font has a glyph for `code` (subset fonts lack the glyphs they don't use).
@@ -501,19 +628,44 @@ impl Metrics {
}
}
/// Whether `code` shows exactly `c` and has a glyph for it.
fn shows(&self, code: u32, c: char) -> bool {
self.unicode.text(code).is_some_and(|t| t.chars().eq(std::iter::once(c))) && self.has_glyph(code)
}
/// The bytes that show `text` in this font, or `None` if some character has no code or
/// no glyph in it (the caller then substitutes another font).
pub fn encode(&self, text: &str) -> Option<Vec<u8>> {
let mut reverse: HashMap<&str, u32> = HashMap::new();
for (code, t) in &self.unicode {
if self.has_glyph(*code) {
reverse.entry(t.as_str()).and_modify(|c| *c = (*c).min(*code)).or_insert(*code);
// The lowest code showing each wanted character. Explicit entries and the encoding are
// checked one by one; each range is asked for the wanted characters it can show.
let wanted: HashSet<char> = text.chars().collect();
let mut reverse: HashMap<char, u32> = HashMap::new();
for (&code, (_, t)) in &self.unicode.explicit {
if let Some(c) = only_char(t)
&& wanted.contains(&c)
&& self.shows(code, c)
{
keep(&mut reverse, c, code);
}
}
for (&code, t) in &self.unicode.encoding {
if let Some(c) = only_char(t)
&& wanted.contains(&c)
&& self.shows(code, c)
{
keep(&mut reverse, c, code);
}
}
for range in &self.unicode.ranges {
for &c in &wanted {
if let Some(code) = range.first_code(c, |m| self.shows(m, c)) {
keep(&mut reverse, c, code);
}
}
}
let mut out = Vec::with_capacity(text.len() * self.code_len);
let mut buf = [0u8; 4];
for ch in text.chars() {
let code = *reverse.get(ch.encode_utf8(&mut buf) as &str)?;
let code = *reverse.get(&ch)?;
let bytes = code.to_be_bytes();
out.extend_from_slice(&bytes[4 - self.code_len.clamp(1, 4)..]);
}
@@ -521,24 +673,156 @@ impl Metrics {
}
}
/// UTF-16BE (with surrogates) from CMap hex bytes.
fn utf16(b: &[u8]) -> String {
let units: Vec<u16> = b.chunks(2).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect();
String::from_utf16_lossy(&units)
/// The UTF-16 code units of CMap hex bytes.
fn code_units(b: &[u8]) -> Vec<u16> {
b.chunks(2).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect()
}
/// `bfchar`/`bfrange` entries of a ToUnicode CMap, at most [`MAX_CMAP_ENTRIES`] of them.
fn parse_to_unicode(data: &[u8], out: &mut HashMap<u32, String>) {
let mut budget = MAX_CMAP_ENTRIES;
/// UTF-16BE (with surrogates) from CMap hex bytes.
fn utf16(b: &[u8]) -> String {
String::from_utf16_lossy(&code_units(b))
}
/// The character `s` is made of, if it is exactly one.
fn only_char(s: &str) -> Option<char> {
let mut chars = s.chars();
let c = chars.next()?;
chars.next().is_none().then_some(c)
}
/// Keeps the lowest code found for `c`.
fn keep(reverse: &mut HashMap<char, u32>, c: char, code: u32) {
reverse.entry(c).and_modify(|r| *r = (*r).min(code)).or_insert(code);
}
/// Spends `cost` of the budget; `false` when less is left.
fn spend(budget: &mut usize, cost: usize) -> bool {
let Some(left) = budget.checked_sub(cost) else { return false };
*budget = left;
true
}
/// Adds an explicit mapping; `false` once the budget is spent.
fn add_explicit(out: &mut Unicode, budget: &mut usize, seq: &mut u32, code: u32, text: String) -> bool {
if !spend(budget, 1) {
return false;
}
*seq = seq.saturating_add(1);
out.explicit.insert(code, (*seq, text));
true
}
/// Adds a sequential range, which covers at least two codes and so costs two; `false` once the budget is spent.
fn add_range(out: &mut Unicode, budget: &mut usize, seq: &mut u32, lo: u32, hi: u32, units: Box<[u16]>) -> bool {
if !spend(budget, 2) {
return false;
}
*seq = seq.saturating_add(1);
out.ranges.push(BfRange { lo, hi, seq: *seq, units });
true
}
/// For each stretch of codes, from its first code on, the latest-declared range covering it (`None`
/// where no range does), sorted by code; a lookup is one binary search over it.
fn cover_index(ranges: &[BfRange]) -> Vec<(u64, Option<usize>)> {
// Openings (first code, record) and closings (one past the last code), each in code order. The cover
// can only change at one of their codes, so the sweep visits those codes in order.
let mut opens: Vec<(u32, u32)> = ranges.iter().enumerate().map(|(i, r)| (r.lo, i as u32)).collect();
opens.sort_unstable();
let mut closes: Vec<u64> = ranges.iter().map(|r| u64::from(r.hi) + 1).collect();
closes.sort_unstable();
// Records that have opened and not yet closed; the top is the latest declared one.
let mut open: BinaryHeap<u32> = BinaryHeap::new();
let mut cover: Vec<(u64, Option<usize>)> = Vec::new();
let (mut next_open, mut next_close) = (0, 0);
loop {
let p = match (opens.get(next_open), closes.get(next_close)) {
(Some(&(lo, _)), Some(&end)) => u64::from(lo).min(end),
(Some(&(lo, _)), None) => u64::from(lo),
(None, Some(&end)) => end,
(None, None) => break,
};
while let Some(&(lo, record)) = opens.get(next_open)
&& u64::from(lo) <= p
{
open.push(record);
next_open += 1;
}
while closes.get(next_close).is_some_and(|&end| end <= p) {
next_close += 1;
}
while open.peek().is_some_and(|&top| ranges.get(top as usize).is_none_or(|r| u64::from(r.hi) < p)) {
open.pop();
}
let winner = open.peek().map(|&top| top as usize);
if cover.last().map(|&(_, w)| w) != Some(winner) {
cover.push((p, winner));
}
}
cover
}
/// One bit for each code below 65 536 in `codes`; empty when there is none.
/// Sized to the highest such code, so a font with a few low codes costs a few words, not 8 KB.
fn bit_set_below_64k(codes: impl Iterator<Item = u32>) -> Vec<u64> {
let low: Vec<u32> = codes.filter(|&code| code < 0x1_0000).collect();
let Some(&max) = low.iter().max() else { return Vec::new() };
let mut bits = vec![0u64; max as usize / 64 + 1];
for code in low {
if let Some(word) = bits.get_mut(code as usize / 64) {
*word |= 1u64 << (code % 64);
}
}
bits
}
/// The latest-declared range covering each code below 65 536, read off the stretches of `cover`; empty
/// when no range covers such a code. The stretches are disjoint, so this takes at most 65 536 steps
/// however many ranges there are.
///
/// The table ends with the last covered code: a code past it has no covering range, and a font whose
/// ranges cover a few low codes costs that many entries, not a fixed 256 KB.
fn owners_below_64k(cover: &[(u64, Option<usize>)]) -> Vec<u32> {
let stretch_end = |i: usize| cover.get(i + 1).map_or(0x1_0000, |&(next, _)| next.min(0x1_0000));
let len = cover
.iter()
.enumerate()
.filter(|&(_, &(start, winner))| winner.is_some() && start < 0x1_0000)
.map(|(i, _)| stretch_end(i))
.max()
.unwrap_or(0);
let mut owner = vec![u32::MAX; usize::try_from(len).unwrap_or(0)];
for (i, &(start, winner)) in cover.iter().enumerate() {
let Some(record) = winner else { continue };
// Record indices are below MAX_CMAP_ENTRIES, which fits in a u32.
let record = record as u32;
for code in start..stretch_end(i) {
if let Some(slot) = owner.get_mut(code as usize) {
*slot = record;
}
}
}
owner
}
/// `bfchar`/`bfrange` entries of a ToUnicode CMap, at most [`MAX_CMAP_ENTRIES`] mappings of them.
fn parse_to_unicode(data: &[u8], out: &mut Unicode) {
parse_to_unicode_within(data, out, MAX_CMAP_ENTRIES);
}
/// [`parse_to_unicode`] with `budget` explicit entries, so tests can use a small one.
fn parse_to_unicode_within(data: &[u8], out: &mut Unicode, mut budget: usize) {
let mut seq = 0u32;
let mut toks = Lexer::new(data);
while let Some(t) = toks.next() {
'scan: while let Some(t) = toks.next() {
match t {
Tok::Word(b"beginbfchar") => {
while let Some([c, u]) = entry(&mut toks, b"endbfchar") {
if let (Tok::Hex(Some(c)), Tok::Hex(Some(u))) = (c, u) {
let Some(left) = budget.checked_sub(1) else { return };
budget = left;
out.insert(be(&c), utf16(&u));
let added = add_explicit(out, &mut budget, &mut seq, be(&c), utf16(&u));
if !added {
break 'scan;
}
}
}
}
@@ -560,9 +844,10 @@ fn parse_to_unicode(data: &[u8], out: &mut HashMap<u32, String>) {
match t {
Tok::Close => break,
Tok::Hex(Some(u)) => {
let Some(left) = budget.checked_sub(1) else { return };
budget = left;
out.insert(c, utf16(&u));
let added = add_explicit(out, &mut budget, &mut seq, c, utf16(&u));
if !added {
break 'scan;
}
}
// A bad entry still stands for its code.
_ => {}
@@ -571,14 +856,13 @@ fn parse_to_unicode(data: &[u8], out: &mut HashMap<u32, String>) {
}
}
Tok::Hex(Some(u)) if hi >= lo && hi - lo < 65536 => {
let mut units: Vec<u16> = u.chunks(2).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect();
for code in lo..=hi {
let Some(left) = budget.checked_sub(1) else { return };
budget = left;
out.insert(code, String::from_utf16_lossy(&units));
if let Some(last) = units.last_mut() {
*last = last.wrapping_add(1);
}
let added = if lo == hi {
add_explicit(out, &mut budget, &mut seq, lo, utf16(&u))
} else {
add_range(out, &mut budget, &mut seq, lo, hi, code_units(&u).into_boxed_slice())
};
if !added {
break 'scan;
}
}
_ => {}
@@ -588,6 +872,9 @@ fn parse_to_unicode(data: &[u8], out: &mut HashMap<u32, String>) {
_ => {}
}
}
out.cover = cover_index(&out.ranges);
out.low = owners_below_64k(&out.cover);
out.explicit_low = bit_set_below_64k(out.explicit.keys().copied());
}
/// A glyph name's Unicode (Annex D names, `uniXXXX`, `uXXXX[XX]`, single letters).
@@ -600,8 +887,8 @@ pub fn glyph_unicode(name: &str) -> Option<char> {
}
/// Code → Unicode for a font: its ToUnicode CMap, else (simple fonts) its encoding.
fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> HashMap<u32, String> {
let mut out = HashMap::new();
fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode {
let mut out = Unicode::default();
if !composite {
let enc = font.get(b"Encoding").map(|e| doc.resolve(e));
let base_name = match enc.as_deref() {
@@ -616,7 +903,7 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> HashMap<u32, Str
};
for (code, u) in table.iter().enumerate() {
if let Some(c) = char::from_u32(*u).filter(|_| *u != 0) {
out.insert(code as u32, c.to_string());
out.encoding.insert(code as u32, c.to_string());
}
}
if let Some(Object::Dict(d)) = enc.as_deref()
@@ -629,8 +916,8 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> HashMap<u32, Str
Object::Int(n) => code = u32::try_from((*n).max(0)).unwrap_or(u32::MAX),
Object::Name(n) => {
match glyph_unicode(&String::from_utf8_lossy(n)) {
Some(c) => out.insert(code, c.to_string()),
None => out.remove(&code),
Some(c) => out.encoding.insert(code, c.to_string()),
None => out.encoding.remove(&code),
};
code = code.saturating_add(1);
}
@@ -653,6 +940,21 @@ mod tests {
use super::*;
/// A font whose ranges cover a few low codes keeps tables that size, not a fixed 64k-entry one.
#[test]
fn low_code_tables_end_with_the_last_covered_code() {
// One range over codes 0x20..=0x21, then nothing.
let cover = [(0x20u64, Some(0usize)), (0x22, None)];
let owner = super::owners_below_64k(&cover);
assert_eq!(owner.len(), 0x22);
assert_eq!((owner[0x1F], owner[0x20], owner[0x21]), (u32::MAX, 0, 0));
assert!(super::owners_below_64k(&[(0x2_0000, Some(0))]).is_empty(), "no code below 65 536");
let bits = super::bit_set_below_64k([3u32, 130, 0x2_0000].into_iter());
assert_eq!(bits.len(), 3);
assert_eq!((bits[0], bits[2]), (1 << 3, 1 << 2));
assert!(super::bit_set_below_64k([0x1_0000u32].into_iter()).is_empty());
}
fn font(doc: &mut Document, entries: Vec<(&str, Object)>) -> Dict {
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("Font"));
@@ -802,9 +1104,10 @@ mod tests {
}
fn to_unicode(cmap: &[u8]) -> HashMap<u32, String> {
let mut out = HashMap::new();
parse_to_unicode(cmap, &mut out);
out
let mut u = Unicode::default();
parse_to_unicode(cmap, &mut u);
let codes: Vec<u32> = u.explicit.keys().copied().chain(u.ranges.iter().flat_map(|r| r.lo..=r.hi)).collect();
codes.into_iter().filter_map(|c| u.text(c).map(|t| (c, t.into_owned()))).collect()
}
#[test]
@@ -852,22 +1155,233 @@ mod tests {
let mut enc = Dict::new();
enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(i64::MAX), Object::name("A"), Object::name("B")]));
let f = font(&mut doc, vec![("Subtype", Object::name("Type1")), ("Encoding", Object::Dict(enc))]);
assert_eq!(unicode_map(&doc, &f, false).get(&u32::MAX).map(String::as_str), Some("B"));
assert_eq!(unicode_map(&doc, &f, false).text(u32::MAX).as_deref(), Some("B"));
}
#[test]
fn sequential_ranges_past_the_entry_cap_still_map() {
// 17 ranges of 65 536 codes: 1 114 112 codes from 0.4 KB of CMap, more than MAX_CMAP_ENTRIES
// (the first 16 ranges use up the whole cap). Each range maps its codes upward from 'A', so
// the last code of the last range, 0x100019, is 'A' + 0x19 = 'Z'.
let mut cmap = b"17 beginbfrange ".to_vec();
for k in 0..17u32 {
cmap.extend(format!("<{:08X}> <{:08X}> <0041> ", k << 16, (k << 16) | 0xffff).bytes());
}
cmap.extend(b"endbfrange");
let mut doc = Document::new_empty();
let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap)));
let f = font(
&mut doc,
vec![
("Subtype", Object::name("Type0")),
("BaseFont", Object::name("Wide")),
("Encoding", Object::name("Identity-H")),
("ToUnicode", Object::Ref(tu)),
],
);
let m = Metrics::from_dict(&doc, &f);
assert_eq!(m.text_of(0x0010_0019).as_deref(), Some("Z"));
}
#[test]
fn a_cmap_adds_at_most_max_entries() {
// 200 ranges of 65 536 codes would be 13 million strings from 4 KB of stream.
// 200 ranges of 65 536 codes would be 13 million codes from 4 KB of stream: 200 records.
let mut cmap = b"200 beginbfrange ".to_vec();
for k in 0..200u32 {
cmap.extend(format!("<{:08X}> <{:08X}> <0041> ", k << 16, (k << 16) | 0xffff).bytes());
}
cmap.extend(b"endbfrange");
assert_eq!(to_unicode(&cmap).len(), MAX_CMAP_ENTRIES);
let mut u = Unicode::default();
parse_to_unicode(&cmap, &mut u);
assert_eq!((u.ranges.len(), u.explicit.len()), (200, 0));
// Explicit entries are what the budget counts: 250 of the 300 here are kept.
let mut many = b"300 beginbfchar ".to_vec();
for k in 0..300u32 {
many.extend(format!("<{k:04X}> <{:04X}> ", 0x4E00 + k).bytes());
}
many.extend(b"endbfchar");
let mut u = Unicode::default();
parse_to_unicode_within(&many, &mut u, 250);
assert_eq!((u.explicit.len(), u.text(249).is_some(), u.text(250).is_some()), (250, true, false));
let mut many = b"begincidrange ".to_vec();
for k in 0..MAX_CMAP_RANGES + 10 {
many.extend(format!("<{k:08X}> <{k:08X}> 1 ").bytes());
}
assert_eq!(parse_cmap(&many).1.len(), MAX_CMAP_RANGES);
}
#[test]
fn a_range_is_one_record_however_many_codes() {
let mut u = Unicode::default();
parse_to_unicode(b"1 beginbfrange <0000> <FFFF> <0041> endbfrange", &mut u);
assert_eq!((u.ranges.len(), u.explicit.len()), (1, 0));
assert_eq!(u.text(0xFFFF).as_deref(), Some("@"), "0x41 + 0xFFFF wraps to U+0040");
}
#[test]
fn sequential_ranges_count_against_the_budget() {
// Range k maps codes k and k + 1 and costs two, so a budget of 250 keeps ranges 0 to 124.
let mut cmap = Vec::new();
for k in 0..300u32 {
cmap.extend(format!("1 beginbfrange <{k:08X}> <{:08X}> <0041> endbfrange ", k + 1).bytes());
}
let mut u = Unicode::default();
parse_to_unicode_within(&cmap, &mut u, 250);
assert_eq!((u.ranges.len(), u.text(125).is_some(), u.text(126).is_none()), (125, true, true));
}
#[test]
fn explicit_and_range_mappings_override_the_encoding() {
let mut doc = Document::new_empty();
let cmap = b"1 beginbfchar <41> <0058> endbfchar 1 beginbfrange <42> <43> <0059> endbfrange";
let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec())));
let f = font(
&mut doc,
vec![
("Subtype", Object::name("Type1")),
("BaseFont", Object::name("Custom")),
("Encoding", Object::name("WinAnsiEncoding")),
("ToUnicode", Object::Ref(tu)),
],
);
let m = Metrics::from_dict(&doc, &f);
let shown: Vec<Option<String>> = (0x41..=0x44).map(|c| m.text_of(c).map(|t| t.into_owned())).collect();
assert_eq!(shown, [Some("X".to_string()), Some("Y".to_string()), Some("Z".to_string()), Some("D".to_string())]);
}
/// Deterministic input for the differential test below.
struct Gen(u64);
impl Gen {
fn below(&mut self, n: u32) -> u32 {
self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
((self.0 >> 33) % u64::from(n)) as u32
}
/// A destination: a unit from a pool of edge cases, a surrogate pair, or a lone high surrogate.
fn units(&mut self) -> Vec<u16> {
const POOL: [u16; 12] = [0x41, 0x42, 0xE9, 0x0, 0xFFFD, 0xFFFE, 0xFFFF, 0x100, 0x3042, 0xD800, 0xDC00, 0xDFFF];
match self.below(6) {
0 => vec![0xD835, 0xDC00 + self.below(0x400) as u16],
1 => vec![0xD800, 0x41],
_ => vec![POOL[self.below(POOL.len() as u32) as usize]],
}
}
}
fn hex(units: &[u16]) -> String {
units.iter().map(|u| format!("{u:04X}")).collect()
}
#[test]
fn lookups_match_the_expanded_mappings() {
// Random ToUnicode CMaps; `expected` is what expanding their entries in order gives.
let mut g = Gen(0x2545_f491_4f6c_dd1d);
for _ in 0..300 {
let mut cmap = String::from("1 begincodespacerange <0000> <FFFF> endcodespacerange ");
let mut expected: HashMap<u32, String> = HashMap::new();
for _ in 0..1 + g.below(12) {
match g.below(3) {
0 => {
let (code, u) = (g.below(300), g.units());
cmap += &format!("1 beginbfchar <{code:04X}> <{}> endbfchar ", hex(&u));
expected.insert(code, String::from_utf16_lossy(&u));
}
1 => {
let (lo, width, u) = (g.below(300), g.below(13), g.units());
cmap += &format!("1 beginbfrange <{lo:04X}> <{:04X}> <{}> endbfrange ", lo + width, hex(&u));
for k in 0..=width {
let mut v = u.clone();
if let Some(last) = v.last_mut() {
*last = last.wrapping_add(k as u16);
}
expected.insert(lo + k, String::from_utf16_lossy(&v));
}
}
_ => {
let (lo, width) = (g.below(300), g.below(5));
let dests: Vec<Vec<u16>> = (0..=width).map(|_| g.units()).collect();
let list: Vec<String> = dests.iter().map(|d| format!("<{}>", hex(d))).collect();
cmap += &format!("1 beginbfrange <{lo:04X}> <{:04X}> [{}] endbfrange ", lo + width, list.join(" "));
for (k, d) in dests.iter().enumerate() {
expected.insert(lo + k as u32, String::from_utf16_lossy(d));
}
}
}
}
let mut doc = Document::new_empty();
let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.clone().into_bytes())));
let f = font(
&mut doc,
vec![
("Subtype", Object::name("Type0")),
("BaseFont", Object::name("Gen")),
("Encoding", Object::name("Identity-H")),
("ToUnicode", Object::Ref(tu)),
],
);
let m = Metrics::from_dict(&doc, &f);
for code in 0..=320u32 {
let got = m.text_of(code).map(|t| t.into_owned());
assert_eq!(got, expected.get(&code).cloned(), "code {code:#06x} in {cmap}");
}
let mut reverse: HashMap<char, u32> = HashMap::new();
for (&code, text) in &expected {
if let Some(c) = only_char(text) {
keep(&mut reverse, c, code);
}
}
let mut chars: Vec<char> = expected.values().flat_map(|t| t.chars()).chain(['A', '\u{FFFD}', '\u{0}', '\u{1D400}']).collect();
chars.sort_unstable();
chars.dedup();
for c in chars {
let want = reverse.get(&c).map(|code| code.to_be_bytes()[2..].to_vec());
assert_eq!(m.encode(&c.to_string()), want, "char {c:?} in {cmap}");
}
}
}
#[test]
fn fffd_lookups_skip_overridden_surrogates() {
// Each record maps every code to itself, so its surrogate offsets decode to U+FFFD. The last
// record overrides the surrogates with letters, so each lookup walks past them to U+FFFD itself.
let mut cmap = "1 beginbfrange <0000> <FFFF> <0000> endbfrange ".repeat(300);
cmap.push_str("1 beginbfrange <D800> <DFFF> <0041> endbfrange");
let mut doc = Document::new_empty();
let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.into_bytes())));
let f = font(
&mut doc,
vec![
("Subtype", Object::name("Type0")),
("BaseFont", Object::name("Gen")),
("Encoding", Object::name("Identity-H")),
("ToUnicode", Object::Ref(tu)),
],
);
let m = Metrics::from_dict(&doc, &f);
assert_eq!(m.encode("\u{FFFD}"), Some(vec![0xFF, 0xFD]));
}
#[test]
fn hostile_ranges_stay_bounded() {
// A range wider than 65 536 codes is ignored, a reversed one maps nothing, and a maximal
// one-code range maps just its own code.
let mut u = Unicode::default();
let cmap = b"1 beginbfrange <00000000> <FFFFFFFF> <0041> endbfrange \
1 beginbfrange <0005> <0001> <0041> endbfrange \
1 beginbfrange <FFFFFFFF> <FFFFFFFF> <FFFF> endbfrange";
parse_to_unicode(cmap, &mut u);
assert_eq!((u.ranges.len(), u.text(u32::MAX).as_deref()), (0, Some("\u{FFFF}")));
}
#[test]
fn low_codes_resolve_to_the_covering_range() {
let mut u = Unicode::default();
let cmap = b"1 beginbfrange <0005> <0007> <0041> endbfrange 1 beginbfrange <0064> <0065> <0042> endbfrange \
1 beginbfrange <0000FFFE> <00010001> <0043> endbfrange";
parse_to_unicode(cmap, &mut u);
let covered: Vec<u32> = (0..0x70).filter(|&c| u.covering(c).is_some()).collect();
assert_eq!(covered, [5, 6, 7, 100, 101]);
assert_eq!((u.covering(0xFFFF).is_some(), u.covering(0x1_0001).is_some(), u.covering(0x1_0002).is_none()), (true, true, true));
}
}
+24 -2
View File
@@ -247,6 +247,9 @@ fn strings(v: &JsValue, ctx: &mut Context) -> JsResult<Vec<String>> {
{
let a = JsArray::from_object(o.clone())?;
let n = a.length(ctx)?;
if n > MAX_ARRAY_LENGTH {
return Err(error(format!("array length exceeds {MAX_ARRAY_LENGTH}")));
}
let mut out = Vec::new();
for i in 0..n {
let x = a.get(i, ctx)?;
@@ -589,15 +592,23 @@ fn timer(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
fn printf(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let fmt = text(&arg(args, 0), ctx)?;
let mut out = String::new();
let check_output = |len: usize| -> JsResult<()> {
if len > MAX_PRINTF_OUTPUT_BYTES {
return Err(error(format!("util.printf output exceeds {MAX_PRINTF_OUTPUT_BYTES} bytes")));
}
Ok(())
};
let mut next = 1;
let mut chars = fmt.chars().peekable();
while let Some(c) = chars.next() {
if c != '%' {
check_output(out.len().saturating_add(c.len_utf8()))?;
out.push(c);
continue;
}
if chars.peek() == Some(&'%') {
chars.next();
check_output(out.len().saturating_add(1))?;
out.push('%');
continue;
}
@@ -619,7 +630,7 @@ fn printf(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue>
}
let mut width = 0usize;
while let Some(d) = chars.peek().and_then(|d| d.to_digit(10)) {
width = width * 10 + d as usize;
width = width.saturating_mul(10).saturating_add(d as usize);
chars.next();
}
let mut prec = None;
@@ -627,11 +638,14 @@ fn printf(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue>
chars.next();
let mut p = 0usize;
while let Some(d) = chars.peek().and_then(|d| d.to_digit(10)) {
p = p * 10 + d as usize;
p = p.saturating_mul(10).saturating_add(d as usize);
chars.next();
}
prec = Some(p);
}
if width > MAX_PRINTF_WIDTH_PRECISION || prec.is_some_and(|p| p > MAX_PRINTF_WIDTH_PRECISION) {
return Err(error(format!("util.printf width or precision exceeds {MAX_PRINTF_WIDTH_PRECISION}")));
}
let conv = chars.next().unwrap_or('s');
let v = arg(args, next);
next += 1;
@@ -660,6 +674,7 @@ fn printf(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue>
}
};
let pad = width.saturating_sub(body.chars().count());
check_output(out.len().saturating_add(pad).saturating_add(body.len()))?;
if zero && matches!(conv, 'd' | 'f') {
let (sign, digits) = if body.starts_with(['-', '+', ' ']) { body.split_at(1) } else { ("", body.as_str()) };
out.push_str(sign);
@@ -992,6 +1007,13 @@ fn event_object(ctx: &mut Context, e: &Event) -> JsObject {
/// Longest script, in bytes.
pub(crate) const MAX_SCRIPT_BYTES: usize = 256 * 1024;
/// Largest width or precision accepted by `util.printf`.
const MAX_PRINTF_WIDTH_PRECISION: usize = 4096;
/// Longest `util.printf` output, in bytes.
const MAX_PRINTF_OUTPUT_BYTES: usize = 1 << 20;
/// Longest array read item by item (`resetForm`, a field's `value` or colours). A sparse array's
/// length costs the script nothing but the host one string per item; real lists are tiny.
const MAX_ARRAY_LENGTH: u64 = 1 << 20;
/// Deepest nesting of `(`, `[` and `{`.
const MAX_BRACKET_DEPTH: usize = 64;
/// Longest run of prefix operators (`!`, `~`, `+`, `-`).
+118
View File
@@ -94,6 +94,124 @@ fn util_printf_printd_and_printx() {
assert_eq!(o.value, "1,234,567.89|00042|hi|FF|1.234,50|Jan 5, 2024 09:07|Friday|(555) 123-4567|ABC");
}
fn assert_printf_error(script: &str, message: &str) {
let o = go(script, &Event::doc("Open"));
assert!(o.error.as_deref().is_some_and(|e| e.starts_with(message)), "{script}: {:?}", o.error);
let o = xfa::run_xfa(
script,
&xfa::XfaEvent { activity: "initialize".into(), target: "form1[0]".into(), ..Default::default() },
&xfa::XfaDoc::default(),
&xfa_model::form(),
Limits::default(),
);
assert!(o.error.as_deref().is_some_and(|e| e.starts_with(message)), "XFA: {script}: {:?}", o.error);
}
#[test]
fn util_printf_rejects_large_width() {
assert_printf_error("util.printf('%99999999999d', 1)", "TypeError: util.printf width or precision exceeds 4096");
}
#[test]
fn util_printf_rejects_large_precision() {
assert_printf_error("util.printf('%.99999999999f', 1)", "TypeError: util.printf width or precision exceeds 4096");
}
#[test]
fn util_printf_rejects_overflowing_width() {
assert_printf_error("util.printf('%9999999999999999999999999999999999999999d', 1)", "TypeError: util.printf width or precision exceeds 4096");
}
#[test]
fn util_printf_rejects_overflowing_precision() {
assert_printf_error("util.printf('%.9999999999999999999999999999999999999999f', 1)", "TypeError: util.printf width or precision exceeds 4096");
}
#[test]
fn util_printf_rejects_limits_for_all_conversions() {
for conv in ['d', 'f', 's', 'x'] {
for spec in ["4097", ".4097", "99999999999", ".99999999999"] {
assert_printf_error(&format!("util.printf('%{spec}{conv}', 1)"), "TypeError: util.printf width or precision exceeds 4096");
}
}
}
#[test]
fn util_printf_bounds_total_output() {
for script in [
"util.printf('%4096d'.repeat(300), 1)",
"util.printf('%04096d'.repeat(300), -1)",
"util.printf('aa'.repeat(524288) + 'a')",
"util.printf('é'.repeat(524289))",
"util.printf('%%%%'.repeat(524288) + '%%')",
"util.printf('%s', 'aa'.repeat(524288) + 'a')",
"util.printf('%s%s', 'aa'.repeat(524288), 'b')",
] {
assert_printf_error(script, "TypeError: util.printf output exceeds 1048576 bytes");
}
}
#[test]
fn util_printf_accepts_limits_and_preserves_formats() {
let o = go(
"event.value = [util.printf('%4096d', 1).length, util.printf('%04096d', -1).length, \
util.printf('%.4096f', 1).length, util.printf('%.4096s', 'a'.repeat(4097)).length, \
util.printf('%4096x', 255).length, util.printf('%.4096x', 255), \
util.printf('%4096d'.repeat(255) + '%4095d', 1).length, util.printf('%4096d'.repeat(256), 1).length, \
util.printf('é'.repeat(524288)).length, util.printf('a%%b:%+06d|%4s|%.2s|%x', 42, 'é', 'é界a', 255)].join('|');",
&Event::field("Calculate", "total", ""),
);
assert_eq!(o.error, None);
assert_eq!(o.value, "4096|4096|4098|4096|4096|FF|1048575|1048576|524288|a%b:+00042| é|é界|FF");
}
fn assert_array_error(script: &str) {
let o = go(script, &Event::field("Calculate", "total", ""));
assert!(o.error.as_deref().is_some_and(|e| e.starts_with("TypeError: array length exceeds 1048576")), "{script}: {:?}", o.error);
assert!(o.requests.is_empty() && o.changed.is_empty(), "{script}");
}
#[test]
fn reset_form_and_field_setters_refuse_huge_arrays() {
for script in [
"this.resetForm(new Array(4294967295))",
"getField('total').value = new Array(4294967295)",
"event.target.value = new Array(4294967295)",
"getField('total').textColor = new Array(4294967295)",
"getField('total').fillColor = new Array(4294967295)",
] {
assert_array_error(script);
}
}
#[test]
fn reset_form_and_field_setters_take_arrays_up_to_the_limit() {
let o = go("this.resetForm(new Array(1 << 20)); getField('total').value = new Array(1 << 20);", &Event::field("Calculate", "total", ""));
assert_eq!(o.error, None);
assert!(matches!(&o.requests[..], [Request::Reset(names)] if names.len() == 1 << 20 && names.iter().all(String::is_empty)));
assert_eq!(o.changed.iter().find(|f| f.name == "total").unwrap().value, [""]);
assert_array_error("this.resetForm(new Array((1 << 20) + 1))");
assert_array_error("getField('total').value = new Array((1 << 20) + 1)");
}
#[test]
fn reset_form_and_field_setters_still_read_short_arrays() {
let mut f = fields();
f.push(FieldState::new("pick", FieldType::ListBox, vec![]));
let o = run(
"this.resetForm(['a', 'b']); getField('pick').value = ['x', 'y', 'z']; getField('total').value = ['x', 'y', 'z'];",
&Event::field("Calculate", "total", ""),
&doc(),
&f,
&[],
Limits::default(),
);
assert_eq!(o.error, None);
assert_eq!(o.requests, [Request::Reset(vec!["a".into(), "b".into()])]);
assert_eq!(o.changed.iter().find(|f| f.name == "pick").unwrap().value, ["x", "y", "z"]);
assert_eq!(o.changed.iter().find(|f| f.name == "total").unwrap().value, ["x"]);
}
#[test]
fn document_requests_and_console() {
let o = go(
+2 -1
View File
@@ -321,7 +321,8 @@ impl Walker<'_> {
}
}
"S" | "s" | "f" | "F" | "f*" | "B" | "B*" | "b" | "b*" => {
if matches!(String::from_utf8_lossy(&op.op).as_ref(), "s" | "b" | "b*")
// Fills close open subpaths implicitly (ISO 32000-2 8.5.3.3); only a bare stroke leaves the path open.
if String::from_utf8_lossy(&op.op).as_ref() != "S"
&& let (Some(a), Some(b)) = (current, start)
{
path.edge(a, b);
+10
View File
@@ -120,6 +120,16 @@ fn rejects_invalid_geometry_and_unsupported_scales() {
assert!(set_scale(&mut d, 0, [0.0; 4], "bad", &Scale::default()).is_err());
}
#[test]
fn filled_open_triangle_snaps_to_closing_edge() {
for (op, closed) in [("f", true), ("F", true), ("f*", true), ("B", true), ("B*", true), ("h f", true), ("S", false)] {
let d = fixture(&format!("40 50 m 160 50 l 100 170 l {op}"), "", &[]);
let paths = snap::geometry(&d, 0).unwrap();
assert_eq!(paths.segments.len(), if closed { 3 } else { 2 }, "{op}");
let hit = paths.snap([70.0, 110.0], 5.0, snap::SnapOptions::default()).unwrap();
assert_eq!(hit.is_some(), closed, "{op}");
}
}
#[test]
fn snap_paths_endpoints_midpoints_and_intersections() {
let d = fixture("10 20 m 110 20 l S 60 0 m 60 80 l S 10 100 100 40 re S", "", &[]);
let paths = snap::geometry(&d, 0).unwrap();
+15
View File
@@ -18,6 +18,11 @@ pub use pdfcraft_fonts::helvetica_width;
pub const DETECTION_MODEL: &str = "text-detection.rten";
pub const RECOGNITION_MODEL: &str = "text-recognition.rten";
/// The recognition model's alphabet, as it was trained (ocrs-models' `DEFAULT_ALPHABET`): its
/// output classes follow this order. The ocrs crate's own copy has an `E` where the training had
/// `€`, which made a scanned "€250" read as "E250".
pub const ALPHABET: &str = " 0123456789!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~€ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
/// The languages the models read (ISO 639-1); all use the Latin alphabet without accents.
pub const LANGUAGES: &[(&str, &str)] = &[("en", "English")];
@@ -109,6 +114,7 @@ impl Ocr {
let params = ocrs::OcrEngineParams {
detection_model: Some(load(&models.detection)?),
recognition_model: Some(load(&models.recognition)?),
alphabet: Some(ALPHABET.into()),
..Default::default()
};
let engine = ocrs::OcrEngine::new(params).map_err(|e| OcrError::Load("ocr engine".into(), e.to_string()))?;
@@ -278,6 +284,15 @@ mod tests {
}
/// The message tells end users what to do; release builds have no cargo.
#[test]
fn the_alphabet_is_the_trained_one() {
let chars: Vec<char> = ALPHABET.chars().collect();
assert_eq!(chars.len(), 96);
// The euro sign follows the ASCII punctuation (class 44; 0 is the CTC blank), not a second E.
assert_eq!(chars.iter().position(|&c| c == '€'), Some(43));
assert_eq!(chars.iter().filter(|&&c| c == 'E').count(), 1);
}
#[test]
fn missing_models_message_is_for_end_users() {
let message = OcrError::NoModels.to_string();
+15 -2
View File
@@ -208,10 +208,19 @@ fn jpeg(px: &[u8], n: usize, w: u32, h: u32, quality: u8) -> Option<Vec<u8>> {
Some(out)
}
/// Whether `decode` is the default decode array for `n` components ([0 1] each), which changes
/// nothing. Scanners write it on page images (#490).
fn is_identity_decode(doc: &Document, decode: &Object, n: usize) -> bool {
let decode = doc.resolve(decode);
let Some(a) = decode.as_array() else { return false };
a.len() == 2 * n && a.chunks(2).all(|p| matches!(p, [lo, hi] if doc.resolve(lo).as_f64() == Some(0.0) && doc.resolve(hi).as_f64() == Some(1.0)))
}
/// A new image stream from 8-bit samples: the original dictionary with the new size and filter.
/// A decode array is dropped: only images without one, or with the default one, get here.
fn image_stream(old: &Dict, px: &[u8], n: usize, w: u32, h: u32, compression: Compression, was_dct: bool) -> Option<Stream> {
let mut d = old.clone();
for k in [&b"Filter"[..], b"DecodeParms", b"Length", b"DL"] {
for k in [&b"Filter"[..], b"DecodeParms", b"Decode", b"Length", b"DL"] {
d.remove(k);
}
d.set(b"Width".to_vec(), Object::Int(w as i64));
@@ -239,7 +248,11 @@ fn image_stream(old: &Dict, px: &[u8], n: usize, w: u32, h: u32, compression: Co
/// Returns the new image and its new soft mask, if the result is smaller.
fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: usize) -> Option<(Stream, Option<(ObjRef, Stream)>)> {
let d = &s.dict;
if d.contains(b"Decode") || d.contains(b"Mask") || d.name(b"ImageMask").is_some() || matches!(d.get(b"ImageMask"), Some(Object::Bool(true))) {
if d.get(b"Decode").is_some_and(|a| !is_identity_decode(doc, a, n))
|| d.contains(b"Mask")
|| d.name(b"ImageMask").is_some()
|| matches!(d.get(b"ImageMask"), Some(Object::Bool(true)))
{
return None;
}
if d.int(b"BitsPerComponent") != Some(8) {
+11 -5
View File
@@ -5,11 +5,12 @@
//! smallest over its uses, through form XObjects); colour and grayscale images above a
//! threshold are resampled (bicubic) to a target resolution and recompressed as JPEG or Flate.
//! A new image replaces the old one only if it is smaller. Images PdfCraft can't decode
//! faithfully (CMYK and other colour spaces, masks, decode arrays, JPEG 2000, JBIG2, CCITT,
//! more than 8 bits) are left alone.
//! faithfully (CMYK and other colour spaces, masks, decode arrays other than the default,
//! JPEG 2000, JBIG2, CCITT, other than 8 bits) are left alone.
//! - **Discard objects:** page thumbnails, alternate images, document tags (structure tree),
//! print settings.
//! - **Clean up:** Flate-compress streams that have no filter.
//! - **Clean up:** drop images and forms that pages list but never draw (such as a deleted
//! image); Flate-compress streams that have no filter.
//!
//! Metadata, attachments, comments, scripts, private data, hidden layers, bookmarks and form
//! fields are discarded by `pdfcraft-redact`'s Remove Hidden Information, which the engine
@@ -20,6 +21,7 @@
mod audit;
mod images;
mod links;
mod unused;
pub use audit::{SpaceCategory, SpaceUse, audit_space};
@@ -125,6 +127,8 @@ pub struct Report {
pub invalid_links: usize,
pub invalid_bookmarks: usize,
pub unreferenced_dests: usize,
/// Images and forms the pages listed but never drew, dropped (Clean Up).
pub unused_xobjects: usize,
}
/// Optimize `doc` in place.
@@ -132,8 +136,9 @@ pub fn optimize(doc: &mut Document, settings: &Settings) -> Result<Report, Optim
optimize_with_progress(doc, settings, &mut |_| true)
}
/// [`optimize`], calling `progress` before each image and before the clean-up. Returning `false`
/// stops with [`OptimizeError::Cancelled`]; `doc` is then partly optimized and should be dropped.
/// [`optimize`], calling `progress` before each image and before the clean-up (and now and then
/// during a long one). Returning `false` stops with [`OptimizeError::Cancelled`]; `doc` is then
/// partly optimized and should be dropped.
pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: &mut dyn FnMut(Stage) -> bool) -> Result<Report, OptimizeError> {
let mut report = Report::default();
let pages = pdfcraft_annot::page_refs(doc).map_err(|_| OptimizeError::NoPages)?;
@@ -141,6 +146,7 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress:
if !progress(Stage::CleanUp) {
return Err(OptimizeError::Cancelled);
}
report.unused_xobjects = unused::remove_unused(doc, &pages, progress)?;
if settings.discard_thumbnails {
for p in &pages {
if doc.get(*p).as_dict().is_some_and(|d| d.contains(b"Thumb")) {
+304
View File
@@ -0,0 +1,304 @@
//! Clean Up: drop image and form XObjects that a page lists in its resources but nothing draws,
//! such as an image deleted with Edit a PDF (its `Do` goes, its resource entry stays). A full
//! rewrite keeps every object that is still referenced, so without this the image would stay in
//! the file.
//!
//! Removing a name something still draws would make content disappear, so the rule is simple
//! and errs on the side of keeping:
//! - only a page's own resources are cleaned: dictionaries nothing else refers to (resources
//! shared with other pages, forms or appearances, and inherited ones, are left alone);
//! - a name goes only if it is written nowhere in any stream the document still uses, found by
//! scanning the bytes wherever the name appears. Nothing can draw a resource by name without
//! writing the name in a stream, whichever resources a viewer then looks it up in;
//! - if any such stream can't be read exactly the way a viewer would read it, nothing is
//! removed. Only pictures (images in an image codec, used only as images) are skipped.
//!
//! So a name another page writes for its own image keeps this page's image of that name too.
use std::collections::{HashMap, HashSet};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use crate::{OptimizeError, Stage};
/// Image codecs: a stream encoded with one holds picture data.
const IMAGE_CODECS: [&[u8]; 6] = [b"DCTDecode", b"DCT", b"JPXDecode", b"JBIG2Decode", b"CCITTFaxDecode", b"CCF"];
/// Most data decoded while looking for names, in all; past it nothing is removed.
const DECODE_BUDGET: usize = 1 << 31;
/// How much data is decoded between two checks for a cancel.
const CANCEL_CHECK: usize = 64 << 20;
/// Where a page's own `/XObject` dictionary is.
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum Place {
/// An object of its own (`/XObject n 0 R`).
Own(ObjRef),
/// Directly in the page's resources object (`/Resources n 0 R`).
InResources(ObjRef),
/// Directly in resources written directly in the page.
InPage(ObjRef),
}
/// How an object is used where a reference to it appears.
#[derive(Clone, Copy, PartialEq)]
enum Use {
/// As a picture: an `/XObject` entry, `/SMask`, `/Mask`, `/Thumb` or an alternate `/Image`.
Picture,
/// As an `/XObject` dictionary (its entries are pictures or forms).
Entries,
Other,
}
/// The objects the document uses (everything a full rewrite keeps): how many references point
/// at each, and which are referenced other than as a picture.
struct Graph {
refs: HashMap<u32, usize>,
not_picture: HashSet<u32>,
}
fn graph(doc: &Document) -> Graph {
let child = |key: &[u8], parent: Use| match (parent, key) {
(Use::Entries, _) => Use::Picture,
(_, b"XObject") => Use::Entries,
(_, b"SMask" | b"Mask" | b"Thumb" | b"Image") => Use::Picture,
_ => Use::Other,
};
let mut g = Graph { refs: HashMap::new(), not_picture: HashSet::new() };
let mut stack: Vec<(Object, Use)> = doc.trailer().iter().map(|(_, v)| (v.clone(), Use::Other)).collect();
while let Some((obj, how)) = stack.pop() {
let expand = |d: &Dict, stack: &mut Vec<(Object, Use)>| stack.extend(d.iter().map(|(k, v)| (v.clone(), child(k, how))));
match obj {
Object::Ref(r) => {
if how != Use::Picture {
g.not_picture.insert(r.num);
}
let n = g.refs.entry(r.num).or_default();
*n += 1;
if *n > 1 {
continue;
}
match &*doc.get(r) {
Object::Dict(d) => expand(d, &mut stack),
Object::Stream(s) => expand(&s.dict, &mut stack),
Object::Array(a) => stack.extend(a.iter().map(|v| (v.clone(), how))),
_ => {}
}
}
Object::Dict(d) => expand(&d, &mut stack),
Object::Array(a) => stack.extend(a.into_iter().map(|v| (v, how))),
_ => {}
}
}
g
}
/// `page`'s own `/XObject` dictionary, if nothing else refers to it.
fn own_xobjects(doc: &Document, page: ObjRef, refs: &HashMap<u32, usize>) -> Option<(Place, Dict)> {
let only_once = |r: &ObjRef| refs.get(&r.num) == Some(&1);
let p = doc.get(page);
let p = p.as_dict()?;
// A node with kids passes its resources on.
if p.name(b"Type") != Some(b"Page") || p.contains(b"Kids") {
return None;
}
let (res, place) = match p.get(b"Resources")? {
Object::Ref(r) if only_once(r) => (doc.get(*r).as_dict()?.clone(), Place::InResources(*r)),
Object::Dict(d) => (d.clone(), Place::InPage(page)),
_ => return None,
};
match res.get(b"XObject")? {
Object::Ref(x) if only_once(x) => Some((Place::Own(*x), doc.get(*x).as_dict()?.clone())),
Object::Dict(x) => Some((place, x.clone())),
_ => None,
}
}
fn hex(b: &u8) -> Option<u8> {
(*b as char).to_digit(16).map(|d| d as u8)
}
/// Record which of `wanted` (none longer than `longest`) are written in `data` (`/Name`, `#xx`
/// escapes decoded), wherever they appear: operands, strings, comments or inline image data.
fn find_names(data: &[u8], wanted: &HashSet<Vec<u8>>, longest: usize, found: &mut HashSet<Vec<u8>>) {
let regular =
|b: u8| !matches!(b, b'\0' | b'\t' | b'\n' | b'\x0C' | b'\r' | b' ' | b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%');
let mut i = 0;
while let Some(slash) = data.get(i..).and_then(|rest| rest.iter().position(|&b| b == b'/')) {
let mut j = i + slash + 1;
let mut name = Vec::new();
let mut too_long = false;
while let Some(&b) = data.get(j) {
if !regular(b) {
break;
}
let (byte, step) = match (b, data.get(j + 1).and_then(hex), data.get(j + 2).and_then(hex)) {
(b'#', Some(h), Some(l)) => (h << 4 | l, 3),
_ => (b, 1),
};
if name.len() < longest {
name.push(byte);
} else {
too_long = true;
}
j += step;
}
if !too_long && wanted.contains(&name) {
found.insert(name);
}
i = j;
}
}
/// What a stream's data is, for finding names in it.
enum Data {
/// A picture: image data in an image codec, used only as an image.
Picture,
Decoded(Vec<u8>),
/// Not readable exactly the way a viewer would read it.
Unknown,
}
/// Whether the document's security handler leaves metadata streams unencrypted.
fn plain_metadata(doc: &Document) -> bool {
doc.trailer()
.get(b"Encrypt")
.map(|e| doc.resolve(e))
.is_some_and(|e| matches!(e.as_dict().and_then(|e| e.get(b"EncryptMetadata")), Some(Object::Bool(false))))
}
fn data(s: &Stream, num: u32, g: &Graph, encrypted: bool, plain_metadata: bool) -> Data {
let d = &s.dict;
// Streams the security handler leaves as stored, which a viewer may still decrypt.
let ty = d.name(b"Type");
if encrypted && (ty == Some(b"XRef") || (ty == Some(b"Metadata") && plain_metadata)) {
return Data::Unknown;
}
// External data, or a filter description that is abbreviated, indirect or doesn't line up:
// a viewer might decode it differently from this check.
if d.contains(b"F")
|| d.contains(b"DP")
|| matches!(d.get(b"Filter"), Some(Object::Ref(_)))
|| matches!(d.get(b"DecodeParms"), Some(Object::Ref(_)))
{
return Data::Unknown;
}
let filters: Vec<&[u8]> = match d.get(b"Filter") {
None => Vec::new(),
Some(Object::Name(n)) => vec![n.as_slice()],
Some(Object::Array(a)) => {
let names: Vec<&[u8]> = a.iter().filter_map(Object::as_name).collect();
if names.len() != a.len() {
return Data::Unknown;
}
names
}
Some(_) => return Data::Unknown,
};
if filters.contains(&&b"Crypt"[..]) {
return Data::Unknown;
}
if filters.iter().any(|f| IMAGE_CODECS.contains(f)) {
// Viewers decode any stream's codec, so picture data skipped here must be a picture.
let picture = d.name(b"Subtype") == Some(b"Image") && !g.not_picture.contains(&num);
return if picture { Data::Picture } else { Data::Unknown };
}
// Parameters only as direct integers, which this check and viewers read alike.
let direct = |p: &Object| match p {
Object::Dict(p) => p.iter().all(|(_, v)| matches!(v, Object::Int(_))),
Object::Null => true,
_ => false,
};
let parms_line_up = match d.get(b"DecodeParms") {
None => true,
Some(p @ (Object::Dict(_) | Object::Null)) => filters.len() == 1 && direct(p),
Some(Object::Array(a)) => a.len() == filters.len() && a.iter().all(direct),
Some(_) => false,
};
if !parms_line_up {
return Data::Unknown;
}
s.decoded_strict().map_or(Data::Unknown, Data::Decoded)
}
/// Drop the image and form XObjects that `pages` list in their own resources but no stream
/// names. Returns how many went. `progress` is asked again every [`CANCEL_CHECK`] bytes
/// decoded; `false` cancels.
pub(crate) fn remove_unused(doc: &mut Document, pages: &[ObjRef], progress: &mut dyn FnMut(Stage) -> bool) -> Result<usize, OptimizeError> {
let g = graph(doc);
let mut seen = HashSet::new();
let mut candidates = Vec::new();
for page in pages {
let Some((place, xobjects)) = own_xobjects(doc, *page, &g.refs) else { continue };
if !seen.insert(place) {
continue;
}
let names: Vec<Vec<u8>> = xobjects
.iter()
.filter(|(_, v)| matches!(&*doc.resolve(v), Object::Stream(s) if matches!(s.dict.name(b"Subtype"), Some(b"Image" | b"Form"))))
.map(|(name, _)| name.clone())
.collect();
if !names.is_empty() {
candidates.push((place, names));
}
}
let wanted: HashSet<Vec<u8>> = candidates.iter().flat_map(|(_, names)| names.iter().cloned()).collect();
let longest = wanted.iter().map(Vec::len).max().unwrap_or(0);
let (encrypted, plain_metadata) = (doc.trailer().contains(b"Encrypt"), plain_metadata(doc));
let mut written = HashSet::new();
let (mut decoded, mut next_check) = (0usize, CANCEL_CHECK);
for num in g.refs.keys() {
if written.len() == wanted.len() {
return Ok(0);
}
let obj = doc.get(ObjRef::new(*num, doc.generation(*num)));
let Object::Stream(s) = &*obj else { continue };
match data(s, *num, &g, encrypted, plain_metadata) {
Data::Picture => {}
Data::Decoded(bytes) => {
decoded = decoded.saturating_add(bytes.len());
if decoded > DECODE_BUDGET {
return Ok(0);
}
if decoded >= next_check {
next_check = decoded.saturating_add(CANCEL_CHECK);
if !progress(Stage::CleanUp) {
return Err(OptimizeError::Cancelled);
}
}
find_names(&bytes, &wanted, longest, &mut written);
}
Data::Unknown => return Ok(0),
}
}
let mut removed = 0;
for (place, names) in candidates {
let gone: Vec<Vec<u8>> = names.into_iter().filter(|n| !written.contains(n)).collect();
if gone.is_empty() {
continue;
}
let strip = |x: &mut Dict| {
for n in &gone {
x.remove(n);
}
};
match place {
Place::Own(x) => doc.update_dict(x, strip)?,
Place::InResources(r) => doc.update_dict(r, |res| {
if let Some(Object::Dict(x)) = res.get_mut(b"XObject") {
strip(x);
}
})?,
Place::InPage(p) => doc.update_dict(p, |d| {
if let Some(Object::Dict(res)) = d.get_mut(b"Resources")
&& let Some(Object::Dict(x)) = res.get_mut(b"XObject")
{
strip(x);
}
})?,
}
removed += gone.len();
}
Ok(removed)
}
+279
View File
@@ -131,6 +131,39 @@ fn images_are_measured_where_drawn_and_downsampled() {
assert_ne!(&out.rgba[i..i + 3], &[255, 255, 255]);
}
#[test]
fn an_identity_decode_array_does_not_stop_reduce() {
// Office scanners write /Decode [0 1 0 1 0 1] (no change) on their page images (#490).
let with_decode = |doc: &mut Document, r: ObjRef, decode: Vec<Object>| {
let mut s = stream(doc, r);
s.dict.set(b"Decode".to_vec(), Object::Array(decode));
doc.set(r, Object::Stream(s));
};
let mut doc = Document::new_empty();
// 900 px drawn 216 pt (3 in) wide → 300 ppi, like a scanned page.
let scan = image(&mut doc, 900, 900, 3, true, None);
with_decode(&mut doc, scan, [0, 1, 0, 1, 0, 1].map(Object::Int).to_vec());
let gray = image(&mut doc, 900, 900, 1, false, None);
with_decode(&mut doc, gray, vec![Object::Real(0.0), Object::Real(1.0)]);
// A decode array that inverts the colours, and one of the wrong length: left alone.
let inverted = image(&mut doc, 900, 900, 3, true, None);
with_decode(&mut doc, inverted, [1, 0, 1, 0, 1, 0].map(Object::Int).to_vec());
let odd = image(&mut doc, 900, 900, 3, true, None);
with_decode(&mut doc, odd, [0, 1].map(Object::Int).to_vec());
page(
&mut doc,
&[("S", scan), ("G", gray), ("I", inverted), ("O", odd)],
"q 216 0 0 216 0 0 cm /S Do Q q 216 0 0 216 216 0 cm /G Do Q q 216 0 0 216 0 300 cm /I Do Q q 216 0 0 216 216 300 cm /O Do Q",
);
let report = optimize(&mut doc, &Settings::default()).unwrap();
assert_eq!(report.images_resampled, 2, "{report:?}");
assert_eq!(stream(&doc, scan).dict.int(b"Width"), Some(450));
assert_eq!(stream(&doc, gray).dict.int(b"Width"), Some(450));
assert!(!stream(&doc, scan).dict.contains(b"Decode"), "the default decode array isn't carried over");
assert_eq!(stream(&doc, inverted).dict.int(b"Width"), Some(900));
assert_eq!(stream(&doc, odd).dict.int(b"Width"), Some(900));
}
#[test]
fn settings_choose_what_happens() {
let mut doc = Document::new_empty();
@@ -304,3 +337,249 @@ fn progress_is_reported_per_image_and_a_refusal_cancels() {
assert!(matches!(r, Err(OptimizeError::Cancelled)), "{r:?}");
assert_eq!(calls, 2, "nothing runs after the refusal");
}
/// A page with `resources` and, if given, `contents`.
fn raw_page(doc: &mut Document, resources: Object, contents: Option<ObjRef>) -> ObjRef {
let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap();
let mut p = Dict::new();
p.set(b"Type".to_vec(), Object::name("Page"));
p.set(b"Parent".to_vec(), Object::Ref(pages));
p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()]));
p.set(b"Resources".to_vec(), resources);
if let Some(c) = contents {
p.set(b"Contents".to_vec(), Object::Ref(c));
}
let r = doc.add(Object::Dict(p));
doc.update_dict(pages, |d| {
let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default();
kids.push(Object::Ref(r));
d.set(b"Count".to_vec(), Object::Int(kids.len() as i64));
d.set(b"Kids".to_vec(), Object::Array(kids));
})
.unwrap();
r
}
/// Builders for the tests below.
fn xobjects(pairs: &[(&str, ObjRef)]) -> Dict {
let mut x = Dict::new();
for (n, r) in pairs {
x.set(n.as_bytes().to_vec(), Object::Ref(*r));
}
x
}
fn resources(x: Dict) -> Dict {
let mut res = Dict::new();
res.set(b"XObject".to_vec(), Object::Dict(x));
res
}
fn content(doc: &mut Document, c: &str) -> ObjRef {
doc.add(Object::Stream(Stream::flate(Dict::new(), c.as_bytes())))
}
fn form(doc: &mut Document, resources: Option<Object>, c: &str) -> ObjRef {
let mut d = Dict::new();
d.set(b"Subtype".to_vec(), Object::name("Form"));
d.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into(), 1.into()]));
if let Some(r) = resources {
d.set(b"Resources".to_vec(), r);
}
doc.add(Object::Stream(Stream::flate(d, c.as_bytes())))
}
fn xobject_names(doc: &Document, resources: &Object) -> Vec<String> {
let res = doc.resolve(resources);
let mut n: Vec<String> =
res.as_dict().unwrap().get(b"XObject").unwrap().as_dict().unwrap().iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).collect();
n.sort();
n
}
fn page_xobjects(doc: &Document, page: ObjRef) -> Vec<String> {
let res = doc.get(page).as_dict().unwrap().get(b"Resources").unwrap().clone();
xobject_names(doc, &res)
}
fn count_images(bytes: &[u8]) -> usize {
let doc = Document::open(Arc::new(bytes.to_vec())).unwrap();
doc.object_numbers()
.into_iter()
.filter(|n| matches!(&*doc.get(ObjRef::new(*n, doc.generation(*n))), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image")))
.count()
}
#[test]
fn images_and_forms_nothing_draws_are_dropped() {
let mut doc = Document::new_empty();
let img = |doc: &mut Document| image(doc, 64, 64, 3, false, None);
// Edit a PDF deleting an image: the page gets a new content stream without the `Do`; the
// old one, which still names the image, is no longer used.
let t1 = img(&mut doc);
let old = content(&mut doc, "q 72 0 0 72 0 0 cm /ImT Do Q");
let t = raw_page(&mut doc, Object::Dict(resources(xobjects(&[("ImT", t1)]))), Some(old));
let new = content(&mut doc, "");
doc.update_dict(t, |p| p.set(b"Contents".to_vec(), Object::Ref(new))).unwrap();
// Dropped: a JPEG a page no longer draws, beside one it does.
let a1 = img(&mut doc);
let a2 = image(&mut doc, 64, 64, 3, true, None);
let c = content(&mut doc, "q 72 0 0 72 0 0 cm /Im1 Do Q");
let a = raw_page(&mut doc, Object::Dict(resources(xobjects(&[("Im1", a1), ("Im2", a2)]))), Some(c));
// Dropped from resources the page alone refers to, written as an object of their own.
let s1 = img(&mut doc);
let own = doc.add(Object::Dict(resources(xobjects(&[("Unused", s1)]))));
let c = content(&mut doc, "");
let s = raw_page(&mut doc, Object::Ref(own), Some(c));
// An image drawn only inside a form stays; one beside it goes.
let (d3, d4) = (img(&mut doc), img(&mut doc));
let f = form(&mut doc, None, "/Im3 Do");
let c = content(&mut doc, "/F Do");
let d = raw_page(&mut doc, Object::Dict(resources(xobjects(&[("F", f), ("Im3", d3), ("Im4", d4)]))), Some(c));
// Kept, though the page's own content doesn't name them:
// - drawn by a form with resources of its own that lack the name (renderers fall back to
// the page's);
let k1 = img(&mut doc);
let mut procset = Dict::new();
procset.set(b"ProcSet".to_vec(), Object::Array(vec![Object::name("PDF")]));
let fk = form(&mut doc, Some(Object::Dict(procset)), "/ImK Do");
let c = content(&mut doc, "/Fk Do");
let k = raw_page(&mut doc, Object::Dict(resources(xobjects(&[("Fk", fk), ("ImK", k1)]))), Some(c));
// - drawn by a Type 3 glyph named like an inert key;
let g6 = img(&mut doc);
let glyph = content(&mut doc, "/Im6 Do");
let mut procs = Dict::new();
procs.set(b"P".to_vec(), Object::Ref(glyph));
let mut t3 = Dict::new();
t3.set(b"Type".to_vec(), Object::name("Font"));
t3.set(b"Subtype".to_vec(), Object::name("Type3"));
t3.set(b"CharProcs".to_vec(), Object::Dict(procs));
let mut fonts = Dict::new();
fonts.set(b"T3".to_vec(), Object::Dict(t3));
let mut res = resources(xobjects(&[("Im6", g6)]));
res.set(b"Font".to_vec(), Object::Dict(fonts));
let c = content(&mut doc, "BT /T3 1 Tf (P) Tj ET");
let g = raw_page(&mut doc, Object::Dict(res), Some(c));
// - after an inline image whose data runs into the operators (no space before `EI`), or
// given as the second of two operands;
let (l1, l2) = (img(&mut doc), img(&mut doc));
let c = content(&mut doc, "BI /W 1 /H 1 /BPC 8 /CS /G /F /AHx ID 00>EI Q q /ImL Do Q /Other /ImL2 Do");
let l = raw_page(&mut doc, Object::Dict(resources(xobjects(&[("ImL", l1), ("ImL2", l2)]))), Some(c));
// - drawn by a form field that is on no page;
let z1 = img(&mut doc);
let c = content(&mut doc, "");
let z = raw_page(&mut doc, Object::Dict(resources(xobjects(&[("Z1", z1)]))), Some(c));
let wap = form(&mut doc, None, "/Z1 Do");
let mut appearance = Dict::new();
appearance.set(b"N".to_vec(), Object::Ref(wap));
let mut widget = Dict::new();
widget.set(b"Subtype".to_vec(), Object::name("Widget"));
widget.set(b"AP".to_vec(), Object::Dict(appearance));
let widget = doc.add(Object::Dict(widget));
let mut acroform = Dict::new();
acroform.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(widget)]));
let root = doc.root().unwrap();
doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(acroform))).unwrap();
// - in resources shared by pages (or with an annotation's appearance), or inherited from the
// page tree: left alone;
let (b1, b2) = (img(&mut doc), img(&mut doc));
let shared = doc.add(Object::Dict(resources(xobjects(&[("X1", b1), ("X2", b2)]))));
let c = content(&mut doc, "0 0 m");
raw_page(&mut doc, Object::Ref(shared), Some(c));
let c = content(&mut doc, "/X1 Do");
raw_page(&mut doc, Object::Ref(shared), Some(c));
let (inh, unused_inh) = (img(&mut doc), img(&mut doc));
let tree = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap();
doc.update_dict(tree, |d| d.set(b"Resources".to_vec(), Object::Dict(resources(xobjects(&[("Inh", inh), ("Old", unused_inh)]))))).unwrap();
// - named by another page for its own image.
let (r1, r2) = (img(&mut doc), img(&mut doc));
let c = content(&mut doc, "");
let r = raw_page(&mut doc, Object::Dict(resources(xobjects(&[("Im9", r1)]))), Some(c));
let c = content(&mut doc, "/Im9 Do");
raw_page(&mut doc, Object::Dict(resources(xobjects(&[("Im9", r2)]))), Some(c));
let before = write_full(&doc, &SaveOptions::default()).unwrap();
let report = optimize(&mut doc, &Settings::default()).unwrap();
assert_eq!(report.unused_xobjects, 4, "{report:?}");
assert!(page_xobjects(&doc, t).is_empty(), "the deleted image goes");
assert_eq!(page_xobjects(&doc, a), ["Im1"]);
assert!(xobject_names(&doc, &Object::Ref(own)).is_empty());
assert!(page_xobjects(&doc, s).is_empty());
assert_eq!(page_xobjects(&doc, d), ["F", "Im3"]);
assert_eq!(page_xobjects(&doc, k), ["Fk", "ImK"]);
assert_eq!(page_xobjects(&doc, g), ["Im6"]);
assert_eq!(page_xobjects(&doc, l), ["ImL", "ImL2"]);
assert_eq!(page_xobjects(&doc, z), ["Z1"]);
assert_eq!(xobject_names(&doc, &Object::Ref(shared)), ["X1", "X2"]);
assert_eq!(page_xobjects(&doc, tree), ["Inh", "Old"]);
assert_eq!(page_xobjects(&doc, r), ["Im9"]);
let after = write_full(&doc, &SaveOptions::default()).unwrap();
assert_eq!((count_images(&before), count_images(&after)), (17, 13), "the dropped images are gone from the file");
}
#[test]
fn nothing_is_dropped_when_a_stream_cannot_be_checked() {
// A page with an image nothing draws, and an appearance stream on it.
let build = |stream: Stream| {
let mut doc = Document::new_empty();
let unused = image(&mut doc, 64, 64, 3, false, None);
let c = content(&mut doc, "");
let page = raw_page(&mut doc, Object::Dict(resources(xobjects(&[("Im1", unused)]))), Some(c));
let other = doc.add(Object::Stream(stream));
let mut appearance = Dict::new();
appearance.set(b"N".to_vec(), Object::Ref(other));
let mut annot = Dict::new();
annot.set(b"AP".to_vec(), Object::Dict(appearance));
doc.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(vec![Object::Dict(annot)]))).unwrap();
let report = optimize(&mut doc, &Settings::default()).unwrap();
(report.unused_xobjects, page_xobjects(&doc, page))
};
let (gone, kept) = ((1, Vec::<String>::new()), (0, vec!["Im1".to_string()]));
// Valid compressed data with extra keys.
let flate = |extra: &[(&str, Object)]| {
let mut s = Stream::flate(Dict::new(), b"0 0 m");
s.dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
for (k, v) in extra {
s.dict.set(k.as_bytes().to_vec(), v.clone());
}
s
};
let parms = |k: &str, v: Object| {
let mut p = Dict::new();
p.set(k.as_bytes().to_vec(), v);
Object::Dict(p)
};
// Readable: the image goes.
assert_eq!(build(flate(&[])), gone);
assert_eq!(build(flate(&[("DecodeParms", parms("Predictor", Object::Int(1)))])), gone);
// The name written with an escape still counts.
assert_eq!(build(Stream::flate(Dict::new(), b"/Im#31 Do")), kept);
// Read differently by viewers, or not at all: nothing goes.
let dangling = Object::Ref(ObjRef::new(999, 0));
let cases = [
Stream::from_raw(flate(&[]).dict, b"not zlib data".to_vec()),
flate(&[("Filter", dangling.clone())]),
flate(&[("Filter", Object::Array(vec![Object::name("FlateDecode"), dangling.clone()]))]),
flate(&[("Filter", Object::Array(vec![Object::name("Crypt"), Object::name("FlateDecode")]))]),
flate(&[("DP", parms("Predictor", Object::Int(1)))]),
flate(&[("DecodeParms", Object::Array(vec![Object::Dict(Dict::new()), Object::Dict(Dict::new())]))]),
flate(&[("DecodeParms", parms("Predictor", dangling))]),
flate(&[("DecodeParms", parms("Predictor", Object::Real(12.5)))]),
flate(&[("F", Object::name("external.dat"))]),
// An image codec on a stream drawn as an appearance: viewers decode it as content.
Stream::from_raw(
{
let mut d = Dict::new();
d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode"));
d
},
vec![0; 8],
),
];
for s in cases {
let dict = s.dict.clone();
assert_eq!(build(s), kept, "{dict:?}");
}
}
+62 -12
View File
@@ -10,7 +10,10 @@
//! encoding dictionaries, and colour-space arrays. Objects whose *identity* matters (pages,
//! annotations, form fields, layers, outline items, structure elements) are never merged.
//! - Two objects are equal when they are structurally identical with references compared after
//! merging, so a font dictionary merges once its font file has. This runs to a fixpoint.
//! merging, so a font dictionary merges once its font file has. This runs to a fixpoint. A
//! reference to a plain value (a name, number, boolean or string, such as ImageMagick's
//! `/ColorSpace 10 0 R` → `/DeviceRGB`) compares as that value; the value objects themselves
//! are never merged.
//! - Only `candidates` (objects created by the operation) are ever replaced or rewritten. Existing
//! objects can serve as the merge target but are not changed, so an incremental save stays
//! small.
@@ -84,7 +87,14 @@ fn canon(map: &HashMap<ObjRef, ObjRef>, mut r: ObjRef) -> ObjRef {
r
}
fn hash_obj(o: &Object, map: &HashMap<ObjRef, ObjRef>, h: &mut impl Hasher) {
/// The value behind `r` when it is a plain value (name, number, boolean or string): it then
/// means that value wherever it is used, whichever object holds it (#878).
fn plain_value(doc: &Document, r: ObjRef) -> Option<std::sync::Arc<Object>> {
let o = doc.get(r);
matches!(*o, Object::Bool(_) | Object::Int(_) | Object::Real(_) | Object::Name(_) | Object::String(_)).then_some(o)
}
fn hash_obj(o: &Object, doc: &Document, map: &HashMap<ObjRef, ObjRef>, h: &mut impl Hasher) {
match o {
Object::Null => 0u8.hash(h),
Object::Bool(b) => (1u8, b).hash(h),
@@ -95,7 +105,7 @@ fn hash_obj(o: &Object, map: &HashMap<ObjRef, ObjRef>, h: &mut impl Hasher) {
Object::Array(a) => {
(6u8, a.len()).hash(h);
for x in a {
hash_obj(x, map, h);
hash_obj(x, doc, map, h);
}
}
Object::Dict(d) => {
@@ -104,28 +114,39 @@ fn hash_obj(o: &Object, map: &HashMap<ObjRef, ObjRef>, h: &mut impl Hasher) {
for (k, v) in d.iter() {
let mut eh = std::collections::hash_map::DefaultHasher::new();
k.hash(&mut eh);
hash_obj(v, map, &mut eh);
hash_obj(v, doc, map, &mut eh);
acc = acc.wrapping_add(eh.finish());
}
(7u8, d.len(), acc).hash(h);
}
Object::Stream(s) => {
8u8.hash(h);
hash_obj(&Object::Dict(s.dict.clone()), map, h);
hash_obj(&Object::Dict(s.dict.clone()), doc, map, h);
s.raw[..].hash(h);
}
Object::Ref(r) => {
let c = canon(map, *r);
(9u8, c.num, c.generation).hash(h);
match plain_value(doc, c) {
Some(v) => hash_obj(&v, doc, map, h),
None => (9u8, c.num, c.generation).hash(h),
}
}
}
}
fn eq_obj(a: &Object, b: &Object, map: &HashMap<ObjRef, ObjRef>) -> bool {
fn eq_obj(a: &Object, b: &Object, doc: &Document, map: &HashMap<ObjRef, ObjRef>) -> bool {
// Plain values behind references compare as the values (as `hash_obj` hashes them).
let plain = |o: &Object| if let Object::Ref(r) = o { plain_value(doc, canon(map, *r)) } else { None };
match (plain(a), plain(b)) {
(Some(x), Some(y)) => return eq_obj(&x, &y, doc, map),
(Some(x), None) => return eq_obj(&x, b, doc, map),
(None, Some(y)) => return eq_obj(a, &y, doc, map),
(None, None) => {}
}
match (a, b) {
(Object::Array(x), Object::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(p, q)| eq_obj(p, q, map)),
(Object::Dict(x), Object::Dict(y)) => x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| eq_obj(v, w, map))),
(Object::Stream(x), Object::Stream(y)) => x.raw == y.raw && eq_obj(&Object::Dict(x.dict.clone()), &Object::Dict(y.dict.clone()), map),
(Object::Array(x), Object::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(p, q)| eq_obj(p, q, doc, map)),
(Object::Dict(x), Object::Dict(y)) => x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| eq_obj(v, w, doc, map))),
(Object::Stream(x), Object::Stream(y)) => x.raw == y.raw && eq_obj(&Object::Dict(x.dict.clone()), &Object::Dict(y.dict.clone()), doc, map),
(Object::Ref(x), Object::Ref(y)) => canon(map, *x) == canon(map, *y),
(Object::Real(x), Object::Real(y)) => x.to_bits() == y.to_bits(),
(Object::String(x), Object::String(y)) => x.bytes == y.bytes,
@@ -201,9 +222,9 @@ pub fn dedupe_resources(doc: &mut Document, candidates: &[ObjRef], index_existin
continue;
}
let mut h = std::collections::hash_map::DefaultHasher::new();
hash_obj(o, &map, &mut h);
hash_obj(o, doc, &map, &mut h);
let bucket = buckets.entry(h.finish()).or_default();
let target = bucket.iter().map(|j| &universe[*j]).find(|(_, other)| eq_obj(o, other, &map)).map(|(t, _)| *t);
let target = bucket.iter().map(|j| &universe[*j]).find(|(_, other)| eq_obj(o, other, doc, &map)).map(|(t, _)| *t);
match target {
Some(t) if cand.contains(r) => {
map.insert(*r, t);
@@ -304,6 +325,35 @@ mod tests {
assert_eq!(doc.get(p1), doc.get(p2), "both pages now use the one graphics state");
}
fn image(doc: &mut Document, colour_space: Object) -> ObjRef {
let mut d = Dict::new();
d.set(b"Subtype".to_vec(), Object::name("Image"));
d.set(b"ColorSpace".to_vec(), colour_space);
doc.add(Object::Stream(Stream { dict: d, raw: b"pixels".to_vec().into() }))
}
#[test]
fn references_to_plain_values_compare_as_the_values() {
// ImageMagick writes `/ColorSpace 10 0 R` with `10 0 obj /DeviceRGB`, one per file (#878).
let mut doc = Document::new_empty();
let rgb1 = doc.add(Object::name("DeviceRGB"));
let rgb2 = doc.add(Object::name("DeviceRGB"));
let grey = doc.add(Object::name("DeviceGray"));
let a = image(&mut doc, Object::Ref(rgb1));
let b = image(&mut doc, Object::Ref(rgb2));
let c = image(&mut doc, Object::name("DeviceRGB")); // written directly: the same image
let d = image(&mut doc, Object::Ref(grey)); // same pixels, another colour space
let mut page = Dict::new();
page.set(b"Images".to_vec(), Object::Array([a, b, c, d].map(Object::Ref).to_vec()));
let p = doc.add(Object::Dict(page));
assert_eq!(dedupe_resources(&mut doc, &[rgb1, rgb2, grey, a, b, c, d, p], false), 2);
let page = doc.get(p);
let images = page.as_dict().unwrap().get(b"Images").unwrap().as_array().unwrap().clone();
assert_eq!(images, [a, a, a, d].map(Object::Ref).to_vec());
let nums = doc.object_numbers();
assert!(nums.contains(&rgb1.num) && nums.contains(&rgb2.num), "the values themselves are never merged");
}
#[test]
fn existing_objects_are_targets_but_never_changed() {
let mut doc = Document::new_empty();
+135 -4
View File
@@ -237,8 +237,19 @@ fn import_pages_mapped(dst: &mut Document, src: &Document, src_pages: &[usize],
let mut new_pages = Vec::new();
let mut done: HashMap<ObjRef, ObjRef> = HashMap::new();
for (page, new, inherited) in targets {
// A page listed twice: make a second, independent page object sharing resources.
let new = if done.contains_key(&page) { dst.add(Object::Null) } else { new };
// A page listed twice: make a second, independent page object sharing resources, and
// with copies of its own annotations (an annotation belongs to one page: its `/P`, its
// popup, a widget's place). Destinations still map to the pages copied.
let repeat = done.contains_key(&page);
let new = if repeat { dst.add(Object::Null) } else { new };
let mut own = repeat.then(|| Copier {
src,
map: HashMap::new(),
pages: copier.pages.clone(),
annots: Vec::new(),
fields: Vec::new(),
ocgs: Vec::new(),
});
let src_dict = src.get(page).as_dict().cloned().unwrap_or_default();
let mut d = Dict::new();
for (k, v) in src_dict.iter() {
@@ -249,13 +260,48 @@ fn import_pages_mapped(dst: &mut Document, src: &Document, src_pages: &[usize],
let list = src.resolve(v).as_array().cloned().unwrap_or_default();
let mut out = Vec::new();
for a in list {
// On a repeated page a form field's widget becomes one more widget of the
// same field (copied already with the page's first copy): one field, one
// value, shown on both pages. Copying the field again would make two fields
// of one name, filled in separately.
let resolved = src.resolve(&a);
let field = resolved.as_dict().filter(|d| d.name(b"Subtype") == Some(b"Widget")).and_then(|d| d.reference(b"Parent"));
if repeat
&& let Some(parent) = field
&& let Some(copied) = copier.map.get(&parent).copied()
{
let r = dst.add(Object::Null);
let c = copier.copy_value(dst, &resolved, Some(r));
dst.set(r, c);
// `/Kids` may be an array object of its own: add to it there, so the
// field keeps its other widgets.
match dst.get(copied).as_dict().and_then(|f| f.get(b"Kids").cloned()) {
Some(Object::Ref(list)) => {
let mut kids = dst.get(list).as_array().cloned().unwrap_or_default();
kids.push(Object::Ref(r));
dst.set(list, Object::Array(kids));
}
_ => dst.update_dict(copied, |f| {
let mut kids = f.get(b"Kids").and_then(|k| k.as_array()).cloned().unwrap_or_default();
kids.push(Object::Ref(r));
f.set(b"Kids".to_vec(), Object::Array(kids));
})?,
}
annot_pages.insert(r, new);
out.push(Object::Ref(r));
continue;
}
// A widget that is its own field (no parent to hang a second widget on) stays
// the one object, shown on both pages, so the field stays one.
let own_field = resolved.as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget") && !d.contains(b"Parent"));
let annots = if own_field { &mut copier } else { own.as_mut().unwrap_or(&mut copier) };
// Direct (inline) annotation dictionaries become indirect objects, so every
// copied annotation can be fixed up the same way.
let r = match a {
Object::Ref(r) => copier.copy_ref(dst, r),
Object::Ref(r) => annots.copy_ref(dst, r),
Object::Dict(_) => {
let r = dst.add(Object::Null);
let c = copier.copy_value(dst, &a, Some(r));
let c = annots.copy_value(dst, &a, Some(r));
dst.set(r, c);
r
}
@@ -280,6 +326,12 @@ fn import_pages_mapped(dst: &mut Document, src: &Document, src_pages: &[usize],
d.set(b"Type".to_vec(), Object::name("Page"));
d.set(b"Parent".to_vec(), Object::Ref(root));
dst.set(new, Object::Dict(d));
// The repeat's own annotations are fixed up, its fields and layers registered, with the rest.
if let Some(own) = own {
copier.annots.extend(own.annots);
copier.fields.extend(own.fields);
copier.ocgs.extend(own.ocgs);
}
done.insert(page, new);
new_pages.push(new);
}
@@ -529,6 +581,85 @@ pub fn combine_selected(sources: &[(&str, &Document, Option<&[usize]>)]) -> Resu
Ok(out)
}
/// One run of pages for [`combine_grouped`]: the file it comes from (`group`: runs with the same
/// group are one file split around, and give the same document), its title, the document, and
/// its pages (0-based, in that order; `None` for all of them).
pub type Run<'a> = (usize, &'a str, &'a Document, Option<&'a [usize]>);
/// Combine Files where a file's pages may be split into several runs with other files' pages
/// between them. Each file (group) is copied once, all its pages together, then the pages are put
/// in the runs' order: its links between its own pages, its form fields and its attachments stay
/// whole, and it gets one top-level bookmark (the title of its first run) at its first page, its
/// own bookmarks nested. Otherwise as [`combine_selected`] (print standard, shared resources).
pub fn combine_grouped(runs: &[Run<'_>]) -> Result<Document, OrganizeError> {
// Each file's pages, in the order its runs list them, and each run's place in that list.
struct File<'a> {
title: &'a str,
src: &'a Document,
pages: Vec<usize>,
}
let mut files: Vec<(usize, File<'_>)> = Vec::new();
let mut slices: Vec<(usize, usize, usize)> = Vec::with_capacity(runs.len());
for (group, title, src, chosen) in runs {
let at = match files.iter().position(|(g, _)| g == group) {
Some(at) => at,
None => {
files.push((*group, File { title, src, pages: Vec::new() }));
files.len() - 1
}
};
let Some((_, file)) = files.get_mut(at) else { continue };
let n = crate::page_count(file.src)?;
let pages: Vec<usize> = match chosen {
Some(p) => {
if let Some(bad) = p.iter().find(|i| **i >= n) {
return Err(OrganizeError::NoSuchPage(*bad));
}
p.to_vec()
}
None => (0..n).collect(),
};
slices.push((at, file.pages.len(), pages.len()));
file.pages.extend(pages);
}
let mut out = Document::new_empty();
let mut marks = Vec::new();
let mut attachments = Vec::new();
let mut copies: Vec<Vec<ObjRef>> = Vec::with_capacity(files.len());
let mut standard: Option<(crate::pdfx::PrintStandard, &Document, HashMap<ObjRef, ObjRef>)> = None;
let mut agreed = true;
for (_, file) in &files {
let at = crate::page_count(&out)?;
let imported = import_pages_mapped(&mut out, file.src, &file.pages, at)?;
copies.push(imported.pages.clone());
if let Some(first) = imported.pages.first() {
marks.push((file.title.to_string(), *first, file.src, imported.page_map));
}
collect_attachments(&mut out, file.src, &mut attachments);
let declared = crate::pdfx::PrintStandard::of(file.src);
match &standard {
None => standard = Some((declared, file.src, imported.objects)),
Some((first, _, _)) => agreed &= first.same_as(&declared),
}
}
// The pages in the runs' order. A file's first run comes before its others, and its pages
// were copied in run order, so its bookmark (its first copied page) is its first page shown.
let mut order: Vec<(ObjRef, Dict)> = Vec::new();
for (file, start, len) in slices {
let run = copies.get(file).and_then(|c| c.get(start..start.saturating_add(len))).unwrap_or_default();
order.extend(run.iter().map(|r| (*r, Dict::new())));
}
rebuild(&mut out, &order)?;
if let Some((declared, src, objects)) = standard.filter(|_| agreed) {
crate::pdfx::carry(&mut out, src, &declared, objects)?;
}
let all: Vec<ObjRef> = out.object_numbers().into_iter().map(|n| ObjRef::new(n, out.generation(n))).collect();
crate::dedupe::dedupe_resources(&mut out, &all, false);
add_outline(&mut out, &marks)?;
set_attachments(&mut out, attachments)?;
Ok(out)
}
/// Copy a source's document-level attachments (`/Names /EmbeddedFiles`) into `dst`.
fn collect_attachments(dst: &mut Document, src: &Document, out: &mut Vec<(Vec<u8>, Object)>) {
let Some(tree) = src
+176 -37
View File
@@ -7,7 +7,7 @@
use std::collections::HashSet;
use pdfcraft_cos::page_labels::{MAX_LABEL_BYTES, MAX_LABEL_TOTAL_BYTES, MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_WORK, MAX_PREFIX_BYTES, alpha, roman};
use pdfcraft_cos::page_labels::{MAX_LABEL_BYTES, MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_WORK, MAX_PREFIX_BYTES, alpha, roman};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString};
use crate::{OrganizeError, page_count, pages_root};
@@ -232,40 +232,114 @@ fn checked_ranges(doc: &Document) -> Result<Vec<LabelRange>, OrganizeError> {
Ok(unique)
}
/// Every page's label. `lenient` (reading a file): a range whose label can't be formatted
/// shows physical page numbers for its pages instead of failing every label.
fn labels_from_ranges(n: usize, ranges: &[LabelRange], lenient: bool) -> Result<Vec<String>, OrganizeError> {
let mut labels = Vec::new();
let mut total = 0usize;
let mut ranges = ranges.iter().peekable();
let mut active = None;
for p in 0..n {
while ranges.peek().is_some_and(|r| r.start <= p) {
active = ranges.next();
}
let label = match active {
Some(r) => {
let label = match r.label(p) {
Ok(label) => label,
Err(_) if lenient => String::new(),
Err(e) => return Err(e),
};
total = total.checked_add(label.len()).ok_or_else(|| invalid_label("the total label size overflows"))?;
if total > MAX_LABEL_TOTAL_BYTES {
return Err(invalid_label("custom labels exceed 4 MiB in total"));
}
if label.is_empty() { (p + 1).to_string() } else { label }
}
None => (p + 1).to_string(),
};
labels.push(label);
}
Ok(labels)
/// A view of every page's label: the sorted ranges and the page count, with no string kept per
/// page. [`PageLabels::label`] builds one label; [`PageLabels::iter`] builds them one at a time, so
/// memory follows the ranges, not the pages.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PageLabels {
ranges: Vec<LabelRange>,
pages: usize,
}
/// Every page's label, as a viewer shows it. Resource limits return an actionable error.
impl PageLabels {
/// Reads the document's `/PageLabels` and page count. A tree that is cyclic, too deep or too
/// large is an error.
pub fn new(doc: &Document) -> Result<Self, OrganizeError> {
Ok(Self { ranges: checked_ranges(doc)?, pages: page_count(doc)? })
}
/// The number of pages the labels cover.
pub fn pages(&self) -> usize {
self.pages
}
/// The label of page `page` (0-based), as a viewer shows it: a page with no range, or with an
/// empty range label, shows its physical number. Errors when the range's label cannot be
/// formatted (over 1,024 UTF-8 bytes, or a number past 32 bits).
pub fn label(&self, page: usize) -> Result<String, OrganizeError> {
if page >= self.pages {
return Err(OrganizeError::NoSuchPage(page));
}
let covering = self.ranges.partition_point(|r| r.start <= page);
let label = match covering.checked_sub(1).and_then(|i| self.ranges.get(i)) {
Some(range) => range.label(page)?,
None => String::new(),
};
Ok(if label.is_empty() { (page + 1).to_string() } else { label })
}
/// Every page's label, in page order, one at a time. Reading is lenient: a label that cannot
/// be formatted shows its physical page number, and the other ranges keep their labels.
pub fn iter(&self) -> impl Iterator<Item = String> + '_ {
(0..self.pages).map(|page| self.label(page).unwrap_or_else(|_| (page + 1).to_string()))
}
}
/// Every page's label, as a viewer shows it. This builds one string per page; for large
/// documents, read single labels or stream them through [`PageLabels`].
pub fn page_labels(doc: &Document) -> Result<Vec<String>, OrganizeError> {
labels_from_ranges(page_count(doc)?, &checked_ranges(doc)?, true)
Ok(PageLabels::new(doc)?.iter().collect())
}
/// Strict check for an edit: every label that the sorted, unique `ranges` produce over `pages`
/// pages must fit. A range is checked from its largest number, so the work follows the ranges
/// (and at most a thousand numbers at each end of a Roman range), not the pages.
fn check_spans(ranges: &[LabelRange], pages: usize) -> Result<(), OrganizeError> {
let Some(last_page) = pages.checked_sub(1) else { return Ok(()) };
for (i, r) in ranges.iter().enumerate() {
// Prefix-only labels have no number to check; a range past the last page is never shown.
if r.start > last_page || r.style == LabelStyle::None {
continue;
}
let end = ranges.get(i + 1).map_or(last_page, |next| next.start.saturating_sub(1).min(last_page));
let lo = u64::from(r.first);
let hi = lo.saturating_add(u64::try_from(end.saturating_sub(r.start)).unwrap_or(u64::MAX));
let prefix = u64::try_from(r.prefix.len()).unwrap_or(u64::MAX);
let remaining = (MAX_LABEL_BYTES as u64).checked_sub(prefix).ok_or_else(|| invalid_label("a prefix exceeds 1024 UTF-8 bytes"))?;
// Numbers past u32::MAX are never formatted, so a label that is too long comes first.
if longest_numeral(r.style, lo, hi.min(u64::from(u32::MAX))) > remaining {
return Err(invalid_label("a label exceeds 1024 UTF-8 bytes"));
}
if hi > u64::from(u32::MAX) {
return Err(invalid_label("a page number overflows"));
}
}
Ok(())
}
/// The longest numeral `style` writes for the numbers `lo..=hi` (`1 <= lo <= hi`), in bytes.
fn longest_numeral(style: LabelStyle, lo: u64, hi: u64) -> u64 {
match style {
// Decimal and alphabetic numerals only grow with the number, so the last one is longest.
LabelStyle::Decimal => u64::from(hi.checked_ilog10().unwrap_or(0)) + 1,
LabelStyle::UpperAlpha | LabelStyle::LowerAlpha => hi.saturating_sub(1) / 26 + 1,
LabelStyle::UpperRoman | LabelStyle::LowerRoman => longest_roman(lo, hi),
LabelStyle::None => 0,
}
}
/// The byte length of [`roman`]'s numeral for `n`: `n / 1000` copies of `m`, then the hundreds,
/// tens and units, each a table lookup.
fn roman_len(n: u64) -> u64 {
// Lengths of i, ii, iii, iv, v, vi, vii, viii, ix (and the same pattern for tens and hundreds).
const UNITS: [u64; 10] = [0, 1, 2, 3, 2, 1, 2, 3, 4, 2];
let digit = |place: u64| UNITS.get(usize::try_from(place % 10).unwrap_or_default()).copied().unwrap_or_default();
n / 1000 + digit(n / 100) + digit(n / 10) + digit(n)
}
/// The longest Roman numeral among `lo..=hi`, in bytes. Within one thousand the longest numeral is
/// the one ending in 888, and every whole thousand between the ends contains it, so only the
/// partial thousands at either end are read.
fn longest_roman(lo: u64, hi: u64) -> u64 {
let (first, last) = (lo / 1000, hi / 1000);
if first == last {
return (lo..=hi).map(roman_len).max().unwrap_or_default();
}
let head = (lo..=first.saturating_mul(1000).saturating_add(999)).map(roman_len).max().unwrap_or_default();
let tail = (last.saturating_mul(1000)..=hi).map(roman_len).max().unwrap_or_default();
// Each whole thousand between the ends peaks at its thousand plus 12 (for 888); the last is largest.
let middle = if last.saturating_sub(first) >= 2 { last.saturating_sub(1).saturating_add(12) } else { 0 };
head.max(tail).max(middle)
}
/// Replace all label ranges (an empty list removes `/PageLabels`).
@@ -293,7 +367,7 @@ pub fn set_page_label_ranges(doc: &mut Document, ranges: &[LabelRange]) -> Resul
if sorted.windows(2).any(|pair| pair.first().zip(pair.get(1)).is_some_and(|(a, b)| a.start == b.start)) {
return Err(invalid_label("range start pages must be unique"));
}
labels_from_ranges(page_count(doc)?, &sorted, false)?;
check_spans(&sorted, page_count(doc)?)?;
let mut nums = Vec::new();
for r in &sorted {
let mut spec = Dict::new();
@@ -366,12 +440,77 @@ pub fn number_pages(doc: &mut Document, from: usize, to: usize, style: LabelStyl
mod tests {
use super::*;
/// No limit on the whole sequence: 4,097 labels of 1,024 bytes (over 4 MiB in total) are all
/// readable, one at a time or by page.
#[test]
fn page_label_limits_bound_organizer_total_output() {
fn label_view_has_no_aggregate_limit() {
let range = LabelRange { start: 0, style: LabelStyle::None, prefix: "x".repeat(MAX_LABEL_BYTES), first: u32::MAX };
let count = MAX_LABEL_TOTAL_BYTES / MAX_LABEL_BYTES;
assert_eq!(labels_from_ranges(count, std::slice::from_ref(&range), false).unwrap().len(), count);
assert!(labels_from_ranges(count + 1, &[range], false).unwrap_err().to_string().contains("4 MiB"));
let view = PageLabels { ranges: vec![range], pages: 4_097 };
assert_eq!(view.iter().map(|label| label.len()).sum::<usize>(), 4_097 * MAX_LABEL_BYTES);
assert_eq!(view.label(4_096).unwrap(), "x".repeat(MAX_LABEL_BYTES));
}
/// The view keeps the ranges, not one label per page: a billion-page document costs no more to
/// view than a short one, and a late label is built without the labels before it.
#[test]
fn label_view_does_not_grow_with_the_page_count() {
let prefix = "x".repeat(1000);
let range = LabelRange { start: 0, style: LabelStyle::Decimal, prefix: prefix.clone(), first: 1 };
let view = PageLabels { ranges: vec![range], pages: 1_000_000_000 };
assert_eq!(view.label(999_999_999).unwrap(), format!("{prefix}1000000000"));
assert_eq!(view.iter().take(2).collect::<Vec<_>>(), [format!("{prefix}1"), format!("{prefix}2")]);
assert_eq!(view.label(1_000_000_000), Err(OrganizeError::NoSuchPage(1_000_000_000)));
}
/// The numeral length formula and the bounded Roman search agree with the formatter.
#[test]
fn numeral_bounds_match_the_formatters() {
for n in (1..=3_000u64).chain([9_999, 10_000, 999_999, 1_000_000, 1_012_888, 1_013_888, u64::from(u32::MAX)]) {
match roman(n, MAX_LABEL_BYTES) {
Some(numeral) => assert_eq!(roman_len(n), numeral.len() as u64, "{n}"),
None => assert!(roman_len(n) > MAX_LABEL_BYTES as u64, "{n}"),
}
}
assert_eq!(longest_numeral(LabelStyle::UpperRoman, 888, 888), 12);
assert_eq!(longest_numeral(LabelStyle::Decimal, 1, 1_000), 4);
assert_eq!(longest_numeral(LabelStyle::LowerAlpha, 1, 27), 2);
let mut seed = 0x2545_f491_4f6c_dd1du64;
for _ in 0..400 {
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
let lo = 1 + (seed >> 33) % 3_000_000;
let hi = lo + (seed >> 40) % 2_500;
assert_eq!(longest_roman(lo, hi), (lo..=hi).map(roman_len).max().unwrap_or_default(), "{lo}..={hi}");
}
}
/// The edit check accepts exactly the ranges whose every label the per-page check accepts,
/// including spans that cross a thousand, numbers past 32 bits and long prefixes.
#[test]
fn span_check_agrees_with_per_label_checks() {
let styles = [LabelStyle::None, LabelStyle::Decimal, LabelStyle::UpperRoman, LabelStyle::LowerAlpha];
let firsts = [1, 3, 990, 999, 1_012_880, 1_013_990, 1_014_000, u32::MAX - 30, u32::MAX];
let prefixes = [0, 500, 1000, 1010, 1020, 1024];
let mut seed = 0x9e37_79b9_7f4a_7c15u64;
let mut next = |bound: usize| {
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
usize::try_from((seed >> 33) % bound as u64).unwrap_or_default()
};
for _ in 0..3_000 {
let pages = next(60);
let count = 1 + next(3);
let mut starts: Vec<usize> = (0..count).map(|_| next(pages + 3)).collect();
starts.sort_unstable();
starts.dedup();
let mut ranges = Vec::new();
for start in starts {
let style = styles[next(styles.len())];
let prefix = "p".repeat(prefixes[next(prefixes.len())]);
let first = firsts[next(firsts.len())];
ranges.push(LabelRange { start, style, prefix, first });
}
let per_label = (0..pages).all(|p| ranges.iter().rev().find(|r| r.start <= p).is_none_or(|r| r.label(p).is_ok()));
assert_eq!(check_spans(&ranges, pages).is_ok(), per_label, "{ranges:?} over {pages} pages");
}
}
#[test]
+153 -6
View File
@@ -24,11 +24,11 @@ pub mod view;
pub use boxes::{BoxSpec, PageBox, page_boxes, set_page_box};
pub use dedupe::dedupe_resources;
pub use import::{SplitBy, combine, combine_selected, extract_pages, import_pages, page_as_form, split, split_ranges};
pub use labels::{LabelRange, LabelStyle, number_pages, page_label_ranges, page_labels, set_page_label_ranges};
pub use import::{Run, SplitBy, combine, combine_grouped, combine_selected, extract_pages, import_pages, page_as_form, split, split_ranges};
pub use labels::{LabelRange, LabelStyle, PageLabels, number_pages, page_label_ranges, page_labels, set_page_label_ranges};
pub use outline::{
Bookmark, OutlineEntry, OutlineError, add_bookmark, add_bookmark_tree, bookmarks, delete_bookmark, move_bookmark, rename_bookmark,
set_bookmark_open, set_bookmark_page,
Bookmark, BookmarkPage, OutlineEntry, OutlineError, add_bookmark, add_bookmark_tree, bookmark_page, bookmarks, delete_bookmark, move_bookmark,
rename_bookmark, set_bookmark_open, set_bookmark_page, top_level_bookmarks,
};
pub use view::{InitialView, displays_doc_title, initial_view, set_initial_view};
@@ -149,6 +149,9 @@ fn check(indices: &[usize], n: usize) -> Result<(), OrganizeError> {
/// Rotate pages by a multiple of 90° (positive = clockwise), adjusting `/Rotate` (§7.7.3.3).
pub fn rotate_pages(doc: &mut Document, indices: &[usize], degrees: i64) -> Result<(), OrganizeError> {
if degrees % 90 != 0 {
return Err(OrganizeError::Invalid("rotation must be a multiple of 90 degrees".into()));
}
let all = walk(doc)?;
check(indices, all.len())?;
let delta = (degrees / 90) * 90;
@@ -192,8 +195,9 @@ pub fn delete_pages(doc: &mut Document, indices: &[usize]) -> Result<(), Organiz
}
/// Bookmarks, and links on the `keep` pages, that go to one of the `gone` pages lose that
/// destination (/Dest, or a GoTo /A), rather than pointing at nothing.
/// destination (/Dest, or a GoTo /A), rather than pointing at nothing; so does the catalog's /OpenAction.
fn drop_destinations_to(doc: &mut Document, gone: &[ObjRef], keep: &[(ObjRef, Dict)]) -> Result<(), OrganizeError> {
let dead_names = drop_named_destinations(doc, gone)?;
let mut holders = outline::items(doc);
for (p, _) in keep {
let annots = doc.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()).map(|a| doc.resolve(&a).as_array().cloned().unwrap_or_default());
@@ -206,7 +210,12 @@ fn drop_destinations_to(doc: &mut Document, gone: &[ObjRef], keep: &[(ObjRef, Di
);
}
let to_gone = |o: Option<&Object>| {
o.map(|o| doc.resolve(o)).and_then(|o| o.as_array().and_then(|a| a.first()).and_then(Object::as_ref)).is_some_and(|r| gone.contains(&r))
let Some(o) = o.map(|o| doc.resolve(o)) else { return false };
match &*o {
Object::Name(n) => dead_names.contains(n),
Object::String(s) => dead_names.contains(&s.bytes),
_ => goes_to(doc, &o, gone),
}
};
let mut dead: Vec<(ObjRef, &[u8])> = Vec::new();
for h in holders {
@@ -219,6 +228,18 @@ fn drop_destinations_to(doc: &mut Document, gone: &[ObjRef], keep: &[(ObjRef, Di
dead.push((h, b"A"));
}
}
// The catalog's /OpenAction is either a destination array or a GoTo action; one that opens a
// deleted page is dropped, as a bookmark's would be.
if let Some(root) = doc.root() {
let open = doc.get(root).as_dict().and_then(|c| c.get(b"OpenAction").map(|o| doc.resolve(o)));
let opens_gone = match open.as_deref() {
Some(Object::Dict(a)) => a.name(b"S") == Some(b"GoTo") && to_gone(a.get(b"D")),
other => to_gone(other),
};
if opens_gone {
dead.push((root, b"OpenAction"));
}
}
for (h, key) in dead {
doc.update_dict(h, |d| {
d.remove(key);
@@ -227,6 +248,132 @@ fn drop_destinations_to(doc: &mut Document, gone: &[ObjRef], keep: &[(ObjRef, Di
Ok(())
}
/// True when a destination (an array, or a dictionary whose /D is one) opens one of the `gone` pages.
fn goes_to(doc: &Document, dest: &Object, gone: &[ObjRef]) -> bool {
let dest = doc.resolve(dest);
let array = match dest.as_dict() {
Some(d) => d.get(b"D").map(|d| doc.resolve(d)),
None => Some(dest.clone()),
};
array.and_then(|a| a.as_array().and_then(|a| a.first()).and_then(Object::as_ref)).is_some_and(|r| gone.contains(&r))
}
/// Remove the named destinations (the catalog's /Names /Dests tree and the legacy /Dests
/// dictionary) that open one of the `gone` pages. Returns the removed names, so that the links
/// using them can lose their destination too. Names of surviving pages are left alone.
fn drop_named_destinations(doc: &mut Document, gone: &[ObjRef]) -> Result<Vec<Vec<u8>>, OrganizeError> {
let mut dead: Vec<Vec<u8>> = Vec::new();
let Some(root) = doc.root() else { return Ok(dead) };
let Some(catalog) = doc.get(root).as_dict().cloned() else { return Ok(dead) };
match catalog.get(b"Dests").cloned() {
Some(Object::Ref(r)) => {
if let Some(mut d) = doc.get(r).as_dict().cloned()
&& prune_legacy(doc, &mut d, gone, &mut dead)
{
doc.update_dict(r, |x| *x = d)?;
}
}
Some(Object::Dict(mut d)) => {
if prune_legacy(doc, &mut d, gone, &mut dead) {
doc.update_dict(root, |c| c.set(b"Dests".to_vec(), Object::Dict(d)))?;
}
}
_ => {}
}
match catalog.get(b"Names").cloned() {
Some(Object::Ref(n)) => {
let tree = doc.get(n).as_dict().and_then(|d| d.get(b"Dests").cloned());
if let Some(new) = tree.and_then(|t| prune_tree_slot(doc, &t, gone, &mut dead).transpose()).transpose()? {
doc.update_dict(n, |d| d.set(b"Dests".to_vec(), Object::Dict(new)))?;
}
}
Some(Object::Dict(mut names)) => {
let tree = names.get(b"Dests").cloned();
if let Some(new) = tree.and_then(|t| prune_tree_slot(doc, &t, gone, &mut dead).transpose()).transpose()? {
names.set(b"Dests".to_vec(), Object::Dict(new));
doc.update_dict(root, |c| c.set(b"Names".to_vec(), Object::Dict(names)))?;
}
}
_ => {}
}
Ok(dead)
}
/// Drop the entries of a legacy /Dests dictionary that go to a `gone` page. True when any went.
fn prune_legacy(doc: &Document, dests: &mut Dict, gone: &[ObjRef], dead: &mut Vec<Vec<u8>>) -> bool {
let keys: Vec<Vec<u8>> = dests.iter().filter(|(_, v)| goes_to(doc, v, gone)).map(|(k, _)| k.clone()).collect();
for k in &keys {
dests.remove(k);
}
dead.extend(keys.iter().cloned());
!keys.is_empty()
}
/// Prune a name tree held in `slot` (a reference, or a dictionary written inline). A reference is
/// updated in place and yields `None`; an inline dictionary that changed is handed back.
fn prune_tree_slot(doc: &mut Document, slot: &Object, gone: &[ObjRef], dead: &mut Vec<Vec<u8>>) -> Result<Option<Dict>, OrganizeError> {
let mut seen = Vec::new();
match slot {
Object::Ref(r) => {
prune_tree_ref(doc, *r, gone, dead, &mut seen)?;
Ok(None)
}
Object::Dict(d) => {
let mut node = d.clone();
Ok(prune_tree_node(doc, &mut node, gone, dead, &mut seen)?.then_some(node))
}
_ => Ok(None),
}
}
fn prune_tree_ref(doc: &mut Document, r: ObjRef, gone: &[ObjRef], dead: &mut Vec<Vec<u8>>, seen: &mut Vec<ObjRef>) -> Result<(), OrganizeError> {
if seen.contains(&r) {
return Ok(());
}
seen.push(r);
let Some(mut node) = doc.get(r).as_dict().cloned() else { return Ok(()) };
if prune_tree_node(doc, &mut node, gone, dead, seen)? {
doc.update_dict(r, |d| *d = node)?;
}
Ok(())
}
/// Drop the /Names pairs of one name-tree node that go to a `gone` page, then do the same for its
/// /Kids. True when `node` itself changed (kids held by reference are written back here).
fn prune_tree_node(
doc: &mut Document,
node: &mut Dict,
gone: &[ObjRef],
dead: &mut Vec<Vec<u8>>,
seen: &mut Vec<ObjRef>,
) -> Result<bool, OrganizeError> {
let mut changed = false;
if let Some(pairs) = node.get(b"Names").map(|n| doc.resolve(n)).and_then(|n| n.as_array().cloned()) {
let mut kept = Vec::with_capacity(pairs.len());
for pair in pairs.chunks(2) {
match pair {
[key, value] if goes_to(doc, value, gone) => {
if let Some(s) = doc.resolve(key).as_string() {
dead.push(s.bytes.clone());
}
}
_ => kept.extend_from_slice(pair),
}
}
if kept.len() != pairs.len() {
node.set(b"Names".to_vec(), Object::Array(kept));
changed = true;
}
}
let kids = node.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default();
for kid in kids {
if let Some(r) = kid.as_ref() {
prune_tree_ref(doc, r, gone, dead, seen)?;
}
}
Ok(changed)
}
/// Take widgets out of the form: out of their field's /Kids, or out of /AcroForm /Fields when
/// they are fields themselves. A field left without kids goes too.
fn drop_widgets(doc: &mut Document, mut widgets: Vec<ObjRef>) -> Result<(), OrganizeError> {
+279 -38
View File
@@ -7,25 +7,45 @@
//! Edits are surgical: they relink only the affected siblings (`/Parent`, `/Prev`, `/Next`,
//! `/First`, `/Last`) and recompute `/Count` values, and an object is rewritten only when one of
//! its values changes. Everything else on an item (colour, style, actions, unknown keys) is kept.
//! Walks are cycle- and size-safe; a broken outline is never followed forever.
//! Walks are cycle-safe and iterative, so a broken or deep outline is never followed forever and
//! never overflows the stack. Listing is lazy: [`bookmark_page`] visits only the bookmarks up to
//! the end of the page it returns.
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString};
use crate::{OrganizeError, walk};
/// Hard cap on items visited (broken or hostile outlines).
const MAX_ITEMS: usize = 100_000;
/// Deepest level a listing enters (the top level is 0). Bookmarks deeper than this are not
/// listed; edits still reach them by path.
const LIST_DEPTH: usize = 32;
/// A bookmark as stored in the file.
/// Deepest name-tree node read when looking up named destinations.
const NAME_TREE_DEPTH: usize = 32;
/// Deepest chain of indirect or named destinations followed for one bookmark.
const DEST_DEPTH: u8 = 8;
/// A bookmark in listing order, with where it sits and where it goes.
#[derive(Clone, Debug, PartialEq)]
pub struct Bookmark {
pub obj: ObjRef,
/// Child indices from the top level (the paths the edit functions take).
pub path: Vec<usize>,
pub title: String,
/// Shown expanded (a positive `/Count`) when it has children.
pub open: bool,
pub children: Vec<Bookmark>,
/// The 0-based page it goes to, when that page is in this document.
pub page: Option<usize>,
}
/// One page of the outline listing (see [`bookmark_page`]).
#[derive(Clone, Debug, PartialEq)]
pub struct BookmarkPage {
pub bookmarks: Vec<Bookmark>,
/// The offset where the next page starts, or `None` when this page is the last.
pub next: Option<usize>,
}
#[derive(Debug, thiserror::Error, PartialEq)]
@@ -56,7 +76,7 @@ fn children_of(doc: &Document, parent: ObjRef, seen: &mut HashSet<ObjRef>) -> Ve
let mut out = Vec::new();
let mut next = doc.get(parent).as_dict().and_then(|d| d.reference(b"First"));
while let Some(r) = next {
if seen.len() >= MAX_ITEMS || !seen.insert(r) {
if !seen.insert(r) {
break;
}
let item = doc.get(r);
@@ -79,23 +99,229 @@ pub(crate) fn items(doc: &Document) -> Vec<ObjRef> {
out
}
/// The bookmark tree.
pub fn bookmarks(doc: &Document) -> Vec<Bookmark> {
let Some(root) = outline_root(doc) else { return Vec::new() };
let mut seen = HashSet::from([root]);
fn build(doc: &Document, parent: ObjRef, seen: &mut HashSet<ObjRef>) -> Vec<Bookmark> {
children_of(doc, parent, seen)
.into_iter()
.map(|r| {
let item = doc.get(r);
let d = item.as_dict().cloned().unwrap_or_default();
let title = d.get(b"Title").map(|t| doc.resolve(t)).and_then(|t| t.as_string().map(|s| s.to_text())).unwrap_or_default();
let open = d.int(b"Count").is_some_and(|c| c > 0);
Bookmark { obj: r, title, open, children: build(doc, r, seen) }
})
.collect()
/// The outline in listing order: each bookmark once, siblings by `/Next`, children right after
/// their parent. An explicit stack keeps the walk iterative. A repeated or malformed item ends its
/// run of siblings there, so a cycle or a broken link stops the walk instead of looping.
struct Visit<'a> {
doc: &'a Document,
seen: HashSet<ObjRef>,
/// One entry per open level: the next sibling to visit, and how many siblings it has visited.
levels: Vec<(Option<ObjRef>, usize)>,
/// The deepest level the walk enters.
max_depth: usize,
/// The bookmark just visited: its children are entered by the next step.
enter: Option<ObjRef>,
}
impl<'a> Visit<'a> {
fn new(doc: &'a Document, max_depth: usize) -> Option<Self> {
let root = outline_root(doc)?;
let first = doc.get(root).as_dict().and_then(|d| d.reference(b"First"));
Some(Self { doc, seen: HashSet::from([root]), levels: vec![(first, 0)], max_depth, enter: None })
}
build(doc, root, &mut seen)
/// Visit the next bookmark, or `None` when the listing is done.
fn step(&mut self) -> Option<ObjRef> {
if let Some(first) = self.enter.take() {
self.levels.push((Some(first), 0));
}
loop {
let depth = self.levels.len().checked_sub(1)?;
let level = self.levels.get_mut(depth)?;
let Some(r) = level.0.take() else {
self.levels.pop();
continue;
};
if !self.seen.insert(r) {
continue;
}
let item = self.doc.get(r);
let Some(d) = item.as_dict() else { continue };
level.0 = d.reference(b"Next");
level.1 = level.1.saturating_add(1);
self.enter = if depth < self.max_depth { d.reference(b"First") } else { None };
return Some(r);
}
}
/// Child indices from the top level to the bookmark last visited.
fn path(&self) -> Vec<usize> {
self.levels.iter().map(|(_, visited)| visited.saturating_sub(1)).collect()
}
}
/// Page targets for a listing: the page index of each page object, and the named destinations
/// (read once, the first time one is needed).
struct Targets {
pages: HashMap<ObjRef, usize>,
names: Option<HashMap<Vec<u8>, Object>>,
}
impl Targets {
fn new(doc: &Document) -> Self {
let pages = walk(doc).unwrap_or_default().into_iter().enumerate().map(|(i, (r, _))| (r, i)).collect();
Self { pages, names: None }
}
/// The named destination `key`: its name-tree entry, else its entry in the `/Dests` dictionary.
fn named(&mut self, doc: &Document, key: &[u8]) -> Option<Object> {
let names = self.names.get_or_insert_with(|| name_tree(doc));
names.get(key).cloned().or_else(|| dests_dict(doc, key))
}
}
/// Every entry of the catalog's `/Names /Dests` name tree, by name. The first entry of a name wins.
/// Iterative, and nodes deeper than `NAME_TREE_DEPTH` are skipped.
fn name_tree(doc: &Document) -> HashMap<Vec<u8>, Object> {
let mut out = HashMap::new();
let Some(root) = doc.root() else { return out };
let Some(names) = doc.get(root).as_dict().and_then(|c| c.get(b"Names").cloned()) else { return out };
let names = doc.resolve(&names);
let Some(tree) = names.as_dict().and_then(|n| n.get(b"Dests").cloned()) else { return out };
// Nodes still to read, with their depth. Kids go on the stack in reverse, so they are read in order.
let mut stack = vec![(tree, 0usize)];
let mut seen: HashSet<ObjRef> = HashSet::new();
while let Some((raw, depth)) = stack.pop() {
if depth > NAME_TREE_DEPTH {
continue;
}
if let Object::Ref(r) = raw
&& !seen.insert(r)
{
continue;
}
let node = doc.resolve(&raw);
let Some(d) = node.as_dict() else { continue };
let pairs = d.get(b"Names").map(|n| doc.resolve(n));
if let Some(pairs) = pairs.as_deref().and_then(Object::as_array) {
for pair in pairs.chunks(2) {
if let [k, v] = pair
&& let Some(key) = name_key(doc, k)
{
out.entry(key).or_insert_with(|| v.clone());
}
}
}
let kids = d.get(b"Kids").map(|k| doc.resolve(k));
if let Some(kids) = kids.as_deref().and_then(Object::as_array) {
for kid in kids.iter().rev() {
stack.push((kid.clone(), depth + 1));
}
}
}
out
}
/// The bytes of a name-tree key, which is a string or a name.
fn name_key(doc: &Document, key: &Object) -> Option<Vec<u8>> {
match &*doc.resolve(key) {
Object::String(s) => Some(s.bytes.clone()),
Object::Name(n) => Some(n.clone()),
_ => None,
}
}
/// A named destination in the catalog's PDF 1.1 `/Dests` dictionary.
fn dests_dict(doc: &Document, key: &[u8]) -> Option<Object> {
let root = doc.root()?;
let dests = doc.get(root).as_dict().and_then(|c| c.get(b"Dests").cloned())?;
let dests = doc.resolve(&dests);
dests.as_dict()?.get(key).cloned()
}
/// The page index a destination names: an explicit `[page /XYZ …]` array, a dictionary whose `/D`
/// is one, or a named destination that resolves to one.
fn page_of_dest(doc: &Document, targets: &mut Targets, dest: &Object, depth: u8) -> Option<usize> {
if depth > DEST_DEPTH {
return None;
}
let dest = doc.resolve(dest);
match &*dest {
Object::Array(a) => match a.first()? {
Object::Ref(page) => targets.pages.get(page).copied(),
// Some producers write a page number in a remote-style destination.
Object::Int(n) => usize::try_from(*n).ok().filter(|&p| p < targets.pages.len()),
_ => None,
},
Object::Dict(d) => page_of_dest(doc, targets, d.get(b"D")?, depth + 1),
Object::String(s) => {
let inner = targets.named(doc, &s.bytes)?;
page_of_dest(doc, targets, &inner, depth + 1)
}
Object::Name(n) => {
let inner = targets.named(doc, n)?;
page_of_dest(doc, targets, &inner, depth + 1)
}
_ => None,
}
}
/// The page an item goes to: its `/Dest`, else the destination of its GoTo action `/A`.
fn item_page(doc: &Document, targets: &mut Targets, d: &Dict) -> Option<usize> {
if let Some(dest) = d.get(b"Dest")
&& let Some(page) = page_of_dest(doc, targets, dest, 0)
{
return Some(page);
}
let action = doc.resolve(d.get(b"A")?);
let dest = action.as_dict()?.get(b"D")?;
page_of_dest(doc, targets, dest, 0)
}
/// A bookmark's title as viewers show it: the decoded text, without NULs or surrounding space.
fn title_of(doc: &Document, d: &Dict) -> String {
let title = d.get(b"Title").map(|t| doc.resolve(t));
let text = match title.as_deref() {
Some(Object::String(s)) => s.to_text(),
Some(Object::Name(n)) => String::from_utf8_lossy(n).into_owned(),
_ => return String::new(),
};
text.trim_matches('\0').trim().to_string()
}
fn describe(doc: &Document, targets: &mut Targets, obj: ObjRef, path: Vec<usize>) -> Bookmark {
let item = doc.get(obj);
let (title, open, page) = match item.as_dict() {
Some(d) => (title_of(doc, d), d.int(b"Count").is_some_and(|c| c > 0), item_page(doc, targets, d)),
None => (String::new(), false, None),
};
Bookmark { obj, path, title, open, page }
}
/// Lists up to `limit` bookmarks from position `offset`, entering no level deeper than `max_depth`.
fn list(doc: &Document, max_depth: usize, offset: usize, limit: usize) -> BookmarkPage {
let mut bookmarks = Vec::new();
let Some(mut visit) = Visit::new(doc, max_depth) else { return BookmarkPage { bookmarks, next: None } };
for _ in 0..offset {
if visit.step().is_none() {
return BookmarkPage { bookmarks, next: None };
}
}
let mut targets = Targets::new(doc);
while bookmarks.len() < limit {
let Some(obj) = visit.step() else { break };
bookmarks.push(describe(doc, &mut targets, obj, visit.path()));
}
let next = if bookmarks.len() == limit && visit.step().is_some() { Some(offset.saturating_add(bookmarks.len())) } else { None };
BookmarkPage { bookmarks, next }
}
/// Every bookmark, in listing order (bookmarks deeper than the listing depth are left out).
pub fn bookmarks(doc: &Document) -> Vec<Bookmark> {
list(doc, LIST_DEPTH, 0, usize::MAX).bookmarks
}
/// The top-level bookmarks, in order.
pub fn top_level_bookmarks(doc: &Document) -> Vec<Bookmark> {
list(doc, 0, 0, usize::MAX).bookmarks
}
/// One page of the bookmarks in listing order: up to `limit` from position `offset`, and the offset
/// where the next page starts. Pages are contiguous, so following `next` lists every bookmark once.
/// Only the bookmarks up to the end of the page are visited, so the first page does not depend on
/// how many bookmarks come after it.
pub fn bookmark_page(doc: &Document, offset: usize, limit: usize) -> BookmarkPage {
list(doc, LIST_DEPTH, offset, limit)
}
/// The object of the bookmark at `path`, and its parent object (the outline root for top-level).
@@ -162,31 +388,46 @@ fn relink(doc: &mut Document, parent: ObjRef, kids: &[ObjRef]) -> Result<()> {
Ok(())
}
/// A node whose `/Count` [`recount`] is working out.
struct Counting {
node: ObjRef,
kids: Vec<ObjRef>,
/// The next child to count.
next: usize,
/// Items visible below the node, so far.
visible: i64,
is_root: bool,
}
/// Recompute every `/Count` (§12.3.3: open items count their visible descendants, closed items
/// the negative of what opening them would show; the root counts all visible items).
fn recount(doc: &mut Document) -> Result<()> {
let Some(root) = outline_root(doc) else { return Ok(()) };
let mut seen = HashSet::from([root]);
// Returns the number of items visible below `node` when `node` is open.
fn visit(doc: &mut Document, node: ObjRef, is_root: bool, seen: &mut HashSet<ObjRef>) -> Result<i64> {
let kids = children_of(doc, node, seen);
let mut visible = 0i64;
for k in &kids {
let below = visit(doc, *k, false, seen)?;
let open = doc.get(*k).as_dict().and_then(|d| d.int(b"Count")).is_some_and(|c| c > 0);
visible += 1 + if open { below } else { 0 };
let kids = children_of(doc, root, &mut seen);
let mut stack = vec![Counting { node: root, kids, next: 0, visible: 0, is_root: true }];
// A node is counted once its children are, so the walk keeps its own stack instead of recursing.
while let Some(top) = stack.last_mut() {
if let Some(&kid) = top.kids.get(top.next) {
top.next += 1;
let kids = children_of(doc, kid, &mut seen);
stack.push(Counting { node: kid, kids, next: 0, visible: 0, is_root: false });
continue;
}
let count = if kids.is_empty() {
let Some(done) = stack.pop() else { break };
let count = if done.kids.is_empty() {
None
} else if is_root || doc.get(node).as_dict().and_then(|d| d.int(b"Count")).is_some_and(|c| c > 0) {
Some(visible)
} else if done.is_root || doc.get(done.node).as_dict().and_then(|d| d.int(b"Count")).is_some_and(|c| c > 0) {
Some(done.visible)
} else {
Some(-visible)
Some(-done.visible)
};
put(doc, node, b"Count", count.map(Object::Int))?;
Ok(visible)
put(doc, done.node, b"Count", count.map(Object::Int))?;
if let Some(parent) = stack.last_mut() {
let open = doc.get(done.node).as_dict().and_then(|d| d.int(b"Count")).is_some_and(|c| c > 0);
parent.visible = parent.visible.saturating_add(1 + if open { done.visible } else { 0 });
}
}
visit(doc, root, true, &mut seen)?;
Ok(())
}
+483 -4
View File
@@ -16,6 +16,18 @@ fn page_rotation_resolves_inheritance_overrides_and_missing_pages() {
assert_eq!(page_rotation(&doc, 3), Err(OrganizeError::NoSuchPage(3)));
}
#[test]
fn rotate_rejects_angles_that_are_not_multiples_of_90() {
// The page tree only turns in quarter steps, so anything else used to be silently truncated
// by the integer division. It has to be an error, and the document must stay as it was.
let mut doc = Document::open(Arc::new(fixture())).unwrap();
assert_eq!(rotate_pages(&mut doc, &[0], 45), Err(OrganizeError::Invalid("rotation must be a multiple of 90 degrees".into())));
assert_eq!(rotate_pages(&mut doc, &[0], 91), Err(OrganizeError::Invalid("rotation must be a multiple of 90 degrees".into())));
assert_eq!(page_rotation(&doc, 0).unwrap(), 90);
rotate_pages(&mut doc, &[0], -90).unwrap();
assert_eq!(page_rotation(&doc, 0).unwrap(), 0);
}
/// A 3-page document with a nested page tree. MediaBox and Rotate are inherited from the root,
/// Resources from an intermediate node; each page's content says which page it is.
fn fixture() -> Vec<u8> {
@@ -158,6 +170,45 @@ fn deleted_pages_are_not_kept_by_what_points_at_them() {
assert_eq!(form.as_dict().and_then(|f| f.get(b"Fields")).and_then(Object::as_array).map(Vec::len), Some(0), "the field went with its page");
}
#[test]
fn deleting_the_opening_page_drops_the_open_action() {
let open_action = |doc: &Document| doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"OpenAction").cloned();
let set_open = |doc: &mut Document, value: Object| {
let root = doc.root().unwrap();
doc.update_dict(root, |c| c.set(b"OpenAction".to_vec(), value)).unwrap();
};
let page = |doc: &Document, i: usize| pages(doc).unwrap()[i].obj;
for as_action in [false, true] {
let target = |doc: &Document, i: usize| {
let dest = Object::Array(vec![Object::Ref(page(doc, i)), Object::Name(b"Fit".to_vec())]);
if as_action {
let mut a = Dict::new();
a.set(b"S".to_vec(), Object::Name(b"GoTo".to_vec()));
a.set(b"D".to_vec(), dest);
Object::Dict(a)
} else {
dest
}
};
// The opening page goes: no OpenAction is left to point at nothing.
let mut doc = doc_a();
let open = target(&doc, 0);
set_open(&mut doc, open);
delete_pages(&mut doc, &[0]).unwrap();
let out = full_roundtrip(&doc);
assert_eq!(labels(&out), ["A2", "A3"]);
assert_eq!(open_action(&out), None);
// Another page goes: the opening page keeps its OpenAction.
let mut doc = doc_a();
let open = target(&doc, 0);
set_open(&mut doc, open);
delete_pages(&mut doc, &[2]).unwrap();
let out = full_roundtrip(&doc);
assert_eq!(labels(&out), ["A1", "A2"]);
assert!(open_action(&out).is_some());
}
}
#[test]
fn cannot_delete_every_page_or_missing_pages() {
let mut doc = open(fixture());
@@ -227,6 +278,42 @@ fn full_save_drops_unreachable_objects() {
assert_eq!(hayro_syntax::Pdf::new(bytes).unwrap().pages().len(), 1);
}
#[test]
fn deleting_a_page_drops_the_named_destinations_to_it() {
// Four names (two in the /Names tree, two in the legacy /Dests dictionary) go to P1 or P3;
// P2 has a link for each. Deleting P1 must drop its names and their links, keep P3's.
let b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /Names << /Dests 6 0 R >> /Dests 7 0 R >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R >>".into(), // 3
"<< /Type /Page /Parent 2 0 R /Annots [8 0 R 9 0 R 10 0 R 11 0 R] >>".into(), // 4
"<< /Type /Page /Parent 2 0 R >>".into(), // 5
"<< /Names [(treeFirst) [3 0 R /Fit] (treeLast) [5 0 R /Fit]] >>".into(), // 6
"<< /legacyFirst [3 0 R /Fit] /legacyLast << /D [5 0 R /Fit] >> >>".into(), // 7
"<< /Type /Annot /Subtype /Link /Rect [0 0 9 9] /Dest (treeFirst) >>".into(), // 8
"<< /Type /Annot /Subtype /Link /Rect [0 0 9 9] /Dest (treeLast) >>".into(), // 9
"<< /Type /Annot /Subtype /Link /Rect [0 0 9 9] /Dest /legacyFirst >>".into(), // 10
"<< /Type /Annot /Subtype /Link /Rect [0 0 9 9] /A << /S /GoTo /D /legacyLast >> >>".into(), // 11
];
let mut doc = open(build(&b, "/Root 1 0 R"));
delete_pages(&mut doc, &[0]).unwrap();
let out = full_roundtrip(&doc);
let cat = out.get(out.root().unwrap()).as_dict().cloned().unwrap();
let tree = out.resolve(out.resolve(cat.get(b"Names").unwrap()).as_dict().unwrap().get(b"Dests").unwrap());
let pairs = tree.as_dict().unwrap().get(b"Names").and_then(Object::as_array).cloned().unwrap();
assert_eq!(pairs.len(), 2, "{pairs:?}");
assert_eq!(pairs[0].as_string().map(|s| s.bytes.clone()), Some(b"treeLast".to_vec()));
let page3 = pages(&out).unwrap()[1].obj;
assert_eq!(out.resolve(&pairs[1]).as_array().and_then(|a| a.first()).and_then(Object::as_ref), Some(page3));
let legacy = out.resolve(cat.get(b"Dests").unwrap()).as_dict().cloned().unwrap();
assert!(!legacy.contains(b"legacyFirst") && legacy.contains(b"legacyLast"), "{legacy:?}");
let links = annots(&out, 0);
assert!(!links[0].contains(b"Dest"), "{:?}", links[0]);
assert!(links[1].contains(b"Dest"), "{:?}", links[1]);
assert!(!links[2].contains(b"Dest"), "{:?}", links[2]);
assert!(links[3].contains(b"A"), "{:?}", links[3]);
}
// ── Combine / extract / split ──────────────────────────────────────────────────────────────────
/// Build a classic-xref PDF from object bodies (object i+1 = bodies[i]).
@@ -356,6 +443,44 @@ fn links_are_rewired_to_copied_pages_or_dropped() {
assert!(!link.contains(b"Dest") && !link.contains(b"A"));
}
/// A widget annotation that is its own form field (no `/Parent`).
fn own_field(doc: &Document, r: ObjRef) -> bool {
doc.get(r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget") && !d.contains(b"Parent"))
}
#[test]
fn repeated_page_gets_independent_annotations() {
// A1 has a link, A2 a link and a form field, A3 a note with a popup; each is listed twice.
let out = full_roundtrip(&extract_pages(&doc_a(), &[0, 0, 1, 1, 2, 2]).unwrap());
let ps = pages(&out).unwrap();
let refs: Vec<Vec<ObjRef>> = (0..6)
.map(|i| page_dict(&out, i).get(b"Annots").map(|a| out.resolve(a).as_array().cloned().unwrap_or_default()).unwrap_or_default())
.map(|list| list.iter().filter_map(|a| a.as_ref()).collect())
.collect();
for i in (0..6).step_by(2) {
assert!(!refs[i].is_empty());
assert_eq!(refs[i].len(), refs[i + 1].len());
for (a, b) in refs[i].iter().zip(&refs[i + 1]) {
// A widget that is its own field stays one object on both pages, so the field stays
// one (#790); every other annotation is copied.
if !own_field(&out, *a) {
assert_ne!(a, b, "each copy of the page has its own annotation objects");
}
}
}
for (i, list) in refs.iter().enumerate() {
for a in list.iter().filter(|a| !own_field(&out, **a)) {
assert_eq!(out.get(*a).as_dict().unwrap().reference(b"P"), Some(ps[i].obj), "/P names the page holding the copy");
}
}
// Each note keeps its own popup, pointing back at that note.
let popups: Vec<ObjRef> = [4, 5].iter().map(|&i| out.get(refs[i][0]).as_dict().unwrap().reference(b"Popup").unwrap()).collect();
assert_ne!(popups[0], popups[1]);
for (k, p) in popups.iter().enumerate() {
assert_eq!(out.get(*p).as_dict().unwrap().reference(b"Parent"), Some(refs[4 + k][0]));
}
}
#[test]
fn form_fields_come_along_and_are_registered() {
let out = full_roundtrip(&extract_pages(&doc_a(), &[1]).unwrap());
@@ -635,6 +760,96 @@ fn layers_are_registered_with_their_default_state() {
assert!(!catalog(&out).contains(b"OCProperties"));
}
/// The top-level bookmarks' titles, in order.
fn top_titles(doc: &Document) -> Vec<String> {
let Some(outlines) = catalog(doc).reference(b"Outlines") else { return Vec::new() };
let mut item = doc.get(outlines).as_dict().and_then(|o| o.reference(b"First"));
let mut titles = Vec::new();
while let Some(r) = item {
let d = doc.get(r).as_dict().cloned().unwrap();
titles.push(d.get(b"Title").and_then(|t| t.as_string()).map(|s| s.to_text()).unwrap());
item = d.reference(b"Next");
}
titles
}
#[test]
fn a_file_split_around_another_is_copied_once_with_one_bookmark() {
// C's first page, then all of A, then C's second page.
let (a, c) = (doc_a(), doc_c());
let out = full_roundtrip(&crate::combine_grouped(&[(0, "C", &c, Some(&[0])), (1, "A", &a, None), (0, "C", &c, Some(&[1]))]).unwrap());
assert_eq!(labels(&out), ["C1", "A1", "A2", "A3", "C2"]);
assert_eq!(top_titles(&out), ["C", "A"], "one bookmark per file, not per run");
let ps = pages(&out).unwrap();
let outlines = out.get(catalog(&out).reference(b"Outlines").unwrap()).as_dict().cloned().unwrap();
let file = out.get(outlines.reference(b"First").unwrap()).as_dict().cloned().unwrap();
assert_eq!(file.get(b"Dest").and_then(|d| d.as_array()).unwrap()[0].as_ref(), Some(ps[0].obj), "C's bookmark: its first page shown");
// Its own bookmarks follow its pages wherever they went: the section is on C2, now last.
let chapter = out.get(file.reference(b"First").unwrap()).as_dict().cloned().unwrap();
let section = out.get(chapter.reference(b"First").unwrap()).as_dict().cloned().unwrap();
assert_eq!(section.get(b"Dest").and_then(|d| d.as_array()).unwrap()[0].as_ref(), Some(ps[4].obj));
// The link from C1 to C2 still works across A's pages between them.
let link = &annots(&out, 0)[0];
assert_eq!(link.get(b"Dest").and_then(|d| d.as_array()).expect("kept").first().and_then(|o| o.as_ref()), Some(ps[4].obj));
// Its attachment once, not once per run.
let names = out.resolve(catalog(&out).get(b"Names").unwrap()).as_dict().cloned().unwrap();
let tree = out.resolve(names.get(b"EmbeddedFiles").unwrap()).as_dict().cloned().unwrap();
let keys: Vec<String> = tree.get(b"Names").and_then(|n| n.as_array()).unwrap().chunks(2).map(|p| p[0].as_string().unwrap().to_text()).collect();
assert_eq!(keys, ["notes.txt"]);
}
#[test]
fn a_page_shown_twice_gets_its_own_annotations() {
// One page with an indirect comment (and its popup), and a second page.
let b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Annots [6 0 R] >>".into(), // 3
"<< /Type /Page /Parent 2 0 R /Contents 7 0 R >>".into(), // 4
body("D1"), // 5
"<< /Type /Annot /Subtype /Text /Rect [0 0 10 10] /Contents (note) /P 3 0 R /Popup 8 0 R >>".into(), // 6
body("D2"), // 7
"<< /Type /Annot /Subtype /Popup /Rect [10 10 50 50] /Parent 6 0 R >>".into(), // 8
];
let d = open(build(&b, "/Root 1 0 R"));
// Page 1 twice, from one file split around its page 2 (and the same through combine_selected).
for out in [
crate::combine_grouped(&[(0, "D", &d, Some(&[0, 1])), (0, "D", &d, Some(&[0]))]).unwrap(),
combine_selected(&[("D", &d, Some(&[0, 1, 0]))]).unwrap(),
] {
let out = full_roundtrip(&out);
assert_eq!(labels(&out), ["D1", "D2", "D1"]);
let ps = pages(&out).unwrap();
let annot_of = |i: usize| page_dict(&out, i).get(b"Annots").and_then(|a| a.as_array()).and_then(|a| a[0].as_ref()).unwrap();
let (first, again) = (annot_of(0), annot_of(2));
assert_ne!(first, again, "two pages, two comments");
for (annot, page) in [(first, ps[0].obj), (again, ps[2].obj)] {
let a = out.get(annot).as_dict().cloned().unwrap();
assert_eq!(a.reference(b"P"), Some(page), "each comment belongs to its own page");
let popup = out.get(a.reference(b"Popup").unwrap()).as_dict().cloned().unwrap();
assert_eq!(popup.reference(b"Parent"), Some(annot), "and its popup to it");
}
}
}
#[test]
fn grouped_runs_keep_their_order_repeats_and_separate_files() {
let (a, b) = (doc_a(), doc_b());
// B's page 2, A's page 1, B's page 1, A's page 1 again (a repeat is a page of its own).
let out = full_roundtrip(
&crate::combine_grouped(&[(0, "B", &b, Some(&[1])), (1, "A", &a, Some(&[0])), (0, "B", &b, Some(&[0])), (1, "A", &a, Some(&[0]))]).unwrap(),
);
assert_eq!(labels(&out), ["B2", "A1", "B1", "A1"]);
assert_eq!(top_titles(&out), ["B", "A"]);
let ps = pages(&out).unwrap();
assert_ne!(ps[1].obj, ps[3].obj, "a page shown twice is two pages");
// The same file added twice on purpose (two groups) keeps two bookmarks, as combine does.
let twice = full_roundtrip(&crate::combine_grouped(&[(0, "A", &a, None), (1, "A again", &a, None)]).unwrap());
assert_eq!(top_titles(&twice), ["A", "A again"]);
// A page that isn't there is refused, as combine_selected does.
assert!(matches!(crate::combine_grouped(&[(0, "A", &a, Some(&[9]))]), Err(crate::OrganizeError::NoSuchPage(9))));
}
#[test]
fn combine_nests_source_bookmarks_and_keeps_attachments() {
let out = full_roundtrip(&combine(&[("C", &doc_c()), ("C again", &doc_c())]).unwrap());
@@ -683,10 +898,72 @@ fn combine_and_insert_share_identical_resources() {
assert_eq!(labels(&d), ["A1", "A2", "A3", "A1", "A2"]);
}
/// A one-page document drawing one image, written the way ImageMagick writes it: the colour
/// space is an indirect `/DeviceRGB` object of its own (#878).
fn indirect_colour_space_doc() -> Document {
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << /XObject << /Im0 5 0 R >> >> /Contents 4 0 R >>".to_vec(),
stream(b"q 100 0 0 100 0 0 cm /Im0 Do Q"),
[
b"<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace 6 0 R /BitsPerComponent 8 /Length 6 >>\nstream\n".as_slice(),
&[255, 0, 0, 0, 0, 255],
b"\nendstream",
]
.concat(),
b"/DeviceRGB".to_vec(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
open(out)
}
#[test]
fn combine_stores_an_image_once_when_its_colour_space_is_an_indirect_name() {
let images = |d: &Document| {
d.object_numbers()
.into_iter()
.filter(|n| matches!(&*d.get(ObjRef::new(*n, d.generation(*n))), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image")))
.count()
};
let src = indirect_colour_space_doc();
let out = full_roundtrip(&crate::combine(&[("one", &src), ("two", &src), ("three", &src)]).unwrap());
assert_eq!(images(&out), 1);
assert_eq!(pages(&out).unwrap().len(), 3);
// Inserting the same pages again reuses the image already there.
let mut d = indirect_colour_space_doc();
crate::import_pages(&mut d, &indirect_colour_space_doc(), &[0], 1).unwrap();
assert_eq!(images(&full_roundtrip(&d)), 1);
}
// ---- bookmarks (M4.6) --------------------------------------------------------------------------
/// The titles with children in brackets (`Parent[Child,Child]`), one per top-level bookmark.
fn titles(b: &[crate::Bookmark]) -> Vec<String> {
b.iter().map(|x| if x.children.is_empty() { x.title.clone() } else { format!("{}[{}]", x.title, titles(&x.children).join(",")) }).collect()
fn level(b: &[crate::Bookmark], at: &mut usize, depth: usize) -> Vec<String> {
let mut out = Vec::new();
while let Some(x) = b.get(*at).filter(|x| x.path.len() == depth + 1) {
*at += 1;
let kids = level(b, at, depth + 1);
out.push(if kids.is_empty() { x.title.clone() } else { format!("{}[{}]", x.title, kids.join(",")) });
}
out
}
let mut at = 0;
level(b, &mut at, 0)
}
fn count_of(doc: &Document, r: ObjRef) -> Option<i64> {
@@ -747,11 +1024,12 @@ fn a_bookmark_tree_nests_entries_by_level_under_a_new_first_bookmark() {
let b = crate::bookmarks(&d);
let root = d.get(d.root().unwrap()).as_dict().unwrap().reference(b"Outlines").unwrap();
assert_eq!((count_of(&d, root), count_of(&d, b[0].obj), count_of(&d, b[0].children[0].obj)), (Some(6), Some(4), Some(2)));
let one = d.get(b[0].children[0].obj).as_dict().cloned().unwrap();
// Listing order: Untitled, One, Deep, Two, Next, Existing.
assert_eq!((count_of(&d, root), count_of(&d, b[0].obj), count_of(&d, b[1].obj)), (Some(6), Some(4), Some(2)));
let one = d.get(b[1].obj).as_dict().cloned().unwrap();
assert_eq!(one.get(b"SE"), Some(&Object::Ref(se)));
let pages = crate::walk(&d).unwrap();
let next = d.get(b[0].children[1].obj).as_dict().cloned().unwrap();
let next = d.get(b[4].obj).as_dict().cloned().unwrap();
assert_eq!(next.get(b"Dest").and_then(|x| x.as_array()).map(|a| a[0].clone()), Some(Object::Ref(pages[2].0)));
assert_eq!(crate::add_bookmark_tree(&mut d, "Untitled", &[]), Err(crate::OutlineError::NoEntries));
@@ -775,6 +1053,136 @@ fn bookmark_edits_keep_unknown_keys_and_touch_few_objects() {
assert_eq!(d.modified_objects(), vec![b[0].obj.num], "a rename rewrites only that item");
}
/// A flat outline of `n` top-level bookmarks, `Item 0` to `Item n-1`, all going to one page.
fn flat_outline(n: usize) -> Vec<u8> {
let mut b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /Outlines 3 0 R >>".into(),
"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(),
format!("<< /Type /Outlines /First 5 0 R /Last {} 0 R /Count {n} >>", 4 + n),
"<< /Type /Page /Parent 2 0 R >>".into(),
];
for k in 0..n {
let prev = if k == 0 { String::new() } else { format!(" /Prev {} 0 R", 4 + k) };
let next = if k + 1 == n { String::new() } else { format!(" /Next {} 0 R", 6 + k) };
b.push(format!("<< /Title (Item {k}) /Parent 3 0 R{prev}{next} /Dest [4 0 R /Fit] >>"));
}
build(&b, "/Root 1 0 R")
}
#[test]
fn a_bookmark_past_the_hundred_thousandth_is_listed() {
let d = open(flat_outline(100_005));
let all = crate::bookmarks(&d);
assert_eq!(all.len(), 100_005);
assert_eq!(all.last().map(|b| b.title.as_str()), Some("Item 100004"));
}
/// A chain of `n` nested bookmarks: each is the first child of the one before it.
fn deep_outline(n: usize) -> Vec<u8> {
let mut b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /Outlines 3 0 R >>".into(),
"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(),
"<< /Type /Outlines /First 5 0 R /Last 5 0 R /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R >>".into(),
];
for k in 0..n {
let parent = if k == 0 { 3 } else { 4 + k };
let first = if k + 1 == n { String::new() } else { format!(" /First {} 0 R /Count -1", 6 + k) };
b.push(format!("<< /Title (Deep {k}) /Parent {parent} 0 R{first} /Dest [4 0 R /Fit] >>"));
}
build(&b, "/Root 1 0 R")
}
#[test]
fn deep_bookmark_chains_edit_without_recursion() {
let mut d = open(deep_outline(100_000));
// Adding at the top level recounts the whole outline, which walks the entire chain.
crate::add_bookmark(&mut d, &[], 1, "Extra", 0).unwrap();
// Listing enters levels 0 to 32 of the chain; the rest is reached by path only.
let listed = crate::bookmarks(&d);
assert_eq!(listed.len(), 34);
assert_eq!(listed.last().map(|b| (b.title.as_str(), b.path.clone())), Some(("Extra", vec![1])));
assert_eq!(listed[32].path.len(), 33);
assert_eq!(crate::bookmark_page(&d, 33, 10).bookmarks.len(), 1);
}
/// A one-page document whose outline is the given bookmark bodies, as objects 5, 6, … in order.
fn outline_with(items: &[&str]) -> Document {
let mut b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /Outlines 3 0 R >>".into(),
"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(),
format!("<< /Type /Outlines /First 5 0 R /Count {} >>", items.len()),
"<< /Type /Page /Parent 2 0 R >>".into(),
];
b.extend(items.iter().map(|s| s.to_string()));
open(build(&b, "/Root 1 0 R"))
}
#[test]
fn hostile_outlines_end_their_runs_and_never_loop() {
let listed = |items: &[&str]| titles(&crate::bookmarks(&outline_with(items)));
// A bookmark that is its own next sibling, or its own first child.
assert_eq!(listed(&["<< /Title (Self) /Parent 3 0 R /Next 5 0 R >>"]), ["Self"]);
assert_eq!(listed(&["<< /Title (Self) /Parent 3 0 R /First 5 0 R >>"]), ["Self"]);
// Two siblings that point at each other: the run ends where it would repeat.
assert_eq!(listed(&["<< /Title (A) /Parent 3 0 R /Next 6 0 R >>", "<< /Title (B) /Parent 3 0 R /Next 5 0 R >>"]), ["A", "B"]);
// A child that leads back to its ancestor.
assert_eq!(listed(&["<< /Title (A) /Parent 3 0 R /First 6 0 R >>", "<< /Title (B) /Parent 5 0 R /First 5 0 R >>"]), ["A[B]"]);
// A broken `/Next` (a missing object, then a number) and a missing first bookmark.
assert_eq!(listed(&["<< /Title (A) /Parent 3 0 R /Next 99 0 R >>"]), ["A"]);
assert_eq!(listed(&["<< /Title (A) /Parent 3 0 R /Next 6 0 R >>", "42"]), ["A"]);
assert!(listed(&[]).is_empty());
}
#[test]
fn bookmark_pages_list_every_bookmark_once() {
let d = open(flat_outline(250));
let mut seen = Vec::new();
let mut offset = 0;
while let Some(next) = {
let page = crate::bookmark_page(&d, offset, 100);
assert!(page.bookmarks.len() <= 100);
seen.extend(page.bookmarks.iter().map(|b| b.title.clone()));
page.next
} {
assert_eq!(next, seen.len(), "a page ends where the next one starts");
offset = next;
}
assert_eq!(seen.len(), 250);
assert_eq!(seen.last().map(String::as_str), Some("Item 249"));
let big = open(flat_outline(100_005));
let first = crate::bookmark_page(&big, 0, 100);
assert_eq!((first.bookmarks.len(), first.next), (100, Some(100)));
let late = crate::bookmark_page(&big, 100_000, 100);
assert_eq!(
late.bookmarks.iter().map(|b| b.title.as_str()).collect::<Vec<_>>(),
["Item 100000", "Item 100001", "Item 100002", "Item 100003", "Item 100004"]
);
assert_eq!((late.bookmarks[4].path.clone(), late.next), (vec![100_004], None));
}
#[test]
fn bookmarks_name_their_pages_through_every_kind_of_destination() {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /Outlines 3 0 R /Names << /Dests 9 0 R >> >>".into(),
"<< /Type /Pages /Kids [4 0 R 5 0 R] /Count 2 /MediaBox [0 0 300 400] >>".into(),
"<< /Type /Outlines /First 6 0 R /Last 11 0 R /Count 6 >>".into(),
"<< /Type /Page /Parent 2 0 R >>".into(),
"<< /Type /Page /Parent 2 0 R >>".into(),
"<< /Title (Named) /Parent 3 0 R /Next 7 0 R /Dest (intro) >>".into(),
"<< /Title (GoTo) /Parent 3 0 R /Prev 6 0 R /Next 8 0 R /A << /S /GoTo /D [5 0 R /Fit] >> >>".into(),
"<< /Title (Appendix) /Parent 3 0 R /Prev 7 0 R /Next 10 0 R /Dest /appendix >>".into(),
"<< /Names [(appendix) 12 0 R (intro) [4 0 R /Fit]] >>".into(),
"<< /Title <FEFF00480069> /Parent 3 0 R /Prev 8 0 R /Next 11 0 R /Dest [1 /Fit] >>".into(),
"<< /Title ( Padded ) /Parent 3 0 R /Prev 10 0 R /Dest [2 0 R /Fit] >>".into(),
"<< /D [5 0 R /Fit] >>".into(),
];
let d = open(build(&objs, "/Root 1 0 R"));
let listed: Vec<String> = crate::bookmarks(&d).iter().map(|b| format!("{}@{:?}", b.title, b.page)).collect();
assert_eq!(listed, ["Named@Some(0)", "GoTo@Some(1)", "Appendix@Some(1)", "Hi@Some(1)", "Padded@None"]);
}
// ---- page labels (M4.4) ------------------------------------------------------------------------
#[test]
@@ -987,6 +1395,53 @@ fn page_label_too_long_range_falls_back_alone() {
assert_eq!(labels[1], "2");
}
/// A document whose page tree is one flat node over `pages` leaves. The label tests below need
/// more pages than `doc_a` has.
fn flat_doc(pages: usize) -> Document {
let kids = (0..pages).map(|i| format!("{} 0 R", i + 3)).collect::<Vec<_>>().join(" ");
let mut objs = vec!["<< /Type /Catalog /Pages 2 0 R >>".to_string(), format!("<< /Type /Pages /Kids [{kids}] /Count {pages} >>")];
objs.extend((0..pages).map(|_| "<< /Type /Page /Parent 2 0 R >>".to_string()));
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, body) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for offset in offsets {
out.extend_from_slice(format!("{offset: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());
open(out)
}
/// Each label is valid (1,004 bytes), but the 5,000 of them total about 5 MB, past the old 4 MiB
/// limit on the whole sequence. Every label is readable.
#[test]
fn page_labels_past_four_mib_in_total_stay_readable() {
let mut d = flat_doc(5_000);
let mut spec = Dict::new();
spec.set(b"P".to_vec(), Object::String(PdfString::text(&"x".repeat(1000))));
spec.set(b"S".to_vec(), Object::name("D"));
let mut tree = Dict::new();
tree.set(b"Nums".to_vec(), Object::Array(vec![Object::Int(0), Object::Dict(spec)]));
let tree = d.add(Object::Dict(tree));
d.update_dict(d.root().unwrap(), |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree))).unwrap();
let labels = crate::page_labels(&d).unwrap();
assert_eq!(labels.len(), 5_000);
assert_eq!(labels[0], format!("{}1", "x".repeat(1000)));
assert_eq!(labels[4_999], format!("{}5000", "x".repeat(1000)));
}
/// The same 5,000 labels written by an edit: the edit is accepted and reads back.
#[test]
fn number_pages_past_four_mib_in_total_is_accepted() {
let mut d = flat_doc(5_000);
crate::number_pages(&mut d, 0, 4_999, crate::LabelStyle::Decimal, &"x".repeat(1000), 1).unwrap();
assert_eq!(crate::page_labels(&d).unwrap()[4_999], format!("{}5000", "x".repeat(1000)));
}
#[test]
fn page_boxes_default_inherit_and_set() {
let mut doc = open(fixture());
@@ -1183,3 +1638,27 @@ fn extract_keeps_images_type3_glyphs_may_draw() {
let names = xobject_names(&part, 0);
assert!(names.iter().any(|n| n.as_slice() == b"ImB"), "pruning must not run through Type 3 resources");
}
/// A page listed twice whose widget's field keeps `/Kids` as an array object of its own: the
/// second widget joins that array, and the field keeps every widget it had.
#[test]
fn a_repeated_widget_joins_an_indirect_kids_array() {
let b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(), // 3
"<< /Type /Page /Parent 2 0 R /Annots [8 0 R] >>".into(), // 4
"<< /Type /Annot /Subtype /Widget /Parent 6 0 R /Rect [10 10 90 30] /P 3 0 R >>".into(), // 5
"<< /FT /Tx /T (name) /V (Ada) /Kids 7 0 R >>".into(), // 6
"[5 0 R 8 0 R]".into(), // 7
"<< /Type /Annot /Subtype /Widget /Parent 6 0 R /Rect [10 40 90 60] /P 4 0 R >>".into(), // 8
];
let src = open(build(&b, "/Root 1 0 R"));
let out = full_roundtrip(&extract_pages(&src, &[0, 0, 1]).unwrap());
let form = out.resolve(catalog(&out).get(b"AcroForm").unwrap());
let fields = form.as_dict().unwrap().get(b"Fields").and_then(Object::as_array).cloned().unwrap();
assert_eq!(fields.len(), 1, "one field");
let field = out.resolve(&fields[0]).as_dict().cloned().unwrap();
let kids = out.resolve(field.get(b"Kids").unwrap()).as_array().cloned().unwrap();
assert_eq!(kids.len(), 3, "both original widgets and the repeat's own: {kids:?}");
}
+6
View File
@@ -9,3 +9,9 @@ license.workspace = true
workspace = true
[dependencies]
[target.'cfg(target_os = "linux")'.dependencies]
# The desktop's light/dark choice (see `desktop_theme.rs`). Already in the tree, with the features
# this needs, through accesskit's accessibility bus; pinned to the same version so nothing is
# built twice.
zbus = "=5.19.0"
+136
View File
@@ -0,0 +1,136 @@
//! The desktop's own light/dark choice, for an interface setting that follows it.
//!
//! winit reports no system theme on Linux — `ActiveEventLoop::system_theme` is `None` on every
//! desktop there — so "Use system setting" has to ask the desktop itself. The XDG Desktop Portal's
//! Settings interface answers on GNOME, KDE and XFCE alike, over X11 and Wayland alike.
use std::sync::mpsc::{Receiver, TryRecvError, channel};
use std::time::Duration;
/// How often [`DesktopTheme::start`] re-reads the desktop's answer. The portal also publishes a
/// change signal, but subscribing needs a connection kept alive for the life of every window; one
/// round trip every few seconds is cheap and reacts soon enough after a theme switch.
const POLL: Duration = Duration::from_secs(4);
/// Whether the desktop asks applications to draw dark. `None` when it says nothing, and when
/// there is no session bus or no portal to ask (a bare X server, a container), so the caller
/// keeps whatever it chose itself.
pub fn prefers_dark() -> Option<bool> {
read()
}
/// Watches the desktop and reports its choice when it changes. [`prefers_dark`] covers the
/// answer at start-up; this covers the user switching theme while the app runs.
#[derive(Default)]
pub struct DesktopTheme {
/// `None` when no watcher thread could be started; the one-shot read still works.
changes: Option<Receiver<Option<bool>>>,
}
impl DesktopTheme {
/// Start watching. Nothing runs before this is called, and calling it once is enough. The
/// first answer is sent straight away, so the caller does not have to ask for it separately.
pub fn start() -> Self {
let (tx, rx) = channel();
let started = std::thread::Builder::new().name("pdfcraft-desktop-theme".into()).spawn(move || {
let mut last = read();
if tx.send(last).is_err() {
return;
}
loop {
std::thread::sleep(POLL);
let now = read();
if now != last {
last = now;
if tx.send(now).is_err() {
return;
}
}
}
});
Self { changes: started.ok().map(|_| rx) }
}
/// The desktop's new answer if it changed since the last call, to be polled once a frame.
/// Several changes in one frame collapse to the newest, and a desktop that stops answering
/// leaves the last answer in place.
pub fn take_change(&mut self) -> Option<bool> {
let Some(changes) = &self.changes else { return None };
let mut newest = None;
loop {
match changes.try_recv() {
Ok(changed) => newest = changed,
Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => return newest,
}
}
}
}
/// Ask the portal once. D-Bus calls block, so this belongs off the UI thread: the watcher's
/// thread is its only caller.
#[cfg(target_os = "linux")]
fn read() -> Option<bool> {
const PORTAL: &str = "org.freedesktop.portal.Desktop";
const SETTINGS: &str = "org.freedesktop.portal.Settings";
// The colour scheme under its GNOME schema name first: GNOME's portal answers it, and so
// does XFCE's, which proxies the GSettings schemas but not the older freedesktop one. KDE
// answers only the second.
const SCHEMAS: [(&str, &str); 2] = [("org.gnome.desktop.interface", "color-scheme"), ("org.freedesktop.interface.color-scheme", "color-scheme")];
let connection = zbus::blocking::Connection::session().ok()?;
for (schema, key) in SCHEMAS {
let Ok(reply) = connection.call_method(Some(PORTAL), "/org/freedesktop/portal/desktop", Some(SETTINGS), "ReadOne", &(schema, key)) else {
continue;
};
let Ok((answer,)) = reply.body().deserialize::<(zbus::zvariant::OwnedValue,)>() else { continue };
// The reply is one variant wrapping a string.
if let Ok(scheme) = String::try_from(&*answer) {
return parse(&scheme);
}
}
None
}
/// The portal's `color-scheme` answer. `default` means the desktop states no preference, so
/// neither does the application.
#[cfg(target_os = "linux")]
fn parse(scheme: &str) -> Option<bool> {
match scheme {
"prefer-dark" => Some(true),
"prefer-light" => Some(false),
_ => None,
}
}
/// macOS and Windows answer through winit, which the caller already has.
#[cfg(not(target_os = "linux"))]
fn read() -> Option<bool> {
None
}
#[cfg(test)]
mod tests {
use super::*;
/// The portal answers with one of three strings; only the two preferences are a choice.
#[test]
#[cfg(target_os = "linux")]
fn the_portal_answer_reads_as_a_choice_or_as_none() {
assert_eq!(parse("prefer-dark"), Some(true));
assert_eq!(parse("prefer-light"), Some(false));
assert_eq!(parse("default"), None);
assert_eq!(parse(""), None);
}
/// Off Linux the caller already has winit's answer, so this adds nothing.
#[test]
#[cfg(not(target_os = "linux"))]
fn other_platforms_answer_through_winit() {
assert_eq!(prefers_dark(), None);
}
/// A watcher that was never started is inert rather than a panic.
#[test]
fn a_watcher_without_a_thread_is_inert() {
assert_eq!(DesktopTheme::default().take_change(), None);
}
}
+1
View File
@@ -3,4 +3,5 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod desktop_theme;
pub mod staging;
+2 -1
View File
@@ -478,7 +478,8 @@ fn appearance_matrix(doc: &Document, form: &Dict, rect: [f64; 4]) -> Option<Matr
rect[0] - b[0] * (rect[2] - rect[0]) / bw,
rect[1] - b[1] * (rect[3] - rect[1]) / bh,
]);
Some(m.then(&a))
// The Do that draws the form applies its /Matrix itself, so only the placement goes here.
Some(a)
}
/// A Form XObject holding page `index` as it prints (user space; BBox = crop box).
+13
View File
@@ -469,3 +469,16 @@ fn printer_option_defaults_and_job_arguments() {
assert!(args.ends_with("-o fit-to-page=false -o InputSlot=Tray2 -o EFMediaType=Heavy1"), "{args}");
assert!(spool::printer_options("../../etc/passwd").is_empty(), "not a queue name");
}
#[test]
fn a_scaled_appearance_is_not_scaled_twice() {
let doc = fixture(1);
let nums = |v: [f64; 6]| Object::Array(v.iter().map(|x| Object::Real(*x)).collect());
let mut form = Dict::new();
form.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, 10.0, 10.0].iter().map(|x| Object::Real(*x)).collect()));
form.set(b"Matrix".to_vec(), nums([2.0, 0.0, 0.0, 2.0, 0.0, 0.0]));
// The form's own /Matrix doubles it to 20x20 when drawn; the placement only has to map 20x20 onto the 40x40 rect.
let m = appearance_matrix(&doc, &form, [10.0, 10.0, 50.0, 50.0]).unwrap();
assert!(close(m.0[0], 2.0) && close(m.0[3], 2.0), "{:?}", m.0);
assert!(close(m.0[4], 10.0) && close(m.0[5], 10.0), "{:?}", m.0);
}
+109 -21
View File
@@ -100,9 +100,24 @@ fn phone(s: &[char], i: usize) -> Option<usize> {
ok.then_some(end - i)
}
/// Scripts written without spaces between words (Chinese, Japanese, Korean, Thai, …): text in
/// them often runs straight into an address, so their letters never count as part of one.
fn spaceless(c: char) -> bool {
matches!(
u32::from(c),
0x0E00..=0x0EFF | 0x1000..=0x109F | 0x1100..=0x11FF | 0x1780..=0x17FF | 0x2E80..=0x9FFF | 0xA960..=0xA97F | 0xAC00..=0xD7FF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0x20000..=0x3FFFF
)
}
/// A letter or digit of an address: ASCII, or of any script written with spaces, so
/// internationalized addresses (RFC 6531, IDN) such as "ayşe@örnek.com.tr" match.
fn address_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c.is_alphanumeric() && !spaceless(c)
}
fn email(s: &[char], i: usize) -> Option<usize> {
let local = |c: &char| c.is_ascii_alphanumeric() || "._%+-".contains(*c);
if !s[i].is_ascii_alphanumeric() || i.checked_sub(1).and_then(|p| s.get(p)).is_some_and(local) {
let local = |c: &char| address_char(*c) || "._%+-".contains(*c);
if !address_char(s[i]) || i.checked_sub(1).and_then(|p| s.get(p)).is_some_and(local) {
return None;
}
let mut j = i;
@@ -114,12 +129,12 @@ fn email(s: &[char], i: usize) -> Option<usize> {
}
let d0 = j + 1;
let mut k = d0;
while k < s.len() && (s[k].is_ascii_alphanumeric() || s[k] == '-' || s[k] == '.' && s.get(k + 1).is_some_and(char::is_ascii_alphanumeric)) {
while k < s.len() && (address_char(s[k]) || s[k] == '-' || s[k] == '.' && s.get(k + 1).is_some_and(|c| address_char(*c))) {
k += 1;
}
let domain: String = s[d0..k].iter().collect();
let tld = domain.rsplit('.').next().unwrap_or("");
(domain.contains('.') && tld.len() >= 2 && tld.chars().all(|c| c.is_ascii_alphabetic())).then_some(k - i)
(domain.contains('.') && tld.chars().count() >= 2 && tld.chars().all(char::is_alphabetic)).then_some(k - i)
}
fn luhn(digits: &[u32]) -> bool {
@@ -164,26 +179,53 @@ fn ssn(s: &[char], i: usize) -> Option<usize> {
(area != "000" && area != "666" && !area.starts_with('9')).then_some(11)
}
const MONTHS: [&str; 12] = ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"];
/// Month names the date pattern knows: English, then the languages whose dates are written
/// "5 Ekim 2024" / "5. Oktober 2024" (day, month name, year).
const MONTHS: [[&str; 12]; 5] = [
["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"],
["ocak", "şubat", "mart", "nisan", "mayıs", "haziran", "temmuz", "ağustos", "eylül", "ekim", "kasım", "aralık"],
["januar", "februar", "märz", "april", "mai", "juni", "juli", "august", "september", "oktober", "november", "dezember"],
["janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre", "novembre", "décembre"],
["gennaio", "febbraio", "marzo", "aprile", "maggio", "giugno", "luglio", "agosto", "settembre", "ottobre", "novembre", "dicembre"],
];
/// A month name or its three-letter abbreviation (optionally with a full stop) at `i`.
/// Abbreviations that may end in a full stop: English and Turkish ("Oca.", "Şub", "Ağu").
const MONTH_ABBREVIATIONS: [[&str; 12]; 2] = [
["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"],
["oca", "şub", "mar", "nis", "may", "haz", "tem", "ağu", "eyl", "eki", "kas", "ara"],
];
/// One lowercase character for `c`, with Turkish dotted and dotless i the same as i, so
/// "MAYIS", "Mayıs" and "EKİM" match and every character stays one character.
fn fold(c: char) -> char {
match c {
'I' | '\u{130}' | '\u{131}' => 'i',
_ => c.to_lowercase().next().unwrap_or(c),
}
}
/// Characters `name` covers at `i`, when it stands there as a whole word (any case).
fn word_at(s: &[char], i: usize, name: &str) -> Option<usize> {
let mut n = 0;
for c in name.chars() {
if s.get(i + n).copied().map(fold) != Some(fold(c)) {
return None;
}
n += 1;
}
(!s.get(i + n).is_some_and(|c| c.is_alphabetic())).then_some(n)
}
/// A month name or its abbreviation (optionally with a full stop) at `i`.
fn month(s: &[char], i: usize) -> Option<usize> {
if word(i.checked_sub(1).and_then(|p| s.get(p))) {
return None;
}
let rest: String = s[i..s.len().min(i + 10)].iter().collect::<String>().to_lowercase();
for m in MONTHS {
for cand in [m, &m[..3]] {
if rest.starts_with(cand) && !rest[cand.len()..].starts_with(|c: char| c.is_alphabetic()) {
let mut n = cand.chars().count();
if cand.len() == 3 && s.get(i + n) == Some(&'.') {
n += 1;
}
return Some(n);
}
}
if let Some(n) = MONTHS.iter().flatten().filter_map(|m| word_at(s, i, m)).max() {
return Some(n);
}
None
let n = MONTH_ABBREVIATIONS.iter().flatten().find_map(|m| word_at(s, i, m))?;
Some(if s.get(i + n) == Some(&'.') { n + 1 } else { n })
}
fn number(s: &[char], i: usize, min: usize, max: usize) -> Option<usize> {
@@ -208,10 +250,11 @@ fn date(s: &[char], i: usize) -> Option<usize> {
{
return Some(a + b + c + 2);
}
// "5 January 2024".
// "5 January 2024", "5 Ekim 2024", "5. Oktober 2024".
let day = i + a + usize::from(s.get(i + a) == Some(&'.'));
if a <= 2
&& let k = i + a + spaces(i + a)
&& k > i + a
&& let k = day + spaces(day)
&& k > day
&& let Some(m) = month(s, k)
{
let k2 = k + m + spaces(k + m);
@@ -323,6 +366,18 @@ mod tests {
);
}
#[test]
fn internationalized_emails() {
assert_eq!(
found(Pattern::Email, "Yazın: ayşe.yılmaz@örnek.com.tr, ÇAĞLAR@ŞİRKET.COM.TR; müller@bücher.de, иван@пример.рф, josé@correo.es."),
["ayşe.yılmaz@örnek.com.tr", "ÇAĞLAR@ŞİRKET.COM.TR", "müller@bücher.de", "иван@пример.рф", "josé@correo.es"]
);
// Text in scripts without spaces stays out of the address around it.
assert_eq!(found(Pattern::Email, "メールはtaro@example.jpまで、联系support@example.cn谢谢"), ["taro@example.jp", "support@example.cn"]);
// Still no address without a dot in the domain or with a one-letter or numeric TLD.
assert_eq!(found(Pattern::Email, "ayşe@örnek, ş@ğ.ü, kişi@alan.123, a@b.c"), Vec::<String>::new());
}
#[test]
fn credit_cards_need_a_valid_checksum() {
assert_eq!(
@@ -346,4 +401,37 @@ mod tests {
["10/01/2026", "2026-10-01", "1.10.26", "March 5, 2024", "Jan. 7 2025", "5 June 2023"]
);
}
#[test]
fn dates_with_turkish_month_names_in_any_case() {
assert_eq!(
found(Pattern::Date, "Tarih: 10 Ekim 2026, 3 Mayıs 2025, 5 Şubat 2024, 5 ŞUBAT 2024, 3 MAYIS 2025, 1 EKİM 2026 ve 9 kasım 2023."),
["10 Ekim 2026", "3 Mayıs 2025", "5 Şubat 2024", "5 ŞUBAT 2024", "3 MAYIS 2025", "1 EKİM 2026", "9 kasım 2023"]
);
assert_eq!(
found(Pattern::Date, "12 Oca 2024, 7 Şub. 2025, 30 Ağu 2023, 1 ARA. 2022, 2 Haz 2021"),
["12 Oca 2024", "7 Şub. 2025", "30 Ağu 2023", "1 ARA. 2022", "2 Haz 2021"]
);
// Words that only start like a month, or a month without a day and year, stay.
assert_eq!(
found(Pattern::Date, "5 Martı 2024, Ekimde 2026, 3 Mayısta 2025, ara 2024, Kasım ayında, 12 Aralık, Aralık 2024"),
Vec::<String>::new()
);
}
#[test]
fn dates_with_german_french_and_italian_month_names() {
assert_eq!(
found(Pattern::Date, "am 10. Oktober 2026, 3 März 2025, le 14 juillet 2024, 1 août 2023, il 25 dicembre 2022"),
["10. Oktober 2026", "3 März 2025", "14 juillet 2024", "1 août 2023", "25 dicembre 2022"]
);
// "Mai" alone, a dotted day without a month and an unknown word are not dates.
assert_eq!(found(Pattern::Date, "Mai 2024, Kapitel 5. Absatz 2024, 5 Oktoberfest 2024"), Vec::<String>::new());
}
#[test]
fn case_folding_keeps_one_character_per_character() {
let s: Vec<char> = "\u{130}\u{131}I\u{15E}".chars().collect();
assert_eq!(s.iter().copied().map(fold).collect::<String>(), "iii\u{15F}");
}
}
+379 -18
View File
@@ -30,6 +30,13 @@ const MAX_LAYER_STATE: usize = 1024;
/// At most this many layers are read from all of `/RBGroups` together.
const MAX_LAYER_GROUP_ENTRIES: usize = 4096;
/// `DocInfo::outline` holds the first this many bookmarks in outline order. Opening a document
/// reads only this window; the rest are listed by page through `pdfcraft_organize`.
const OUTLINE_WINDOW: usize = 10_000;
/// Deepest level `DocInfo::outline` nests (the top level is 0); deeper bookmarks are left out.
const OUTLINE_DEPTH: usize = 32;
fn load_options(password: Option<&str>) -> LoadOptions {
LoadOptions { password: password.map(str::to_owned), max_decompressed_size: Some(LOAD_STREAM_LIMIT), ..LoadOptions::default() }
}
@@ -51,6 +58,8 @@ pub struct DocInfo {
pub xfa: Option<Xfa>,
pub pages: Vec<PageInfo>,
pub outline: Vec<OutlineItem>,
/// The outline has bookmarks that `outline` leaves out (see `OUTLINE_WINDOW`, `OUTLINE_DEPTH`).
pub outline_more: bool,
pub annotations: Vec<Annotation>,
pub fields: Vec<Field>,
pub links: Vec<Link>,
@@ -264,6 +273,9 @@ pub struct Annotation {
pub locked: bool,
/// `/IT`, the intent: `FreeTextCallout`, `PolygonCloud`, `FreeTextTypeWriter`…
pub intent: Option<String>,
/// A Fill & Sign mark: typed text, a check, cross, dot or line, or a typed, drawn or picture
/// signature or initials (the rules of `pdfcraft_annot::is_fill_sign`).
pub fill_sign: bool,
}
impl Annotation {
@@ -472,8 +484,7 @@ impl<'a> Inspector<'a> {
Err(reason) => info.warnings.push(format!("Page labels were skipped: {reason}; using physical page numbers.")),
}
if let Some(first) = catalog.get(b"Outlines").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(b"First").ok()) {
let mut seen = HashSet::new();
info.outline = self.outline_siblings(first, &mut seen, 0);
(info.outline, info.outline_more) = self.outline_window(first);
}
self.annotations(info);
let mut field_seen = HashSet::new();
@@ -553,6 +564,25 @@ impl<'a> Inspector<'a> {
self.resolve(d.get(key).ok()?).as_name().ok().map(|n| String::from_utf8_lossy(n).into_owned())
}
/// Whether an annotation is a Fill & Sign mark. Keep in step with
/// `pdfcraft_annot::is_fill_sign`, which the engine's comment list uses.
fn is_fill_sign(&self, d: &Dictionary, subtype: &str) -> bool {
let yes = |key: &[u8]| matches!(d.get(key).map(|o| self.resolve(o)), Ok(Object::Boolean(true)));
if yes(b"PCFillSign") {
return true;
}
match subtype {
"FreeText" => self.name(d, b"IT").as_deref() == Some("FreeTextTypeWriter"),
"Stamp" => match self.name(d, b"Name").as_deref() {
Some("PCCheck" | "PCCross" | "PCDot" | "PCLine" | "PCTypedSignature") => true,
Some("PCCustomSignature" | "PCCustomInitials") => yes(b"PCPictureImage"),
_ => false,
},
"Ink" => self.text(d, b"Subj").as_deref() == Some("Signature"),
_ => false,
}
}
fn page_of(&self, o: &Object) -> Option<usize> {
match o {
Object::Reference(id) => self.page_index.get(id).copied(),
@@ -646,34 +676,49 @@ impl<'a> Inspector<'a> {
// ── outline ─────────────────────────────────────────────────────────────────────────────
fn outline_siblings(&self, first: &'a Object, seen: &mut HashSet<ObjectId>, depth: u32) -> Vec<OutlineItem> {
let mut items = Vec::new();
let mut cur = Some(first);
while let Some(o) = cur {
/// The window `DocInfo::outline` keeps: the first `OUTLINE_WINDOW` bookmarks in outline order,
/// nested no deeper than `OUTLINE_DEPTH`, and whether any bookmark was left out. Iterative, and
/// each bookmark is read once, so the work and memory stay within the window.
fn outline_window(&self, first: &'a Object) -> (Vec<OutlineItem>, bool) {
let mut seen = HashSet::new();
// The listed bookmarks in outline order, with their depth.
let mut listed: Vec<(usize, OutlineItem)> = Vec::new();
// One entry per open level: its next sibling, or `None` once that run of siblings has ended.
let mut levels: Vec<Option<&'a Object>> = vec![Some(first)];
let mut more = false;
while let Some(depth) = levels.len().checked_sub(1) {
let Some(slot) = levels.get_mut(depth) else { break };
let Some(o) = slot.take() else {
levels.pop();
continue;
};
if let Object::Reference(id) = o
&& !seen.insert(*id)
{
break; // cycle
continue; // cycle: this run of siblings ends here
}
let Some(d) = self.dict(o) else { break };
let Some(d) = self.dict(o) else { continue };
if listed.len() >= OUTLINE_WINDOW {
more = true;
break;
}
*slot = d.get(b"Next").ok();
let dest = d
.get(b"Dest")
.ok()
.and_then(|dest| self.dest(dest, 0))
.or_else(|| d.get(b"A").ok().and_then(|a| self.dict(a)).and_then(|a| a.get(b"D").ok()).and_then(|dest| self.dest(dest, 0)));
let (page, view) = dest.map_or((None, DestView::Top), |(p, v)| (Some(p), v));
let children = match (d.get(b"First").ok(), depth < 32) {
(Some(f), true) => self.outline_siblings(f, seen, depth + 1),
_ => Vec::new(),
};
let open = d.get(b"Count").ok().and_then(|c| c.as_i64().ok()).is_some_and(|c| c > 0);
items.push(OutlineItem { title: self.text(d, b"Title").unwrap_or_default(), page, view, children, open });
cur = d.get(b"Next").ok();
if items.len() > 100_000 {
break;
match d.get(b"First").ok() {
Some(child) if depth < OUTLINE_DEPTH => levels.push(Some(child)),
Some(_) => more = true,
None => {}
}
let open = d.get(b"Count").ok().and_then(|c| c.as_i64().ok()).is_some_and(|c| c > 0);
let title = self.text(d, b"Title").unwrap_or_default();
listed.push((depth, OutlineItem { title, page, view, children: Vec::new(), open }));
}
items
(nest_outline(listed), more)
}
// ── page labels (ISO 32000-2 §12.4.2) ───────────────────────────────────────────────────
@@ -871,6 +916,7 @@ impl<'a> Inspector<'a> {
_ => Vec::new(),
};
let in_reply_to = d.get(b"IRT").ok().and_then(|o| self.dict(o)).and_then(|p| self.text(p, b"NM"));
let fill_sign = self.is_fill_sign(d, &subtype);
info.annotations.push(Annotation {
page,
subtype,
@@ -886,6 +932,7 @@ impl<'a> Inspector<'a> {
quads,
locked: d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0) & 128 != 0,
intent: self.name(d, b"IT"),
fill_sign,
});
}
}
@@ -1179,6 +1226,33 @@ fn text_string(bytes: &[u8]) -> String {
pdfcraft_cos::PdfString::literal(bytes).to_text()
}
/// Nests a pre-order list of `(depth, item)` into the outline tree.
fn nest_outline(listed: Vec<(usize, OutlineItem)>) -> Vec<OutlineItem> {
let mut roots = Vec::new();
// The open ancestors, outermost first; each collects its children until it closes.
let mut open: Vec<(usize, OutlineItem)> = Vec::new();
for (depth, item) in listed {
while open.last().is_some_and(|(d, _)| *d >= depth) {
close_outline(&mut open, &mut roots);
}
open.push((depth, item));
}
while !open.is_empty() {
close_outline(&mut open, &mut roots);
}
roots
}
/// Closes the innermost open bookmark: it joins its parent's children, or the top level.
fn close_outline(open: &mut Vec<(usize, OutlineItem)>, roots: &mut Vec<OutlineItem>) {
if let Some((_, item)) = open.pop() {
match open.last_mut() {
Some((_, parent)) => parent.children.push(item),
None => roots.push(item),
}
}
}
fn rect4(o: &Object) -> [f32; 4] {
match o {
Object::Array(a) if a.len() == 4 => {
@@ -1310,6 +1384,204 @@ mod tests {
}
}
// Xref-stream fixtures exercise indirect /Length lookup during document loading: stream 2's /Length is `3 0 R`,
// `containers` lists compressed (id, container, index) entries and `objects` the other objects in the file.
fn compressed_length_pdf(containers: &[(u32, u32, u16)], objects: &[(u32, &[u8])]) -> Vec<u8> {
let mut pdf = b"%PDF-1.7\n".to_vec();
let ids = containers.iter().flat_map(|&(id, container, _)| [id, container]).chain(objects.iter().map(|&(id, _)| id));
let xref_id = ids.max().unwrap_or(3) + 1;
let mut records = vec![[0u8; 7]; (xref_id + 1) as usize];
records[0] = [0, 0, 0, 0, 0, 255, 255];
let mut append = |id: u32, body: &[u8]| {
records[id as usize][0] = 1;
records[id as usize][1..5].copy_from_slice(&(pdf.len() as u32).to_be_bytes());
pdf.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
pdf.extend_from_slice(body);
pdf.extend_from_slice(b"\nendobj\n");
};
append(1, b"<< /Type /Catalog >>");
append(2, b"<< /Length 3 0 R >>\nstream\nhello\nendstream");
for &(id, body) in objects {
append(id, body);
}
for &(id, container, index) in containers {
records[id as usize][0] = 2;
records[id as usize][1..5].copy_from_slice(&container.to_be_bytes());
records[id as usize][5..7].copy_from_slice(&index.to_be_bytes());
}
let xref_offset = pdf.len();
records[xref_id as usize][0] = 1;
records[xref_id as usize][1..5].copy_from_slice(&(xref_offset as u32).to_be_bytes());
pdf.extend_from_slice(
format!("{xref_id} 0 obj\n<< /Type /XRef /Size {} /Root 1 0 R /W [1 4 2] /Length {} >>\nstream\n", records.len(), records.len() * 7)
.as_bytes(),
);
for record in records {
pdf.extend_from_slice(&record);
}
pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref_offset}\n%%EOF\n").as_bytes());
pdf
}
// Stream 2's /Length starts a chain of `hops` indirect /Length lookups, each naming a new object; only the last
// link's /Length is direct. Compressed: link k is the integer 3 + 2k (the length it measures) alone in the object
// stream 4 + 2k, whose own /Length is link k + 1. Plain: link k is the stream 3 + k, whose /Length is link k + 1.
fn length_chain_pdf(hops: u32, compressed: bool) -> Vec<u8> {
let length = |hop: u32, next: u32, data: &str| if hop + 1 < hops { format!("{next} 0 R") } else { data.len().to_string() };
let (mut containers, mut objects) = (Vec::new(), Vec::new());
let mut value = 5; // the length of stream 2's data, "hello"
for hop in 0..hops {
if compressed {
let (id, container) = (3 + 2 * hop, 4 + 2 * hop);
let index = format!("{id} 0 ");
let data = format!("{index}{value}");
let dict = format!("/Type /ObjStm /N 1 /First {} /Length {}", index.len(), length(hop, id + 2, &data));
objects.push((container, format!("<< {dict} >>\nstream\n{data}\nendstream")));
containers.push((id, container, 0));
value = data.len();
} else {
objects.push((3 + hop, format!("<< /Length {} >>\nstream\n\nendstream", length(hop, 4 + hop, ""))));
}
}
let objects: Vec<_> = objects.iter().map(|(id, body)| (*id, body.as_bytes())).collect();
compressed_length_pdf(&containers, &objects)
}
// Lenient loading keeps a document whose stream 2 has an unresolvable /Length: the catalog, and stream 2's
// dictionary without its data.
fn load_without_stream_2_data(pdf: &[u8]) -> Document {
let doc = Document::load_mem(pdf).unwrap();
assert!(doc.catalog().unwrap().has_type(b"Catalog"));
assert!(doc.get_object((2, 0)).unwrap().as_stream().unwrap().content.is_empty());
doc
}
#[test]
fn compressed_object_self_container_does_not_recurse() {
let doc = load_without_stream_2_data(&compressed_length_pdf(&[(3, 3, 0)], &[]));
assert!(doc.get_object((3, 0)).is_err());
}
#[test]
fn compressed_object_mutual_containers_do_not_recurse() {
let doc = load_without_stream_2_data(&compressed_length_pdf(&[(3, 4, 0), (4, 3, 0)], &[]));
assert!(doc.get_object((3, 0)).is_err());
assert!(doc.get_object((4, 0)).is_err());
}
#[test]
fn object_stream_length_cycle_does_not_recurse() {
let pdf = compressed_length_pdf(&[(3, 4, 0)], &[(4, b"<< /Type /ObjStm /N 1 /First 4 /Length 3 0 R >>\nstream\n3 0 5\nendstream")]);
let doc = load_without_stream_2_data(&pdf);
// The container's /Length is the object it holds: the container is kept without data, so 3 never loads.
assert!(doc.get_object((4, 0)).unwrap().as_stream().unwrap().content.is_empty());
assert!(doc.get_object((3, 0)).is_err());
}
#[test]
fn compressed_object_container_chain_does_not_recurse() {
let containers: Vec<_> = (3..100_003).map(|id| (id, id + 1, 0)).collect();
let pdf = compressed_length_pdf(&containers, &[(100_003, b"<< /Type /ObjStm /N 1 /First 4 /Length 5 >>\nstream\n3 0 5\nendstream")]);
let doc = load_without_stream_2_data(&pdf);
// The object stream in the file loads, but the xref puts 3 in the compressed container 4, which is refused.
assert_eq!(doc.get_object((100_003, 0)).unwrap().as_stream().unwrap().content, b"3 0 5");
assert!(doc.get_object((3, 0)).is_err());
}
#[test]
fn compressed_objects_and_indirect_stream_length_still_load() {
let pdf = compressed_length_pdf(
&[(3, 4, 0), (5, 4, 1)],
&[(4, b"<< /Type /ObjStm /N 2 /First 8 /Length 17 >>\nstream\n3 0 5 2 5 (value)\nendstream")],
);
let doc = Document::load_mem_with_options(&pdf, LoadOptions { strict: true, ..Default::default() }).unwrap();
assert_eq!(doc.get_object((3, 0)).unwrap().as_i64().unwrap(), 5);
assert_eq!(doc.get_object((5, 0)).unwrap().as_str().unwrap(), b"value");
assert_eq!(doc.get_object((2, 0)).unwrap().as_stream().unwrap().content, b"hello");
}
#[test]
fn compressed_length_in_a_container_with_an_indirect_length_still_loads() {
// Two hops, as in a valid file: stream 2's /Length (3) is compressed in container 4, whose own /Length (5) is
// a plain indirect integer.
let pdf =
compressed_length_pdf(&[(3, 4, 0)], &[(4, b"<< /Type /ObjStm /N 1 /First 4 /Length 5 0 R >>\nstream\n3 0 5\nendstream"), (5, b"5")]);
let doc = Document::load_mem_with_options(&pdf, LoadOptions { strict: true, ..Default::default() }).unwrap();
assert_eq!(doc.get_object((3, 0)).unwrap().as_i64().unwrap(), 5);
assert_eq!(doc.get_object((5, 0)).unwrap().as_i64().unwrap(), 5);
assert_eq!(doc.get_object((2, 0)).unwrap().as_stream().unwrap().content, b"hello");
}
#[test]
fn compressed_length_chain_does_not_recurse() {
// Every container is an ordinary object in the file and every hop names a new object, so neither the
// container check nor the cycle history stops the chain; each hop recursed once more (a 512 KiB stack
// overflowed within 150 hops). Lookups past the cap fail: the first link never loads, while the last one,
// next to the direct /Length, does.
let hops = 5_000;
let doc = load_without_stream_2_data(&length_chain_pdf(hops, true));
assert!(doc.get_object((3, 0)).is_err());
assert!(doc.get_object((3 + 2 * (hops - 1), 0)).unwrap().as_i64().is_ok());
// The cap itself: loading link j reads its container's /Length, a chain of 2 * (hops - 1 - j) lookups, and
// 16 are allowed, so the 9th link from the end loads and the 10th does not.
assert!(doc.get_object((3 + 2 * (hops - 9), 0)).is_ok());
assert!(doc.get_object((3 + 2 * (hops - 10), 0)).is_err());
}
#[test]
fn plain_stream_length_chain_does_not_recurse() {
// The same recursion without object streams: each stream's /Length names the next stream (a 512 KiB stack
// overflowed within 200 hops). A stream is no length, so every link is kept as a stream without data.
let hops = 5_000;
let doc = load_without_stream_2_data(&length_chain_pdf(hops, false));
assert!((3..3 + hops).all(|id| doc.get_object((id, 0)).is_ok_and(|link| link.as_stream().is_ok_and(|s| s.content.is_empty()))));
}
fn indexed_object_stream(pairs: &[(u32, usize)], members: &[u8]) -> lopdf::Stream {
let mut content = Vec::new();
for &(id, offset) in pairs {
content.extend_from_slice(format!("{id} {offset} ").as_bytes());
}
let mut dict = Dictionary::new();
dict.set("Type", "ObjStm");
dict.set("N", pairs.len() as i64);
dict.set("First", content.len() as i64);
content.extend_from_slice(members);
lopdf::Stream::new(dict, content)
}
#[test]
fn object_stream_member_extents_bound_owned_strings() {
let mut members = vec![b'('; 32];
members.extend(vec![b'x'; 1 << 20]);
members.extend([b')'; 32]);
// Put the innermost (complete) string first in index order, then overlapping starts
// and duplicates. Only this first member at offset 31 may own the large string.
let mut pairs = vec![(100, 31)];
pairs.extend((0..31).map(|offset| (offset as u32 + 1, offset)));
pairs.extend([(200, 31), (201, 31)]);
let stream = indexed_object_stream(&pairs, &members);
let parsed = lopdf::ObjectStream::new_with_limit(&stream, Some(2 << 20)).unwrap();
assert_eq!(parsed.objects.get(&(100, 0)).unwrap().as_str().unwrap().len(), 1 << 20);
let total: usize = parsed.objects.values().filter_map(|object| object.as_str().ok()).map(<[u8]>::len).sum();
assert!(total <= stream.content.len(), "owned {total} string bytes from {} decoded bytes", stream.content.len());
assert!(!parsed.objects.contains_key(&(200, 0)));
assert!(!parsed.objects.contains_key(&(201, 0)));
}
#[test]
fn object_stream_member_extents_preserve_index_order_and_whitespace() {
let members = b" \n42\n /Name \n[true 7]\n << /Key (value) >>\n (last)";
// Unsorted offsets, and object 9 appears twice: its last index entry still wins.
let pairs = [(9, 0), (12, 21), (10, 5), (11, 12), (9, 41)];
let parsed = lopdf::ObjectStream::new(&indexed_object_stream(&pairs, members)).unwrap();
assert_eq!(parsed.objects.len(), 4);
assert_eq!(parsed.objects.get(&(9, 0)).unwrap().as_str().unwrap(), b"last");
assert_eq!(parsed.objects.get(&(10, 0)).unwrap().as_name().unwrap(), b"Name");
assert_eq!(parsed.objects.get(&(11, 0)).unwrap().as_array().unwrap(), &[Object::Boolean(true), Object::Integer(7)]);
assert_eq!(parsed.objects.get(&(12, 0)).unwrap().as_dict().unwrap().get(b"Key").unwrap().as_str().unwrap(), b"value");
}
#[test]
fn view_and_user_space_round_trip_for_every_rotation() {
for rotation in [0u16, 90, 180, 270] {
@@ -1758,6 +2030,95 @@ trailer << /Root 1 0 R >>
assert_eq!(titles, ["第一章", "目录", "A\u{FFFD}B", "概述", "Café", "A"]);
}
/// A file whose objects are `bodies` in order (object 1 first), with a cross-reference table.
fn pdf_of(bodies: &[String]) -> Vec<u8> {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, body) in bodies.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.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", bodies.len() + 1).as_bytes());
out
}
/// A one-page file whose outline is `items` (objects 5, 6, … in order).
fn outline_file(items: &[String]) -> Vec<u8> {
let mut bodies = vec![
"<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".to_string(),
"<< /Type /Page /Parent 2 0 R >>".to_string(),
format!("<< /Type /Outlines /First 5 0 R /Count {} >>", items.len()),
];
bodies.extend(items.iter().cloned());
pdf_of(&bodies)
}
/// The titles of `items`, with children in brackets (`Parent[Child]`).
fn shape(items: &[OutlineItem]) -> Vec<String> {
items.iter().map(|o| if o.children.is_empty() { o.title.clone() } else { format!("{}[{}]", o.title, shape(&o.children).join(",")) }).collect()
}
#[test]
fn outline_window_keeps_the_first_bookmarks_and_says_when_more_follow() {
const COUNT: usize = OUTLINE_WINDOW + 5;
let items: Vec<String> = (0..COUNT)
.map(|k| {
let prev = if k == 0 { String::new() } else { format!(" /Prev {} 0 R", 4 + k) };
let next = if k + 1 == COUNT { String::new() } else { format!(" /Next {} 0 R", 6 + k) };
format!("<< /Title (Item {k}) /Parent 4 0 R{prev}{next} /Dest [3 0 R /Fit] >>")
})
.collect();
let info = inspect(Arc::new(outline_file(&items)), None).expect("opens");
assert_eq!(info.outline.len(), OUTLINE_WINDOW);
assert_eq!(info.outline.last().map(|o| o.title.as_str()), Some("Item 9999"));
assert!(info.outline_more);
let one = inspect(Arc::new(outline_file(&["<< /Title (Only) /Parent 4 0 R /Dest [3 0 R /Fit] >>".into()])), None).expect("opens");
assert_eq!(shape(&one.outline), ["Only"]);
assert!(!one.outline_more);
}
#[test]
fn outline_window_nests_no_deeper_than_its_depth_limit() {
// A chain of 40 bookmarks, each the first child of the one before.
let items: Vec<String> = (0..40)
.map(|k| {
let parent = if k == 0 { 4 } else { 4 + k };
let first = if k + 1 == 40 { String::new() } else { format!(" /First {} 0 R", 6 + k) };
format!("<< /Title (Deep {k}) /Parent {parent} 0 R{first} /Dest [3 0 R /Fit] >>")
})
.collect();
let info = inspect(Arc::new(outline_file(&items)), None).expect("opens");
let mut levels = 0;
let mut level = info.outline.first();
while let Some(item) = level {
levels += 1;
level = item.children.first();
}
assert_eq!(levels, OUTLINE_DEPTH + 1);
assert!(info.outline_more);
}
#[test]
fn outline_window_ends_cycles_and_broken_links_without_looping() {
let listed = |items: &[&str]| {
let items: Vec<String> = items.iter().map(|s| s.to_string()).collect();
shape(&inspect(Arc::new(outline_file(&items)), None).expect("opens").outline)
};
assert_eq!(listed(&["<< /Title (Self) /Parent 4 0 R /Next 5 0 R >>"]), ["Self"]);
assert_eq!(listed(&["<< /Title (Self) /Parent 4 0 R /First 5 0 R >>"]), ["Self"]);
assert_eq!(listed(&["<< /Title (A) /Parent 4 0 R /Next 6 0 R >>", "<< /Title (B) /Parent 4 0 R /Next 5 0 R >>"]), ["A", "B"]);
assert_eq!(listed(&["<< /Title (A) /Parent 4 0 R /First 6 0 R >>", "<< /Title (B) /Parent 5 0 R /First 5 0 R >>"]), ["A[B]"]);
assert_eq!(listed(&["<< /Title (A) /Parent 4 0 R /Next 99 0 R >>"]), ["A"]);
assert_eq!(listed(&["<< /Title (A) /Parent 4 0 R /Next 6 0 R >>", "42"]), ["A"]);
}
#[test]
fn destination_points_map_to_the_displayed_page() {
// A crop box away from the origin: x 100..400, y 50..450.
+2 -2
View File
@@ -20,8 +20,8 @@ pub use inspect::{
};
pub use pixels::Pixels;
pub use raster::{
MAX_PIXELS, MAX_SIDE, PageRenderer, RenderConfig, RenderPool, RenderRequest, RenderStats, RenderWarning, RenderedPage, RequestKind, Tile,
device_pixels, effective_scale,
MAX_PIXELS, MAX_SIDE, PageRenderer, RenderConfig, RenderPool, RenderRequest, RenderStats, RenderWarning, RenderedPage, RequestKind, Retiring,
Tile, device_pixels, effective_scale,
};
pub use text::{PageText, TextGlyph};
+352 -1
View File
@@ -883,6 +883,48 @@ impl RenderPool {
}
}
/// A pool's threads after [`RenderPool::retire`]. Idle ones exit at once; one still rendering is
/// told to stop at its next drawing operator and exits when its render returns, which a single
/// long operator (such as decoding a huge image) can delay.
pub struct Retiring(Vec<JoinHandle<()>>);
impl Retiring {
/// The threads that haven't exited yet.
pub fn running(&mut self) -> usize {
self.0.retain(|h| !h.is_finished());
self.0.len()
}
/// Whether every thread has exited.
pub fn exited(&self) -> bool {
self.0.iter().all(JoinHandle::is_finished)
}
}
impl RenderPool {
/// No replacement threads: when the watchdog gives up on a render, the request (and any
/// later one) is answered with an error instead of starting another thread. For a caller that
/// bounds how many render threads exist at once: the stuck thread keeps running until its
/// operator returns, and a replacement would run beside it.
pub fn without_replacements(self) -> Self {
*lock(&self.replacements_left) = 0;
self
}
/// This pool's threads that haven't exited (workers and replacements; 0 when rendering inline).
pub fn threads(&self) -> usize {
lock(&self._workers).iter().filter(|h| !h.is_finished()).count()
}
/// Drop the pool but keep its threads' handles, for a caller that bounds how many render
/// threads run at once: a render the watchdog gave up on can outlive its pool.
pub fn retire(self) -> Retiring {
let handles = std::mem::take(&mut *lock(&self._workers));
drop(self);
Retiring(handles)
}
}
impl Drop for RenderPool {
fn drop(&mut self) {
#[cfg(not(target_arch = "wasm32"))]
@@ -1294,6 +1336,58 @@ mod tests {
assert!(initializer.join().unwrap().unwrap().is_some());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_retired_pool_counts_its_threads_until_they_exit() {
// Idle: its thread leaves as soon as the pool is gone.
let mut idle = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default()).retire();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while idle.running() > 0 && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(idle.running(), 0);
// Busy in a step that can't be interrupted: still counted after the pool is dropped.
let pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default());
*lock(&pool.shared.slow_page) = Some((0, std::time::Duration::from_secs(2)));
pool.set_queue(vec![RenderRequest { page: 0, scale: 1.0, ..Default::default() }]);
while pool.shared.render_started.load(Ordering::Relaxed) == 0 && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(2));
}
let mut busy = pool.retire();
assert_eq!(busy.running(), 1, "the render is still under way");
while busy.running() > 0 && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(busy.running(), 0, "and the thread exits when it returns");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_pool_without_replacements_never_runs_a_second_thread() {
// The watchdog gives up on a render stuck in one long step; with no replacement, the
// request is answered with an error and the stuck thread is the only one.
let mut pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default()).without_replacements();
pool.set_stuck_after(std::time::Duration::from_millis(50));
*lock(&pool.shared.slow_page) = Some((0, std::time::Duration::from_secs(2)));
pool.set_queue(vec![RenderRequest { page: 0, scale: 1.0, ..Default::default() }]);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let answer = loop {
assert!(pool.threads() <= 1, "{} render threads", pool.threads());
if let Some(page) = pool.try_recv() {
break page;
}
assert!(std::time::Instant::now() < deadline, "the stuck render is answered");
std::thread::sleep(std::time::Duration::from_millis(5));
};
assert!(answer.error.is_some(), "answered with an error, not a second attempt");
assert!(pool.threads() <= 1);
let mut retired = pool.retire();
while retired.running() > 0 {
assert!(std::time::Instant::now() < deadline, "the stuck thread exits when its step ends");
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn page_panic_retires_shared_generation_and_healthy_pages_use_private_parsers() {
@@ -1661,7 +1755,11 @@ mod tests {
let object = Object::from_bytes(data.as_bytes()).unwrap();
assert_eq!(Function::new(&object).unwrap().eval([0.5].into_iter().collect()).unwrap().as_slice(), &[0.5]);
let over = format!("<< /FunctionType 3 /Domain [0 1] /Functions [{data}] /Bounds [] /Encode [0 1] >>");
assert!(Function::new(&Object::from_bytes(over.as_bytes()).unwrap()).is_none());
// The 64 functions above nest exactly 128 levels (each dictionary and its /Functions
// array, then the leaf's /Domain array), the parser's nesting cap. 65 nest 130, so the
// parser refuses them before the function-depth guard sees them (a refusal either way).
// `function_limits_depth_guard_refuses_a_chain_of_references` tests the guard itself.
assert!(Object::from_bytes(over.as_bytes()).is_none(), "the parser refuses direct nesting past its cap");
// One root plus 10,000 leaves is one node past the common budget. This input remains
// below a megabyte and does not attempt excessive recursion or an allocation failure.
@@ -1677,6 +1775,43 @@ mod tests {
assert_eq!(Function::new(&object).is_some(), children == 9999);
}
}
/// Stitching functions that each name the next through `N 0 R` nest no direct objects, so
/// the parser's nesting cap doesn't stop them and the function-depth guard does.
#[test]
fn function_limits_depth_guard_refuses_a_chain_of_references() {
use hayro::hayro_interpret::Function;
use hayro_syntax::object::{Object, ObjectIdentifier};
for functions in [64, 65] {
// Objects 4 onwards: each stitching function names the next one; the last is a leaf.
let objects: String = (4..4 + functions)
.map(|number| {
let body = if number < 3 + functions {
format!("<< /FunctionType 3 /Domain [0 1] /Functions [{} 0 R] /Bounds [] /Encode [0 1] >>", number + 1)
} else {
"<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>".to_owned()
};
format!("{number} 0 obj {body} endobj\n")
})
.collect();
let parsed = hayro_syntax::Pdf::new(
format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 20 20] >> endobj\n\
{objects}trailer << /Root 1 0 R >>\n%%EOF"
)
.into_bytes(),
)
.unwrap();
let object = parsed.xref().get::<Object<'_>>(ObjectIdentifier::new(4, 0)).unwrap();
let function = Function::new(&object);
if functions == 64 {
assert_eq!(function.unwrap().eval([0.5].into_iter().collect()).unwrap().as_slice(), &[0.5]);
} else {
assert!(function.is_none(), "65 functions are one past the function-depth guard");
}
}
}
#[test]
fn function_limits_stitching_cycles_are_refused_and_shared_children_work() {
use hayro::hayro_interpret::Function;
@@ -3353,6 +3488,222 @@ trailer << /Root 1 0 R >>
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws");
}
/// `depth` direct arrays (`array`), dictionaries with a key (`dict`) or both in turn (`mixed`)
/// around `null`.
fn nested(depth: usize, kind: &str) -> String {
let is_dict = |level| kind == "dict" || (kind == "mixed" && level % 2 == 0);
let mut value = String::new();
for level in 0..depth {
value.push_str(if is_dict(level) { "<< /N " } else { "[" });
}
value.push_str("null");
for level in (0..depth).rev() {
value.push_str(if is_dict(level) { " >>" } else { "]" });
}
value
}
/// `depth` dictionaries nested without keys (`<< << >> >>`), closed or not.
fn keyless(depth: usize, closed: bool) -> String {
let mut value = "<< ".repeat(depth);
if closed {
value.push_str(&">> ".repeat(depth));
}
value
}
/// A 40 × 40 page that runs `content`, with a correct xref table and `trailer` at the end
/// of a trailer that also points at an `/Info` title.
fn nesting_pdf(trailer: &str, content: &str) -> Vec<u8> {
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = vec![0];
for (index, body) in [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << >> >>".to_string(),
format!("<< /Length {} >> stream\n{content}\nendstream", content.len()),
"<< /Title (Nested) >>".to_string(),
]
.iter()
.enumerate()
{
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", index + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(b"xref\n0 6\n0000000000 65535 f \n");
for offset in &offsets[1..] {
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer << /Size 6 /Root 1 0 R /Info 5 0 R {trailer} >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
bytes
}
/// Opens `bytes` and renders its one page, which must render without an error.
fn nesting_render(bytes: Vec<u8>) -> RenderedPage {
let parsed = Pdf::new(bytes.clone()).expect("xref repair can recover the catalog when the trailer is malformed");
assert_eq!(parsed.pages().len(), 1);
let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!((page.width, page.height), (40, 40));
page
}
/// Direct arrays and dictionaries used to recurse without a bound while skipping even
/// unused trailer values and content operands. Exercise the vendored patch in workspace CI.
#[test]
fn deeply_nested_direct_objects_do_not_overflow_the_stack() {
use hayro_syntax::object::Object;
use hayro_syntax::reader::{Reader, ReaderExt};
let pdf = |unused: &str, content: &str| nesting_pdf(&format!("/Unused {unused}"), content);
let paint = "1 0 0 rg 0 0 4 4 re f";
for kind in ["array", "dict", "mixed"] {
// Run the crash reproducer first, so the unpatched red state is a stack overflow.
let page = nesting_render(pdf(&nested(100_000, kind), paint));
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "{kind}: repaired trailer");
for depth in [20, 128, 129] {
let value = nested(depth, kind);
let siblings = format!("{value} [] []");
let mut reader = Reader::new(siblings.as_bytes());
assert_eq!(reader.skip::<Object<'_>>(false).is_some(), depth <= 128, "{kind}: depth {depth}");
if depth > 128 {
assert_eq!(reader.offset(), 0, "failed skips restore the offset");
reader.jump(value.len());
}
// A failed skip must unwind its depth; successful siblings must not accumulate it.
for _ in 0..2 {
reader.skip_white_spaces();
assert!(reader.skip::<Object<'_>>(false).is_some());
}
let page = nesting_render(pdf(&value, paint));
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "{kind}: depth {depth}");
}
// Many siblings exercise the offset constructor and ensure depth is per nesting.
let siblings = "[] ".repeat(256);
let mut reader = Reader::new_with(siblings.as_bytes(), 0);
for _ in 0..256 {
assert!(reader.skip::<Object<'_>>(false).is_some());
reader.skip_white_spaces();
}
}
// Content-stream arrays take the Object path, rather than MaybeRef<Object>.
for depth in [100_000, 20, 128, 129] {
let value = nested(depth, "array");
let mut reader = Reader::new(value.as_bytes());
assert_eq!(reader.skip::<Object<'_>>(true).is_some(), depth <= 128, "content: depth {depth}");
let content = format!("{value} discard {paint}");
let page = nesting_render(pdf("null", &content));
if depth <= 128 {
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "content: depth {depth}");
}
}
}
/// A dictionary with an object where a key belongs is repaired by reading that object, not
/// skipping it, and that read recursed without a bound when the object was another such
/// dictionary: `<<` 100,000 times aborted the process. Here as a content-stream operand.
#[test]
fn dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack() {
for closed in [true, false] {
let content = format!("1 0 0 rg 0 0 4 4 re f {} discard", keyless(100_000, closed));
let page = nesting_render(nesting_pdf("", &content));
assert_eq!(px40(&page, 1, 38), &[255, 0, 0, 255], "closed: {closed}");
}
}
/// The same, where a key of the trailer belongs: the xref table can't be used, and repair
/// finds the catalog.
#[test]
fn dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack() {
for closed in [true, false] {
let page = nesting_render(nesting_pdf(&keyless(100_000, closed), "1 0 0 rg 0 0 4 4 re f"));
assert_eq!(px40(&page, 1, 38), &[255, 0, 0, 255], "closed: {closed}");
}
}
/// The same as the properties of marked content (`/Tag <<<<<< … BDC`).
#[test]
fn dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack() {
for closed in [true, false] {
let properties = if closed { "<<".repeat(100_000) + &">>".repeat(100_000) } else { "<<".repeat(100_000) };
let content = format!("1 0 0 rg 0 0 4 4 re f /Tag {properties} BDC 0 0 1 rg 36 36 4 4 re f EMC");
let page = nesting_render(nesting_pdf("", &content));
assert_eq!(px40(&page, 1, 38), &[255, 0, 0, 255], "closed: {closed}");
}
}
/// Through the same three paths, dictionaries without keys are read up to the nesting cap
/// (128 dictionaries in all) and refused one past it.
#[test]
fn dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap() {
use hayro_syntax::object::{FromBytes, Object};
use hayro_syntax::reader::{Reader, ReaderExt};
let (red, blue, white) = ([255, 0, 0, 255], [0, 0, 255, 255], [255, 255, 255, 255]);
for depth in [128, 129] {
let within = depth <= 128;
let value = keyless(depth, true);
assert_eq!(Object::from_bytes(value.as_bytes()).is_some(), within, "object: {depth}");
assert_eq!(Reader::new(value.as_bytes()).read_without_context::<Object<'_>>().is_some(), within, "operand: {depth}");
// Content reading stops at an operand it can't read, so the blue square is drawn
// only when the dictionaries were read.
let properties = "<<".repeat(depth) + &">>".repeat(depth);
for (path, operand) in [("operand", format!("{value} discard")), ("BDC", format!("/Tag {properties} BDC"))] {
let content = format!("1 0 0 rg 0 0 4 4 re f {operand} 0 0 1 rg 36 36 4 4 re f");
let page = nesting_render(nesting_pdf("", &content));
assert_eq!(px40(&page, 1, 38), &red, "{path}: {depth}");
assert_eq!(px40(&page, 38, 1), if within { &blue } else { &white }, "{path}: {depth}");
}
// The trailer is the outermost dictionary. Within the cap it is read (with its
// /Info); past it, repair recovers the catalog but not the trailer.
let bytes = nesting_pdf(&keyless(depth - 1, true), "1 0 0 rg 0 0 4 4 re f");
let parsed = Pdf::new(bytes.clone()).expect("the trailer or repair finds the catalog");
assert_eq!(parsed.metadata().title, within.then(|| b"Nested".to_vec()), "trailer: {depth}");
assert_eq!(px40(&nesting_render(bytes), 1, 38), &red, "trailer: {depth}");
}
}
/// The cap must also fit small stacks: skip, read, iterate down with the array iterators and
/// format (`{:?}`) the deepest nesting it admits, of arrays, dictionaries, both and
/// dictionaries without keys, on a 512 KiB thread in a debug build. Measured when this was
/// written (macOS arm64, dev profile): at most about 100 KiB, for `{:?}` of 128 dictionaries.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn nesting_at_the_cap_fits_a_512_kib_stack() {
use hayro_syntax::object::{FromBytes, Object};
use hayro_syntax::reader::{Reader, ReaderExt};
let worker = std::thread::Builder::new().stack_size(512 * 1024).spawn(|| {
// (kind, arrays and dictionaries `{:?}` shows, levels walked down to the end)
for (kind, arrays, dicts, levels) in [("array", 128, 0, 129), ("dict", 0, 128, 129), ("mixed", 64, 64, 129), ("keyless", 0, 1, 1)] {
let value = if kind == "keyless" { keyless(128, true) } else { nested(128, kind) };
assert!(Reader::new(value.as_bytes()).skip::<Object<'_>>(false).is_some(), "{kind}: skip");
assert!(Reader::new(value.as_bytes()).skip::<Object<'_>>(true).is_some(), "{kind}: skip in content");
assert!(Reader::new(value.as_bytes()).read_without_context::<Object<'_>>().is_some(), "{kind}: operand");
let object = Object::from_bytes(value.as_bytes()).expect("the cap admits 128 levels");
let shown = format!("{object:?}");
assert_eq!((shown.matches("Array(").count(), shown.matches("Dict(").count()), (arrays, dicts), "{kind}: {{:?}}");
let (mut walked, mut next) = (0, Some(object));
while let Some(object) = next.take() {
walked += 1;
next = match object {
Object::Array(array) => {
assert_eq!(array.raw_iter().count(), 1, "{kind}: raw_iter");
assert!(array.flex_iter().next::<Object<'_>>().is_some(), "{kind}: flex_iter");
array.iter::<Object<'_>>().next()
}
Object::Dict(dict) => dict.get::<Object<'_>>("N"),
_ => None,
};
}
assert_eq!(walked, levels, "{kind}: iterate");
}
});
// A stack overflow aborts the process; a failed check is a panic, shown as it is.
if let Err(panic) = worker.expect("spawn a 512 KiB thread").join() {
std::panic::resume_unwind(panic);
}
}
/// From the nightly `cargo xtask fuzz`: many small inline images, each with "EI" (followed
/// by a space) inside its data, and no text anywhere. To decide whether such an "EI" ends the
/// data, the parser re-read the rest of the stream as content, and that re-read did the same
+271 -13
View File
@@ -30,6 +30,24 @@ pub struct TextGlyph {
pub direction: [f32; 2],
}
/// `s` folded for case-insensitive search, one character for one character.
///
/// The dotted and dotless i of Turkish and Azerbaijani (`İ` U+0130, `ı` U+0131) and the Latin
/// `I`/`i` all fold to `i`. `str::to_lowercase` follows no locale: it turns `İ` into `i` plus a
/// combining dot (two characters) and leaves `ı` alone, so neither `İSTANBUL` nor `IRMAK` could
/// be found by typing `istanbul` or `ırmak`. Search can't know the document's language, so the
/// four are treated as one letter; this only adds matches where an `ı` or `İ` is involved.
fn fold_case(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'I' | 'i' | '\u{130}' | '\u{131}' => out.push('i'),
_ => out.extend(c.to_lowercase()),
}
}
out
}
/// The text of one page, in content order, with line structure.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PageText {
@@ -76,7 +94,7 @@ impl PageText {
/// Search with Acrobat's find options: case-sensitive, whole words only.
pub fn find_opts(&self, needle: &str, case_sensitive: bool, whole_words: bool) -> Vec<std::ops::Range<usize>> {
let fold = |s: &str| if case_sensitive { s.to_string() } else { s.to_lowercase() };
let fold = |s: &str| if case_sensitive { s.to_string() } else { fold_case(s) };
let needle: Vec<char> = fold(needle).split_whitespace().collect::<Vec<_>>().join(" ").chars().collect();
if needle.is_empty() {
return Vec::new();
@@ -88,7 +106,12 @@ impl PageText {
chars.push((' ', i));
}
for c in fold(&g.text).chars() {
chars.push((if c.is_whitespace() { ' ' } else { c }, i));
let c = if c.is_whitespace() { ' ' } else { c };
// The query collapses a run of whitespace to one space, so collapse the page too.
if c == ' ' && chars.last().is_some_and(|k| k.0 == ' ') {
continue;
}
chars.push((c, i));
}
}
let word = |k: Option<&(char, usize)>| k.is_some_and(|c| c.0.is_alphanumeric());
@@ -190,21 +213,115 @@ impl PageText {
/// Merge the boxes of `range` into one rectangle per line (for highlighting).
pub fn line_rects(&self, range: std::ops::Range<usize>) -> Vec<[f32; 4]> {
let mut out: Vec<(u32, [f32; 4])> = Vec::new();
for i in range {
let Some(g) = self.glyphs.get(i) else { break };
let line = self.line_of[i];
self.merged_rects(range)
}
/// One rectangle per run of consecutive glyphs on one line, for glyph indices in ascending
/// order (a reading-order range, or [`Self::glyphs_in`]). A run stops at a skipped glyph, so
/// the rectangle never covers text that is not selected.
pub fn glyph_rects(&self, glyphs: &[usize]) -> Vec<[f32; 4]> {
self.merged_rects(glyphs.iter().copied())
}
fn merged_rects(&self, glyphs: impl IntoIterator<Item = usize>) -> Vec<[f32; 4]> {
let mut out: Vec<(u32, usize, [f32; 4])> = Vec::new();
for i in glyphs {
let (Some(g), Some(&line)) = (self.glyphs.get(i), self.line_of.get(i)) else { break };
match out.last_mut() {
Some((l, r)) if *l == line => {
r[0] = r[0].min(g.rect[0]);
r[1] = r[1].min(g.rect[1]);
r[2] = r[2].max(g.rect[2]);
r[3] = r[3].max(g.rect[3]);
Some((l, last, r)) if *l == line && last.checked_add(1) == Some(i) => {
*last = i;
union(r, &g.rect);
}
_ => out.push((line, g.rect)),
_ => out.push((line, i, g.rect)),
}
}
out.into_iter().map(|(_, r)| r).collect()
out.into_iter().map(|(_, _, r)| r).collect()
}
/// The glyphs whose centre lies inside `rect` (view space, corners in either order), in
/// reading order: what a column selection covers (Alt/Option-drag, Acrobat's column select).
/// A glyph only partly inside counts when its centre is, so a rectangle drawn a little into
/// the next column does not pick up the edge of its text.
pub fn glyphs_in(&self, rect: [f32; 4]) -> Vec<usize> {
let (x0, x1) = (rect[0].min(rect[2]), rect[0].max(rect[2]));
let (y0, y1) = (rect[1].min(rect[3]), rect[1].max(rect[3]));
let inside = |g: &TextGlyph| {
let (cx, cy) = ((g.rect[0] + g.rect[2]) / 2.0, (g.rect[1] + g.rect[3]) / 2.0);
(x0..=x1).contains(&cx) && (y0..=y1).contains(&cy)
};
self.glyphs.iter().enumerate().filter(|(_, g)| inside(g)).map(|(i, _)| i).collect()
}
/// The text of a column selection ([`Self::glyphs_in`]) as it looks on the page: one row per
/// visual line, top to bottom. Pieces of different lines that sit side by side (table cells,
/// newspaper columns) share a row and are separated by a tab, so a pasted table lands in
/// spreadsheet cells. Rows of right-to-left text put their pieces right to left. When any
/// selected glyph does not run horizontally (CJK vertical writing, turned text, a page shown
/// turned by `/Rotate`) rows mean nothing, and the lines come out one per row in reading order
/// instead.
pub fn column_text(&self, glyphs: &[usize]) -> String {
// The selected glyphs of each line, with their box: (glyphs, bbox).
let mut lines: Vec<(Vec<usize>, [f32; 4])> = Vec::new();
let mut line_ids: Vec<u32> = Vec::new();
for &i in glyphs {
let (Some(g), Some(&line)) = (self.glyphs.get(i), self.line_of.get(i)) else { continue };
match (lines.last_mut(), line_ids.last()) {
(Some((members, bb)), Some(&l)) if l == line => {
members.push(i);
union(bb, &g.rect);
}
_ => {
lines.push((vec![i], g.rect));
line_ids.push(line);
}
}
}
// Within the layout's own tolerance for reading a glyph as unturned.
let vertical = glyphs.iter().any(|&i| self.glyphs.get(i).is_some_and(|g| g.direction[1].abs() > WORD_GAP));
let piece = |members: &[usize]| {
let mut s = String::new();
let mut prev: Option<usize> = None;
for &i in members {
let Some(g) = self.glyphs.get(i) else { continue };
let gap = self.space_before.get(i).copied().unwrap_or(false) || prev.and_then(|p| p.checked_add(1)) != Some(i);
if prev.is_some() && gap {
s.push(' ');
}
s.push_str(&g.text);
prev = Some(i);
}
s
};
if vertical {
return lines.iter().map(|(members, _)| piece(members)).collect::<Vec<_>>().join("\n");
}
// Rows: a line joins the row whose first line its vertical centre falls within.
let mut order: Vec<usize> = (0..lines.len()).collect();
order.sort_by(|a, b| {
let cy = |k: usize| lines.get(k).map_or(0.0, |(_, bb)| (bb[1] + bb[3]) / 2.0);
cy(*a).total_cmp(&cy(*b))
});
let mut rows: Vec<(f32, f32, Vec<usize>)> = Vec::new();
for k in order {
let Some((_, bb)) = lines.get(k) else { continue };
let cy = (bb[1] + bb[3]) / 2.0;
match rows.last_mut() {
Some((top, bottom, members)) if cy >= *top && cy <= *bottom => members.push(k),
_ => rows.push((bb[1], bb[3], vec![k])),
}
}
let rtl = |members: &[usize]| members.iter().any(|&i| self.glyphs.get(i).is_some_and(|g| g.text.chars().any(is_rtl)));
let mut out = Vec::with_capacity(rows.len());
for (_, _, mut members) in rows {
let right_to_left = members.iter().all(|&k| lines.get(k).is_some_and(|(m, _)| rtl(m)));
members.sort_by(|a, b| {
let x = |k: usize| lines.get(k).map_or(0.0, |(_, bb)| bb[0]);
if right_to_left { x(*b).total_cmp(&x(*a)) } else { x(*a).total_cmp(&x(*b)) }
});
let cells: Vec<String> = members.iter().filter_map(|&k| lines.get(k)).map(|(m, _)| piece(m)).collect();
out.push(cells.join("\t"));
}
out.join("\n")
}
}
@@ -906,6 +1023,45 @@ mod tests {
assert_eq!(to_upright([290.0, 0.0, 300.0, 10.0], 1, 300.0, 200.0), [0.0, 0.0, 10.0, 10.0]);
}
#[test]
fn turkish_dotted_and_dotless_i_match_in_any_case() {
// "İSTANBUL IRMAK KIŞ" on one line: each word typed in lower case must find it, and the
// reverse. `to_lowercase` alone made `İ` two characters and left `ı` unmatched.
let mut v = Vec::new();
word(&mut v, "İSTANBUL", 10.0, 10.0, 7.0);
word(&mut v, "IRMAK", 80.0, 10.0, 7.0);
word(&mut v, "KIŞ", 130.0, 10.0, 7.0);
let upper = layout(v);
assert_eq!(upper.plain_text(), "İSTANBUL IRMAK KIŞ");
assert_eq!(upper.find("istanbul"), vec![0..8]);
assert_eq!(upper.find("İstanbul"), vec![0..8]);
assert_eq!(upper.find("ırmak"), vec![8..13]);
assert_eq!(upper.find("kış"), vec![13..16]);
assert_eq!(upper.find("istanbul ırmak kış"), vec![0..16], "a phrase across the words");
assert_eq!(upper.find_opts("ırmak", false, true), vec![8..13], "whole words");
assert!(upper.find_opts("istanbul", true, false).is_empty(), "case-sensitive search stays exact");
assert_eq!(upper.find_opts("İSTANBUL", true, false), vec![0..8]);
let mut v = Vec::new();
word(&mut v, "istanbul", 10.0, 10.0, 7.0);
word(&mut v, "ırmak", 80.0, 10.0, 7.0);
word(&mut v, "kış", 130.0, 10.0, 7.0);
let lower = layout(v);
assert_eq!(lower.find("İSTANBUL"), vec![0..8]);
assert_eq!(lower.find("IRMAK"), vec![8..13]);
assert_eq!(lower.find("KIŞ"), vec![13..16]);
}
#[test]
fn case_folding_keeps_one_character_per_character() {
for s in ["İSTANBUL", "ırmak", "Hello WORLD", "ÇAĞLAR", "Straße", "ΣΟΦΙΑ", "日本語"] {
assert_eq!(fold_case(s).chars().count(), s.chars().count(), "{s}");
}
assert_eq!(fold_case("Hello WORLD"), "hello world");
assert_eq!(fold_case("ÇAĞLAR Şişli"), "çağlar şişli");
assert_eq!(fold_case("İIıi"), "iiii");
}
fn g(t: &str, x0: f32, y0: f32, x1: f32) -> TextGlyph {
let rect = [x0, y0, x1, y0 + 10.0];
TextGlyph { text: t.into(), rect, quad: [[x0, y0], [x1, y0], [x1, rect[3]], [x0, rect[3]]], direction: [1.0, 0.0] }
@@ -932,6 +1088,27 @@ mod tests {
assert_eq!(t.nearest(52.0, 14.0), Some(5));
}
#[test]
fn phrase_search_ignores_repeated_spaces() {
// "quick brown" drawn with two space glyphs in a row. Text extraction keeps both spaces,
// but the phrase search has always collapsed runs of whitespace in the query, so the page
// text has to be collapsed the same way or the phrase is never found (#814).
let mut v = Vec::new();
word(&mut v, "quick", 10.0, 10.0, 6.0);
v.push(g(" ", 46.0, 10.0, 52.0));
v.push(g(" ", 52.0, 10.0, 58.0));
word(&mut v, "brown", 58.0, 10.0, 6.0);
let n = v.len();
let t = PageText { glyphs: v, line_of: vec![0; n], space_before: vec![false; n] };
assert_eq!(t.plain_text(), "quick brown");
assert_eq!(t.find("quick brown"), vec![0..12]);
assert_eq!(t.find("quick brown"), vec![0..12], "the query's own run of spaces is collapsed too");
assert_eq!(t.find("quick\tbrown"), vec![0..12]);
assert_eq!(t.find("quick"), vec![0..5]);
assert_eq!(t.find("brown"), vec![7..12]);
assert!(t.find("quick absent").is_empty());
}
fn word(v: &mut Vec<TextGlyph>, s: &str, x: f32, y: f32, advance: f32) {
for (i, c) in s.chars().enumerate() {
let x0 = x + i as f32 * advance;
@@ -1120,4 +1297,85 @@ mod tests {
let slant = skewed.quad[3][0] - skewed.quad[0][0];
assert!((slant - 10.0).abs() < 1e-3, "the skewed glyph's upright edge leans by half its 20 pt height: {skewed:?}");
}
/// A three-row table, "Name Qty / Apple 12 / Pear 7": the columns sit far enough apart that
/// reading order takes one column, then the other.
fn table() -> PageText {
let mut v = Vec::new();
for (row, (name, qty)) in [("Name", "Qty"), ("Apple", "12"), ("Pear", "7")].into_iter().enumerate() {
let y = 10.0 + row as f32 * 14.0;
word(&mut v, name, 10.0, y, 6.0);
word(&mut v, qty, 100.0, y, 6.0);
}
layout(v)
}
/// Issue #740: a column selection takes only what is inside the rectangle.
#[test]
fn a_column_selection_takes_one_table_column() {
let t = table();
assert_eq!(t.plain_text(), "Name\nApple\nPear\nQty\n12\n7", "reading order is column by column");
let qty = t.glyphs_in([95.0, 0.0, 130.0, 60.0]);
assert_eq!(t.column_text(&qty), "Qty\n12\n7");
assert_eq!(t.glyph_rects(&qty).len(), 3, "one highlight per row");
// Corners in either order.
assert_eq!(t.glyphs_in([130.0, 60.0, 95.0, 0.0]), qty);
}
/// Across columns the text comes out row by row, cells separated by tabs.
#[test]
fn a_column_selection_across_columns_reads_row_by_row() {
let t = table();
let all = t.glyphs_in([0.0, 0.0, 200.0, 60.0]);
assert_eq!(t.column_text(&all), "Name\tQty\nApple\t12\nPear\t7");
// The middle row only, and only the start of "Apple": centres decide.
let part = t.glyphs_in([0.0, 25.0, 31.0, 33.0]);
assert_eq!(t.column_text(&part), "Appl");
assert_eq!(t.glyph_rects(&part), vec![[10.0, 24.0, 34.0, 34.0]]);
}
/// A rectangle that misses every glyph, or is not a number, selects nothing.
#[test]
fn an_empty_or_degenerate_column_selection_is_empty() {
let t = table();
for r in [[300.0, 300.0, 400.0, 400.0], [f32::NAN, 0.0, 200.0, 60.0], [50.0, 50.0, 50.0, 50.0]] {
let none = t.glyphs_in(r);
assert!(none.is_empty(), "{r:?}");
assert_eq!(t.column_text(&none), "");
assert!(t.glyph_rects(&none).is_empty());
}
assert_eq!(PageText::default().column_text(&[0, 7, usize::MAX]), "", "indices past the text are ignored");
}
/// Right-to-left rows put their cells right to left.
#[test]
fn right_to_left_rows_read_right_to_left() {
let mut v = Vec::new();
// "שלום" (right) and "עולם" (left), each drawn visually.
word(&mut v, "םולש", 100.0, 10.0, 6.0);
word(&mut v, "םלוע", 10.0, 10.0, 6.0);
let t = layout(v);
let all = t.glyphs_in([0.0, 0.0, 200.0, 30.0]);
assert_eq!(t.column_text(&all), "שלום\tעולם");
}
/// Vertical lines (CJK vertical writing) come out one per row, in reading order.
#[test]
fn vertical_lines_keep_reading_order() {
// Written downwards: the em box's edge towards the next line is its left one.
let cell = |t: &str, x: f32, y: f32| TextGlyph {
text: t.into(),
rect: [x, y, x + 10.0, y + 10.0],
quad: [[x, y], [x, y + 10.0], [x + 10.0, y + 10.0], [x + 10.0, y]],
direction: [0.0, 1.0],
};
let t = PageText {
// Right column 日本 first, then the left column 語文, each top to bottom.
glyphs: vec![cell("日", 40.0, 10.0), cell("本", 40.0, 20.0), cell("語", 20.0, 10.0), cell("文", 20.0, 20.0)],
line_of: vec![0, 0, 1, 1],
space_before: vec![false; 4],
};
let all = t.glyphs_in([0.0, 0.0, 60.0, 40.0]);
assert_eq!(t.column_text(&all), "日本\n語文");
}
}
+5 -1
View File
@@ -54,7 +54,11 @@ ECDSA P-256/P-384; the store integration is tested with software-backed keys. A
or token whose driver registers a CNG key storage provider is such an identity too: the key
is opened for signing without the silent flag, so the provider shows its own PIN dialog
(checked by hand with a SafeNet token; CI has no hardware). A store identity signs with the
issuers Windows chains its certificate to, without the root.
issuers Windows chains its certificate to, without the root. Store certificates that can't
sign (no private key, another key type, a key CNG can't open) are returned by
`windows::list` as `unusable` with the reason and the Windows error code, shown greyed in
the Sign dialog and by `sign_windows_ids`, instead of being left out silently; a key that
needs a prompt to open is listed and asks at signing (issue #179).
- **Validation:** `/ByteRange` and the CMS are read from the file's own bytes; the digest,
the signature value and the signer's chain (against a `TrustStore`) are checked. Later
revisions are diffed against the signed one, and the changes are classified (signing, form
+175 -29
View File
@@ -1,14 +1,19 @@
//! Signing identities in the Windows Current User Personal ("My") certificate store.
//! Private keys remain in CNG; Windows may ask permission to use a key.
//!
//! Certificates that can't sign through PdfCraft (no usable private key, a key type it doesn't
//! sign with) are reported in [`Listing::unusable`] with the reason rather than left out
//! silently, so a missing identity can be explained (issue #179).
use std::sync::Arc;
use rustls_cng::cert::CertContext;
use rustls_cng::key::{AlgorithmGroup, SignaturePadding};
use rustls_cng::error::CngError;
use rustls_cng::key::{AlgorithmGroup, NCryptKey, SignaturePadding};
use rustls_cng::store::{CertStore, CertStoreType};
use crate::keys::{DigestAlg, ExternalKey, PrivateKey, PublicKey, StoreKey};
use crate::{Certificate, DigitalId, SignError};
use crate::{Certificate, DigitalId, SignError, x509};
struct WindowsKey {
context: CertContext,
@@ -25,16 +30,39 @@ impl ExternalKey for WindowsKey {
// signing handle without the silent flag so the provider can show its own dialog.
let key = self.context.acquire_key(false).map_err(|e| {
SignError::Crypto(format!(
"the Windows certificate store key couldn't be opened for signing (is the smart card or token connected?): {e}"
"the Windows certificate store key couldn't be opened for signing (is the smart card or token connected?): {}",
describe(&e)
))
})?;
let raw = key
.sign(&digest, padding)
.map_err(|e| SignError::Crypto(format!("the Windows certificate store didn't sign (key use may have been cancelled): {e}")))?;
let raw = key.sign(&digest, padding).map_err(|e| {
SignError::Crypto(format!("the Windows certificate store didn't sign (key use may have been cancelled): {}", describe(&e)))
})?;
self.public.store_signature_der(&raw)
}
}
/// A Personal store certificate PdfCraft can't sign with, and why.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Unusable {
/// The subject's common name (else organization, else the DN), or a placeholder when
/// the certificate couldn't be read at all.
pub subject: String,
/// SHA-256 of the certificate as upper-case hex pairs (`Certificate::fingerprint`).
pub fingerprint: String,
/// Why it can't sign, as a clause that follows the name ("its private key is …").
pub reason: String,
/// The store holds no private key for it: a certificate someone else issued, filed under
/// Personal. Not a problem with an identity, so the Sign dialog doesn't show these.
pub no_private_key: bool,
}
/// The Personal store's signing identities and the certificates that can't sign.
#[derive(Default)]
pub struct Listing {
pub ids: Vec<DigitalId>,
pub unusable: Vec<Unusable>,
}
/// The `windows:<SHA-256 of the certificate DER>` reference kept for an identity.
pub fn reference(certificate: &Certificate) -> String {
let digest = DigestAlg::Sha256.digest(&[&certificate.raw]);
@@ -45,6 +73,7 @@ pub fn reference(certificate: &Certificate) -> String {
/// Windows finds for the certificate, so a signature embeds them as it does for a file's.
pub fn find(reference_or_name: &str) -> Result<DigitalId, SignError> {
let (context, mut id) = entries()?
.ids
.into_iter()
.find(|(_, id)| {
reference(&id.certificate) == reference_or_name
@@ -62,39 +91,156 @@ fn issuers(context: &CertContext, certificate: &Certificate) -> Vec<Certificate>
chain.iter().filter(|der| **der != certificate.raw).filter_map(|der| Certificate::parse(der).ok()).collect()
}
/// List usable RSA, P-256 and P-384 CNG identities in Current User > Personal.
/// Certificates with unsupported keys or without an accessible private key are skipped.
/// List usable RSA, P-256 and P-384 CNG identities in Current User > Personal
/// (the `ids` of [`list`]).
pub fn identities() -> Result<Vec<DigitalId>, SignError> {
Ok(entries()?.into_iter().map(|(_, id)| id).collect())
Ok(entries()?.ids.into_iter().map(|(_, id)| id).collect())
}
/// Each usable identity with the store's certificate context it came from.
fn entries() -> Result<Vec<(CertContext, DigitalId)>, SignError> {
/// List Current User > Personal: the RSA, P-256 and P-384 identities whose private key CNG can
/// open, and every other certificate with the reason it can't sign. A key that needs a prompt
/// to open counts as usable; the prompt appears at signing.
pub fn list() -> Result<Listing, SignError> {
let Entries { ids, unusable } = entries()?;
Ok(Listing { ids: ids.into_iter().map(|(_, id)| id).collect(), unusable })
}
/// Each usable identity with the store's certificate context it came from, and the rest.
struct Entries {
ids: Vec<(CertContext, DigitalId)>,
unusable: Vec<Unusable>,
}
fn entries() -> Result<Entries, SignError> {
let store = CertStore::open(CertStoreType::CurrentUser, "My")
.map_err(|e| SignError::Crypto(format!("the Windows Current User Personal store couldn't be opened: {e}")))?;
let contexts = store.find_all().map_err(|e| SignError::Crypto(format!("the Windows certificate store couldn't be searched: {e}")))?;
let mut out = Vec::new();
.map_err(|e| SignError::Crypto(format!("the Windows Current User Personal store couldn't be opened: {}", describe(&e))))?;
let contexts =
store.find_all().map_err(|e| SignError::Crypto(format!("the Windows certificate store couldn't be searched: {}", describe(&e))))?;
let mut ids = Vec::new();
let mut unusable = Vec::new();
for context in contexts {
let Ok(certificate) = Certificate::parse(context.as_der()) else { continue };
// Enumeration must not open permission or PIN dialogs. Signing may prompt later.
let Ok(key) = context.acquire_key(true) else { continue };
// P-521, brainpool and Ed25519 certificates parse (to verify with) but aren't signed with.
let supported = match certificate.public_key.store_signing_key() {
Some(StoreKey::Rsa) => key.algorithm_group().is_ok_and(|a| a == AlgorithmGroup::Rsa),
Some(StoreKey::Ecdsa(want)) => {
key.algorithm_group().is_ok_and(|a| a == AlgorithmGroup::Ecdsa) && key.bits().is_ok_and(|bits| bits == want)
}
None => false,
let der = context.as_der();
let fingerprint = DigestAlg::Sha256.digest(&[der]).iter().map(|b| format!("{b:02X}")).collect::<Vec<_>>().join(" ");
let mut skip = |subject: String, reason: String, no_private_key: bool| {
unusable.push(Unusable { subject, fingerprint: fingerprint.clone(), reason, no_private_key });
};
if !supported {
let certificate = match Certificate::parse(der) {
Ok(c) => c,
Err(e) => {
let subject = x509::subject_of(der).map(|n| n.display_name()).unwrap_or_else(|| "(unreadable certificate)".to_string());
skip(subject, format!("the certificate can't be read: {e}"), false);
continue;
}
};
// P-521, brainpool and Ed25519 certificates parse (to verify with) but aren't signed with.
let Some(want) = certificate.public_key.store_signing_key() else {
let what = certificate.public_key.describe();
skip(certificate.display_name(), format!("its {what} key can't sign through PdfCraft yet (RSA, P-256 and P-384 only)"), false);
continue;
};
// Enumeration must not open permission or PIN dialogs. Signing may prompt later.
match context.acquire_key(true) {
Ok(key) => {
if let Err(reason) = matches_key(&key, want) {
skip(certificate.display_name(), reason, false);
continue;
}
// The silent handle was only for the checks above; `sign` acquires its own.
drop(key);
}
// The key opens only with a prompt (strong private key protection, a PIN): usable.
Err(CngError::WindowsError(NTE_SILENT_CONTEXT | NTE_UI_REQUIRED)) => {}
Err(e) => {
let no_private_key = e == CngError::WindowsError(CRYPT_E_NO_KEY_PROPERTY);
let reason = if no_private_key {
"it has no private key in the store".to_string()
} else {
format!("its private key can't be opened: {}", describe(&e))
};
skip(certificate.display_name(), reason, no_private_key);
continue;
}
}
// The silent handle was only for the checks above; `sign` acquires its own.
drop(key);
let public = certificate.public_key.clone();
let key = PrivateKey::external(public.clone(), Arc::new(WindowsKey { context: context.clone(), public }));
let friendly_name = Some(certificate.display_name());
out.push((context, DigitalId { key, certificate, chain: Vec::new(), friendly_name }));
ids.push((context, DigitalId { key, certificate, chain: Vec::new(), friendly_name }));
}
Ok(Entries { ids, unusable })
}
/// Whether the store's key is what the certificate needs (RSA; ECDSA on its curve).
fn matches_key(key: &NCryptKey, want: StoreKey) -> Result<(), String> {
let group = key.algorithm_group().map_err(|e| format!("its private key's algorithm couldn't be read: {}", describe(&e)))?;
let name = |g: &AlgorithmGroup| match g {
AlgorithmGroup::Rsa => "an RSA".to_string(),
AlgorithmGroup::Ecdsa => "an ECDSA".to_string(),
other => format!("a {other:?}"),
};
let expected = match want {
StoreKey::Rsa => AlgorithmGroup::Rsa,
StoreKey::Ecdsa(_) => AlgorithmGroup::Ecdsa,
};
if group != expected {
return Err(format!("its certificate holds {} key but the store's private key is {} key", name(&expected), name(&group)));
}
if let StoreKey::Ecdsa(bits) = want {
let actual = key.bits().map_err(|e| format!("its private key's size couldn't be read: {}", describe(&e)))?;
if actual != bits {
return Err(format!("its certificate holds a P-{bits} key but the store's private key has {actual} bits"));
}
}
Ok(())
}
// Error codes (winerror.h) that explain why a key can't be opened.
const NTE_NO_KEY: u32 = 0x8009_000D;
const NTE_PERM: u32 = 0x8009_0010;
const NTE_NOT_FOUND: u32 = 0x8009_0011;
const NTE_BAD_PROVIDER: u32 = 0x8009_0013;
const NTE_BAD_PROV_TYPE: u32 = 0x8009_0014;
const NTE_BAD_KEYSET: u32 = 0x8009_0016;
const NTE_PROV_DLL_NOT_FOUND: u32 = 0x8009_001E;
const NTE_SILENT_CONTEXT: u32 = 0x8009_0022;
const NTE_NOT_SUPPORTED: u32 = 0x8009_0029;
const NTE_UI_REQUIRED: u32 = 0x8009_002E;
const NTE_DEVICE_NOT_READY: u32 = 0x8009_0030;
const NTE_DEVICE_NOT_FOUND: u32 = 0x8009_0035;
const NTE_USER_CANCELLED: u32 = 0x8009_0036;
const CRYPT_E_NO_KEY_PROPERTY: u32 = 0x8009_200B;
/// A CNG error with the Windows code and, for the usual ones, what it means.
fn describe(e: &CngError) -> String {
let CngError::WindowsError(code) = e else { return e.to_string() };
let meaning = match *code {
NTE_NO_KEY | NTE_BAD_KEYSET | NTE_NOT_FOUND => "the key is missing from its key store (deleted, or imported under another Windows account)",
NTE_PERM => "access to the key was denied",
NTE_BAD_PROVIDER | NTE_BAD_PROV_TYPE | NTE_PROV_DLL_NOT_FOUND => {
"its key provider isn't available to CNG (a legacy CryptoAPI-only provider, or its driver isn't installed)"
}
NTE_NOT_SUPPORTED => "its key provider doesn't support the operation",
NTE_DEVICE_NOT_READY | NTE_DEVICE_NOT_FOUND => "the smart card or token isn't available",
NTE_USER_CANCELLED => "the request was cancelled",
NTE_SILENT_CONTEXT | NTE_UI_REQUIRED => "the key needs a prompt to open",
CRYPT_E_NO_KEY_PROPERTY => "the certificate has no private key",
0x8010_0000..=0x8010_FFFF => "the smart card isn't available",
_ => return format!("Windows error 0x{code:08X}"),
};
format!("{meaning} (Windows error 0x{code:08X})")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn errors_are_explained_with_their_code() {
assert_eq!(
describe(&CngError::WindowsError(NTE_BAD_KEYSET)),
"the key is missing from its key store (deleted, or imported under another Windows account) (Windows error 0x80090016)"
);
assert_eq!(describe(&CngError::WindowsError(0x8010_000C)), "the smart card isn't available (Windows error 0x8010000C)");
assert_eq!(describe(&CngError::WindowsError(0x57)), "Windows error 0x00000057");
assert_eq!(describe(&CngError::InvalidHashLength), "Invalid hash length");
}
Ok(out)
}
+19 -1
View File
@@ -57,6 +57,11 @@ impl Name {
self.get(C)
}
/// The name Acrobat shows: the common name, else the organization, else the DN.
pub fn display_name(&self) -> String {
self.common_name().or(self.organization()).map(str::to_string).unwrap_or_else(|| self.display())
}
/// "CN=Ada Lovelace, O=Example, E=ada@example.com"
pub fn display(&self) -> String {
self.attrs
@@ -400,7 +405,7 @@ impl Certificate {
/// The display name Acrobat uses: the common name, else the organization, else the DN.
pub fn display_name(&self) -> String {
self.subject.common_name().or(self.subject.organization()).map(str::to_string).unwrap_or_else(|| self.subject.display())
self.subject.display_name()
}
/// SHA-256 fingerprint as upper-case hex pairs.
@@ -452,6 +457,19 @@ impl Certificate {
}
}
/// The subject alone, to name a certificate `Certificate::parse` rejects (one with a key type
/// PdfCraft doesn't support, say). `None` when even that can't be read.
pub fn subject_of(raw: &[u8]) -> Option<Name> {
let cert = Tlv::parse_all(raw).ok()?.expect(tag::SEQUENCE, "Certificate").ok()?;
let tbs = cert.children().ok()?.into_iter().next()?;
let mut f = tbs.children().ok()?.into_iter().peekable();
if f.peek().is_some_and(|t| t.tag == tag::ctx(0)) {
f.next();
}
// serial, signature algorithm, issuer, validity, then the subject.
Name::parse(&f.nth(4)?).ok()
}
/// The chain from `leaf` up through `pool`, as far as issuers can be found and may issue: each
/// issuer's key verifies the certificate below it, it is a CA allowed to sign certificates, its
/// `pathLenConstraint` allows the CA certificates below it, and (when `at` is given) it was valid
+23 -3
View File
@@ -1086,7 +1086,12 @@ fn a_timestamp_token_may_sign_with_a_different_digest_than_its_imprint() {
#[cfg(windows)]
#[test]
fn windows_store_enumeration_and_missing_identity() {
assert!(pdfcraft_sign::windows::identities().is_ok());
let listing = pdfcraft_sign::windows::list().unwrap();
assert_eq!(listing.ids.len(), pdfcraft_sign::windows::identities().unwrap().len());
// Whatever this machine's store holds, every certificate left out is named and explained.
for u in &listing.unusable {
assert!(!u.subject.is_empty() && !u.reason.is_empty() && u.fingerprint.len() == 95, "{u:?}");
}
assert!(pdfcraft_sign::windows::find("windows:no such signer").is_err());
}
@@ -1108,9 +1113,10 @@ impl Drop for Certificates {
fn drop(&mut self) {
for thumbprint in &self.0 {
// New-SelfSignedCertificate also leaves a public copy of a self-signed or CA
// certificate in Intermediate Certification Authorities (CA).
// certificate in Intermediate Certification Authorities (CA). -DeleteKey fails on
// a certificate without a key; the plain removal then runs.
let out = powershell(&format!(
"Remove-Item -LiteralPath 'Cert:\\CurrentUser\\My\\{thumbprint}' -DeleteKey; $copy='Cert:\\CurrentUser\\CA\\{thumbprint}'; if (Test-Path -LiteralPath $copy) {{ Remove-Item -LiteralPath $copy }}"
"$p='Cert:\\CurrentUser\\My\\{thumbprint}'; try {{ Remove-Item -LiteralPath $p -DeleteKey }} catch {{ Remove-Item -LiteralPath $p }}; $copy='Cert:\\CurrentUser\\CA\\{thumbprint}'; if (Test-Path -LiteralPath $copy) {{ Remove-Item -LiteralPath $copy }}"
));
if !out.status.success() {
eprintln!("test certificate cleanup failed: {}", String::from_utf8_lossy(&out.stderr));
@@ -1156,6 +1162,20 @@ fn signing_with_windows_store_identities() {
assert_eq!(signature.modification, Modification::None);
assert_eq!(signature.signer.as_deref(), Some(name.as_str()));
}
// A certificate without a private key (issued to someone else, filed under Personal) is
// reported as such rather than listed or dropped silently (issue #179).
let ada = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap().certificate;
let cer = std::env::temp_dir().join(format!("pdfcraft-windows-store-{unique}.cer"));
std::fs::write(&cer, &ada.raw).unwrap();
let out = powershell(&format!("(Import-Certificate -FilePath '{}' -CertStoreLocation 'Cert:\\CurrentUser\\My').Thumbprint", cer.display()));
let _ = std::fs::remove_file(&cer);
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
created.0.push(String::from_utf8(out.stdout).unwrap().trim().to_string());
let listing = pdfcraft_sign::windows::list().unwrap();
let reported = listing.unusable.iter().find(|u| u.fingerprint == ada.fingerprint()).expect("key-less certificate reported");
assert!(reported.no_private_key, "{reported:?}");
assert_eq!(reported.subject, ada.display_name());
assert!(listing.ids.iter().all(|id| id.certificate.raw != ada.raw));
let thumbprints = created.0.clone();
drop(created);
for thumbprint in thumbprints {
+31 -1
View File
@@ -28,6 +28,36 @@ pub struct RunProgress {
pub message: Option<String>,
}
/// Keep each desktop result, including when input basenames repeat or an earlier run already
/// used the name. The search is bounded and never falls back to an occupied destination.
#[cfg(not(target_arch = "wasm32"))]
fn write_result(dir: &std::path::Path, name: &str, bytes: &[u8]) -> std::io::Result<std::path::PathBuf> {
use std::io::{Error, ErrorKind};
let source = std::path::Path::new(name);
let filename = source.file_name().ok_or_else(|| Error::from(ErrorKind::InvalidInput))?;
let stem = source.file_stem().unwrap_or(filename).to_string_lossy();
for number in 1..=10_000 {
let path = if number == 1 {
dir.join(filename)
} else {
let suffix = match source.extension() {
Some(ext) => format!("{stem} ({number}).{}", ext.to_string_lossy()),
None => format!("{stem} ({number})"),
};
dir.join(suffix)
};
match std::fs::symlink_metadata(&path) {
Ok(_) => continue,
Err(e) if e.kind() == ErrorKind::NotFound => {
crate::editing::write_atomically(&path.to_string_lossy(), bytes)?;
return Ok(path);
}
Err(e) => return Err(e),
}
}
Err(Error::from(ErrorKind::AlreadyExists))
}
impl PdfCraftApp {
/// Built-in actions, then the user's.
pub fn all_actions(&self) -> Vec<Action> {
@@ -75,7 +105,7 @@ impl PdfCraftApp {
}
let r = pdfcraft_engine::actions::run_on(&action, &name, Arc::new(bytes), |_, _| {}).and_then(|r| {
#[cfg(not(target_arch = "wasm32"))]
crate::editing::write_atomically(&dir.join(&name).to_string_lossy(), &r.bytes).map_err(|e| e.to_string())?;
write_result(&dir, &name, &r.bytes).map_err(|e| e.to_string())?;
#[cfg(target_arch = "wasm32")]
crate::editing::download(&name, &r.bytes)?;
Ok(())
+256 -80
View File
@@ -60,7 +60,7 @@ fn texture_bytes(tex: &TextureHandle) -> usize {
/// Visible grid rows, plus one adjacent row in either direction. Work is independent of
/// document length, including after a large scroll jump.
fn thumbnail_rows(top: f32, bottom: f32, row_height: f32, rows: usize) -> Range<usize> {
pub(crate) fn thumbnail_rows(top: f32, bottom: f32, row_height: f32, rows: usize) -> Range<usize> {
let first = (top.max(0.0) / row_height).floor() as usize;
let end = (bottom.max(0.0) / row_height).ceil() as usize;
first.saturating_sub(1).min(rows)..end.saturating_add(1).min(rows)
@@ -196,17 +196,45 @@ pub struct Find {
pub in_panel: bool,
}
/// A text selection on one page, in reading-order glyph indices.
/// A text selection on one page.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Selection {
page: usize,
anchor: usize,
head: usize,
span: Span,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Span {
/// Reading order, between two glyph indices.
Run { anchor: usize, head: usize },
/// Column select (#740): the glyphs inside a rectangle in page view space, drawn from the
/// corner the drag started at (`rect[0..2]`) to the pointer (`rect[2..4]`).
Column { rect: [f32; 4] },
}
impl Selection {
fn range(&self) -> Range<usize> {
self.anchor.min(self.head)..self.anchor.max(self.head) + 1
fn run(page: usize, anchor: usize, head: usize) -> Self {
Self { page, span: Span::Run { anchor, head } }
}
fn range(anchor: usize, head: usize) -> Range<usize> {
anchor.min(head)..anchor.max(head).saturating_add(1)
}
/// One rectangle per selected piece of a line, in view space (highlighting, markup quads).
fn rects(&self, text: &PageText) -> Vec<[f32; 4]> {
match self.span {
Span::Run { anchor, head } => text.line_rects(Self::range(anchor, head)),
Span::Column { rect } => text.glyph_rects(&text.glyphs_in(rect)),
}
}
/// The selected text, as Copy puts it on the clipboard.
fn text(&self, text: &PageText) -> String {
match self.span {
Span::Run { anchor, head } => text.text_of(Self::range(anchor, head)),
Span::Column { rect } => text.column_text(&text.glyphs_in(rect)),
}
}
}
@@ -306,6 +334,12 @@ pub struct DocView {
shown: bool,
/// Turns wheel input into page turns in single-page view.
wheel: crate::wheel_pager::WheelPager,
/// Until this time, apply precise (touchpad) wheel input 1:1 and suppress egui's eased copy.
/// A precision touchpad sends pixel-precise `MouseWheel` events; on Windows they arrive with
/// no `TouchPhase::Start`, so egui eases deltas of 8 px or more and the page lags the finger
/// (#759). The window outlasts a single frame so egui's internal easing tail is suppressed as
/// it decays, instead of adding on top of the 1:1 motion.
precise_scroll_until: f64,
pub(crate) auto_scroll: crate::autoscroll::AutoScroll,
/// Pages selected in the organize grid or the Pages panel (0-based). Empty means "the current page".
pub selected: BTreeSet<usize>,
@@ -319,6 +353,7 @@ pub struct DocView {
pub line_editor: Option<crate::edit_text_ui::LineEditor>,
/// Edit text & images: the images per page (by document generation), and the selected one.
pub(crate) edit_images: HashMap<usize, (u64, Vec<pdfcraft_engine::PageImage>)>,
pub(crate) objects: crate::object_ui::ObjectSelection,
pub image_selection: Option<crate::edit_text_ui::ImageSelection>,
/// A paragraph box being dragged (moved, or resized from its right edge) in Edit text.
pub block_drag: Option<crate::edit_text_ui::BlockDrag>,
@@ -503,6 +538,7 @@ impl DocView {
zoom_anchor: None,
shown: false,
wheel: Default::default(),
precise_scroll_until: 0.0,
auto_scroll: Default::default(),
selected: BTreeSet::new(),
select_anchor: None,
@@ -510,6 +546,7 @@ impl DocView {
edit_lines: HashMap::new(),
line_editor: None,
edit_images: HashMap::new(),
objects: Default::default(),
image_selection: None,
block_drag: None,
pending_action: None,
@@ -679,7 +716,7 @@ impl DocView {
let s = self.selection?;
let text = self.texts.get(&s.page)?;
let page = info.pages.get(s.page)?;
let quads: Vec<[f64; 8]> = text.line_rects(s.range()).into_iter().map(|r| page.view_rect_to_quad(r)).collect();
let quads: Vec<[f64; 8]> = s.rects(text).into_iter().map(|r| page.view_rect_to_quad(r)).collect();
(!quads.is_empty()).then_some((s.page, quads))
}
@@ -704,7 +741,7 @@ impl DocView {
/// Select text on `page` from glyph `from` to glyph `to` (tests and automation).
pub fn select_text(&mut self, page: usize, from: usize, to: usize) {
self.selection = Some(Selection { page, anchor: from, head: to });
self.selection = Some(Selection::run(page, from, to));
}
/// Open the find bar (or focus it if already open).
@@ -795,7 +832,7 @@ impl DocView {
pub fn selected_text(&self) -> Option<String> {
let s = self.selection?;
let t = self.texts.get(&s.page)?;
Some(t.text_of(s.range())).filter(|x| !x.is_empty())
Some(s.text(t)).filter(|x| !x.is_empty())
}
pub fn thumb(&self, page: usize) -> Option<&TextureHandle> {
@@ -945,7 +982,7 @@ impl DocView {
if t.glyphs.is_empty() {
return false;
}
self.selection = Some(Selection { page, anchor: 0, head: t.glyphs.len() - 1 });
self.selection = Some(Selection::run(page, 0, t.glyphs.len() - 1));
true
}
@@ -1357,7 +1394,7 @@ impl DocView {
let avail_w = (self.viewport_w - 2.0 * SIDE).max(100.0);
let per_row = if self.layout == PageLayout::TwoUp { 2.0 } else { 1.0 };
match self.fit {
Fit::Width => self.zoom = (avail_w - GAP * (per_row - 1.0)) / (max_w * PT * per_row),
Fit::Width => self.zoom = (avail_w - GAP * (per_row - 1.0)) / (w * PT * per_row),
Fit::Page => {
let zw = (avail_w - GAP * (per_row - 1.0)) / (w * PT * per_row);
let zh = (self.viewport_h - 2.0 * MARGIN) / (h * PT);
@@ -1369,6 +1406,16 @@ impl DocView {
self.zoom = self.zoom.clamp(0.08, 64.0);
}
/// Width of the scroll content. Single-page view sizes it to the page it shows, so a
/// wider page elsewhere in the document can't push the shown one off-centre.
fn content_width(&self, info: &DocInfo, avail_w: f32) -> f32 {
let single = (self.layout == PageLayout::Single).then(|| self.current.min(info.pages.len().saturating_sub(1)));
let shown = info.pages.iter().enumerate().filter(|&(i, _)| single.is_none_or(|c| c == i));
let max_w = shown.map(|(_, p)| self.display_size(p).0).fold(0.0, f32::max);
let per_row = if self.layout == PageLayout::TwoUp { 2.0 } else { 1.0 };
(max_w * self.zoom * PT * per_row + 2.0 * SIDE).max(avail_w)
}
/// Page rects in content coordinates (origin at the scroll content's top-left).
fn layout(&self, info: &DocInfo, content_w: f32) -> Vec<Rect> {
let s = self.zoom * PT;
@@ -1612,7 +1659,9 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context) {
view.fit = Fit::Width;
view.goto = Some((view.current, 0.0));
}
if pressed(cmd(Key::G)) {
if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::G)) {
view.find_step(false);
} else if pressed(cmd(Key::G)) {
view.find_step(true);
}
if pressed(cmd(Key::OpenBracket)) {
@@ -1621,9 +1670,6 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context) {
if pressed(cmd(Key::CloseBracket)) {
view.view_history(true);
}
if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::G)) {
view.find_step(false);
}
// ⌘C arrives as a Copy event on most platforms.
let copy = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Copy)));
if copy && let Some(text) = view.selected_text() {
@@ -1728,7 +1774,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
None => {}
}
// No dialog, close prompt or palette over the page: only then does page input count.
let unobstructed = app.dialog.is_none() && app.close_request.is_none() && !app.palette_open;
let unobstructed = !app.modal_open() && !app.palette_open;
let view = &mut app.views[index];
if view.organize {
organize_grid(view, info, doc.allows_assembly(), doc.dirty, unobstructed, ui, &t);
return;
@@ -1756,21 +1803,19 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
Vec2::ZERO
};
let max_w = info.pages.iter().map(|p| view.display_size(p).0).fold(0.0, f32::max)
* view.zoom
* PT
* if view.layout == PageLayout::TwoUp { 2.0 } else { 1.0 };
let content_w = (max_w + 2.0 * SIDE).max(avail.width());
let content_w = view.content_width(info, avail.width());
let rects = view.layout(info, content_w);
let middle_gesture = view.auto_scroll.blocks_input();
// Single page: when the whole page fits, the wheel would do nothing, so it turns pages
// instead; zoomed in far enough to pan, it pans. Touch drags are untouched: with nothing
// to pan, touch users turn pages with the rail buttons, the page box and the arrow keys.
// Runs before `visible_pages` so the frame that turns the page draws it.
let mut single_paging = false;
if view.layout == PageLayout::Single {
// Within a point, so layout rounding can't stop a fitting page from turning.
let fits = rects.get(view.current.min(rects.len().saturating_sub(1))).is_some_and(|r| r.height() + 2.0 * MARGIN <= avail.height() + 1.0);
let can_turn = fits && !middle_gesture && unobstructed && ui.rect_contains_pointer(avail);
single_paging = can_turn;
// Every wheel event goes to the pager, so it follows each trackpad touch to its end
// even while the page can't turn.
ui.input(|i| {
@@ -1788,6 +1833,46 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
ui.input_mut(|i| i.smooth_scroll_delta.y = 0.0);
}
}
// Touchpad scrolling 1:1 (#759). A precision touchpad sends pixel-precise `MouseWheel`
// events; on Windows they carry no `TouchPhase::Start`, so egui never takes its no-smoothing
// path and eases deltas of 8 px or more, leaving the page a frame behind the finger. Apply
// the precise delta ourselves this frame through the un-animated scroll path, and suppress
// egui's eased copy while it decays so it neither doubles nor trails the motion. A coarse
// mouse wheel keeps egui's easing (pleasant for chunky notches).
let now = ui.input(|i| i.time);
let mut precise_scroll = Vec2::ZERO;
if ui.rect_contains_pointer(avail) {
let mut coarse_wheel = false;
ui.input(|i| {
for e in &i.events {
if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = e {
if modifiers.command || modifiers.ctrl {
continue; // Zooming, not scrolling.
}
match unit {
// Shift scrolls sideways, matching egui's own wheel handling.
egui::MouseWheelUnit::Point if modifiers.shift => precise_scroll.x += delta.x + delta.y,
egui::MouseWheelUnit::Point => precise_scroll += *delta,
egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => coarse_wheel = true,
}
}
}
});
if coarse_wheel {
view.precise_scroll_until = 0.0;
} else if precise_scroll != Vec2::ZERO {
view.precise_scroll_until = now + 0.25;
}
}
// Paging owns the vertical axis in single-page view; leave it to the pager.
if single_paging {
precise_scroll.y = 0.0;
}
if now < view.precise_scroll_until {
ui.input_mut(|i| i.smooth_scroll_delta = Vec2::ZERO);
} else {
precise_scroll = Vec2::ZERO;
}
let visible_pages: Vec<usize> = match view.layout {
PageLayout::Single => vec![view.current.min(rects.len() - 1)],
_ => (0..rects.len()).collect(),
@@ -1836,7 +1921,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let selects_text = match tool {
QuickTool::Comment(t) => t.markup().is_some() || t == comments::CommentTool::ReplaceText,
QuickTool::Select => !preparing,
QuickTool::Redact => true,
QuickTool::Redact | QuickTool::ColumnSelect => true,
QuickTool::Hand
| QuickTool::Measure(_)
| QuickTool::Crop
@@ -1887,6 +1972,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
} else {
view.prepare.selected = None;
}
view.objects.prepare(doc, tool == QuickTool::EditText && can_modify, ui.ctx());
let added = doc.added.clone();
let doc_links = if tool == QuickTool::Link { doc.links.clone() } else { Vec::new() };
if editing_content {
@@ -1917,8 +2003,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
// Only interactions pause; the document must keep its original colours.
ui.set_opacity(opacity);
}
// Middle-button auto-scroll and the keyboard (positive y moves the content down).
let delta = auto_delta - vec2(0.0, key_scroll);
// Middle-button auto-scroll, the keyboard and precise touchpad input (#759); positive y
// moves the content down. All three scroll without easing, unlike egui's wheel default.
let delta = auto_delta - vec2(0.0, key_scroll) + precise_scroll;
if delta != Vec2::ZERO {
ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none());
}
@@ -2124,8 +2211,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
stamp_placed = true;
}
}
// Over a mark already placed, the Fill & Sign tool picks it up (comments, below).
if let QuickTool::Fill(ft) = tool
&& allowed
&& !comments::fill_grabs(ui, &pcx, view)
{
match crate::fill_sign::page_input(
ui,
@@ -2172,7 +2261,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
field_placed |= o.placed;
o.consumed || field_tool.is_some()
} else {
tool == QuickTool::Select && crate::forms_ui::page_input(ui, &resp, &xf, i, info, &form, can_fill, view)
tool == QuickTool::Select
&& !comments::select_grabs(ui, &pcx, view)
&& crate::forms_ui::page_input(ui, &resp, &xf, i, info, &form, can_fill, view)
};
// Redact draws boxes off text; so does Highlight (an area highlight, as in Acrobat).
let area_tool = tool == QuickTool::Redact && can_modify || tool == QuickTool::Comment(comments::CommentTool::Highlight) && allowed;
@@ -2184,11 +2275,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
};
crate::redact_ui::page_input(ui, &resp, &xf, i, info, over_text, view)
};
let on_content = editing_content && can_modify && {
let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(ui, &resp, &xf, i, doc, view);
let on_content = !on_objects && editing_content && can_modify && {
crate::content_ui::page_input(ui, &resp, &xf, i, info, &added, tool == QuickTool::AddText, &text_style, view)
|| tool == QuickTool::AddText
};
let on_edit_text = tool == QuickTool::EditText && can_modify && {
let on_edit_text = !on_objects && tool == QuickTool::EditText && can_modify && {
let generation = doc.edit_generation();
let lines = match view.edit_lines.get(&i) {
Some((g, l)) if *g == generation => l.clone(),
@@ -2211,9 +2303,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|| crate::edit_text_ui::page_input(ui, &resp, &xf, i, info, &lines, view)
};
let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(ui, &resp, &xf, i, info, &doc_links, view);
let consumed = on_edit_text || on_link || on_content || boxing || on_field || comments::page_input(ui, &resp, &pcx, view);
let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || comments::page_input(ui, &resp, &pcx, view);
let preview_target = (tool == QuickTool::Select && !comments_hidden).then_some(view.comments.selected).flatten();
let preview_target =
(matches!(tool, QuickTool::Select | QuickTool::Fill(_)) && !comments_hidden).then_some(view.comments.selected).flatten();
view.signature_drag.prepare(ui.ctx(), doc, preview_target, scale);
view.signature_drag.paint(painter, &pcx, &view.comments, view.pending_edit.as_ref());
@@ -2237,9 +2330,16 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
}
}
if let Some(sel) = view.selection.filter(|s| s.page == i) {
for lr in text.line_rects(sel.range()) {
for lr in sel.rects(&text) {
painter.rect_filled(to_screen(lr), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70));
}
// The rectangle being drawn, as Acrobat shows it while column-selecting.
if let Span::Column { rect } = sel.span
&& resp.dragged()
{
let outline = Stroke::new(1.0, Color32::from_rgb(0x3A, 0x7B, 0xF0));
painter.rect_stroke(to_screen(rect), CornerRadius::ZERO, outline, egui::StrokeKind::Middle);
}
}
if selects_text
&& !consumed
@@ -2248,20 +2348,44 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let (vx, vy) = xf.screen_to_view(p);
let over_text = text.glyphs.iter().any(|g| vx >= g.rect[0] && vx <= g.rect[2] && vy >= g.rect[1] && vy <= g.rect[3]);
let over_link = info.links.iter().any(|l| l.page == i && xf.user_rect(info, i, l.rect).contains(p));
if over_text && !over_link {
// Column select (#740): the Column select tool, or Alt (Option on macOS) held
// while a drag starts, as in Acrobat.
let column = tool == QuickTool::ColumnSelect || ui.input(|inp| inp.modifiers.alt);
if column && !over_link {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
} else if over_text && !over_link {
ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
}
let press_here = ui.input(|inp| inp.pointer.press_origin()).is_some_and(|o| r.contains(o));
if resp.drag_started() && press_here && !over_link {
let origin = ui.input(|inp| inp.pointer.press_origin()).unwrap_or(p);
let (ox, oy) = xf.screen_to_view(origin);
view.selection = text.nearest(ox, oy).map(|a| Selection { page: i, anchor: a, head: a });
view.selection = if column {
Some(Selection { page: i, span: Span::Column { rect: [ox, oy, vx, vy] } })
} else {
text.nearest(ox, oy).map(|a| Selection::run(i, a, a))
};
}
if resp.dragged()
&& let Some(sel) = view.selection.as_mut().filter(|s| s.page == i)
&& let Some(h) = text.nearest(vx, vy)
{
sel.head = h;
match &mut sel.span {
Span::Run { head, .. } => {
if let Some(h) = text.nearest(vx, vy) {
*head = h;
}
}
Span::Column { rect } => {
rect[2] = vx;
rect[3] = vy;
}
}
}
// A rectangle around no text selects nothing.
if resp.drag_stopped()
&& view.selection.is_some_and(|s| s.page == i && matches!(s.span, Span::Column { .. }) && s.text(&text).is_empty())
{
view.selection = None;
}
// Quick clicks widen the selection: two a word, three the line, four the page.
// egui counts up to three, so four come from the run kept here. The markup and
@@ -2284,15 +2408,16 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
_ => text.glyphs.len().checked_sub(1).map(|last| (0, last)),
});
if let Some((first, last)) = span {
view.selection = Some(Selection { page: i, anchor: first, head: last });
view.selection = Some(Selection::run(i, first, last));
}
} else if resp.clicked() && !over_link {
// ⇧-click extends a selection on this page to the click, keeping its
// anchor (#527). Otherwise (no shift, or no selection on this page; a
// selection cannot yet span pages) a click clears the selection.
// anchor (#527). Otherwise (no shift, no selection on this page, or a
// column selection, which has no anchor glyph; a selection cannot yet
// span pages) a click clears the selection.
let shift = ui.input(|inp| inp.modifiers.shift);
match (view.selection.as_mut().filter(|s| shift && s.page == i), text.nearest(vx, vy)) {
(Some(sel), Some(h)) => sel.head = h,
match (view.selection.as_mut().filter(|s| shift && s.page == i).map(|s| &mut s.span), text.nearest(vx, vy)) {
(Some(Span::Run { head, .. }), Some(h)) => *head = h,
_ => view.selection = None,
}
}
@@ -2310,6 +2435,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
if editing_content {
crate::content_ui::paint_page(ui, painter, &xf, i, info, &added, view);
}
if tool == QuickTool::EditText {
crate::object_ui::paint(painter, &xf, i, info, &view.objects);
}
if tool == QuickTool::Link {
crate::link_ui::paint(ui, painter, &xf, i, info, &doc_links, view);
}
@@ -2537,6 +2665,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
if typed.is_some() {
view.pending_edit = typed;
}
if let Some(why) = view.objects.notice.take() {
refused = Some(why);
}
let form_notice = view.forms.notice.take();
// One crop, then back to selecting (as Acrobat does).
let cropped = view.pending_edit.as_ref().is_some_and(|e| matches!(e, pdfcraft_engine::Edit::SetPageBox { .. }));
@@ -2895,16 +3026,10 @@ fn notices(
let xfa = doc.xfa.as_ref();
if let Some((icon, color, template, arg)) = signed {
let mut open = false;
egui::Frame::NONE.fill(t.accent_soft).inner_margin(egui::Margin::symmetric(14, 7)).show(ui, |ui| {
ui.horizontal(|ui| {
ui.add(icons::image(icon, 16.0, color));
ui.label(egui::RichText::new(crate::i18n::fmt(tl!(template), &[("by", &arg)])).color(t.text));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::widgets::pill_button(ui, tl!("Signature panel"), false).clicked() {
open = true;
}
});
});
notice_bar(ui, t, icon, color, crate::i18n::fmt(tl!(template), &[("by", &arg)]), |ui| {
if crate::widgets::pill_button(ui, tl!("Signature panel"), false).clicked() {
open = true;
}
});
return open.then_some(Notice::Signatures);
}
@@ -2955,29 +3080,23 @@ fn notices(
None
};
let (icon, text, fields) = msg?;
egui::Frame::NONE.fill(t.accent_soft).inner_margin(egui::Margin::symmetric(14, 7)).show(ui, |ui| {
ui.horizontal(|ui| {
ui.add(icons::image(icon, 16.0, t.accent_text));
ui.label(egui::RichText::new(text).color(t.text));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if icons::button(ui, "x", 22.0, false, tl!("Dismiss")).clicked() {
view.notice_dismissed = true;
}
if fields {
let label = if view.highlight_fields { tl!("Hide field highlights") } else { tl!("Highlight fields") };
if crate::widgets::pill_button(ui, label, view.highlight_fields).clicked() {
view.highlight_fields = !view.highlight_fields;
toggled = Some(Notice::FieldHighlights(view.highlight_fields));
}
}
if secured && crate::widgets::pill_button(ui, tl!("Security settings"), false).clicked() {
open_security = true;
}
if repaired && !secured && info.fields.is_empty() && crate::widgets::pill_button(ui, tl!("Details"), false).clicked() {
open_repairs = true;
}
});
});
notice_bar(ui, t, icon, t.accent_text, text, |ui| {
if icons::button(ui, "x", 22.0, false, tl!("Dismiss")).clicked() {
view.notice_dismissed = true;
}
if fields {
let label = if view.highlight_fields { tl!("Hide field highlights") } else { tl!("Highlight fields") };
if crate::widgets::pill_button(ui, label, view.highlight_fields).clicked() {
view.highlight_fields = !view.highlight_fields;
toggled = Some(Notice::FieldHighlights(view.highlight_fields));
}
}
if secured && crate::widgets::pill_button(ui, tl!("Security settings"), false).clicked() {
open_security = true;
}
if repaired && !secured && info.fields.is_empty() && crate::widgets::pill_button(ui, tl!("Details"), false).clicked() {
open_repairs = true;
}
});
if open_repairs {
return Some(Notice::Repairs);
@@ -2985,6 +3104,37 @@ fn notices(
open_security.then_some(Notice::Security).or(toggled)
}
/// One notice bar: an icon and a message on the left, `buttons` (added right to left) on the
/// right. The buttons are laid out first, so the message only gets the width they leave: it
/// wraps there (or, when very little is left, is cut short with the full text on hover) instead
/// of running under the buttons and past the document area into the side panel (#785). The
/// buttons are centred on a row of button height, and the message's first line is centred on
/// the same row; a wrapped message grows the bar downwards.
fn notice_bar(ui: &mut egui::Ui, t: &Tokens, icon: &str, icon_color: Color32, text: String, buttons: impl FnOnce(&mut egui::Ui)) {
const ROW: f32 = 28.0;
const ICON: f32 = 16.0;
egui::Frame::NONE.fill(t.accent_soft).inner_margin(egui::Margin::symmetric(14, 7)).show(ui, |ui| {
let width = ui.available_width();
ui.allocate_ui_with_layout(vec2(width, ROW), egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.set_min_height(ROW);
buttons(ui);
let rest = ui.available_width().max(0.0);
ui.allocate_ui_with_layout(vec2(rest, ROW), egui::Layout::top_down(egui::Align::Min), |ui| {
let font = egui::TextStyle::Body.resolve(ui.style());
let line = ui.fonts_mut(|f| f.row_height(&font)).max(ICON);
ui.add_space(((ROW - line) / 2.0).max(0.0));
ui.with_layout(egui::Layout::left_to_right(egui::Align::Min), |ui| {
let (slot, _) = ui.allocate_exact_size(vec2(ICON, line), Sense::hover());
icons::paint(ui, Rect::from_center_size(slot.center(), vec2(ICON, ICON)), icon, ICON, icon_color);
let label = egui::Label::new(egui::RichText::new(text).color(t.text));
// Wrapping into a sliver would stack the message a few letters per line.
ui.add(if ui.available_width() >= 120.0 { label.wrap() } else { label.truncate() });
});
});
});
});
}
/// The floating quick-action bar at the left edge of the document area.
fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
@@ -3136,7 +3286,7 @@ fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) {
}
/// The organize toolbar: page operations on the selection (Acrobat's Organize Pages bar).
fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool, ui: &mut egui::Ui, t: &Tokens) {
fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool, unobstructed: bool, ui: &mut egui::Ui, t: &Tokens) {
let targets = view.target_pages();
let n = info.pages.len();
let (first, last) = (targets.first().copied().unwrap_or(0), targets.last().copied().unwrap_or(0));
@@ -3242,8 +3392,9 @@ fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, dirty: b
});
});
});
// Keys act on the selection unless a text field has focus.
if editable && !ui.ctx().egui_wants_keyboard_input() {
// Keys act on the selection unless a text field or a dialog has them (Delete under
// Preferences deleted the selected pages).
if editable && unobstructed && !ui.ctx().egui_wants_keyboard_input() {
use egui::{Key, Modifiers};
let (del, esc) = ui.input_mut(|i| {
(
@@ -3274,7 +3425,7 @@ fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, dirty: b
/// Organize pages: a thumbnail grid (Acrobat's Organize Pages view). Click selects, ⌘-click
/// toggles, ⇧-click extends; double-click opens the page.
/// The gap (0 = before the first page, n = after the last) the pointer points at in the grid.
fn drop_gap(cells: &[(usize, Rect)], p: Pos2) -> Option<usize> {
pub(crate) fn drop_gap(cells: &[(usize, Rect)], p: Pos2) -> Option<usize> {
let (i, r) = cells.iter().min_by(|(_, a), (_, b)| a.distance_sq_to_pos(p).total_cmp(&b.distance_sq_to_pos(p)))?;
Some(if p.x < r.center().x { *i } else { i + 1 })
}
@@ -3294,16 +3445,16 @@ fn gap_button(ui: &mut egui::Ui, gap: usize, at: Pos2, height: f32, label: Strin
resp.on_hover_text(label).clicked()
}
fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool, auto_scroll_enabled: bool, ui: &mut egui::Ui, t: &Tokens) {
fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool, unobstructed: bool, ui: &mut egui::Ui, t: &Tokens) {
let ppp = ui.ctx().pixels_per_point();
// The page image scales with the zoom; the padding and the page number don't.
let cell = vec2(146.0 * view.grid_zoom + 44.0, 194.0 * view.grid_zoom + 56.0);
let anchor = view.grid_anchor.take();
let mut open_page = None;
organize_toolbar(view, info, editable, dirty, ui, t);
organize_toolbar(view, info, editable, dirty, unobstructed, ui, t);
let viewport = ui.available_rect_before_wrap();
view.viewport_screen = viewport;
let auto_delta = if auto_scroll_enabled {
let auto_delta = if unobstructed {
view.auto_scroll.update(ui, viewport, true)
} else {
view.auto_scroll.cancel();
@@ -3525,7 +3676,7 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool
/// A rendered raster as texture data. Its words are premultiplied RGBA bytes, exactly
/// `Color32`s, so the renderer's buffer becomes the image without a copy on the UI thread.
fn texture_image(size: [usize; 2], pixels: pdfcraft_render::Pixels) -> egui::ColorImage {
pub(crate) fn texture_image(size: [usize; 2], pixels: pdfcraft_render::Pixels) -> egui::ColorImage {
match bytemuck::allocation::try_cast_vec::<u32, Color32>(pixels.into_words()) {
Ok(px) if px.len() == size[0].saturating_mul(size[1]) => egui::ColorImage::new(size, px),
Ok(px) => egui::ColorImage::from_rgba_premultiplied(size, bytemuck::cast_slice(&px)),
@@ -4080,6 +4231,31 @@ trailer << /Root 1 0 R >>
}
}
#[test]
fn fit_width_fits_the_shown_page_in_single_page_view_only() {
let page =
|width: f32, height: f32| pdfcraft_render::PageInfo { width, height, label: String::new(), crop: [0.0, 0.0, width, height], rotation: 0 };
let info = DocInfo { pages: vec![page(300.0, 400.0), page(600.0, 400.0)], ..Default::default() };
let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());
(v.fit, v.viewport_w, v.viewport_h) = (Fit::Width, 1000.0, 800.0);
v.layout = PageLayout::Single;
v.fit_zoom(&info);
let narrow = v.zoom;
// The shown page fills the width between the side gutters, not centred for the wide page.
let shown = v.layout(&info, v.content_width(&info, 1000.0))[0];
assert!((shown.left() - SIDE).abs() < 1e-2 && (shown.width() - 860.0).abs() < 1e-2, "{shown:?}");
v.current = 1;
v.fit_zoom(&info);
assert!((narrow / v.zoom - 2.0).abs() < 1e-3, "the narrow page gets twice the zoom of the wide one");
// Scrolling views keep one zoom for the whole document.
v.layout = PageLayout::Continuous;
v.fit_zoom(&info);
let wide = v.zoom;
v.current = 0;
v.fit_zoom(&info);
assert_eq!(v.zoom, wide, "continuous fit width holds still across page sizes");
}
#[test]
fn layouts_match_their_commands_and_names() {
for l in PageLayout::ORDER {
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+51 -4
View File
@@ -37,9 +37,27 @@ pub(crate) fn mac_shortcuts(ctx: &egui::Context) -> bool {
ctx.os() == egui::os::OperatingSystem::Mac
}
/// How `s` is written on this platform.
/// How `s` is written on this platform, using the resolved current interface language.
pub(crate) fn shortcut_label(ctx: &egui::Context, s: Shortcut) -> String {
s.label(mac_shortcuts(ctx))
let key = crate::i18n::key_name(s.key);
if mac_shortcuts(ctx) {
Shortcut { key, ..s }.label(true)
} else {
let mut parts = Vec::new();
if s.command || s.mac_ctrl {
parts.push(crate::i18n::key_name("Ctrl"));
}
if s.shift {
parts.push(crate::i18n::key_name("Shift"));
}
parts.push(key);
parts.join("+")
}
}
/// The command modifier for help text such as “Strg-scroll”; bindings stay in the engine.
pub(crate) fn command_modifier_label(ctx: &egui::Context) -> &'static str {
if mac_shortcuts(ctx) { Shortcut::command_name(true) } else { crate::i18n::key_name("Ctrl") }
}
/// How a registered command's shortcut is written on this platform ("" without one).
@@ -457,6 +475,7 @@ impl PdfCraftApp {
}
}
"view.marquee_zoom" => self.quick_tool = crate::QuickTool::MarqueeZoom,
"edit.column_select" => self.quick_tool = crate::QuickTool::ColumnSelect,
"edit.snapshot" => {
self.quick_tool = crate::QuickTool::Snapshot;
self.notify_tr("Drag a rectangle around the area to copy");
@@ -623,7 +642,6 @@ impl PdfCraftApp {
/// Render a top-level menu's registered commands (with live labels, shortcuts and enablement).
pub(crate) fn registry_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui, menu: &str) {
let mac = mac_shortcuts(ui.ctx());
for spec in commands::menu(menu) {
let label = commands::current_label(spec, &app.session, app.active_ids().map(|(_, id)| id));
let label = crate::i18n::menu_label(spec.id, &label);
@@ -660,7 +678,7 @@ pub(crate) fn registry_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui, menu: &str
}
continue;
}
let shortcut = spec.shortcut.map(|s| s.label(mac)).unwrap_or_default();
let shortcut = spec.shortcut.map(|s| shortcut_label(ui.ctx(), s)).unwrap_or_default();
let enabled = app.command_enabled(spec);
let resp = ui.add_enabled(enabled, egui::Button::new(label).shortcut_text(shortcut));
if resp.clicked() {
@@ -670,3 +688,32 @@ pub(crate) fn registry_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui, menu: &str
}
let _ = widgets::menu_item;
}
#[cfg(test)]
mod tests {
use super::*;
use egui::os::OperatingSystem;
#[test]
fn localized_shortcut_labels_follow_language_and_platform() {
let previous = crate::i18n::current();
let ctx = egui::Context::default();
for os in [OperatingSystem::Windows, OperatingSystem::Nix] {
ctx.set_os(os);
crate::i18n::set_current(crate::i18n::Lang::from_code("de").unwrap());
assert_eq!(shortcut_label(&ctx, Shortcut::cmd("K")), "Strg+K");
assert_eq!(shortcut_label(&ctx, Shortcut::cmd_shift("S")), "Strg+Umschalt+S");
assert_eq!(command_shortcut_label(&ctx, "view.read_mode"), "Strg+H");
assert_eq!(command_modifier_label(&ctx), "Strg");
assert_eq!(Shortcut::cmd_shift("S").label(false), "Ctrl+Shift+S", "engine/control labels keep their stable spelling");
crate::i18n::set_current(crate::i18n::Lang::EN);
assert_eq!(shortcut_label(&ctx, Shortcut::cmd_shift("S")), "Ctrl+Shift+S");
}
ctx.set_os(OperatingSystem::Mac);
crate::i18n::set_current(crate::i18n::Lang::from_code("de").unwrap());
assert_eq!(shortcut_label(&ctx, Shortcut::cmd_shift("S")), "⇧⌘S");
assert_eq!(command_shortcut_label(&ctx, "view.read_mode"), "⌃⌘H");
assert_eq!(command_modifier_label(&ctx), "⌘");
crate::i18n::set_current(previous);
}
}
+14 -1
View File
@@ -1,5 +1,5 @@
//! Comment Properties (Acrobat: right-click a comment ▸ Properties…; audit "Comment
//! properties"): Appearance (colour, opacity, line thickness, note icon), General (author,
//! properties"): Appearance (colour, fill, opacity, line thickness, note icon), General (author,
//! subject, modified) and Review History (status changes), with Acrobat's Locked box.
use egui::{Align, Layout};
@@ -100,6 +100,9 @@ impl PdfCraftApp {
if let Some(w) = p.width {
style.width = w;
}
if p.fillable && tool.has_fill() {
style.fill = p.fill;
}
self.comment_prefs.set_style(tool, style);
self.notify_fmt("New {tool} comments will look like this one", &[("tool", &crate::i18n::in_sentence(tl!(tool.label())))]);
}
@@ -128,6 +131,9 @@ pub fn edits(d: &PropsDraft) -> Vec<Edit> {
if e.restylable && (color.is_some() || opacity.is_some() || width.is_some() || endings.is_some()) {
out.push(Edit::StyleAnnotation { page: d.page, index: d.index, color, opacity, width, endings });
}
if e.restylable && e.fillable && e.fill != o.fill {
out.push(Edit::FillAnnotation { page: d.page, index: d.index, fill: e.fill });
}
let author = (e.author != o.author).then(|| e.author.clone());
let subject = (e.subject != o.subject).then(|| e.subject.clone());
let icon = (e.icon != o.icon).then_some(e.icon).flatten();
@@ -194,6 +200,13 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo
e.color = Some(c);
}
ui.end_row();
if e.fillable {
ui.label(tl!("Fill colour"));
if let Some(f) = crate::comments::fill_picker(ui, e.fill) {
e.fill = f;
}
ui.end_row();
}
ui.label(tl!("Opacity"));
let mut pct = e.opacity * 100.0;
if ui.add(egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%")).changed() {
+113 -6
View File
@@ -191,6 +191,11 @@ impl CommentTool {
self.draws() || self.clicks_points()
}
/// Whether the fill-colour control applies: shapes with an interior (#686).
pub fn has_fill(self) -> bool {
matches!(self, Self::Rectangle | Self::Oval | Self::Polygon | Self::Cloud)
}
/// A placeholder shape of this kind (for per-tool default styles).
fn sample(self) -> Shape {
match self {
@@ -287,6 +292,13 @@ impl CommentPrefs {
}
}
/// The tool's fill (`None`: no fill). Only shapes with an interior keep one.
pub fn set_fill(&mut self, tool: CommentTool, fill: Option<Rgb>) {
if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) {
s.fill = if tool.has_fill() { fill.filter(|c| c.iter().all(|x| x.is_finite())).map(|c| c.map(|x| x.clamp(0.0, 1.0))) } else { None };
}
}
pub fn set_style(&mut self, tool: CommentTool, style: Style) {
if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) {
*s = style;
@@ -521,10 +533,10 @@ fn is_markup(subtype: &str) -> bool {
matches!(subtype, "Highlight" | "Underline" | "StrikeOut" | "Squiggly")
}
/// Rectangles, ovals, text boxes and stamps. A callout's `/Rect` also holds its leader line, so it
/// only moves.
/// Rectangles, ovals, text boxes, stamps and drawings (drawn signatures among them). A callout's
/// `/Rect` also holds its leader line, so it only moves.
fn resizable(a: &Annotation) -> bool {
matches!(a.subtype.as_str(), "Square" | "Circle" | "FreeText" | "Stamp") && a.intent.as_deref() != Some("FreeTextCallout")
matches!(a.subtype.as_str(), "Square" | "Circle" | "FreeText" | "Stamp" | "Ink") && a.intent.as_deref() != Some("FreeTextCallout")
}
const HANDLES: [(i8, i8); 8] = [(-1, -1), (0, -1), (1, -1), (1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0)];
@@ -576,6 +588,7 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>,
let page_rect = cx.xf.rect;
let pressed_here = origin.is_some_and(|o| page_rect.contains(o));
let over_page = pointer.is_some_and(|p| page_rect.contains(p));
let fill_grab = fill_grabs(ui, cx, view);
let cv = &mut view.comments;
if cv.gesture.is_some() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
cv.gesture = None;
@@ -768,10 +781,47 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>,
true
}
QuickTool::Select => select_input(ui, resp, cx, view, pointer, origin, pressed_here),
QuickTool::Fill(_) if fill_grab => select_input(ui, resp, cx, view, pointer, origin, pressed_here),
_ => false,
}
}
/// With a Fill & Sign tool, the Fill & Sign marks already on the page are picked up as with the
/// Select tool, as in Acrobat: hovering one shows the move cursor, a click selects it, a drag
/// moves it and the selected one's handles resize it. Elsewhere the tool places its mark. While
/// the button is down the press decides; otherwise the pointer (egui clears the press origin on
/// release, so a click is located by the pointer).
pub(crate) fn fill_grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView) -> bool {
matches!(cx.tool, QuickTool::Fill(_)) && grabs(ui, cx, view, |a| a.fill_sign)
}
/// With the Select tool, a comment that would move (not text markup) is picked up before a form
/// field under it, so a signature placed over a field can still be dragged off it.
pub(crate) fn select_grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView) -> bool {
cx.tool == QuickTool::Select && grabs(ui, cx, view, |a| !is_markup(&a.subtype))
}
/// Whether the press (or, with no button down, the pointer) is on a `pick` comment or on the
/// selected one's resize handles.
fn grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView, pick: impl Fn(&Annotation) -> bool) -> bool {
if cx.hidden {
return false;
}
let cv = &view.comments;
if matches!(cv.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. }) if page == cx.page) {
return true;
}
let (pointer, origin, down) = ui.input(|i| (i.pointer.hover_pos(), i.pointer.press_origin(), i.pointer.any_down()));
let Some(p) = (if down { origin } else { pointer }).filter(|p| cx.xf.rect.contains(*p)) else { return false };
let on_handle = cv
.selected
.filter(|(page, _)| *page == cx.page)
.and_then(|(_, i)| cx.get(i))
.filter(|a| pick(a) && cx.allowed && resizable(a))
.is_some_and(|a| HANDLES.into_iter().any(|h| handle_pos(cx.screen_rect(a), h).distance(p) <= 7.0));
on_handle || cx.hit(p).is_some_and(pick)
}
fn clamp_to(r: Rect, p: Pos2) -> Pos2 {
pos2(p.x.clamp(r.left(), r.right()), p.y.clamp(r.top(), r.bottom()))
}
@@ -816,6 +866,11 @@ fn select_input(
// On screen, the image is also turned by the view's rotation.
let turned = !cx.xf.rot.is_multiple_of(180);
let aspect_ratio = view.signature_drag.aspect_ratio(cx.page, a.index).map(|ratio| if turned { ratio.recip() } else { ratio });
// A drawn signature keeps its shape from the corners, as image signatures do.
let aspect_ratio = aspect_ratio.or_else(|| {
let r = cx.screen_rect(a);
(a.fill_sign && a.subtype == "Ink" && r.height() > 0.0).then(|| r.width() / r.height())
});
cv.gesture = Some(Gesture::Resize { page: cx.page, index: a.index, handle: h, from: o, aspect_ratio });
consumed = true;
} else if let Some(a) = cx.hit(o)
@@ -917,9 +972,9 @@ pub(crate) fn page_after_text(resp: &egui::Response, cx: &PageCx<'_>, view: &mut
pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) {
let cv = &view.comments;
let pointer = ui.input(|i| i.pointer.hover_pos());
if cx.tool == QuickTool::Select
&& cv.gesture.is_none()
if cv.gesture.is_none()
&& let Some(a) = pointer.and_then(|p| cx.hit(p))
&& (cx.tool == QuickTool::Select || matches!(cx.tool, QuickTool::Fill(_)) && a.fill_sign)
&& cv.selected != Some((cx.page, a.index))
{
for r in cx.screen_rects(a) {
@@ -1333,7 +1388,23 @@ pub fn swatch_grid(ui: &mut egui::Ui, current: Option<Rgb>) -> Option<Rgb> {
picked
}
/// The comment tools' extra quick-bar controls: pin, colour, opacity and thickness.
/// "No fill" and the colour swatches; returns the choice clicked (`Some(None)`: no fill).
pub fn fill_picker(ui: &mut egui::Ui, current: Option<Rgb>) -> Option<Option<Rgb>> {
let mut picked = None;
ui.push_id("fill-picker", |ui| {
ui.vertical(|ui| {
if ui.selectable_label(current.is_none(), tl!("No fill")).clicked() {
picked = Some(None);
}
if let Some(c) = swatch_grid(ui, current) {
picked = Some(Some(c));
}
});
});
picked
}
/// The comment tools' extra quick-bar controls: pin, colour, fill, opacity and thickness.
pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &mut CommentPrefs) {
let style = prefs.style(tool);
if icons::button(ui, "pin", 32.0, prefs.pinned, if prefs.pinned { tl!("Keep tool selected: on") } else { tl!("Keep tool selected") }).clicked() {
@@ -1349,6 +1420,16 @@ pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &m
ui.close();
}
});
if tool.has_fill() {
let resp = fill_button(ui, style.fill);
egui::Popup::menu(&resp).align(egui::RectAlign::RIGHT_START).show(|ui| {
ui.label(egui::RichText::new(tl!("Fill colour")).font(theme::semibold(12.0)));
if let Some(f) = fill_picker(ui, style.fill) {
prefs.set_fill(tool, f);
ui.close();
}
});
}
let resp = icons::button(ui, "blend", 32.0, false, tl!("Opacity"));
egui::Popup::menu(&resp).align(egui::RectAlign::RIGHT_START).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
ui.set_min_width(180.0);
@@ -1371,6 +1452,32 @@ pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &m
}
}
/// The quick bar's fill button: a filled square in the fill colour, or a crossed-out outline
/// for no fill.
fn fill_button(ui: &mut egui::Ui, fill: Option<Rgb>) -> egui::Response {
let (r, resp) = ui.allocate_exact_size(vec2(32.0, 32.0), Sense::click());
let sq = Rect::from_center_size(r.center(), vec2(16.0, 16.0));
let edge = Stroke::new(1.0, Color32::from_black_alpha(90));
match fill {
Some(c) => {
ui.painter().rect_filled(sq, 2.0, color32(c));
}
None => {
ui.painter().line_segment([sq.left_bottom(), sq.right_top()], Stroke::new(1.5, Color32::from_rgb(0xD3, 0x2F, 0x2F)));
}
}
ui.painter().rect_stroke(sq, 2.0, edge, egui::StrokeKind::Inside);
if resp.hovered() {
ui.painter().rect_stroke(r.shrink(2.0), 6.0, Stroke::new(1.0, Color32::from_black_alpha(40)), egui::StrokeKind::Inside);
}
let label = match fill {
Some(_) => tl!("Fill colour"),
None => tl!("Fill colour: none"),
};
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
resp.on_hover_text(label)
}
/// Status badge icon and label for a review state name.
pub fn status_badge(state: &str) -> Option<(&'static str, &'static str, Color32)> {
match state {
+1 -1
View File
@@ -371,7 +371,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
// Pressing a button takes the focus from the text: that is not a click away.
let on_buttons = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| buttons.contains(p));
cancel |= ui.input(|i| i.key_pressed(egui::Key::Escape));
commit = done || resp.lost_focus() && !on_buttons;
commit = done || resp.lost_focus() && !on_buttons && !view.objects.hold_editor;
});
// Page text is drawn with the standard fonts; anything else would be saved as `?` (#125).
undrawable = self::undrawable(t, memo);
+12
View File
@@ -662,6 +662,17 @@ impl Host for crate::PdfCraftApp {
"selected_comment": v.comments.selected.map(|(p, i)| json!({ "page": p + 1, "index": i + 1 })),
"comment_composer_open": v.comments.composer.is_some(),
})),
// The Combine files tab: how its files show, and each grid thumbnail's state.
"combine": {
"showing": self.combine_showing(),
"view": self.combine_view.as_str(),
"zoom": self.combine_zoom,
"files": self.combine_draft.iter().map(|f| f.name.clone()).collect::<Vec<_>>(),
"selected": self.combine_selection(),
"thumbnails": self.combine_thumbnails().iter().map(|t| format!("{t:?}")).collect::<Vec<_>>(),
"expanded": self.combine_expanded().iter().filter_map(|i| self.combine_draft.get(*i).map(|f| f.name.clone())).collect::<Vec<_>>(),
"preview": self.combine_preview().map(|(file, page, render)| json!({ "file": file, "page": page + 1, "render": format!("{render:?}") })),
},
"quick_tool": match self.quick_tool {
crate::QuickTool::Measure(t) => format!("measure-{}", t.name()),
crate::QuickTool::Select => "select".to_string(),
@@ -675,6 +686,7 @@ impl Host for crate::PdfCraftApp {
crate::QuickTool::SignArea { certify: true } => "certify".to_string(),
crate::QuickTool::MarqueeZoom => "marquee-zoom".to_string(),
crate::QuickTool::Snapshot => "snapshot".to_string(),
crate::QuickTool::ColumnSelect => "column-select".to_string(),
crate::QuickTool::Stamp(k) => format!("stamp-{}", k.name().trim_start_matches("PC").to_ascii_lowercase()),
crate::QuickTool::CustomStamp(i) => format!("custom-stamp-{i}"),
crate::QuickTool::Fill(f) => format!("fill-{}", f.command().trim_start_matches("sign.fill.")),
+24 -14
View File
@@ -227,15 +227,15 @@ impl PdfCraftApp {
}
/// Save an optimized copy (Reduce File Size, Optimize PDF) next to the original, reporting
/// the saving.
/// the saving. A Reduce File Size copy that isn't smaller is not saved (#490).
pub(crate) fn save_optimized(
&mut self,
id: pdfcraft_engine::DocId,
suffix: &str,
kind: crate::optimize_ui::OptimizeKind,
result: Result<(Arc<Vec<u8>>, String), pdfcraft_engine::EditError>,
) {
let Some(doc) = self.session.get(id) else { return };
let (before, name) = (doc.bytes.len(), format!("{} ({suffix}).pdf", stem(&doc.name)));
let (before, name) = (doc.bytes.len(), format!("{} ({}).pdf", stem(&doc.name), kind.suffix()));
let (bytes, detail) = match result {
Ok(r) => r,
Err(e) => {
@@ -244,18 +244,28 @@ impl PdfCraftApp {
}
};
let after = bytes.len();
let saved = move |app: &mut PdfCraftApp, place: String| {
let pct = 100.0 * (1.0 - after as f64 / before.max(1) as f64);
app.notify_fmt(
"Saved {place}: {before} → {after} ({pct}% smaller){detail}",
&[
("place", &place),
("before", &crate::panels::human_size(before)),
("after", &crate::panels::human_size(after)),
("pct", &format!("{pct:.0}")),
("detail", &detail),
],
if kind == crate::optimize_ui::OptimizeKind::Reduce && after >= before {
self.notify_fmt(
"This file is already as small as it can be made ({size}). No copy was saved.",
&[("size", &crate::panels::human_size(before))],
);
return;
}
let saved = move |app: &mut PdfCraftApp, place: String| {
let (b, a) = (crate::panels::human_size(before), crate::panels::human_size(after));
if after < before {
let pct = 100.0 * (1.0 - after as f64 / before.max(1) as f64);
app.notify_fmt(
"Saved {place}: {before} → {after} ({pct}% smaller){detail}",
&[("place", &place), ("before", &b), ("after", &a), ("pct", &format!("{pct:.0}")), ("detail", &detail)],
);
} else {
// Optimize PDF still saves: what it discards may matter more than the size.
app.notify_fmt(
"Saved {place}: {before} → {after} (not smaller){detail}",
&[("place", &place), ("before", &b), ("after", &a), ("detail", &detail)],
);
}
};
#[cfg(not(target_arch = "wasm32"))]
{
+8 -7
View File
@@ -1036,36 +1036,37 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) {
ACTUAL_SIZE, COPY, FIND_NEXT, FIND_PREV, FIT_WIDTH, PAGE_LEVEL, PAGE_NEXT, PAGE_PREV, ROTATE_CCW, ROTATE_CW, SELECT_ALL, ZOOM_IN,
ZOOM_OUT,
};
let mac = crate::commands::mac_shortcuts(ui.ctx());
// Registered commands first (always in sync with the real bindings), then the
// keys the document view handles itself.
let mut rows: Vec<(String, String)> = pdfcraft_engine::commands::COMMANDS
.iter()
.filter_map(|c| c.shortcut.map(|k| (k.label(mac), tl!(c.label).trim_end_matches('…').to_string())))
.filter_map(|c| {
c.shortcut.map(|k| (crate::commands::shortcut_label(ui.ctx(), k), tl!(c.label).trim_end_matches('…').to_string()))
})
.collect();
let key = |s: pdfcraft_engine::commands::Shortcut| s.label(mac);
let key = |s: pdfcraft_engine::commands::Shortcut| crate::commands::shortcut_label(ui.ctx(), s);
let pair = |a, b| format!("{} / {}", key(a), key(b));
// Key names, translated where a catalog has them (not scanned as UI literals).
let named = |k: &str| tl!(k).to_string();
let scroll = crate::i18n::fmt(
tl!("Zoom in / out (also pinch or {key}-scroll)"),
&[("key", pdfcraft_engine::commands::Shortcut::command_name(mac))],
&[("key", crate::commands::command_modifier_label(ui.ctx()))],
);
for (k, v) in [
(pair(FIND_NEXT, FIND_PREV), tl!("Next / previous match").to_string()),
(key(COPY), tl!("Copy selected text").to_string()),
(named("Double-click"), tl!("Select a word").to_string()),
(named("Esc"), tl!("Clear selection / close find").to_string()),
(crate::i18n::key_name("Esc").to_string(), tl!("Clear selection / close find").to_string()),
(key(ACTUAL_SIZE), tl!("Actual size").to_string()),
(key(PAGE_LEVEL), tl!("Zoom to page level").to_string()),
(key(FIT_WIDTH), tl!("Fit to width").to_string()),
(pair(ZOOM_IN, ZOOM_OUT), scroll),
(pair(ROTATE_CW, ROTATE_CCW), tl!("Rotate view").to_string()),
(named("Home / End"), tl!("First / last page").to_string()),
(format!("{} / {}", crate::i18n::key_name("Home"), crate::i18n::key_name("End")), tl!("First / last page").to_string()),
(format!("← / →, {}", pair(PAGE_PREV, PAGE_NEXT)), tl!("Previous / next page").to_string()),
(named("V"), tl!("Select (V)").to_string()),
(named("H / Space (hold)"), tl!("Hand (H)").to_string()),
(named("Delete"), tl!("Delete selected pages (Organize)").to_string()),
(crate::i18n::key_name("Delete").to_string(), tl!("Delete selected pages (Organize)").to_string()),
(key(SELECT_ALL), tl!("Select all pages (Organize)").to_string()),
] {
rows.push((k, v));
+61 -2
View File
@@ -81,12 +81,21 @@ pub(crate) fn extras_panel(ui: &mut egui::Ui, e: &mut Extras) -> bool {
}
impl LineEditor {
pub(crate) fn has_changes(&self) -> bool {
self.text != self.original || self.look != self.look0 || self.extras != self.extras0
}
/// How far the box may grow to the right, when the paragraph is a single line (a multi-line
/// paragraph rewraps to its own width and the box doesn't grow).
pub fn growth(&self) -> Option<f32> {
(self.max_width > self.rect.width()).then_some(self.max_width)
}
/// Whether the edited text differs from the original (unsaved changes).
pub fn has_unsaved_text(&self) -> bool {
self.text != self.original
}
/// The formatting the panel changed.
pub fn style(&self) -> pdfcraft_engine::BlockStyle {
let (l, o) = (&self.look, &self.look0);
@@ -161,6 +170,30 @@ fn color32(color: [f64; 3]) -> Color32 {
)
}
/// The dark box behind text too pale to read on white.
const DARK_EDITOR_FILL: Color32 = Color32::from_gray(0x26);
/// WCAG relative luminance of an sRGB colour.
fn luminance(c: Color32) -> f32 {
let channel = |v: u8| {
let v = f32::from(v) / 255.0;
if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
};
0.2126 * channel(c.r()) + 0.7152 * channel(c.g()) + 0.0722 * channel(c.b())
}
/// WCAG contrast ratio between two colours (1 to 21).
fn contrast(a: Color32, b: Color32) -> f32 {
let (la, lb) = (luminance(a), luminance(b));
(la.max(lb) + 0.05) / (la.min(lb) + 0.05)
}
/// The inline editor's background for text drawn in `text`: white, or a dark box when the text
/// reads better on that (white or pale text, which would vanish on white).
pub(crate) fn editor_fill(text: Color32) -> Color32 {
if contrast(text, Color32::WHITE) >= contrast(text, DARK_EDITOR_FILL) { Color32::WHITE } else { DARK_EDITOR_FILL }
}
/// A selected page image, and what the pointer is doing to it.
#[derive(Clone, Debug, PartialEq)]
pub struct ImageSelection {
@@ -496,7 +529,11 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
let page = view.line_editor.as_ref()?.page;
let xf = view.page_xform(page)?;
let viewport_right = view.viewport_rect().right();
let hold = view.objects.hold_editor;
let ed = view.line_editor.as_mut()?;
if hold {
ed.focus = true;
}
// Reproject the source box every frame. The page may have been zoomed, scrolled or rotated
// while the format panel was open.
ed.rect = xf.user_rect(info, ed.page, ed.source_rect).expand(2.0);
@@ -520,7 +557,11 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
}) + 8.0;
width = width.max(needed).min(ed.max_width);
}
egui::Frame::NONE.fill(Color32::WHITE).stroke(Stroke::new(1.5, ACCENT)).inner_margin(egui::Margin::symmetric(2, 0)).show(ui, |ui| {
// White, unless the text is too pale to read on white (white text from a coloured
// banner, #913): then a dark box, with a caret that shows on it.
let fill = editor_fill(text_color);
egui::Frame::NONE.fill(fill).stroke(Stroke::new(1.5, ACCENT)).inner_margin(egui::Margin::symmetric(2, 0)).show(ui, |ui| {
ui.visuals_mut().text_cursor.stroke.color = if fill == Color32::WHITE { Color32::BLACK } else { Color32::WHITE };
let rows = ed.text.lines().count().max(1);
let r = ui.add(
egui::TextEdit::multiline(&mut ed.text)
@@ -539,7 +580,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
let apply = ui.input(|i| i.key_pressed(egui::Key::Enter) && i.modifiers.command);
if esc {
done = Some(false);
} else if apply || (r.lost_focus() && !outside) {
} else if apply || (r.lost_focus() && !outside && !hold) {
done = Some(true);
}
});
@@ -559,3 +600,21 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
None => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pale_text_is_edited_on_a_dark_box() {
// #913: white text from a coloured banner was typed onto a white box and vanished.
for pale in [Color32::WHITE, Color32::from_rgb(0xFF, 0xFF, 0x00), Color32::from_gray(0xE0), Color32::from_rgb(0xFF, 0xA5, 0x00)] {
assert_eq!(editor_fill(pale), DARK_EDITOR_FILL, "{pale:?}");
assert!(contrast(pale, editor_fill(pale)) >= 4.5, "{pale:?} is readable");
}
// Ordinary dark or saturated text keeps the white box.
for dark in [Color32::BLACK, Color32::from_rgb(0x1F, 0x2A, 0x6B), Color32::from_rgb(0xC0, 0x00, 0x00), Color32::from_gray(0x60)] {
assert_eq!(editor_fill(dark), Color32::WHITE, "{dark:?}");
}
}
}
+44 -6
View File
@@ -85,7 +85,8 @@ impl PdfCraftApp {
true
}
Err(e) => {
self.notify_fmt("{label} failed: {e}", &[("label", &crate::i18n::action_label(&label)), ("e", &e.to_string())]);
let message = if matches!(edit, Edit::MoveObjects { .. }) { crate::object_ui::move_error(&e) } else { e.to_string() };
self.notify_fmt("{label} failed: {e}", &[("label", &crate::i18n::action_label(&label)), ("e", &message)]);
false
}
}
@@ -349,6 +350,7 @@ impl PdfCraftApp {
|| v.content.blocked().is_some()
|| v.forms.focus.as_ref().is_some_and(|f| crate::forms_ui::draft_edit(f, &doc.form).is_some())
|| v.fill_text.as_ref().is_some_and(|t| !t.text.trim().is_empty())
|| v.line_editor.as_ref().map_or_else(|| false, |ed| ed.has_unsaved_text())
}
/// Save the active document. Returns `true` if it was written.
@@ -403,7 +405,7 @@ impl PdfCraftApp {
}
#[cfg(target_arch = "wasm32")]
{
let _ = (target, path);
let _ = path;
if !self.flatten_fill_sign_for_save(id) {
return false;
}
@@ -414,16 +416,32 @@ impl PdfCraftApp {
return false;
}
};
match download(&name, &bytes) {
// An embedding page that asked for saves (`?host=parent`) gets the bytes; otherwise
// the browser downloads them.
let to_host = self.host_save.is_some();
let delivered = match &self.host_save {
Some(save) => save(&name, &bytes, matches!(target, SaveTarget::As)),
None => download(&name, &bytes),
};
match delivered {
Ok(()) => {
let _ = self.session.mark_saved(id, bytes, None);
if let Some(doc) = self.session.get(id) {
self.views[index].document_changed(&doc.info);
if let Some(doc) = self.session.get(id)
&& let Some(view) = self.views.get_mut(index)
{
view.document_changed(&doc.info);
}
// The host reports where the file went (and any failure) itself.
if !to_host {
self.notify_fmt("Downloaded {name}", &[("name", &name)]);
}
self.notify_fmt("Downloaded {name}", &[("name", &name)]);
after(self);
true
}
Err(e) if to_host => {
self.notify_fmt("Couldn't save {name}: {e}", &[("name", &name), ("e", &e)]);
false
}
Err(e) => {
self.notify_fmt("Couldn't download {name}: {e}", &[("name", &name), ("e", &e.to_string())]);
false
@@ -603,8 +621,14 @@ impl PdfCraftApp {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
/// Publish whether any document has unsaved work, for the browser's reload warning (#812).
pub(crate) fn sync_unsaved_flag(&self) {
self.unsaved_flag.store(self.first_dirty().is_some(), std::sync::atomic::Ordering::Relaxed);
}
/// Intercept window close while documents have unsaved changes.
pub(crate) fn guard_quit(&mut self, ctx: &egui::Context) {
self.sync_unsaved_flag();
if !ctx.input(|i| i.viewport().close_requested()) {
return;
}
@@ -630,6 +654,7 @@ fn comment_page(edit: &Edit) -> Option<usize> {
| Edit::MoveAnnotation { page, .. }
| Edit::ResizeAnnotation { page, .. }
| Edit::StyleAnnotation { page, .. }
| Edit::FillAnnotation { page, .. }
| Edit::SetAnnotationInfo { page, .. } => Some(*page),
_ => None,
}
@@ -972,6 +997,19 @@ mod revert_draft_tests {
}
}
#[test]
fn unsaved_flag_follows_dirty_state() {
use std::sync::atomic::Ordering;
// `guard_quit` runs every frame, also on the web, where no close request ever arrives.
let ctx = egui::Context::default();
let mut app = app();
app.guard_quit(&ctx);
assert!(!app.unsaved_flag.load(Ordering::Relaxed));
assert!(app.apply_edit(Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("Changed".into()) }));
app.guard_quit(&ctx);
assert!(app.unsaved_flag.load(Ordering::Relaxed));
}
#[test]
fn successful_revert_clears_focused_or_queued_drafts_only_on_its_document() {
for queued in [false, true] {
+10 -2
View File
@@ -366,7 +366,7 @@ impl PdfCraftApp {
self.notify_fmt("{name} is too large for a word list (at most 1 MB).", &[("name", &name)]);
return;
}
self.redact_search.words = pdfcraft_engine::redact_word_list(&String::from_utf8_lossy(&bytes)).join("\n");
self.redact_search.words = pdfcraft_engine::redact_word_list(&pdfcraft_engine::decode_text(&bytes)).join("\n");
self.redact_search.mode = crate::redact_ui::SearchMode::Words;
}
}
@@ -597,6 +597,7 @@ impl PdfCraftApp {
}
};
let safe = |t: &str| t.chars().map(|c| if c.is_alphanumeric() || " -_.,()".contains(c) { c } else { '_' }).collect::<String>();
let mut used = std::collections::HashSet::new();
let named: Vec<(String, Arc<Vec<u8>>)> = parts
.into_iter()
.map(|(a, b, bytes)| {
@@ -607,7 +608,14 @@ impl PdfCraftApp {
None if a == b => format!("{stem} (page {a}).pdf"),
None => format!("{stem} (pages {a}-{b}).pdf"),
};
(name, bytes)
// Equal titles (or titles that sanitize alike) get (2), (3), ... so no part overwrites another; compared case-insensitively.
let mut unique = name.clone();
let mut n = 1;
while !used.insert(unique.to_lowercase()) {
n += 1;
unique = format!("{} ({n}).pdf", name.trim_end_matches(".pdf"));
}
(unique, bytes)
})
.collect();
self.write_files(&named, "Choose a folder for the split files");
+8 -3
View File
@@ -406,8 +406,10 @@ pub(crate) fn page_input(
}
match tool {
FillTool::Text => {
view.fill_text = Some(TypeBox { page, at: [at[0], at[1] + TEXT_SIZE * 0.6], text: String::new(), focus: true });
None
// Clicking away from an open box commits it: the page sees this click before the box
// sees its lost focus, so replacing the box first would drop what was typed.
let open = view.fill_text.replace(TypeBox { page, at: [at[0], at[1] + TEXT_SIZE * 0.6], text: String::new(), focus: true });
open.filter(|tb| !tb.text.trim().is_empty()).map(|tb| FillAction::Edit(Box::new(typed(tb.page, tb.at, tb.text.trim(), author))))
}
FillTool::Date => Some(match date_text {
Ok(text) => FillAction::Edit(Box::new(typed(page, [at[0], at[1] + TEXT_SIZE * 0.6], text, author))),
@@ -455,7 +457,10 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
let mut cancel = false;
egui::Area::new(egui::Id::new(("fill-text", view.id.0))).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| {
let Some(t) = view.fill_text.as_mut() else { return };
let width = ((t.text.len().max(8) as f32) * TEXT_SIZE as f32 * 0.6 * zoom).clamp(60.0, 600.0);
let width = ((t.text.chars().count().max(8) as f32) * TEXT_SIZE as f32 * 0.6 * zoom).clamp(60.0, 600.0);
// The area's max rect is last frame's size, which caps the edit's width; widen it so the
// box grows as text is typed.
ui.set_min_width(width);
let r = ui.add(
egui::TextEdit::singleline(&mut t.text)
.font(egui::FontId::proportional((TEXT_SIZE as f32 * zoom).max(8.0)))
+45
View File
@@ -29,6 +29,9 @@
Preferences التفضيلات
Preferences… التفضيلات…
Interface language لغة الواجهة
Interface size حجم الواجهة
Auto ({percent}%) تلقائي ({percent}%)
Auto follows the system's text size. Pages always render at the display's full resolution. يتبع الوضع التلقائي حجم النص في النظام. تُعرض الصفحات دائمًا بالدقة الكاملة للشاشة.
Identity الهوية
Name on new comments الاسم على التعليقات الجديدة
Open… فتح…
@@ -74,6 +77,7 @@
Fit visible ملاءمة المرئي
Marquee zoom تكبير بالتحديد
Take a snapshot التقاط لقطة
Column select تحديد عمود
Full screen mode وضع ملء الشاشة
Read mode وضع القراءة
Switch light / dark theme التبديل بين السمة الفاتحة والداكنة
@@ -1094,6 +1098,7 @@
Dates التواريخ
Text in images isn't found; recognise text (OCR) first. النص داخل الصور لا يُعثر عليه؛ استخدم التعرف على النص (OCR) أولًا.
No matches. لا توجد تطابقات.
Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth. بعض الإشارات المرجعية غير معروضة هنا: تعرض اللوحة عددًا محدودًا منها، وبعمق تداخل محدود.
{n} match(es) marked for redaction. عُلِّم {n} تطابق للحجب.
Multiple words or phrases كلمات أو عبارات متعددة
One word or phrase per line كلمة أو عبارة واحدة في كل سطر
@@ -1496,6 +1501,8 @@
Right Edge الحافة اليمنى
Language اللغة
Saved {place}: {before} → {after} ({pct}% smaller){detail} تم الحفظ {place}: {before} → {after} (أصغر بنسبة {pct}%){detail}
This file is already as small as it can be made ({size}). No copy was saved. هذا الملف صغير بالفعل قدر الإمكان ({size}). لم تُحفظ أي نسخة.
Saved {place}: {before} → {after} (not smaller){detail} تم الحفظ {place}: {before} → {after} (ليس أصغر){detail}
Couldn't save the image: {e} تعذّر حفظ الصورة: {e}
Adding images arrives on the web with file pickers for images إضافة الصور ستتوفر على الويب مع منتقيات ملفات الصور
Replacing images on page {p} arrives on the web with image pickers ({i}) استبدال الصور في الصفحة {p} سيتوفر على الويب مع منتقيات الصور ({i})
@@ -2163,6 +2170,44 @@ certificate Subject صاحب الشهادة
A selected field has no widget on any page, so the fields can't be edited together. أحد الحقول المحددة ليس له عنصر واجهة في أي صفحة، لذا لا يمكن تحرير الحقول معًا.
Text recognition isn't installed: its models are missing (run `cargo xtask models`, or set PDFCRAFT_MODELS). التعرف على النص غير مثبت: نماذجه مفقودة (شغّل `cargo xtask models`، أو اضبط PDFCRAFT_MODELS).
({n} at {dpi} dpi, the largest size they can be drawn at) ({n} بدقة {dpi} dpi، وهي أكبر دقة يمكن رسمها بها)
# Multi-object selection and movement (#844)
@plural {n} object selected|{n} objects selected تم تحديد {n} عنصر|تم تحديد {n} عنصر|تم تحديد {n} عنصرين|تم تحديد {n} عناصر|تم تحديد {n} عنصرًا|تم تحديد {n} عنصر
Drag the selection to move it. Esc clears the selection. اسحب التحديد لنقله. يمسح Esc التحديد.
Shift-click to select several objects. Drag empty space to select an area. انقر مع الضغط على Shift لتحديد عدة عناصر. اسحب في مساحة فارغة لتحديد منطقة.
Select at most 1000 objects at a time. حدد 1000 عنصر كحد أقصى في المرة الواحدة.
Finish or discard the text being edited before selecting several objects. أكمل تحرير النص أو تجاهله قبل تحديد عدة عناصر.
Move object نقل عنصر
Move objects نقل عناصر
Vertical text cannot be moved with this selection. لا يمكن نقل النص العمودي مع هذا التحديد.
Text used as a clipping path cannot be moved with this selection. لا يمكن نقل النص المستخدم كمسار قص مع هذا التحديد.
These objects could not be moved. Select them again and try a smaller move. تعذر نقل هذه العناصر. حددها مجددًا وحاول نقلها بمسافة أقل.
This page contains objects that cannot be selected together. تحتوي هذه الصفحة على عناصر لا يمكن تحديدها معًا.
Fill colour لون التعبئة
No fill بلا تعبئة
Fill colour: none لون التعبئة: بلا
# Keyboard key names, distinct from command labels.
key Ctrl Ctrl
key Shift Shift
key Delete Delete
key Esc Esc
key Home Home
key End End
key Space مسافة
Grid view عرض الشبكة
List view عرض القائمة
Files are combined in the order shown. Drag them to change it. تُدمج الملفات بالترتيب المعروض. اسحبها لتغيير الترتيب.
Preview unavailable: {e} المعاينة غير متاحة: {e}
Pages: {k} of {n} الصفحات: {k} من {n}
Check the pages تحقق من الصفحات
Switch to List view to change the pages it takes. انتقل إلى عرض القائمة لتغيير الصفحات التي يأخذها.
Expand all توسيع الكل
Collapse all طي الكل
Remove page إزالة الصفحة
Remove {n} pages إزالة {n} صفحات
# Shared panel docking
Reset panel layout إعادة تعيين تخطيط اللوحات
Float panel تعويم اللوحة
Move to group نقل إلى مجموعة
Can't render this file because it contains an unexpected character in line 6 and column 47.
+45 -11
View File
@@ -55,6 +55,9 @@
Preferences Настройки
Preferences… Настройки…
Interface language Език на интерфейса
Interface size Размер на интерфейса
Auto ({percent}%) Автоматично ({percent}%)
Auto follows the system's text size. Pages always render at the display's full resolution. Автоматично следва размера на текста в системата. Страниците винаги се изобразяват с пълната разделителна способност на екрана.
Identity Самоличност
Name on new comments Име в новите коментари
Open… Отвори…
@@ -109,6 +112,7 @@
Rotate view clockwise Завърти изгледа по часовниковата стрелка
Rotate view counterclockwise Завърти изгледа обратно на часовниковата стрелка
Take a snapshot Направи моментна снимка
Column select Избор на колона
Full screen mode Цял екран
Read mode Режим на четене
Switch light / dark theme Превключи светла / тъмна тема
@@ -786,6 +790,7 @@
Dates Дати
Text in images isn't found; recognise text (OCR) first. Текстът в изображения не се открива; първо разпознайте текста (OCR).
No matches. Няма съвпадения.
Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth. Някои отметки не са показани тук: панелът показва ограничен брой, с ограничена дълбочина на влагане.
{n} match(es) marked for redaction. {n} съвпадение(я) маркирани за заличаване.
Multiple words or phrases Няколко думи или фрази
One word or phrase per line По една дума или фраза на ред
@@ -1448,6 +1453,8 @@
Right Edge Десен ръб
Language Език
Saved {place}: {before} → {after} ({pct}% smaller){detail} Записано {place}: {before} → {after} (с {pct}% по-малко){detail}
This file is already as small as it can be made ({size}). No copy was saved. Файлът вече е възможно най-малък ({size}). Не е записано копие.
Saved {place}: {before} → {after} (not smaller){detail} Записано {place}: {before} → {after} (не е по-малко){detail}
Couldn't save the image: {e} Изображението не можа да бъде записано: {e}
Adding images arrives on the web with file pickers for images Добавянето на изображения в уеб версията ще стане достъпно с инструментите за избор на файлове с изображения
Replacing images on page {p} arrives on the web with image pickers ({i}) Замяната на изображения на страница {p} в уеб версията ще стане достъпна с инструментите за избор на изображения ({i})
@@ -2103,17 +2110,44 @@ action wizard Start Старт
The field {name} no longer exists. Полето {name} вече не съществува.
A selected field has no widget on any page, so the fields can't be edited together. Едно от избраните полета няма елемент на нито една страница, затова полетата не могат да се редактират заедно.
({n} at {dpi} dpi, the largest size they can be drawn at) ({n} с {dpi} dpi, най-голямата резолюция, с която могат да бъдат изобразени)
Zoom out (⌘−) Намали мащаба (⌘−)
Zoom in (⌘+) Увеличи мащаба (⌘+)
Rotate view clockwise (⇧⌘+) Завърти изгледа по часовниковата стрелка (⇧⌘+)
Open a PDF (⌘O) Отвори PDF файл (⌘O)
Print (⌘P) Печат (⌘P)
Document properties (⌘D) Свойства на документа (⌘D)
New bookmark (⌘B) Нова отметка (⌘B)
Previous (⇧⌘G) Предишно (⇧⌘G)
Next (⌘G) Следващо (⌘G)
Zoom in / out (also pinch or ⌘-scroll) Увеличи / намали (също с щипване или ⌘ + превъртане)
Text recognition isn't installed: its models are missing (run `cargo xtask models`, or set PDFCRAFT_MODELS). Разпознаването на текст не е инсталирано: моделите му липсват (изпълнете `cargo xtask models` или задайте PDFCRAFT_MODELS).
# Multi-object selection and movement (#844)
@plural {n} object selected|{n} objects selected Избран е {n} обект|Избрани са {n} обекта
Drag the selection to move it. Esc clears the selection. Плъзнете избраното, за да го преместите. Esc премахва избора.
Shift-click to select several objects. Drag empty space to select an area. Щракнете с Shift, за да изберете няколко обекта. Плъзнете в празно място, за да изберете област.
Select at most 1000 objects at a time. Изберете най-много 1000 обекта наведнъж.
Finish or discard the text being edited before selecting several objects. Завършете или отменете редактирането на текста, преди да изберете няколко обекта.
Move object Преместване на обект
Move objects Преместване на обекти
Vertical text cannot be moved with this selection. Вертикалният текст не може да се премести с този избор.
Text used as a clipping path cannot be moved with this selection. Текстът, използван като изрязващ контур, не може да се премести с този избор.
These objects could not be moved. Select them again and try a smaller move. Тези обекти не можаха да бъдат преместени. Изберете ги отново и опитайте по-малко отместване.
This page contains objects that cannot be selected together. Тази страница съдържа обекти, които не могат да се изберат заедно.
Fill colour Цвят на запълване
No fill Без запълване
Fill colour: none Цвят на запълване: няма
# Keyboard key names, distinct from command labels.
key Ctrl Ctrl
key Shift Shift
key Delete Delete
key Esc Esc
key Home Home
key End End
key Space Интервал
Grid view Изглед мрежа
List view Изглед списък
Files are combined in the order shown. Drag them to change it. Файловете се обединяват в показания ред. Плъзнете ги, за да го промените.
Preview unavailable: {e} Няма преглед: {e}
Pages: {k} of {n} Страници: {k} от {n}
Check the pages Проверете страниците
Switch to List view to change the pages it takes. Превключете към изглед списък, за да промените кои страници се вземат.
Expand all Разгъни всички
Collapse all Свий всички
Remove page Премахни страницата
Remove {n} pages Премахни {n} страници
# Shared panel docking
Reset panel layout Възстанови оформлението на панелите
Float panel Откачи панела
Move to group Премести в група
Can't render this file because it contains an unexpected character in line 8 and column 16.
+29 -2
View File
@@ -24,6 +24,9 @@
Preferences Předvolby
Preferences… Předvolby…
Interface language Jazyk rozhraní
Interface size Velikost rozhraní
Auto ({percent}%) Automaticky ({percent} %)
Auto follows the system's text size. Pages always render at the display's full resolution. Automaticky se řídí velikostí textu v systému. Stránky se vždy vykreslují v plném rozlišení displeje.
Identity Identita
Name on new comments Jméno autora nových komentářů
Enable Acrobat JavaScript Povolit Acrobat JavaScript
@@ -51,6 +54,8 @@
Document properties… Vlastnosti dokumentu…
Export to image… Exportovat jako obrázek…
Reduce file size… Zmenšit velikost souboru…
This file is already as small as it can be made ({size}). No copy was saved. Soubor je už tak malý, jak to jde ({size}). Žádná kopie nebyla uložena.
Saved {place}: {before} → {after} (not smaller){detail} Uloženo {place}: {before} → {after} (není menší){detail}
Optimize PDF… Optimalizovat PDF…
Export to text… Exportovat jako text…
Export to Word… Exportovat do formátu Word…
@@ -65,6 +70,7 @@
Find… Najít…
Advanced search… Rozšířené hledání…
Take a snapshot Pořídit snímek
Column select Výběr sloupce
Clear form Vymazat formulář
Copy pages Kopírovat stránky
Cut pages Vyjmout stránky
@@ -145,6 +151,29 @@
Import list… Importovat seznam…
{n} word(s) or phrase(s) Slova nebo fráze: {n}
{name} is too large for a word list (at most 1 MB). {name} je pro seznam slov příliš velký (nejvýše 1 MB).
# Multi-object selection and movement (#844)
@plural {n} object selected|{n} objects selected Vybrán {n} objekt|Vybrány {n} objekty|Vybráno {n} objektů
Drag the selection to move it. Esc clears the selection. Přetažením výběr přesunete. Esc zruší výběr.
Shift-click to select several objects. Drag empty space to select an area. Kliknutím se Shiftem vyberete více objektů. Tažením v prázdném místě vyberete oblast.
Select at most 1000 objects at a time. Vyberte nejvýše 1000 objektů najednou.
Finish or discard the text being edited before selecting several objects. Před výběrem více objektů dokončete nebo zahoďte úpravu textu.
Move object Přesunout objekt
Move objects Přesunout objekty
Vertical text cannot be moved with this selection. Svislý text nelze přesunout s tímto výběrem.
Text used as a clipping path cannot be moved with this selection. Text použitý jako ořezová cesta nelze přesunout s tímto výběrem.
These objects could not be moved. Select them again and try a smaller move. Tyto objekty se nepodařilo přesunout. Vyberte je znovu a zkuste menší posun.
This page contains objects that cannot be selected together. Tato stránka obsahuje objekty, které nelze vybrat společně.
# Keyboard key names, distinct from command labels.
key Ctrl Ctrl
key Shift Shift
key Delete Delete
key Esc Esc
key Home Home
key End End
key Space Mezerník
# Shared panel docking
Reset panel layout Obnovit rozložení panelů
Float panel Uvolnit panel
Move to group Přesunout do skupiny
@@ -153,5 +182,3 @@
Resize panel window Změnit velikost okna panelu
Tools Nástroje
Inspector Inspektor
Document Dokument
Close panel Zavřít panel
Can't render this file because it contains an unexpected character in line 7 and column 81.
+45 -11
View File
@@ -18,6 +18,9 @@
Preferences Einstellungen
Preferences… Einstellungen…
Interface language Sprache der Oberfläche
Interface size Größe der Oberfläche
Auto ({percent}%) Automatisch ({percent} %)
Auto follows the system's text size. Pages always render at the display's full resolution. Automatisch folgt der Textgröße des Systems. Seiten werden immer in der vollen Auflösung des Bildschirms dargestellt.
Identity Identität
Name on new comments Name für neue Kommentare
Open… Öffnen…
@@ -44,6 +47,7 @@
Fit visible Sichtbaren Inhalt einpassen
Marquee zoom Auswahlrahmen-Zoom
Take a snapshot Schnappschuss erstellen
Column select Spaltenauswahl
Full screen mode Vollbildmodus
Read mode Lesemodus
Switch light / dark theme Helles / dunkles Design umschalten
@@ -1075,6 +1079,7 @@
Dates Datumsangaben
Text in images isn't found; recognise text (OCR) first. Text in Bildern wird nicht gefunden; führe zuerst Text erkennen (OCR) aus.
No matches. Keine Treffer.
Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth. Einige Lesezeichen werden hier nicht angezeigt: Das Fenster listet nur eine begrenzte Anzahl bis zu einer begrenzten Tiefe auf.
{n} match(es) marked for redaction. {n} Treffer zum Schwärzen markiert.
Multiple words or phrases Mehrere Wörter oder Ausdrücke
One word or phrase per line Ein Wort oder Ausdruck pro Zeile
@@ -1476,6 +1481,8 @@
Right Edge Rechter Rand
Language Sprache
Saved {place}: {before} → {after} ({pct}% smaller){detail} {place} gespeichert: {before} → {after} ({pct} % kleiner){detail}
This file is already as small as it can be made ({size}). No copy was saved. Diese Datei ist bereits so klein wie möglich ({size}). Es wurde keine Kopie gespeichert.
Saved {place}: {before} → {after} (not smaller){detail} {place} gespeichert: {before} → {after} (nicht kleiner){detail}
Couldn't save the image: {e} Das Bild konnte nicht gespeichert werden: {e}
Adding images arrives on the web with file pickers for images Bilder hinzufügen kommt im Web zusammen mit der Bildauswahl
Replacing images on page {p} arrives on the web with image pickers ({i}) Bilder auf Seite {p} ersetzen kommt im Web zusammen mit der Bildauswahl ({i})
@@ -2141,17 +2148,44 @@ action wizard Start Starten
The field {name} no longer exists. Das Feld {name} existiert nicht mehr.
A selected field has no widget on any page, so the fields can't be edited together. Ein ausgewähltes Feld hat auf keiner Seite ein Widget, daher können die Felder nicht gemeinsam bearbeitet werden.
({n} at {dpi} dpi, the largest size they can be drawn at) ({n} mit {dpi} dpi, der größten Auflösung, in der sie gezeichnet werden können)
Zoom in / out (also pinch or ⌘-scroll) Vergrößern / Verkleinern (auch per Pinch-Geste oder ⌘ + Scrollen)
Open a PDF (⌘O) PDF öffnen (⌘O)
Print (⌘P) Drucken (⌘P)
Document properties (⌘D) Dokumenteigenschaften (⌘D)
Zoom out (⌘−) Verkleinern (⌘−)
Zoom in (⌘+) Vergrößern (⌘+)
Rotate view clockwise (⇧⌘+) Ansicht im Uhrzeigersinn drehen (⇧⌘+)
New bookmark (⌘B) Neues Lesezeichen (⌘B)
Previous (⇧⌘G) Vorheriger (⇧⌘G)
Next (⌘G) Nächster (⌘G)
Text recognition isn't installed: its models are missing (run `cargo xtask models`, or set PDFCRAFT_MODELS). Die Texterkennung ist nicht installiert, ihre Modelle fehlen (führe `cargo xtask models` aus oder setze PDFCRAFT_MODELS).
# Multi-object selection and movement (#844)
@plural {n} object selected|{n} objects selected {n} Objekt ausgewählt|{n} Objekte ausgewählt
Drag the selection to move it. Esc clears the selection. Ziehen Sie die Auswahl, um sie zu verschieben. Esc hebt die Auswahl auf.
Shift-click to select several objects. Drag empty space to select an area. Mit Umschalt-Klick mehrere Objekte auswählen. Ziehen Sie in einem leeren Bereich, um einen Auswahlrahmen aufzuziehen.
Select at most 1000 objects at a time. Wählen Sie höchstens 1000 Objekte gleichzeitig aus.
Finish or discard the text being edited before selecting several objects. Schließen Sie die Textbearbeitung ab oder verwerfen Sie sie, bevor Sie mehrere Objekte auswählen.
Move object Objekt verschieben
Move objects Objekte verschieben
Vertical text cannot be moved with this selection. Vertikaler Text kann mit dieser Auswahl nicht verschoben werden.
Text used as a clipping path cannot be moved with this selection. Text, der als Beschneidungspfad dient, kann mit dieser Auswahl nicht verschoben werden.
These objects could not be moved. Select them again and try a smaller move. Diese Objekte konnten nicht verschoben werden. Wählen Sie sie erneut aus und versuchen Sie eine kleinere Verschiebung.
This page contains objects that cannot be selected together. Diese Seite enthält Objekte, die nicht gemeinsam ausgewählt werden können.
Fill colour Füllfarbe
No fill Keine Füllung
Fill colour: none Füllfarbe: keine
# Keyboard key names, distinct from command labels.
key Ctrl Strg
key Shift Umschalt
key Delete Entf
key Esc Esc
key Home Pos1
key End Ende
key Space Leertaste
Grid view Rasteransicht
List view Listenansicht
Files are combined in the order shown. Drag them to change it. Dateien werden in der gezeigten Reihenfolge kombiniert. Zieh sie, um sie zu ändern.
Preview unavailable: {e} Keine Vorschau: {e}
Pages: {k} of {n} Seiten: {k} von {n}
Check the pages Prüfe die Seiten
Switch to List view to change the pages it takes. Wechsle zur Listenansicht, um die übernommenen Seiten zu ändern.
Expand all Alle ausklappen
Collapse all Alle einklappen
Remove page Seite entfernen
Remove {n} pages {n} Seiten entfernen
# Shared panel docking
Reset panel layout Bereichsanordnung zurücksetzen
Float panel Bereich frei platzieren
Move to group In Gruppe verschieben
Can't render this file because it contains an unexpected character in line 6 and column 51.
Loaded 100 of 178 files, more files were not shown because too many files have changed in this diff. Show more